Author SHA1 Message Date
Christian Vidal Wolf fd5d7458b9 Add column filters to Missing Data view and include Classification in editable columns 2026-04-12 18:32:08 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 39869edcef feat: replace global EditPanel with focused 3-field panel in Missing Data
When editing from Missing Data view, only Classification, Launch Date
and Ready to Order fields are shown in a lightweight slide-in panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:23:23 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 ce9e91bad1 feat: add Edit button to each row in Missing Data view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:19:20 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 7eeccdaf8c feat: add Missing Data module as new sidebar entry
Replaces the tab approach with a dedicated 'Missing Data' module in the
sidebar. The module has two sub-tabs: 'Missing Classification' and
'Missing Launch Date'. Shows SKU, Name, Classification, Launch Date and
Ready to Order columns. Includes a local Excel serial date formatter so
dates that slipped through the App.tsx pre-processing are rendered
correctly instead of showing as raw numbers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:11:57 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 fdf878a4f8 feat: add Missing Class / Launch tab to Article Details
Adds a new 'Missing Class / Launch' tab in Article Details that surfaces
items where Classification is empty or Launch Date is blank/01-01-1900.
The tab shows Classification, Launch Date, and Ready to Order columns for
quick reference. Column indices for Launch Date and Ready to Order are
resolved dynamically from the Excel headers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:02:52 +02:00
Christian Vidal Wolf d1f1c9a467 feat: restrict History and Pending Validation tabs to master user 2026-04-11 15:00:58 +02:00
Christian Vidal Wolf 7ecd1c45d0 UI: Add group verification for Dimensions inconsistencies and persist to DB 2026-04-11 11:48:56 +02:00
Christian Vidal Wolf 0e797e06bb feat: add voice-to-text input using Web Speech API 2026-04-11 11:34:57 +02:00
Christian Vidal Wolf 5104dfd59d chore: install buzz package for Python-style type checking 2026-04-11 11:31:58 +02:00
Christian Vidal Wolf 385ea2972c feat: increase default page size from 25 to 100 in ProductDescriptions and ArticleDetails 2026-04-11 11:18:58 +02:00
Christian Vidal Wolf aebf46493b UI: Increase column widths for description headers to prevent cutoff 2026-04-11 11:15:41 +02:00
Christian Vidal Wolf dcdf50593d Comprehensive audit: Replace backend JWT dependency universally with anon key and strict cache rules 2026-04-10 19:56:42 +02:00
Christian Vidal Wolf d9cc1f4506 Fix silent failure on getAllSyncedRows by forcing anon key 2026-04-10 19:52:30 +02:00
Christian Vidal Wolf d3c90120f4 Force anon key on getHistory and expose errors visually 2026-04-10 19:48:33 +02:00
Christian Vidal Wolf 1c72eae60a Disable fetch cache to sync history across computers 2026-04-10 19:45:05 +02:00
Christian Vidal Wolf 61daf8083f fix: preserve 0 values in EditPanel form inputs 2026-04-10 12:55:47 +02:00
Christian Vidal Wolf 15913706fa fix: show 0 values in DimensionsView instead of empty 2026-04-10 12:53:47 +02:00
Christian Vidal Wolf 20c4cb1a40 fix: preserve 0 values in badge check 2026-04-10 12:50:15 +02:00
Christian Vidal Wolf 95eb6ab91b feat: add Edit button to history items to modify changes 2026-04-10 12:46:26 +02:00
Christian Vidal Wolf be584600fc feat: add clear X button to all search inputs 2026-04-10 12:39:31 +02:00
Christian Vidal Wolf dbcd6d622f Implement smart merge of synced data to preserve external updates 2026-04-10 12:31:57 +02:00
Christian Vidal Wolf 8849303f58 Fix data overwrite on reload by ignoring duplicates in dropbox-sync 2026-04-10 12:28:48 +02:00
Christian Vidal Wolf 7a5e1a1b26 Fix token definition oversight 2026-04-10 12:25:58 +02:00
Christian Vidal Wolf bdd15cbd33 Show specific critical error message 2026-04-10 12:16:06 +02:00
Christian Vidal Wolf 3c821b83a0 Gracefully handle JWT expired and warn user to re-login 2026-04-10 12:09:49 +02:00
Christian Vidal Wolf db91aeb842 Improve Supabase error reporting for diagnostic purposes 2026-04-10 12:06:16 +02:00
Christian Vidal Wolf ee73b85100 Fix save changes looping issue and authorize all Supabase API calls 2026-04-10 12:03:12 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 a92e136372 fix: update Dropbox file path to correct location
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:51:41 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 1bff4f11a6 fix: use refresh token flow for permanent Dropbox access
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:24:28 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 8e5cc6745a fix: use DROPBOX_ACCESS_TOKEN directly in proxy (skip refresh flow)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:20:16 +02:00
Christian Vidal Wolf 1aaf454ba5 fix: use raw fetch for Dropbox API instead of SDK, add better error logging 2026-04-10 11:01:55 +02:00
Christian Vidal Wolf 90e60017da feat: sync Excel data to Supabase on load - detect new Dropbox files automatically 2026-04-10 10:54:50 +02:00
Christian Vidal Wolf 077e78513c fix: use POST with upsert instead of PATCH to handle new products 2026-04-10 10:48:05 +02:00
Christian Vidal Wolf 0139e1de20 fix: add debug logs to handleSaveAll to diagnose save issue 2026-04-10 10:45:11 +02:00
Christian Vidal Wolf bb1af0dbbd fix: correctly track hasUnsavedChanges when only 1 pending change remains 2026-04-10 10:37:40 +02:00
Christian Vidal Wolf 4e6563d38d Move filter icon next to header text in all views 2026-04-10 10:05:59 +02:00
Christian Vidal Wolf 19df2d04e4 Move filter icon closer to header text in ArticleDetails 2026-04-10 10:02:26 +02:00
Christian Vidal Wolf 23f4b508c8 Delete history entry after revert 2026-04-10 09:47:35 +02:00
Christian Vidal Wolf 0cf4f59937 Warn when reverting to original state (no actual changes) 2026-04-10 09:42:04 +02:00
Christian Vidal Wolf 7bf37eb017 style: adjust ASIN column width to fit 10 characters in ProductDescriptions 2026-04-10 09:28:49 +02:00
Christian Vidal Wolf 161e77a76b Set ASIN column width to 90px 2026-04-10 09:25:34 +02:00
Christian Vidal Wolf 0c75e91c4e Reduce ASIN column width to 120px 2026-04-10 09:23:51 +02:00
20 changed files with 1517 additions and 324 deletions
+59 -7
View File
@@ -1,16 +1,67 @@
// Vercel serverless function — proxies the Dropbox file server-side (no CORS) const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY;
const DROPBOX_APP_SECRET = process.env.DROPBOX_APP_SECRET;
const DROPBOX_REFRESH_TOKEN = process.env.DROPBOX_REFRESH_TOKEN;
async function getAccessToken() {
const response = await fetch('https://api.dropboxapi.com/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: DROPBOX_REFRESH_TOKEN,
client_id: DROPBOX_APP_KEY,
client_secret: DROPBOX_APP_SECRET,
})
});
const data = await response.json();
if (!data.access_token) {
throw new Error('Failed to get access token: ' + JSON.stringify(data));
}
return data.access_token;
}
export default async function handler(req, res) { export default async function handler(req, res) {
const fileUrl = let accessToken;
'https://dl.dropboxusercontent.com/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx' + try {
'?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1'; accessToken = await getAccessToken();
} catch (err) {
console.error('Token refresh error:', err);
return res.status(500).json({ error: err.message });
}
if (req.method === 'GET' && req.query.info === '1') {
try {
const fileInfo = await fetch('https://api.dropboxapi.com/2/files/get_metadata', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: '/CRAZE GmbH/Sales Reports/Data Matrix.xlsx' })
});
const data = await fileInfo.json();
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
return res.json({ rev: data.rev, size: data.size, server_modified: data.server_modified });
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
try { try {
const upstream = await fetch(fileUrl, { const upstream = await fetch('https://content.dropboxapi.com/2/files/download', {
headers: { 'User-Agent': 'Mozilla/5.0' }, method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Dropbox-API-Arg': JSON.stringify({ path: '/CRAZE GmbH/Sales Reports/Data Matrix.xlsx' })
}
}); });
if (!upstream.ok) { if (!upstream.ok) {
return res.status(upstream.status).send(`Dropbox error: ${upstream.status}`); const errText = await upstream.text();
console.error('Dropbox API error:', upstream.status, errText);
return res.status(upstream.status).send('Dropbox error: ' + errText);
} }
const buffer = await upstream.arrayBuffer(); const buffer = await upstream.arrayBuffer();
@@ -18,6 +69,7 @@ export default async function handler(req, res) {
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
res.send(Buffer.from(buffer)); res.send(Buffer.from(buffer));
} catch (err) { } catch (err) {
console.error('Dropbox proxy error:', err);
res.status(500).send('Proxy error: ' + err.message); res.status(500).send('Proxy error: ' + err.message);
} }
} }
+58
View File
@@ -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 });
}
}
+254
View File
@@ -9,10 +9,13 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@google/genai": "^1.29.0", "@google/genai": "^1.29.0",
"@supabase/supabase-js": "^2.103.0",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"buzz": "^2.0.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"dropbox": "^10.34.0",
"express": "^4.21.2", "express": "^4.21.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
@@ -1175,6 +1178,92 @@
"win32" "win32"
] ]
}, },
"node_modules/@supabase/auth-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.0.tgz",
"integrity": "sha512-6zAanO6c+6gpHOlt5Lb9TlBBkJdZiUWkWCJKAxzkywBDcwaHlLJKXnjQGX6GyVCyKRR1e7sTq4re/yRTH6U/9A==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.103.0.tgz",
"integrity": "sha512-YrneV2NjskUkkmkZ2Jt2n3elBgbWzV4Y1M9MM370z2Zd5ZPFqFbY8KIoPwuNjtAGE9YrpKBxnbZqeF07BiN9Og==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz",
"integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.103.0.tgz",
"integrity": "sha512-rC3sRxYdPZymkp2CZR1MiNQgbOleD01bGsW8VxEKRR5nMkLZ1NgAS1QTQf78Wh30czFyk505ZYr9Od8/mWT2TA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.103.0.tgz",
"integrity": "sha512-gcPtXzZ6izyyBVf2of7K3dEt8CScPJn8VcSlQq6oWL9QoE1kqfQl0oFrOMHd5qrcADewxI7OxxosLB8W4XqtIQ==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.0",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.103.0.tgz",
"integrity": "sha512-DHmlvdAXwtOmZNbkIZi4lkobPR3XjIzoOgzoz5duMf6G+sDeY015YrzMJCnqdccuYr7X5x4yYuSwF//RoN2dvQ==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.103.0.tgz",
"integrity": "sha512-j/6q5+LtXbR/YOLSLhy7Na74RD1cV2v+KwIIuuqMEjk1JpLEEyu0ynwDHpGoxMncDQl+R5FogaVqZm+85lZvtw==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.103.0",
"@supabase/functions-js": "2.103.0",
"@supabase/postgrest-js": "2.103.0",
"@supabase/realtime-js": "2.103.0",
"@supabase/storage-js": "2.103.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
@@ -1549,6 +1638,17 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.4"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -1602,6 +1702,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@@ -1659,6 +1768,12 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.27", "version": "10.4.27",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
@@ -1816,6 +1931,12 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/buzz": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/buzz/-/buzz-2.0.0.tgz",
"integrity": "sha512-eYeTETPJp7hWUX7j3o8iJNR8VLaaWRuOYYPWTSQqA5pIxZ2g3ZJUdyjfNZnGvr85IDhfr4ONQQflGp8+MC034A==",
"license": "MIT"
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1905,6 +2026,18 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -1985,6 +2118,15 @@
} }
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -2025,6 +2167,41 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/dropbox": {
"version": "10.34.0",
"resolved": "https://registry.npmjs.org/dropbox/-/dropbox-10.34.0.tgz",
"integrity": "sha512-5jb5/XzU0fSnq36/hEpwT5/QIep7MgqKuxghEG44xCu7HruOAjPdOb3x0geXv5O/hd0nHpQpWO+r5MjYTpMvJg==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.1"
},
"engines": {
"node": ">=0.10.3"
},
"peerDependencies": {
"@types/node-fetch": "^2.5.7"
}
},
"node_modules/dropbox/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2112,6 +2289,21 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.4", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
@@ -2318,6 +2510,22 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/formdata-polyfill": { "node_modules/formdata-polyfill": {
"version": "4.0.10", "version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -2564,6 +2772,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -2609,6 +2832,15 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -3709,6 +3941,12 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -4367,6 +4605,22 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wmf": { "node_modules/wmf": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+3
View File
@@ -12,10 +12,13 @@
}, },
"dependencies": { "dependencies": {
"@google/genai": "^1.29.0", "@google/genai": "^1.29.0",
"@supabase/supabase-js": "^2.103.0",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"buzz": "^2.0.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"dropbox": "^10.34.0",
"express": "^4.21.2", "express": "^4.21.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
+184 -76
View File
@@ -6,7 +6,7 @@ import { TopBar } from './components/TopBar';
import { ProductDescriptions } from './components/ProductDescriptions'; import { ProductDescriptions } from './components/ProductDescriptions';
import { MatrixView } from './components/MatrixView'; import { MatrixView } from './components/MatrixView';
import { EditPanel } from './components/EditPanel'; import { EditPanel } from './components/EditPanel';
import { getAllSyncedRows, saveRowToSupabase, resetAllPendingRows, saveHistoryEntry } from './lib/supabase'; import { getAllSyncedRows, saveRowToSupabase, resetAllPendingRows, saveHistoryEntry, deleteHistoryEntry } from './lib/supabase';
import { getStoredSession, signOut, type AuthSession } from './lib/auth'; import { getStoredSession, signOut, type AuthSession } from './lib/auth';
import { LoginPage } from './components/LoginPage'; import { LoginPage } from './components/LoginPage';
import { DimensionsView } from './components/DimensionsView'; import { DimensionsView } from './components/DimensionsView';
@@ -15,6 +15,7 @@ import { ArticleDetails } from './components/ArticleDetails';
import { HistoryView } from './components/HistoryView'; import { HistoryView } from './components/HistoryView';
import { UndoToast } from './components/UndoToast'; import { UndoToast } from './components/UndoToast';
import { PendingValidationView } from './components/PendingValidationView'; import { PendingValidationView } from './components/PendingValidationView';
import { MissingDataView } from './components/MissingDataView';
export default function App() { export default function App() {
const [session, setSession] = useState<AuthSession | null>(() => getStoredSession()); const [session, setSession] = useState<AuthSession | null>(() => getStoredSession());
@@ -27,7 +28,7 @@ export default function App() {
hasUnsavedChanges: false, hasUnsavedChanges: false,
asinColumnIndex: null asinColumnIndex: null
}); });
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history'>('descriptions'); const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data'>('descriptions');
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]); const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null); const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
const [isLoadingDefault, setIsLoadingDefault] = useState(true); const [isLoadingDefault, setIsLoadingDefault] = useState(true);
@@ -47,79 +48,126 @@ export default function App() {
useEffect(() => { useEffect(() => {
const loadDefaultData = async () => { const loadDefaultData = async () => {
// In dev: Vite proxy handles /dropbox-file (see vite.config.ts)
// In prod: Vercel serverless function at /api/dropbox-proxy handles it
const fileUrl = import.meta.env.DEV
? '/dropbox-file/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1'
: '/api/dropbox-proxy';
setIsLoadingDefault(true); setIsLoadingDefault(true);
setDefaultLoadError(null); setDefaultLoadError(null);
try { try {
console.log('Fetching Data-Matrix.xlsx via Vite proxy...'); const isDev = import.meta.env.DEV;
const response = await fetch(fileUrl);
let rows: any[][];
if (!response.ok) { let allData: any[][];
throw new Error(`HTTP ${response.status}: ${response.statusText}`); let fileMeta = { rev: '', size: 0 };
}
if (isDev) {
const arrayBuffer = await response.arrayBuffer(); const fileUrl = '/dropbox-file/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1';
if (arrayBuffer.byteLength < 100) { console.log('Fetching Data-Matrix.xlsx from Dropbox...');
throw new Error('File too small — possibly empty or error response'); const response = await fetch(fileUrl);
} if (!response.ok) throw new Error(`HTTP ${response.status}`);
const arrayBuffer = await response.arrayBuffer();
const wb = XLSX.read(arrayBuffer, { type: 'array' }); if (arrayBuffer.byteLength < 100) throw new Error('File too small');
const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname]; const wb = XLSX.read(arrayBuffer, { type: 'array' });
const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 }); const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname];
if (data.length > 0) { allData = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
const rawHeaders = data[0]; rows = allData.slice(1);
const rawRows = data.slice(1); fileMeta = { rev: 'dev', size: arrayBuffer.byteLength };
} else {
// Find ASIN column index from headers (case insensitive) console.log('Fetching file info from Dropbox...');
const asinIdx = (rawHeaders as string[]).findIndex((h: string) => const infoRes = await fetch('/api/dropbox-proxy?info=1');
String(h).toLowerCase().trim() === 'asin' if (infoRes.ok) {
); fileMeta = await infoRes.json();
if (asinIdx !== -1) { console.log('File meta:', fileMeta);
console.log('ASIN column found at index:', asinIdx);
} }
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);
}
if (rows.length > 0) {
const headers = allData.slice(0, 1)[0];
console.log('Syncing Excel data to Supabase...');
const syncRes = await fetch('/api/dropbox-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows, fileMeta })
});
if (syncRes.ok) {
const syncResult = await syncRes.json();
console.log('Supabase sync result:', syncResult);
} else {
const errorText = await syncRes.text();
console.warn('Supabase sync failed:', syncRes.status, errorText);
}
console.log('Fetching synced data from Supabase...');
const syncedData = await getAllSyncedRows(session?.access_token);
const editableColumns = new Set([
COLUMNS.CLASSIFICATION,
COLUMNS.LONG_DE, COLUMNS.LONG_EN,
COLUMNS.SHORT_DE, COLUMNS.SHORT_EN,
COLUMNS.DETAILS_DE, COLUMNS.DETAILS_EN,
COLUMNS.INNER_L, COLUMNS.INNER_W, COLUMNS.INNER_H,
COLUMNS.OUTER_L, COLUMNS.OUTER_W, COLUMNS.OUTER_H,
COLUMNS.UNITS_OUTER, COLUMNS.MOQ,
COLUMNS.VERIFIED_DIMS
]);
headers.forEach((h: any, i: number) => {
const hl = String(h || '').toLowerCase();
if (hl.includes('srp') || hl.includes('uvp') || hl.includes('40') || hl.includes('price')) {
editableColumns.add(i);
}
});
console.log('Applying Supabase overrides...');
const syncedData = await getAllSyncedRows();
const articleNoIdx = COLUMNS.ARTICLE_NO; const articleNoIdx = COLUMNS.ARTICLE_NO;
const processedRows = rows.map(row => {
const processedRows = rawRows.map(row => {
const articleNo = String(row[articleNoIdx]); const articleNo = String(row[articleNoIdx]);
const synced = syncedData[articleNo]; 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.status === 'pending') {
if (synced && synced.status === 'pending') { setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' })); }
} }
// Format numeric/price fields to 2 decimal places return finalRow.map((val: any, idx: number) => {
return finalRow.map((val, idx) => {
if (val === undefined || val === null || val === '') return val; if (val === undefined || val === null || val === '') return val;
const header = (rawHeaders[idx] || '').toLowerCase(); const header = (headers[idx] || '').toLowerCase();
// Skip Article No, Barcodes, and other code-like fields
// But allow if it's a weight/measure column (e.g. Article NW (kg))
if ((header.includes('id') || header.includes('no') || header.includes('code') || if ((header.includes('id') || header.includes('no') || header.includes('code') ||
header.includes('art.') || header.includes('barcode') || header.includes('article')) && header.includes('art.') || header.includes('barcode') || header.includes('article')) &&
!(header.includes('nw') || header.includes('gw') || header.includes('weight') || header.includes('kg'))) { !(header.includes('nw') || header.includes('gw') || header.includes('weight') || header.includes('kg'))) {
return val; return val;
} }
const formatKeywords = ['price', 'eur', 'cost', 'msrp', 'net', 'gross', 'netto', 'brutto', 'pp', 'pph', 'uvp', 'vpe', 'stk', 'nw', 'gw', 'weight', 'kg']; 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)); const shouldFormat = formatKeywords.some(kw => header.includes(kw));
if (typeof val === 'number') { if (typeof val === 'number') {
return Number(val.toFixed(2)); return Number(val.toFixed(2));
} }
if (typeof val === 'string') { if (typeof val === 'string') {
const normalized = val.trim().replace(',', '.'); const normalized = val.trim().replace(',', '.');
const num = parseFloat(normalized); const num = parseFloat(normalized);
@@ -130,9 +178,13 @@ export default function App() {
return val; return val;
}); });
}); });
const asinIdx = (headers as string[]).findIndex((h: string) =>
String(h).toLowerCase().trim() === 'asin'
);
setAppState({ setAppState({
headers: rawHeaders, headers: headers,
data: processedRows, data: processedRows,
fileName: 'Data-Matrix.xlsx (Cloud Sync)', fileName: 'Data-Matrix.xlsx (Cloud Sync)',
fileDate: new Date(), fileDate: new Date(),
@@ -172,13 +224,13 @@ export default function App() {
const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 }); const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
if (data.length > 0) { if (data.length > 0) {
const rawHeaders = data[0]; const headers = data[0];
const rawRows = data.slice(1); const rawRows = data.slice(1);
const processedRows = rawRows.map(row => { const processedRows = rawRows.map(row => {
return row.map((val, idx) => { return row.map((val, idx) => {
if (val === undefined || val === null || val === '') return val; if (val === undefined || val === null || val === '') return val;
const header = (rawHeaders[idx] || '').toLowerCase(); const header = (headers[idx] || '').toLowerCase();
if (header.includes('id') || header.includes('no') || header.includes('code') || if (header.includes('id') || header.includes('no') || header.includes('code') ||
header.includes('art.') || header.includes('barcode') || header.includes('article')) { header.includes('art.') || header.includes('barcode') || header.includes('article')) {
@@ -217,7 +269,7 @@ export default function App() {
}); });
setAppState({ setAppState({
headers: rawHeaders, headers: headers,
data: processedRows, data: processedRows,
fileName: file.name, fileName: file.name,
fileDate: new Date(), fileDate: new Date(),
@@ -233,7 +285,14 @@ export default function App() {
const handleExport = () => { const handleExport = () => {
if (appState.data.length === 0) return; if (appState.data.length === 0) return;
const wsData = [appState.headers, ...appState.data]; const wsData = [
appState.headers,
...appState.data.map(row => {
const copy = [...row];
delete copy[COLUMNS.VERIFIED_DIMS];
return copy.slice(0, appState.headers.length);
})
];
const ws = XLSX.utils.aoa_to_sheet(wsData); const ws = XLSX.utils.aoa_to_sheet(wsData);
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Products'); XLSX.utils.book_append_sheet(wb, ws, 'Products');
@@ -243,7 +302,7 @@ export default function App() {
// 3. Post-export: Reset pending statuses in Supabase // 3. Post-export: Reset pending statuses in Supabase
console.log('Resetting pending statuses in Supabase...'); console.log('Resetting pending statuses in Supabase...');
resetAllPendingRows().then(success => { resetAllPendingRows(session?.access_token).then(success => {
if (success) { if (success) {
console.log('Successfully reset all pending statuses'); console.log('Successfully reset all pending statuses');
setRowStatuses({}); // Clear local statuses setRowStatuses({}); // Clear local statuses
@@ -281,7 +340,7 @@ export default function App() {
setAppState(prev => { setAppState(prev => {
const newData = [...prev.data]; const newData = [...prev.data];
newData[pending.rowIndex] = pending.originalData; newData[pending.rowIndex] = pending.originalData;
const stillPending = Object.keys(pendingRows).length > 1; const stillPending = Object.keys(pendingRows).length > 0;
return { ...prev, data: newData, hasUnsavedChanges: stillPending }; return { ...prev, data: newData, hasUnsavedChanges: stillPending };
}); });
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; }); setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
@@ -289,25 +348,59 @@ export default function App() {
}; };
const handleSaveAll = async () => { const handleSaveAll = async () => {
console.log('[handleSaveAll] Starting save, pendingRows:', pendingRows);
const entries = Object.entries(pendingRows) as [string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }][]; const entries = Object.entries(pendingRows) as [string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }][];
if (entries.length === 0) return; if (entries.length === 0) {
console.log('[handleSaveAll] No entries to save, returning');
return;
}
setIsSavingAll(true); setIsSavingAll(true);
let allSuccess = true; let failedArticles: string[] = [];
for (const [articleNo, { newData, originalData, articleName }] of entries) { let jwtExpired = false;
const success = await saveRowToSupabase(articleNo, newData); const token = session?.access_token;
if (success) {
// Also save to history try {
await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown'); 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' })); if (result.success) {
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; }); // 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 { } else {
setRowStatuses(prev => ({ ...prev, [articleNo]: 'error' })); setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
allSuccess = 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) => { const captureState = (message: string) => {
@@ -394,7 +487,7 @@ export default function App() {
isSavingAll={isSavingAll} isSavingAll={isSavingAll}
/> />
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<Sidebar activeModule={activeModule} setActiveModule={setActiveModule} /> <Sidebar activeModule={activeModule} setActiveModule={setActiveModule} userEmail={session.user.email} />
<main className="flex-1 overflow-auto relative p-6 bg-[#041021]"> <main className="flex-1 overflow-auto relative p-6 bg-[#041021]">
{isLoadingDefault ? ( {isLoadingDefault ? (
<div className="flex flex-col items-center justify-center h-full text-slate-400"> <div className="flex flex-col items-center justify-center h-full text-slate-400">
@@ -470,10 +563,21 @@ export default function App() {
onEdit={(index) => setEditingRowIndex(index)} onEdit={(index) => setEditingRowIndex(index)}
/> />
)} )}
{activeModule === 'missing_data' && (
<MissingDataView
data={appState.data}
headers={appState.headers}
onSaveRow={handleSaveRow}
onCaptureState={captureState}
/>
)}
{activeModule === 'history' && ( {activeModule === 'history' && (
<HistoryView <HistoryView
headers={appState.headers} headers={appState.headers}
onRevert={(articleNo, revertedData) => { data={appState.data}
sessionToken={session?.access_token}
onEdit={(index) => setEditingRowIndex(index)}
onRevert={async (articleNo, revertedData, historyId) => {
// Find the row in appState.data and update it // Find the row in appState.data and update it
const rowIndex = appState.data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === articleNo); const rowIndex = appState.data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === articleNo);
if (rowIndex !== -1) { if (rowIndex !== -1) {
@@ -493,6 +597,10 @@ export default function App() {
articleName: String(revertedData[COLUMNS.ARTICLE_NAME] || articleNo), articleName: String(revertedData[COLUMNS.ARTICLE_NAME] || articleNo),
} }
})); }));
// Delete the history entry after revert
if (historyId) {
await deleteHistoryEntry(String(historyId), session?.access_token);
}
} }
}} }}
/> />
+27 -19
View File
@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X as XIcon } from 'lucide-react'; import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover'; import { ColumnFilterPopover } from './ColumnFilterPopover';
@@ -18,7 +18,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
const [lineFilter, setLineFilter] = useState(''); const [lineFilter, setLineFilter] = useState('');
const [sortCol, setSortCol] = useState<number | null>(null); const [sortCol, setSortCol] = useState<number | null>(null);
const [sortDesc, setSortDesc] = useState(false); const [sortDesc, setSortDesc] = useState(false);
const [pageSize, setPageSize] = useState(25); const [pageSize, setPageSize] = useState(100);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({}); const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null); const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
@@ -45,7 +45,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
// Search filter // Search filter
if (search) { if (search) {
const s = search.toLowerCase(); 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_NO] || '').toLowerCase().includes(s) ||
String(r.row[COLUMNS.ARTICLE_NAME] || '').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) => { result.sort((a, b) => {
const valA = a.row[sortCol]; const valA = a.row[sortCol];
const valB = b.row[sortCol]; const valB = b.row[sortCol];
if (typeof valA === 'number' && typeof valB === 'number') { if (typeof valA === 'number' && typeof valB === 'number') {
return sortDesc ? valB - valA : valA - valB; return sortDesc ? valB - valA : valA - valB;
} }
const sA = String(valA || ''); const sA = String(valA || '');
const sB = String(valB || ''); const sB = String(valB || '');
return sortDesc ? sB.localeCompare(sA) : sA.localeCompare(sB); 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') => { 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>; 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 = { const styles = {
success: "bg-green-500/10 text-green-400 border-green-500/20", success: "bg-green-500/10 text-green-400 border-green-500/20",
warning: "bg-yellow-500/10 text-yellow-400 border-yellow-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); }} onClick={() => { setActiveTab(tab.id); setPage(1); }}
className={cn( className={cn(
"px-4 py-2 rounded-md text-sm font-medium transition-colors", "px-4 py-2 rounded-md text-sm font-medium transition-colors",
activeTab === tab.id activeTab === tab.id
? "bg-indigo-600 text-white shadow-md" ? "bg-indigo-600 text-white shadow-md"
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white" : "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..." placeholder="Search SKU or Name..."
value={search} value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }} onChange={e => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-9 pr-4 py-2 bg-slate-900/50 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500" className="w-full pl-9 pr-10 py-2 bg-slate-900/50 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/> />
{search && (
<button
onClick={() => { setSearch(''); setPage(1); }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div> </div>
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && ( {(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
<button <button
@@ -196,7 +204,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
}} }}
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20" className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
> >
<XIcon className="w-4 h-4" /> <X className="w-4 h-4" />
Clear All Column Filters Clear All Column Filters
</button> </button>
)} )}
@@ -223,29 +231,29 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
{ col: COLUMNS.DETAILS_DE, label: 'Details DE' }, { col: COLUMNS.DETAILS_DE, label: 'Details DE' },
{ col: COLUMNS.DETAILS_EN, label: 'Details EN' }, { col: COLUMNS.DETAILS_EN, label: 'Details EN' },
].map(({ col, label }) => ( ].map(({ col, label }) => (
<th <th
key={col} key={col}
className="px-3 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30" className="px-3 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }} style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
> >
<div className="flex items-center justify-between gap-1 overflow-hidden"> <div className="flex items-center overflow-hidden">
<div className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}> <span className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
{label} {label}
{sortCol === col && ( {sortCol === col && (
sortDesc ? <ChevronDown className="w-3 h-3 shrink-0" /> : <ChevronUp className="w-3 h-3 shrink-0" /> sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
)} )}
</div> </span>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setOpenFilterCol(openFilterCol === col ? null : col); setOpenFilterCol(openFilterCol === col ? null : col);
}} }}
className={cn( className={cn(
"p-1 rounded hover:bg-slate-700 transition-colors shrink-0", "p-0.5 rounded hover:bg-slate-700 transition-colors -my-1",
(columnFilters[col]?.length || 0) > 0 ? "text-indigo-400 bg-indigo-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100" (columnFilters[col]?.length || 0) > 0 ? "text-indigo-400 bg-indigo-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
)} )}
> >
<Filter className="w-3.5 h-3.5" /> <Filter className="w-3 h-3" />
</button> </button>
</div> </div>
@@ -340,7 +348,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-xs text-slate-500"> <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>Showing {paginatedData.length} of {filteredData.length} articles</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+10 -2
View File
@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Search, Check } from 'lucide-react'; import { Search, Check, X } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
interface ColumnFilterPopoverProps { interface ColumnFilterPopoverProps {
@@ -50,9 +50,17 @@ export function ColumnFilterPopover({
placeholder="Filter values..." placeholder="Filter values..."
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 text-xs text-white focus:outline-none focus:border-blue-500" className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 pr-7 text-xs text-white focus:outline-none focus:border-blue-500"
autoFocus autoFocus
/> />
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-3 h-3" />
</button>
)}
</div> </div>
<div className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar"> <div className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar">
+39 -8
View File
@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X as XIcon, Undo2 } from 'lucide-react'; import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X, Undo2 } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
import { ColumnFilterPopover } from './ColumnFilterPopover'; import { ColumnFilterPopover } from './ColumnFilterPopover';
@@ -101,13 +101,15 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
const parts = key.split('x').map(Number); const parts = key.split('x').map(Number);
const volume = parts[0] * parts[1] * parts[2]; const volume = parts[0] * parts[1] * parts[2];
const isVerified = rows.some(({ row }) => row[COLUMNS.VERIFIED_DIMS] === true);
result.push({ result.push({
key, key,
innerDims: key, innerDims: key,
rows, rows,
volume, volume,
isInconsistent: !outerMatch || !unitsMatch || !moqMatch, isInconsistent: (!outerMatch || !unitsMatch || !moqMatch) && !isVerified,
discrepancies: { discrepancies: {
outer: !outerMatch, outer: !outerMatch,
units: !unitsMatch, units: !unitsMatch,
@@ -221,6 +223,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
setPendingAction({ group, sourceRow, fieldType: 'all' }); setPendingAction({ group, sourceRow, fieldType: 'all' });
}; };
const handleVerifyGroup = (e: React.MouseEvent, group: DimensionGroup) => {
e.stopPropagation();
group.rows.forEach(({ row, index }) => {
const newRow = [...row];
newRow[COLUMNS.VERIFIED_DIMS] = true;
onSaveRow(index, newRow);
});
onCaptureState(`Verified dimensions for ${group.rows.length} products`);
};
const executeNearDupSync = async () => { const executeNearDupSync = async () => {
if (!pendingNearDupSync) return; if (!pendingNearDupSync) return;
const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync; const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
@@ -307,8 +319,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
placeholder="Search SKU or Name in groups..." placeholder="Search SKU or Name in groups..."
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500" className="w-full pl-9 pr-10 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500"
/> />
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -368,7 +388,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
className="p-2 text-red-400 hover:text-red-300 transition-colors" className="p-2 text-red-400 hover:text-red-300 transition-colors"
title="Clear all filters" title="Clear all filters"
> >
<XIcon className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
)} )}
</div> </div>
@@ -576,6 +596,17 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
)} )}
</div> </div>
{group.isInconsistent && (
<button
onClick={(e) => handleVerifyGroup(e, group)}
className="px-2 py-1 bg-emerald-600/10 hover:bg-emerald-600 hover:text-white text-emerald-500 rounded border border-emerald-600/30 text-[10px] font-bold transition-colors flex items-center gap-1"
title="Mark as correct to hide from inconsistent list"
>
<CheckCircle2 className="w-3 h-3" />
Mark Correct
</button>
)}
{group.isInconsistent && !expandedGroups.has(group.key) && ( {group.isInconsistent && !expandedGroups.has(group.key) && (
<div className="text-[10px] font-bold text-blue-400 bg-blue-400/5 px-2 py-1 rounded border border-blue-400/20"> <div className="text-[10px] font-bold text-blue-400 bg-blue-400/5 px-2 py-1 rounded border border-blue-400/20">
OPEN TO SYNC FIELDS OPEN TO SYNC FIELDS
@@ -613,7 +644,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
<div className="text-[10px] text-slate-500 truncate max-w-[200px]">{row[COLUMNS.ARTICLE_NAME]}</div> <div className="text-[10px] text-slate-500 truncate max-w-[200px]">{row[COLUMNS.ARTICLE_NAME]}</div>
</td> </td>
<td className="px-4 py-3 text-slate-400 font-mono"> <td className="px-4 py-3 text-slate-400 font-mono">
{row[COLUMNS.INNER_L] || '-'} × {row[COLUMNS.INNER_W] || '-'} × {row[COLUMNS.INNER_H] || '-'} {row[COLUMNS.INNER_L] !== undefined && row[COLUMNS.INNER_L] !== null ? row[COLUMNS.INNER_L] : '-'} × {row[COLUMNS.INNER_W] !== undefined && row[COLUMNS.INNER_W] !== null ? row[COLUMNS.INNER_W] : '-'} × {row[COLUMNS.INNER_H] !== undefined && row[COLUMNS.INNER_H] !== null ? row[COLUMNS.INNER_H] : '-'}
</td> </td>
<td className={cn( <td className={cn(
"px-4 py-3 font-mono", "px-4 py-3 font-mono",
@@ -628,7 +659,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
> >
{syncing?.key === group.key && syncing?.field === 'outer' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />} {syncing?.key === group.key && syncing?.field === 'outer' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
</button> </button>
<span>{row[COLUMNS.OUTER_L] || '-'} × {row[COLUMNS.OUTER_W] || '-'} × {row[COLUMNS.OUTER_H] || '-'}</span> <span>{row[COLUMNS.OUTER_L] !== undefined && row[COLUMNS.OUTER_L] !== null ? row[COLUMNS.OUTER_L] : '-'} × {row[COLUMNS.OUTER_W] !== undefined && row[COLUMNS.OUTER_W] !== null ? row[COLUMNS.OUTER_W] : '-'} × {row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? row[COLUMNS.OUTER_H] : '-'}</span>
</div> </div>
</td> </td>
<td className={cn( <td className={cn(
@@ -644,7 +675,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
> >
{syncing?.key === group.key && syncing?.field === 'units' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />} {syncing?.key === group.key && syncing?.field === 'units' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
</button> </button>
<span>{row[COLUMNS.UNITS_OUTER] || '-'}</span> <span>{row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? row[COLUMNS.UNITS_OUTER] : '-'}</span>
</div> </div>
</td> </td>
<td className={cn( <td className={cn(
@@ -660,7 +691,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
> >
{syncing?.key === group.key && syncing?.field === 'moq' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />} {syncing?.key === group.key && syncing?.field === 'moq' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
</button> </button>
<span>{row[COLUMNS.MOQ] || '-'}</span> <span>{row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? row[COLUMNS.MOQ] : '-'}</span>
</div> </div>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
+51 -12
View File
@@ -1,8 +1,9 @@
import React, { useState } from 'react'; import React, { useState, useRef, useCallback } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2 } from 'lucide-react'; import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2, Mic, MicOff } from 'lucide-react';
import { generateGemini } from '../services/gemini'; import { generateGemini } from '../services/gemini';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { useSpeechRecognition } from '../lib/useSpeechRecognition';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
interface EditPanelProps { interface EditPanelProps {
@@ -49,6 +50,9 @@ interface FieldEditorProps {
onGenerate: () => void; onGenerate: () => void;
onChange: (val: string) => void; onChange: (val: string) => void;
onKeyDown: (e: React.KeyboardEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void;
isListening?: boolean;
onToggleVoice?: () => void;
voiceSupported?: boolean;
} }
const FieldEditor = ({ const FieldEditor = ({
@@ -61,7 +65,10 @@ const FieldEditor = ({
isLoading, isLoading,
onGenerate, onGenerate,
onChange, onChange,
onKeyDown onKeyDown,
isListening,
onToggleVoice,
voiceSupported
}: FieldEditorProps) => ( }: FieldEditorProps) => (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -73,6 +80,21 @@ const FieldEditor = ({
Can translate from existing Can translate from existing
</div> </div>
)} )}
{voiceSupported && (
<button
onClick={onToggleVoice}
className={cn(
"flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded transition-colors",
isListening
? "bg-red-600/20 text-red-400 animate-pulse"
: "bg-slate-700/50 text-slate-400 hover:bg-slate-600 hover:text-white"
)}
title={isListening ? "Stop recording" : "Voice input"}
>
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
{isListening ? 'Stop' : 'Voice'}
</button>
)}
<button <button
onClick={onGenerate} onClick={onGenerate}
disabled={isLoading} disabled={isLoading}
@@ -111,14 +133,14 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
shortEn: row[COLUMNS.SHORT_EN] || '', shortEn: row[COLUMNS.SHORT_EN] || '',
detailsDe: row[COLUMNS.DETAILS_DE] || '', detailsDe: row[COLUMNS.DETAILS_DE] || '',
detailsEn: row[COLUMNS.DETAILS_EN] || '', detailsEn: row[COLUMNS.DETAILS_EN] || '',
innerW: row[COLUMNS.INNER_W] || '', innerW: row[COLUMNS.INNER_W] !== undefined && row[COLUMNS.INNER_W] !== null ? String(row[COLUMNS.INNER_W]) : '',
innerL: row[COLUMNS.INNER_L] || '', innerL: row[COLUMNS.INNER_L] !== undefined && row[COLUMNS.INNER_L] !== null ? String(row[COLUMNS.INNER_L]) : '',
innerH: row[COLUMNS.INNER_H] || '', innerH: row[COLUMNS.INNER_H] !== undefined && row[COLUMNS.INNER_H] !== null ? String(row[COLUMNS.INNER_H]) : '',
outerW: row[COLUMNS.OUTER_W] || '', outerW: row[COLUMNS.OUTER_W] !== undefined && row[COLUMNS.OUTER_W] !== null ? String(row[COLUMNS.OUTER_W]) : '',
outerL: row[COLUMNS.OUTER_L] || '', outerL: row[COLUMNS.OUTER_L] !== undefined && row[COLUMNS.OUTER_L] !== null ? String(row[COLUMNS.OUTER_L]) : '',
outerH: row[COLUMNS.OUTER_H] || '', outerH: row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? String(row[COLUMNS.OUTER_H]) : '',
unitsOuter: row[COLUMNS.UNITS_OUTER] || '', unitsOuter: row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? String(row[COLUMNS.UNITS_OUTER]) : '',
moq: row[COLUMNS.MOQ] || '', moq: row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? String(row[COLUMNS.MOQ]) : '',
}); });
const [loadingField, setLoadingField] = useState<string | null>(null); const [loadingField, setLoadingField] = useState<string | null>(null);
@@ -127,6 +149,17 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null); const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set()); const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
const handleSpeechResult = useCallback((transcript: string) => {
setFormData(prev => ({ ...prev, longDe: prev.longDe + ' ' + transcript }));
}, []);
const { isListening: isListeningDe, start: startListeningDe, stop: stopListeningDe, isSupported: speechSupported } = useSpeechRecognition({ onResult: handleSpeechResult });
const handleSpeechResultEn = useCallback((transcript: string) => {
setFormData(prev => ({ ...prev, longEn: prev.longEn + ' ' + transcript }));
}, []);
const { isListening: isListeningEn, start: startListeningEn, stop: stopListeningEn } = useSpeechRecognition({ onResult: handleSpeechResultEn, lang: 'en-US' });
const isModified = (field: keyof typeof formData) => { const isModified = (field: keyof typeof formData) => {
const colMap: Record<string, number> = { const colMap: Record<string, number> = {
longDe: COLUMNS.LONG_DE, longDe: COLUMNS.LONG_DE,
@@ -146,7 +179,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
}; };
const colIndex = (colMap as Record<string, number>)[field as string]; const colIndex = (colMap as Record<string, number>)[field as string];
if (colIndex === undefined) return false; if (colIndex === undefined) return false;
return formData[field] !== (row[colIndex] || ''); return formData[field] !== (row[colIndex] !== undefined && row[colIndex] !== null ? String(row[colIndex]) : '');
}; };
const handleGenerate = async (field: keyof typeof formData) => { const handleGenerate = async (field: keyof typeof formData) => {
@@ -351,6 +384,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
onGenerate={() => handleGenerate('longDe')} onGenerate={() => handleGenerate('longDe')}
onChange={(val) => setFormData(p => ({...p, longDe: val}))} onChange={(val) => setFormData(p => ({...p, longDe: val}))}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
isListening={isListeningDe}
onToggleVoice={isListeningDe ? stopListeningDe : startListeningDe}
voiceSupported={speechSupported}
/> />
<FieldEditor <FieldEditor
title="Long Description (EN)" title="Long Description (EN)"
@@ -363,6 +399,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
onGenerate={() => handleGenerate('longEn')} onGenerate={() => handleGenerate('longEn')}
onChange={(val) => setFormData(p => ({...p, longEn: val}))} onChange={(val) => setFormData(p => ({...p, longEn: val}))}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
isListening={isListeningEn}
onToggleVoice={isListeningEn ? stopListeningEn : startListeningEn}
voiceSupported={speechSupported}
/> />
<FieldEditor <FieldEditor
title="Short Description (DE)" title="Short Description (DE)"
+42 -8
View File
@@ -1,15 +1,18 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag } from 'lucide-react'; import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag, Search, X, Edit2 } from 'lucide-react';
import { getHistory, HistoryEntry } from '../lib/supabase'; import { getHistory, deleteHistoryEntry, HistoryEntry } from '../lib/supabase';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
interface HistoryViewProps { interface HistoryViewProps {
headers: string[]; headers: string[];
onRevert: (articleNo: string, oldData: ExcelRow) => void; data: ExcelRow[];
onRevert: (articleNo: string, oldData: ExcelRow, historyId?: number) => void;
onEdit?: (rowIndex: number) => void;
sessionToken?: string;
} }
export function HistoryView({ headers, onRevert }: HistoryViewProps) { export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: HistoryViewProps) {
const [history, setHistory] = useState<HistoryEntry[]>([]); const [history, setHistory] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -21,11 +24,23 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
const loadHistory = async () => { const loadHistory = async () => {
setLoading(true); setLoading(true);
const data = await getHistory(); const data = await getHistory(sessionToken);
setHistory(data); setHistory(data);
setLoading(false); setLoading(false);
}; };
const handleRevert = (entry: HistoryEntry) => {
if (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) {
const currentRow = data.find(r => String(r[0]) === entry.product_id);
if (currentRow && JSON.stringify(currentRow) === JSON.stringify(entry.old_data)) {
if (!window.confirm("Reverting will restore original data. No actual changes will be made. Continue?")) {
return;
}
}
onRevert(entry.product_id, entry.old_data, entry.id);
}
};
const getChangedFields = (oldData: ExcelRow, newData: ExcelRow) => { const getChangedFields = (oldData: ExcelRow, newData: ExcelRow) => {
const changes: { header: string; old: any; new: any; index: number }[] = []; const changes: { header: string; old: any; new: any; index: number }[] = [];
const maxLen = Math.max(oldData.length, newData.length); const maxLen = Math.max(oldData.length, newData.length);
@@ -88,8 +103,16 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
placeholder="Search history..." placeholder="Search history..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="pl-10 pr-4 py-2 bg-slate-800 border border-slate-700 rounded-md text-sm text-slate-200 focus:outline-none focus:border-blue-500 transition-colors w-64" className="pl-10 pr-10 py-2 bg-slate-800 border border-slate-700 rounded-md text-sm text-slate-200 focus:outline-none focus:border-blue-500 transition-colors w-64"
/> />
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div> </div>
<button <button
onClick={loadHistory} onClick={loadHistory}
@@ -156,10 +179,21 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) { if (onEdit) {
onRevert(entry.product_id, entry.old_data); const idx = data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === entry.product_id);
if (idx !== -1) onEdit(idx);
} }
}} }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors border border-blue-500/20"
>
<Edit2 className="w-4 h-4" />
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleRevert(entry);
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-orange-500/10 text-orange-400 hover:bg-orange-500/20 transition-colors border border-orange-500/20" className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-orange-500/10 text-orange-400 hover:bg-orange-500/20 transition-colors border border-orange-500/20"
> >
<RotateCcw className="w-4 h-4" /> <RotateCcw className="w-4 h-4" />
+15 -7
View File
@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { ExcelRow } from '../types'; import { ExcelRow } from '../types';
import { Search, Filter, ChevronDown, ChevronUp, X as XIcon } from 'lucide-react'; import { Search, Filter, ChevronDown, ChevronUp, X } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover'; import { ColumnFilterPopover } from './ColumnFilterPopover';
@@ -170,8 +170,16 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
placeholder="Search all columns..." placeholder="Search all columns..."
value={search} value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }} onChange={e => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-medium" className="w-full pl-9 pr-10 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-medium"
/> />
{search && (
<button
onClick={() => { setSearch(''); setPage(1); }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div> </div>
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && ( {(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
<button <button
@@ -181,7 +189,7 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
}} }}
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20" className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
> >
<XIcon className="w-4 h-4" /> <X className="w-4 h-4" />
Clear All Column Filters Clear All Column Filters
</button> </button>
)} )}
@@ -197,8 +205,8 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
key={index} key={index}
className="px-4 py-3 font-medium border-b border-slate-700 transition-colors select-none group relative" className="px-4 py-3 font-medium border-b border-slate-700 transition-colors select-none group relative"
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center overflow-hidden">
<div <span
className="flex items-center gap-1 cursor-pointer hover:text-white" className="flex items-center gap-1 cursor-pointer hover:text-white"
onClick={() => handleSort(index)} onClick={() => handleSort(index)}
> >
@@ -206,14 +214,14 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
{sortCol === index && ( {sortCol === index && (
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" /> sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
)} )}
</div> </span>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setOpenFilterCol(openFilterCol === index ? null : index); setOpenFilterCol(openFilterCol === index ? null : index);
}} }}
className={cn( className={cn(
"p-1 rounded hover:bg-slate-700 transition-colors", "p-0.5 rounded hover:bg-slate-700 transition-colors -my-1",
(columnFilters[index]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10 opacity-100" : "text-slate-500 opacity-0 group-hover:opacity-100" (columnFilters[index]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10 opacity-100" : "text-slate-500 opacity-0 group-hover:opacity-100"
)} )}
> >
+440
View File
@@ -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>
);
}
+10 -2
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter } from 'lucide-react'; import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter, X } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
interface PendingValidationViewProps { interface PendingValidationViewProps {
@@ -81,8 +81,16 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert
placeholder="Search by article no or name..." placeholder="Search by article no or name..."
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
className="bg-slate-800 border border-slate-700 rounded-md pl-9 pr-3 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-blue-500 w-64" className="bg-slate-800 border border-slate-700 rounded-md pl-9 pr-10 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-blue-500 w-64"
/> />
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div> </div>
</div> </div>
</div> </div>
+12 -3
View File
@@ -282,9 +282,18 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
className="w-full pl-4 pr-10 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all" className="w-full pl-4 pr-10 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
/> />
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500"> {search ? (
<Package className="w-4 h-4" /> <button
</div> 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> </div>
{/* ── Filter tabs ── */} {/* ── Filter tabs ── */}
+24 -16
View File
@@ -24,21 +24,21 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
const [licenseFilter, setLicenseFilter] = useState(''); const [licenseFilter, setLicenseFilter] = useState('');
const [sortCol, setSortCol] = useState<number | null>(null); const [sortCol, setSortCol] = useState<number | null>(null);
const [sortDesc, setSortDesc] = useState(false); const [sortDesc, setSortDesc] = useState(false);
const [pageSize, setPageSize] = useState(25); const [pageSize, setPageSize] = useState(100);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({}); const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null); const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({ const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
[COLUMNS.ARTICLE_NO]: 100, [COLUMNS.ARTICLE_NO]: 110,
[COLUMNS.ARTICLE_NAME]: 250, [COLUMNS.ARTICLE_NAME]: 450, // More flexible space for name
[COLUMNS.ASIN]: asinColumnIndex !== null ? 150 : 0, ...(asinColumnIndex !== null ? { [asinColumnIndex]: 100 } : {}),
[COLUMNS.LINE]: 80, [COLUMNS.LINE]: 80,
[COLUMNS.LICENSE]: 120, [COLUMNS.LICENSE]: 120,
[COLUMNS.CLASSIFICATION]: 100, [COLUMNS.CLASSIFICATION]: 100,
[COLUMNS.LONG_DE]: 80, [COLUMNS.LONG_DE]: 110,
[COLUMNS.LONG_EN]: 80, [COLUMNS.LONG_EN]: 110,
[COLUMNS.SHORT_DE]: 80, [COLUMNS.SHORT_DE]: 110,
[COLUMNS.SHORT_EN]: 80, [COLUMNS.SHORT_EN]: 110,
}); });
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]); const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
@@ -196,7 +196,7 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
}; };
const Badge = ({ content, row }: { content: any, row: ExcelRow }) => { const Badge = ({ content, row }: { content: any, row: ExcelRow }) => {
if (content) return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30"></span>; if (content !== undefined && content !== null && content !== '') return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30"></span>;
// EOL Exception: OOC and stock <= 0 // EOL Exception: OOC and stock <= 0
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim(); const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
@@ -246,8 +246,16 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
placeholder="Search Article Name or No..." placeholder="Search Article Name or No..."
value={search} value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }} onChange={e => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" className="w-full pl-9 pr-10 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/> />
{search && (
<button
onClick={() => { setSearch(''); setPage(1); }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div> </div>
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && ( {(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
<button <button
@@ -301,20 +309,20 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
className="px-4 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30" className="px-4 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }} style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
> >
<div className="flex items-center justify-between gap-1 overflow-hidden"> <div className="flex items-center overflow-hidden">
<div className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}> <span className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
{label} {label}
{sortCol === col && ( {sortCol === col && (
sortDesc ? <ChevronDown className="w-4 h-4 shrink-0" /> : <ChevronUp className="w-4 h-4 shrink-0" /> sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />
)} )}
</div> </span>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setOpenFilterCol(openFilterCol === col ? null : col); setOpenFilterCol(openFilterCol === col ? null : col);
}} }}
className={cn( className={cn(
"p-1 rounded hover:bg-slate-700 transition-colors shrink-0", "p-0.5 rounded hover:bg-slate-700 transition-colors -my-1",
(columnFilters[col]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100" (columnFilters[col]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
)} )}
> >
@@ -366,7 +374,7 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td> <td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
<td className="px-4 py-3 font-medium text-white truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td> <td className="px-4 py-3 font-medium text-white truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
{asinColumnIndex !== null && ( {asinColumnIndex !== null && (
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[asinColumnIndex] || 150 }} title={row[asinColumnIndex]}>{row[asinColumnIndex] || '—'}</td> <td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[asinColumnIndex] || 100 }} title={row[asinColumnIndex]}>{row[asinColumnIndex] || '—'}</td>
)} )}
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LINE] }}>{row[COLUMNS.LINE]}</td> <td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LINE] }}>{row[COLUMNS.LINE]}</td>
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LICENSE] }} title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td> <td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LICENSE] }} title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
+15 -7
View File
@@ -1,22 +1,30 @@
import React from 'react'; import React from 'react';
import { FileText, Table, Box, DollarSign, Package, Clock, History } from 'lucide-react'; import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
interface SidebarProps { interface SidebarProps {
activeModule: string; activeModule: string;
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history') => void; setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data') => void;
userEmail: string;
} }
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) { export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarProps) {
const navItems = [ const isMasterUser = userEmail?.toLowerCase() === 'christian.vidal@craze-group.com';
type ModuleId = 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data';
const navItems: { id: ModuleId; label: string; icon: React.ElementType }[] = [
{ id: 'matrix', label: 'Matrix', icon: Table }, { id: 'matrix', label: 'Matrix', icon: Table },
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText }, { id: 'descriptions', label: 'Product Descriptions', icon: FileText },
{ id: 'article_details', label: 'Article Details', icon: Package }, { id: 'article_details', label: 'Article Details', icon: Package },
{ id: 'dimensions', label: 'Dimensions', icon: Box }, { id: 'dimensions', label: 'Dimensions', icon: Box },
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign }, { id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
{ id: 'pending_validation', label: 'Pending Validation', icon: Clock }, { id: 'missing_data', label: 'Missing Data', icon: AlertTriangle },
{ id: 'history', label: 'Change History', icon: History }, ...(isMasterUser ? [
] as const; { id: 'pending_validation' as ModuleId, label: 'Pending Validation', icon: Clock },
{ id: 'history' as ModuleId, label: 'Change History', icon: History }
] : [])
];
return ( return (
<aside className="w-60 bg-slate-800 border-r border-slate-700 flex flex-col shrink-0"> <aside className="w-60 bg-slate-800 border-r border-slate-700 flex flex-col shrink-0">
+196 -155
View File
@@ -1,162 +1,19 @@
import { ExcelRow } from '../types';
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co'; const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
const SUPABASE_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv'; const SUPABASE_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
async function fetchWithRetry( export interface ExcelRow extends Array<any> {}
url: string,
options: RequestInit, export interface SyncedRow {
retries = 2, data: ExcelRow;
delayMs = 1000 status?: 'pending' | 'synced';
): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(url, options);
if (res.ok || res.status < 500 || attempt === retries) return res;
} catch (err) {
lastError = err as Error;
if (attempt === retries) throw lastError;
}
await new Promise(r => setTimeout(r, delayMs));
}
throw lastError ?? new Error('fetch failed');
} }
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow): Promise<boolean> { export async function getAllSyncedRows(token?: string): Promise<Record<string, SyncedRow>> {
try {
// Single upsert: POST with Prefer=resolution=merge-duplicates
// This handles both INSERT (new article) and UPDATE (existing) atomically.
// The old PATCH approach silently failed for new articles because Supabase
// returns 200 OK with an empty body when no rows match — indistinguishable
// from a successful update.
const response = await fetchWithRetry(`${SUPABASE_URL}/rest/v1/products_sync`, {
method: 'POST',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Content-Type': 'application/json',
'Prefer': 'resolution=merge-duplicates'
},
body: JSON.stringify({
id: articleNo,
data: rowData,
status_check: 'pending',
updated_at: new Date().toISOString()
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('Supabase upsert failed:', response.status, errorData);
return false;
}
return true;
} catch (error) {
console.error('Error saving to Supabase:', error);
return false;
}
}
export async function getAllSyncedRows(): Promise<Record<string, { data: ExcelRow, status: string }>> {
try {
// Explicit limit to avoid Supabase's default 1000-row cap
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_sync?select=id,data,status_check&limit=10000`,
{
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Range': '0-9999'
}
}
);
if (!response.ok) return {};
const data = await response.json();
const result: Record<string, { data: ExcelRow, status: string }> = {};
data.forEach((item: any) => {
result[item.id] = {
data: item.data,
status: item.status_check || 'original'
};
});
return result;
} catch (error) {
console.error('Error fetching from Supabase:', error);
return {};
}
}
export async function resetAllPendingRows(): Promise<boolean> {
try {
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?status_check=eq.pending`, {
method: 'PATCH',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
status_check: 'original',
updated_at: new Date().toISOString()
})
});
return response.ok;
} catch (error) {
console.error('Error resetting statuses in Supabase:', error);
return false;
}
}
export interface HistoryEntry {
id: string;
product_id: string;
article_name: string;
old_data: ExcelRow;
new_data: ExcelRow;
changed_at: string;
changed_by: string;
}
export async function saveHistoryEntry(
articleNo: string,
articleName: string,
oldData: ExcelRow,
newData: ExcelRow,
userEmail: string
): Promise<boolean> {
try {
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_history`, {
method: 'POST',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
product_id: articleNo,
article_name: articleName,
old_data: oldData,
new_data: newData,
changed_at: new Date().toISOString(),
changed_by: userEmail
})
});
return response.ok;
} catch (error) {
console.error('Error saving history to Supabase:', error);
return false;
}
}
export async function getHistory(): Promise<HistoryEntry[]> {
try { try {
const response = await fetch( const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.desc&limit=100`, `${SUPABASE_URL}/rest/v1/products?select=product_id,data,status&order=updated_at.desc`,
{ {
cache: 'no-store',
headers: { headers: {
'apikey': SUPABASE_KEY, 'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}` 'Authorization': `Bearer ${SUPABASE_KEY}`
@@ -164,10 +21,194 @@ export async function getHistory(): Promise<HistoryEntry[]> {
} }
); );
if (!response.ok) return []; if (!response.ok) {
return await response.json(); const errText = await response.text();
console.error('getAllSyncedRows failed:', response.status, errText);
return {};
}
const rows = await response.json();
const result: Record<string, SyncedRow> = {};
for (const row of rows) {
result[row.product_id] = { data: row.data, status: row.status };
}
return result;
} catch (error) { } catch (error) {
console.error('Error fetching history from Supabase:', error); console.error('Error fetching synced rows from Supabase:', error);
return []; return {};
} }
} }
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, token?: string): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Prefer': 'resolution=merge-duplicates'
},
body: JSON.stringify({
product_id: articleNo,
data: rowData,
status: 'synced',
updated_at: new Date().toISOString()
})
}
);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
return {
success: false,
error: `${response.status} ${response.statusText}: ${err.message || err.error_description || 'Unknown error'}`
};
}
return { success: true };
} catch (error: any) {
console.error('Error saving to Supabase:', error);
return { success: false, error: error.message || 'Network error' };
}
}
export interface HistoryEntry {
id?: number;
product_id: string;
article_name: string;
old_data: ExcelRow;
new_data: ExcelRow;
changed_by: string;
changed_at: string;
}
export async function saveHistoryEntry(
productId: string,
articleName: string,
oldData: ExcelRow,
newData: ExcelRow,
changedBy: string,
token?: string
): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_history`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Prefer': 'return=minimal'
},
body: JSON.stringify({
product_id: productId,
article_name: articleName,
old_data: oldData,
new_data: newData,
changed_by: changedBy,
changed_at: new Date().toISOString()
})
}
);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
return {
success: false,
error: `History ${response.status}: ${err.message || 'Unknown error'}`
};
}
return { success: true };
} catch (error: any) {
console.error('Error saving history to Supabase:', error);
return { success: false, error: error.message || 'Network error' };
}
}
export async function getHistory(token?: string): Promise<HistoryEntry[]> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.desc&limit=100`,
{
cache: 'no-store',
headers: {
'apikey': SUPABASE_KEY,
// Try using only the ANON key just in case RLS or token expiry is failing silently
'Authorization': `Bearer ${SUPABASE_KEY}`
}
}
);
if (!response.ok) {
const err = await response.text();
return [{
id: 'error-' + Date.now(),
product_id: 'ERROR',
article_name: `Failed: ${response.status} ${err}`,
old_data: [],
new_data: [],
changed_at: new Date().toISOString(),
changed_by: 'system'
}];
}
return await response.json();
} catch (error: any) {
console.error('Error fetching history:', error);
return [{
id: 'error-' + Date.now(),
product_id: 'EXCEPTION',
article_name: `Message: ${error.message}`,
old_data: [],
new_data: [],
changed_at: new Date().toISOString(),
changed_by: 'system'
}];
}
}
export async function deleteHistoryEntry(id: string, token?: string): Promise<boolean> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(id)}`,
{
method: 'DELETE',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`
}
}
);
return response.ok;
} catch (error) {
console.error('Error deleting history from Supabase:', error);
return false;
}
}
export async function resetAllPendingRows(token?: string): Promise<boolean> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products?status=eq.pending`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Prefer': 'return=minimal'
},
body: JSON.stringify({ status: 'synced' })
}
);
return response.ok;
} catch (error) {
console.error('Error resetting pending rows in Supabase:', error);
return false;
}
}
+74
View File
@@ -0,0 +1,74 @@
import { useState, useCallback, useRef, useEffect } from 'react';
interface UseSpeechRecognitionOptions {
onResult: (transcript: string) => void;
lang?: string;
}
interface UseSpeechRecognitionReturn {
isListening: boolean;
start: () => void;
stop: () => void;
isSupported: boolean;
}
export function useSpeechRecognition({ onResult, lang = 'en-US' }: UseSpeechRecognitionOptions): UseSpeechRecognitionReturn {
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<any>(null);
const isSupported = typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window);
useEffect(() => {
if (!isSupported) return;
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = lang;
recognition.onresult = (event: any) => {
let transcript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
if (event.results[event.resultIndex].isFinal) {
onResult(transcript);
}
};
recognition.onerror = () => {
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
};
recognitionRef.current = recognition;
return () => {
try {
recognition.stop();
} catch (e) {}
};
}, [isSupported, lang, onResult]);
const start = useCallback(() => {
if (!recognitionRef.current || isListening) return;
try {
recognitionRef.current.start();
setIsListening(true);
} catch (e) {}
}, [isListening]);
const stop = useCallback(() => {
if (!recognitionRef.current || !isListening) return;
try {
recognitionRef.current.stop();
setIsListening(false);
} catch (e) {}
}, [isListening]);
return { isListening, start, stop, isSupported };
}
+2 -1
View File
@@ -35,5 +35,6 @@ export const COLUMNS = {
INNER_H: 44, INNER_H: 44,
OUTER_W: 47, OUTER_W: 47,
OUTER_L: 48, OUTER_L: 48,
OUTER_H: 49 OUTER_H: 49,
VERIFIED_DIMS: 100
}; };
+2 -1
View File
@@ -1,5 +1,6 @@
{ {
"rewrites": [ "rewrites": [
{ "source": "/api/dropbox-proxy", "destination": "api/dropbox-proxy.js" } { "source": "/api/dropbox-proxy", "destination": "api/dropbox-proxy.js" },
{ "source": "/api/dropbox-sync", "destination": "api/dropbox-sync.js" }
] ]
} }