mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 11:55:23 +02:00
feat: implement manual user validation and user deletion flow
This commit is contained in:
@@ -3,6 +3,21 @@
|
|||||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
|
# Business Central OAuth client credentials
|
||||||
|
BC_TENANT_ID="MY_BC_TENANT_ID"
|
||||||
|
BC_CLIENT_ID="MY_BC_CLIENT_ID"
|
||||||
|
BC_CLIENT_SECRET="MY_BC_CLIENT_SECRET"
|
||||||
|
BC_COMPANY_ID="MY_BC_COMPANY_ID"
|
||||||
|
BC_WRITE_METHOD="PATCH"
|
||||||
|
BC_WRITE_URL_TEMPLATE="{{itemsUrl}}('{{itemNo}}')"
|
||||||
|
BC_WRITE_BODY_TEMPLATE='{"cpnpNo":"{{cpnpNo}}"}'
|
||||||
|
BC_ITEMS_WRITE_METHOD="PATCH"
|
||||||
|
BC_ITEMS_WRITE_URL_TEMPLATE="{{itemsUrl}}('{{itemNo}}')"
|
||||||
|
BC_ITEMS_WRITE_BODY_TEMPLATE=''
|
||||||
|
BC_UOM_WRITE_METHOD="PATCH"
|
||||||
|
BC_UOM_WRITE_URL_TEMPLATE="{{itemUnitsOfMeasureUrl}}('{{itemNo}}')"
|
||||||
|
BC_UOM_WRITE_BODY_TEMPLATE=''
|
||||||
|
|
||||||
# APP_URL: The URL where this applet is hosted.
|
# APP_URL: The URL where this applet is hosted.
|
||||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ coverage/
|
|||||||
agentdb.rvf*
|
agentdb.rvf*
|
||||||
ruvector.db
|
ruvector.db
|
||||||
claude-flow.config.json
|
claude-flow.config.json
|
||||||
|
scratch/
|
||||||
|
|||||||
@@ -16,5 +16,12 @@ View your app in AI Studio: https://ai.studio/apps/bac9908e-4996-4e4c-8ec7-3add5
|
|||||||
1. Install dependencies:
|
1. Install dependencies:
|
||||||
`npm install`
|
`npm install`
|
||||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
3. Run the app:
|
3. To use the Business Central download button, set:
|
||||||
|
`BC_TENANT_ID`, `BC_CLIENT_ID`, `BC_CLIENT_SECRET`, `BC_COMPANY_ID`
|
||||||
|
4. If your Business Central writable endpoint is not the default `PATCH`, also set:
|
||||||
|
`BC_WRITE_METHOD`, `BC_WRITE_URL_TEMPLATE`, `BC_WRITE_BODY_TEMPLATE`
|
||||||
|
5. For the safer BC sync preview/apply flow, you can also set:
|
||||||
|
`BC_ITEMS_WRITE_METHOD`, `BC_ITEMS_WRITE_URL_TEMPLATE`, `BC_ITEMS_WRITE_BODY_TEMPLATE`,
|
||||||
|
`BC_UOM_WRITE_METHOD`, `BC_UOM_WRITE_URL_TEMPLATE`, `BC_UOM_WRITE_BODY_TEMPLATE`
|
||||||
|
6. Run the app:
|
||||||
`npm run dev`
|
`npm run dev`
|
||||||
|
|||||||
+8
-5
@@ -1,5 +1,5 @@
|
|||||||
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||||
const SUPABASE_ANON_KEY = process.env.SUPABASE_SERVICE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||||
|
|
||||||
const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY;
|
const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY;
|
||||||
const DROPBOX_APP_SECRET = process.env.DROPBOX_APP_SECRET;
|
const DROPBOX_APP_SECRET = process.env.DROPBOX_APP_SECRET;
|
||||||
@@ -32,9 +32,12 @@ async function fetchSupabaseTable(table) {
|
|||||||
for (let page = 0; page < 50; page++) {
|
for (let page = 0; page < 50; page++) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${SUPABASE_URL}/rest/v1/${table}?select=*&limit=${PAGE_SIZE}&offset=${offset}`,
|
`${SUPABASE_URL}/rest/v1/${table}?select=*&limit=${PAGE_SIZE}&offset=${offset}`,
|
||||||
{ headers: { apikey: SUPABASE_ANON_KEY, Authorization: `Bearer ${SUPABASE_ANON_KEY}` } }
|
{ headers: { apikey: SUPABASE_SERVICE_KEY, Authorization: `Bearer ${SUPABASE_SERVICE_KEY}` } }
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error(`Supabase ${table} fetch failed: ${res.status}`);
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(`Supabase ${table} fetch failed (${res.status}): ${txt}`);
|
||||||
|
}
|
||||||
const batch = await res.json();
|
const batch = await res.json();
|
||||||
rows.push(...batch);
|
rows.push(...batch);
|
||||||
if (batch.length < PAGE_SIZE) break;
|
if (batch.length < PAGE_SIZE) break;
|
||||||
@@ -99,8 +102,8 @@ export default async function handler(req, res) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [syncedRows, history] = await Promise.all([
|
const [syncedRows, history] = await Promise.all([
|
||||||
fetchSupabaseTable('synced_rows'),
|
fetchSupabaseTable('products'),
|
||||||
fetchSupabaseTable('item_history'),
|
fetchSupabaseTable('products_history'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
const SUPABASE_URL = process.env.SUPABASE_URL || 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||||
|
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY;
|
||||||
|
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||||
|
const ALLOWED_ORIGIN = 'https://craze-data-check.vercel.app';
|
||||||
|
|
||||||
|
const MASTER_USERS = new Set([
|
||||||
|
'christian.vidal@craze-group.com',
|
||||||
|
'jingying.shi@craze-group.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function setCors(req, res) {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (origin === ALLOWED_ORIGIN || (origin && (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')))) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, apikey');
|
||||||
|
res.setHeader('Access-Control-Max-Age', '86400');
|
||||||
|
res.setHeader('Vary', 'Origin');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
setCors(req, res);
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.status(204).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Authenticate caller
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: Missing token' });
|
||||||
|
}
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_ANON_KEY,
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userRes.ok) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await userRes.json();
|
||||||
|
const callerEmail = user.email;
|
||||||
|
const callerId = user.id;
|
||||||
|
|
||||||
|
if (!callerEmail) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: Invalid user payload' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMaster = MASTER_USERS.has(callerEmail.toLowerCase());
|
||||||
|
const { action } = req.body;
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
return res.status(400).json({ error: 'Missing action' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Action: Check Validation Status ---
|
||||||
|
if (action === 'check-status') {
|
||||||
|
if (isMaster) {
|
||||||
|
return res.json({ validated: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvalsRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?id=eq.${callerId}&select=validated`, {
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!approvalsRes.ok) {
|
||||||
|
const errText = await approvalsRes.text();
|
||||||
|
console.error('Failed to query user approvals:', errText);
|
||||||
|
return res.status(500).json({ error: 'Failed to query database' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvals = await approvalsRes.json();
|
||||||
|
const isApproved = approvals.length > 0 && approvals[0].validated === true;
|
||||||
|
return res.json({ validated: isApproved });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Admin-only Actions ---
|
||||||
|
if (!isMaster) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: Admin access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'list') {
|
||||||
|
// Fetch all users from GoTrue Admin API
|
||||||
|
const usersRes = await fetch(`${SUPABASE_URL}/auth/v1/admin/users`, {
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!usersRes.ok) {
|
||||||
|
const errText = await usersRes.text();
|
||||||
|
console.error('Failed to fetch auth users:', errText);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch users from authentication' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersData = await usersRes.json();
|
||||||
|
const authUsers = usersData.users || [];
|
||||||
|
|
||||||
|
// Fetch validation mappings from public.user_approvals
|
||||||
|
const approvalsRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?select=*`, {
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!approvalsRes.ok) {
|
||||||
|
const errText = await approvalsRes.text();
|
||||||
|
console.error('Failed to fetch approvals:', errText);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch user approvals' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvals = await approvalsRes.json();
|
||||||
|
const approvalMap = new Map(approvals.map(a => [a.id, a.validated]));
|
||||||
|
|
||||||
|
const mergedUsers = authUsers.map(u => {
|
||||||
|
const email = u.email;
|
||||||
|
const id = u.id;
|
||||||
|
const createdAt = u.created_at;
|
||||||
|
|
||||||
|
let status = 'Pending';
|
||||||
|
if (MASTER_USERS.has(email?.toLowerCase())) {
|
||||||
|
status = 'Master';
|
||||||
|
} else if (approvalMap.has(id)) {
|
||||||
|
status = approvalMap.get(id) ? 'Validated' : 'Pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id, email, created_at: createdAt, status };
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ users: mergedUsers });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'validate') {
|
||||||
|
const { targetUserId, email, validated } = req.body;
|
||||||
|
if (!targetUserId || !email) {
|
||||||
|
return res.status(400).json({ error: 'Missing targetUserId or email' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert into user_approvals table
|
||||||
|
const upsertRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Prefer': 'resolution=merge-duplicates,return=representation'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: targetUserId,
|
||||||
|
email,
|
||||||
|
validated,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upsertRes.ok) {
|
||||||
|
const errText = await upsertRes.text();
|
||||||
|
console.error('Failed to upsert approval:', errText);
|
||||||
|
return res.status(500).json({ error: 'Failed to update approval status' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete') {
|
||||||
|
const { targetUserId } = req.body;
|
||||||
|
if (!targetUserId) {
|
||||||
|
return res.status(400).json({ error: 'Missing targetUserId' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent master user self-deletion via API
|
||||||
|
const { data: targetUserRes } = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${targetUserId}`, {
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||||
|
}
|
||||||
|
}).then(r => r.json().catch(() => ({})));
|
||||||
|
|
||||||
|
if (targetUserRes && MASTER_USERS.has(targetUserRes.email?.toLowerCase())) {
|
||||||
|
return res.status(400).json({ error: 'Cannot delete a master user account' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Delete user from auth
|
||||||
|
const deleteAuthRes = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${targetUserId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deleteAuthRes.ok) {
|
||||||
|
const errText = await deleteAuthRes.text();
|
||||||
|
console.error('Failed to delete auth user:', errText);
|
||||||
|
return res.status(500).json({ error: 'Failed to delete user from authentication' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete user approval record from public.user_approvals if exists
|
||||||
|
await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?id=eq.${targetUserId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(400).json({ error: 'Invalid action' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in users-admin function:', err);
|
||||||
|
return res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-5
@@ -133,12 +133,17 @@ function normalizeForComparison(field, value) {
|
|||||||
return '0';
|
return '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === null || value === undefined || value === '') return null;
|
|
||||||
|
|
||||||
if (strField.includes('date')) {
|
if (strField.includes('date')) {
|
||||||
return formatDateForBc(value);
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '0001-01-01';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedDate = formatDateForBc(value);
|
||||||
|
return normalizedDate === '0001-01-01' ? '0001-01-01' : normalizedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
return value.replace(/\r\n/g, '\n').trimEnd();
|
return value.replace(/\r\n/g, '\n').trimEnd();
|
||||||
}
|
}
|
||||||
@@ -286,6 +291,8 @@ function makePreviewSection({
|
|||||||
writeMethod,
|
writeMethod,
|
||||||
writeUrlTemplate,
|
writeUrlTemplate,
|
||||||
writeBodyTemplate,
|
writeBodyTemplate,
|
||||||
|
supported = true,
|
||||||
|
supportReason = null,
|
||||||
}) {
|
}) {
|
||||||
const changes = buildFieldChanges(fieldPreviews, currentRecord, desiredPayload);
|
const changes = buildFieldChanges(fieldPreviews, currentRecord, desiredPayload);
|
||||||
return {
|
return {
|
||||||
@@ -298,7 +305,9 @@ function makePreviewSection({
|
|||||||
writeMethod: writeMethod || null,
|
writeMethod: writeMethod || null,
|
||||||
writeUrlTemplate: writeUrlTemplate || null,
|
writeUrlTemplate: writeUrlTemplate || null,
|
||||||
writeBodyTemplate: writeBodyTemplate || null,
|
writeBodyTemplate: writeBodyTemplate || null,
|
||||||
canApply: Boolean(writeUrlTemplate),
|
canApply: Boolean(writeUrlTemplate) && supported,
|
||||||
|
supported,
|
||||||
|
supportReason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +319,7 @@ export function buildBusinessCentralSyncPreview(config, mapping, snapshot) {
|
|||||||
fieldPreviews: mapping.itemsFields,
|
fieldPreviews: mapping.itemsFields,
|
||||||
writeMethod: config.itemsWriteMethod || config.writeMethod,
|
writeMethod: config.itemsWriteMethod || config.writeMethod,
|
||||||
writeUrlTemplate: config.itemsWriteUrlTemplate || config.writeUrlTemplate,
|
writeUrlTemplate: config.itemsWriteUrlTemplate || config.writeUrlTemplate,
|
||||||
writeBodyTemplate: config.itemsWriteBodyTemplate || config.writeBodyTemplate || null,
|
writeBodyTemplate: config.itemsWriteBodyTemplate || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const itemUnitsSection = makePreviewSection({
|
const itemUnitsSection = makePreviewSection({
|
||||||
@@ -464,6 +473,10 @@ async function applyItemUnitsSection(config, token, snapshot, preview, context)
|
|||||||
return { applied: false, reason: 'No itemUnitsOfMeasure changes' };
|
return { applied: false, reason: 'No itemUnitsOfMeasure changes' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!preview.itemUnitsOfMeasure.supported) {
|
||||||
|
return { applied: false, reason: preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only' };
|
||||||
|
}
|
||||||
|
|
||||||
if (!config.itemUnitsWriteUrlTemplate) {
|
if (!config.itemUnitsWriteUrlTemplate) {
|
||||||
return { applied: false, reason: 'itemUnitsOfMeasure write template not configured' };
|
return { applied: false, reason: 'itemUnitsOfMeasure write template not configured' };
|
||||||
}
|
}
|
||||||
@@ -487,6 +500,13 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev
|
|||||||
const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
|
const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
|
||||||
const preview = buildBusinessCentralSyncPreview(config, mapping, snapshot);
|
const preview = buildBusinessCentralSyncPreview(config, mapping, snapshot);
|
||||||
|
|
||||||
|
const hasItemUnitsChanges = preview.itemUnitsOfMeasure.changes.some(change => change.changed);
|
||||||
|
if (hasItemUnitsChanges && !snapshot.itemUnitsOfMeasure) {
|
||||||
|
const error = new Error(`BC itemUnitsOfMeasure row missing for ${mapping.articleNo}. This BC API cannot update these fields until the row exists or BC exposes an upsert action.`);
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
if (previewToken && previewToken !== preview.previewToken) {
|
if (previewToken && previewToken !== preview.previewToken) {
|
||||||
const error = new Error('Preview token mismatch. BC data changed or preview is stale.');
|
const error = new Error('Preview token mismatch. BC data changed or preview is stale.');
|
||||||
error.statusCode = 409;
|
error.statusCode = 409;
|
||||||
@@ -551,6 +571,9 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev
|
|||||||
previewToken: preview.previewToken,
|
previewToken: preview.previewToken,
|
||||||
results,
|
results,
|
||||||
preview,
|
preview,
|
||||||
|
warning: preview.itemUnitsOfMeasure.changes.some(change => change.changed) && !preview.itemUnitsOfMeasure.supported
|
||||||
|
? (preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only')
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+81
-39
@@ -21,7 +21,11 @@ import { UndoToast } from './components/UndoToast';
|
|||||||
import { PendingValidationView } from './components/PendingValidationView';
|
import { PendingValidationView } from './components/PendingValidationView';
|
||||||
import { MissingDataView } from './components/MissingDataView';
|
import { MissingDataView } from './components/MissingDataView';
|
||||||
import { CosmeticItemsView } from './components/CosmeticItemsView';
|
import { CosmeticItemsView } from './components/CosmeticItemsView';
|
||||||
|
import { ControlDashboardView } from './components/ControlDashboardView';
|
||||||
|
import { UserManagementView } from './components/UserManagementView';
|
||||||
import { downloadBusinessCentralItemsExcel, previewBusinessCentralSync, applyBusinessCentralSync, isPreviewTokenMismatchError } from './services/businessCentral';
|
import { downloadBusinessCentralItemsExcel, previewBusinessCentralSync, applyBusinessCentralSync, isPreviewTokenMismatchError } from './services/businessCentral';
|
||||||
|
import { ControlTabId, type DashboardDrilldownRequest } from './lib/controlDashboard';
|
||||||
|
|
||||||
const FORCED_ZERO_STOCK_SKUS = new Set([
|
const FORCED_ZERO_STOCK_SKUS = new Set([
|
||||||
'11631VC', '1237VC', '1238VC', '1652VC', '1653VC', '1684VC', '1688VC', '1717VC',
|
'11631VC', '1237VC', '1238VC', '1652VC', '1653VC', '1684VC', '1688VC', '1717VC',
|
||||||
'1718VC', '180VC', '1832VC', '2025VC', '2027VC', '2180VC', '2181VC', '2210VC',
|
'1718VC', '180VC', '1832VC', '2025VC', '2027VC', '2180VC', '2181VC', '2210VC',
|
||||||
@@ -92,7 +96,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' | 'missing_data' | 'cosmetic_items'>('descriptions');
|
const [activeModule, setActiveModule] = useState<ControlTabId>('control_dashboard');
|
||||||
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);
|
||||||
@@ -104,11 +108,59 @@ export default function App() {
|
|||||||
const [isSyncingBC, setIsSyncingBC] = useState(false);
|
const [isSyncingBC, setIsSyncingBC] = useState(false);
|
||||||
const [isDownloadingBCExcel, setIsDownloadingBCExcel] = useState(false);
|
const [isDownloadingBCExcel, setIsDownloadingBCExcel] = useState(false);
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
const [dashboardDrilldown, setDashboardDrilldown] = useState<DashboardDrilldownRequest | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[App] session changed:', session ? 'logged in' : 'logged out');
|
console.log('[App] session changed:', session ? 'logged in' : 'logged out');
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
|
// Background user validation status check
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const checkUserStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users-admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${session.access_token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ action: 'check-status' })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn('[App] Validation check failed, logging out.');
|
||||||
|
handleSignOut();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.validated) {
|
||||||
|
alert('Tu usuario ya no está autorizado o está pendiente de validación.');
|
||||||
|
handleSignOut();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[App] Background validation check error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check immediately on mount/session change
|
||||||
|
checkUserStatus();
|
||||||
|
|
||||||
|
// Check periodically every 5 minutes
|
||||||
|
const interval = setInterval(checkUserStatus, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(BC_SYNC_QUEUE_STORAGE_KEY, JSON.stringify(bcSyncQueue));
|
localStorage.setItem(BC_SYNC_QUEUE_STORAGE_KEY, JSON.stringify(bcSyncQueue));
|
||||||
@@ -235,30 +287,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolvedCols = resolveColumnIndices(extendedHeaders);
|
const resolvedCols = resolveColumnIndices(extendedHeaders);
|
||||||
const editableColumns = new Set([
|
const articleNoIdx = resolvedCols.ARTICLE_NO;
|
||||||
resolvedCols.CLASSIFICATION,
|
|
||||||
resolvedCols.LONG_DE, resolvedCols.LONG_EN,
|
|
||||||
resolvedCols.SHORT_DE, resolvedCols.SHORT_EN,
|
|
||||||
resolvedCols.DETAILS_DE, resolvedCols.DETAILS_EN,
|
|
||||||
resolvedCols.INNER_L, resolvedCols.INNER_W, resolvedCols.INNER_H,
|
|
||||||
resolvedCols.OUTER_L, resolvedCols.OUTER_W, resolvedCols.OUTER_H,
|
|
||||||
resolvedCols.UNITS_OUTER, resolvedCols.MOQ,
|
|
||||||
resolvedCols.VERIFIED_DIMS,
|
|
||||||
resolvedCols.VALIDATED_CHECK,
|
|
||||||
resolvedCols.VALIDATED_NOTE,
|
|
||||||
resolvedCols.PRODUCT_TYPE,
|
|
||||||
resolvedCols.ITEM_TO_LOGISTIC,
|
|
||||||
resolvedCols.CPNP_NO,
|
|
||||||
]);
|
|
||||||
|
|
||||||
headers.forEach((h: any, i: number) => {
|
|
||||||
const hl = String(h || '').toLowerCase();
|
|
||||||
if (hl.includes('srp') || hl.includes('uvp') || hl.includes('40') || hl.includes('price')) {
|
|
||||||
editableColumns.add(i);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|
||||||
const processedRows = rows.map(row => {
|
const processedRows = rows.map(row => {
|
||||||
const articleNo = String(row[articleNoIdx]);
|
const articleNo = String(row[articleNoIdx]);
|
||||||
const synced = syncedData[articleNo];
|
const synced = syncedData[articleNo];
|
||||||
@@ -300,17 +329,6 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. For Master Data (indices < 100, like UVP/SRP), only restore if it's an active edit (pending)
|
|
||||||
// This protects against the "Column Shift" bug where old saved indices might be wrong.
|
|
||||||
if (synced?.status === 'pending' || synced?.status === 'edited') {
|
|
||||||
editableColumns.forEach(idx => {
|
|
||||||
const value = synced?.data?.[idx];
|
|
||||||
if (idx < 100 && value !== undefined && value !== null) {
|
|
||||||
finalRow[idx] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update row status in UI if it's not the default 'excel'
|
// Update row status in UI if it's not the default 'excel'
|
||||||
if (synced?.status && synced.status !== 'excel') {
|
if (synced?.status && synced.status !== 'excel') {
|
||||||
setRowStatuses(prev => ({ ...prev, [articleNo]: synced.status! }));
|
setRowStatuses(prev => ({ ...prev, [articleNo]: synced.status! }));
|
||||||
@@ -372,7 +390,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
asinColumnIndex: asinIdx !== -1 ? asinIdx : null
|
asinColumnIndex: asinIdx !== -1 ? asinIdx : null
|
||||||
});
|
});
|
||||||
setActiveModule('descriptions');
|
setActiveModule('control_dashboard');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load from Dropbox:', err);
|
console.error('Failed to load from Dropbox:', err);
|
||||||
@@ -465,7 +483,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
asinColumnIndex: null
|
asinColumnIndex: null
|
||||||
});
|
});
|
||||||
setActiveModule('descriptions');
|
setActiveModule('control_dashboard');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsBinaryString(file);
|
reader.readAsBinaryString(file);
|
||||||
@@ -694,7 +712,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDiscardSelectedBcQueue = () => {
|
const handleDiscardSelectedBcQueue = () => {
|
||||||
const selectedCount = Object.values(bcSyncQueue).filter(e => e.selected).length;
|
const selectedCount = (Object.values(bcSyncQueue) as BcSyncQueueEntry[]).filter(e => e.selected).length;
|
||||||
if (selectedCount === 0) return;
|
if (selectedCount === 0) return;
|
||||||
if (!window.confirm(`Discard ${selectedCount} pending BC sync(s)? They will be removed from the queue.`)) return;
|
if (!window.confirm(`Discard ${selectedCount} pending BC sync(s)? They will be removed from the queue.`)) return;
|
||||||
setBcSyncQueue(prev => {
|
setBcSyncQueue(prev => {
|
||||||
@@ -1055,6 +1073,21 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
asinColumnIndex={appState.asinColumnIndex}
|
asinColumnIndex={appState.asinColumnIndex}
|
||||||
onEdit={(index) => setEditingRowIndex(index)}
|
onEdit={(index) => setEditingRowIndex(index)}
|
||||||
rowStatuses={rowStatuses}
|
rowStatuses={rowStatuses}
|
||||||
|
dashboardDrilldown={dashboardDrilldown}
|
||||||
|
onDashboardDrilldownApplied={() => setDashboardDrilldown(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeModule === 'control_dashboard' && (
|
||||||
|
<ControlDashboardView
|
||||||
|
headers={appState.headers}
|
||||||
|
data={appState.data}
|
||||||
|
pendingRows={pendingRows}
|
||||||
|
rowStatuses={rowStatuses}
|
||||||
|
activeModule={activeModule}
|
||||||
|
onDrillDown={(request) => {
|
||||||
|
setDashboardDrilldown(request);
|
||||||
|
setActiveModule(request.tabId);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeModule === 'matrix' && (
|
{activeModule === 'matrix' && (
|
||||||
@@ -1081,6 +1114,8 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
onCaptureState={captureState}
|
onCaptureState={captureState}
|
||||||
onEdit={(index) => setEditingRowIndex(index)}
|
onEdit={(index) => setEditingRowIndex(index)}
|
||||||
rowStatuses={rowStatuses}
|
rowStatuses={rowStatuses}
|
||||||
|
dashboardDrilldown={dashboardDrilldown}
|
||||||
|
onDashboardDrilldownApplied={() => setDashboardDrilldown(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeModule === 'article_details' && (
|
{activeModule === 'article_details' && (
|
||||||
@@ -1088,6 +1123,8 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
data={appState.data}
|
data={appState.data}
|
||||||
onEdit={(index) => setEditingRowIndex(index)}
|
onEdit={(index) => setEditingRowIndex(index)}
|
||||||
rowStatuses={rowStatuses}
|
rowStatuses={rowStatuses}
|
||||||
|
dashboardDrilldown={dashboardDrilldown}
|
||||||
|
onDashboardDrilldownApplied={() => setDashboardDrilldown(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeModule === 'pending_validation' && (
|
{activeModule === 'pending_validation' && (
|
||||||
@@ -1115,6 +1152,8 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
onCaptureState={captureState}
|
onCaptureState={captureState}
|
||||||
rowStatuses={rowStatuses}
|
rowStatuses={rowStatuses}
|
||||||
onQueueBcSync={handleQueueBcSync}
|
onQueueBcSync={handleQueueBcSync}
|
||||||
|
dashboardDrilldown={dashboardDrilldown}
|
||||||
|
onDashboardDrilldownApplied={() => setDashboardDrilldown(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeModule === 'history' && (
|
{activeModule === 'history' && (
|
||||||
@@ -1150,6 +1189,9 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeModule === 'user_management' && session && (
|
||||||
|
<UserManagementView session={session} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { ExcelRow } from '../types';
|
import { ExcelRow } from '../types';
|
||||||
import { useColumns } from '../contexts/ColumnsContext';
|
import { useColumns } from '../contexts/ColumnsContext';
|
||||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X, Maximize2 } from 'lucide-react';
|
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X, Maximize2 } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
import { SyncStatusPill } from './SyncStatusPill';
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
import { type DashboardDrilldownRequest } from '../lib/controlDashboard';
|
||||||
|
|
||||||
interface ArticleDetailsProps {
|
interface ArticleDetailsProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
rowStatuses: Record<string, string>;
|
rowStatuses: Record<string, string>;
|
||||||
|
dashboardDrilldown?: DashboardDrilldownRequest | null;
|
||||||
|
onDashboardDrilldownApplied?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
|
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
|
||||||
|
|
||||||
export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProps) {
|
export function ArticleDetails({ data, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: ArticleDetailsProps) {
|
||||||
const COLUMNS = useColumns();
|
const COLUMNS = useColumns();
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -35,6 +38,18 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
[COLUMNS.DETAILS_EN]: 150,
|
[COLUMNS.DETAILS_EN]: 150,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'article_details') return;
|
||||||
|
|
||||||
|
const focus = dashboardDrilldown.focus as TabType;
|
||||||
|
setActiveTab(focus);
|
||||||
|
setSearch('');
|
||||||
|
setLineFilter('');
|
||||||
|
setColumnFilters({});
|
||||||
|
setPage(1);
|
||||||
|
onDashboardDrilldownApplied?.();
|
||||||
|
}, [dashboardDrilldown?.id, dashboardDrilldown?.tabId, dashboardDrilldown?.focus, onDashboardDrilldownApplied]);
|
||||||
|
|
||||||
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
|
|||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ArrowDownRight, ArrowUpRight, LayoutDashboard, RefreshCw } from 'lucide-react';
|
||||||
|
import { ExcelRow } from '../types';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import {
|
||||||
|
ArticleDetailsDashboardSnapshot,
|
||||||
|
CosmeticDashboardSnapshot,
|
||||||
|
DashboardDrilldownRequest,
|
||||||
|
DescriptionsDashboardSnapshot,
|
||||||
|
PendingRowInfo,
|
||||||
|
SnapshotStore,
|
||||||
|
computeArticleDetailsDashboardSnapshot,
|
||||||
|
computeCosmeticDashboardSnapshot,
|
||||||
|
computeDescriptionsDashboardSnapshot,
|
||||||
|
computePricingDashboardSnapshot,
|
||||||
|
ensureDailyDashboardSnapshot,
|
||||||
|
getDaysAgoKey,
|
||||||
|
loadDashboardSnapshots,
|
||||||
|
} from '../lib/controlDashboard';
|
||||||
|
|
||||||
|
interface ControlDashboardViewProps {
|
||||||
|
headers: string[];
|
||||||
|
data: ExcelRow[];
|
||||||
|
pendingRows: Record<string, PendingRowInfo>;
|
||||||
|
rowStatuses: Record<string, string>;
|
||||||
|
activeModule?: string;
|
||||||
|
onOpenTab?: (tabId: string) => void;
|
||||||
|
onDrillDown?: (request: DashboardDrilldownRequest) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricKey = keyof Pick<DescriptionsDashboardSnapshot, 'ok' | 'longDeMissing' | 'longEnMissing' | 'shortDeMissing' | 'shortEnMissing'>;
|
||||||
|
type ArticleMetricKey = keyof Pick<ArticleDetailsDashboardSnapshot, 'ok' | 'detailsDeMissing' | 'detailsEnMissing'>;
|
||||||
|
type CosmeticMetricKey = keyof Pick<CosmeticDashboardSnapshot, 'ok' | 'cpnpMissing'>;
|
||||||
|
type PricingMetricKey = 'ok' | 'itemToLogisticMissing' | 'uvpMissing' | 'srpIntMissing' | 'srpUkMissing' | 'unitsOuterMissing' | 'outerWMissing' | 'outerLMissing' | 'outerHMissing' | 'units40fMissing' | 'moqMissing' | 'weightIssues';
|
||||||
|
type SnapshotKey = MetricKey | ArticleMetricKey | CosmeticMetricKey | PricingMetricKey;
|
||||||
|
|
||||||
|
const METRICS: Array<{
|
||||||
|
key: SnapshotKey;
|
||||||
|
label: string;
|
||||||
|
toneClass: string;
|
||||||
|
positiveIsGood: boolean;
|
||||||
|
drilldownFocus: string;
|
||||||
|
}> = [
|
||||||
|
{ key: 'ok', label: 'All OK', toneClass: 'border-emerald-400/30 bg-emerald-400/15 text-emerald-100', positiveIsGood: true, drilldownFocus: 'complete' },
|
||||||
|
{ key: 'longDeMissing', label: 'Missing Long DE', toneClass: 'border-sky-400/30 bg-sky-400/15 text-sky-100', positiveIsGood: false, drilldownFocus: 'missingLongDE' },
|
||||||
|
{ key: 'longEnMissing', label: 'Missing Long EN', toneClass: 'border-indigo-400/30 bg-indigo-400/15 text-indigo-100', positiveIsGood: false, drilldownFocus: 'missingLongEN' },
|
||||||
|
{ key: 'shortDeMissing', label: 'Missing Short DE', toneClass: 'border-fuchsia-400/30 bg-fuchsia-400/15 text-fuchsia-100', positiveIsGood: false, drilldownFocus: 'missingShortDE' },
|
||||||
|
{ key: 'shortEnMissing', label: 'Missing Short EN', toneClass: 'border-rose-400/30 bg-rose-400/15 text-rose-100', positiveIsGood: false, drilldownFocus: 'missingShortEN' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ARTICLE_METRICS: Array<{
|
||||||
|
key: ArticleMetricKey;
|
||||||
|
label: string;
|
||||||
|
toneClass: string;
|
||||||
|
positiveIsGood: boolean;
|
||||||
|
drilldownFocus: string;
|
||||||
|
}> = [
|
||||||
|
{ key: 'ok', label: 'All OK', toneClass: 'border-indigo-400/30 bg-indigo-400/15 text-indigo-100', positiveIsGood: true, drilldownFocus: 'all' },
|
||||||
|
{ key: 'detailsDeMissing', label: 'Missing Details DE', toneClass: 'border-cyan-400/30 bg-cyan-400/15 text-cyan-100', positiveIsGood: false, drilldownFocus: 'missingDetailsDE' },
|
||||||
|
{ key: 'detailsEnMissing', label: 'Missing Details EN', toneClass: 'border-amber-400/30 bg-amber-400/15 text-amber-100', positiveIsGood: false, drilldownFocus: 'missingDetailsEN' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRICING_METRICS: Array<{
|
||||||
|
key: PricingMetricKey;
|
||||||
|
label: string;
|
||||||
|
toneClass: string;
|
||||||
|
positiveIsGood: boolean;
|
||||||
|
drilldownFocus: string;
|
||||||
|
}> = [
|
||||||
|
{ key: 'ok', label: 'All OK', toneClass: 'border-amber-400/30 bg-amber-400/15 text-amber-100', positiveIsGood: true, drilldownFocus: 'all' },
|
||||||
|
{ key: 'itemToLogisticMissing', label: 'Missing Item to Logistic', toneClass: 'border-fuchsia-400/30 bg-fuchsia-400/15 text-fuchsia-100', positiveIsGood: false, drilldownFocus: 'itemToLogisticMissing' },
|
||||||
|
{ key: 'uvpMissing', label: 'Missing UVP (€)', toneClass: 'border-emerald-400/30 bg-emerald-400/15 text-emerald-100', positiveIsGood: false, drilldownFocus: 'uvpMissing' },
|
||||||
|
{ key: 'srpIntMissing', label: 'Missing SRP INT', toneClass: 'border-indigo-400/30 bg-indigo-400/15 text-indigo-100', positiveIsGood: false, drilldownFocus: 'srpIntMissing' },
|
||||||
|
{ key: 'srpUkMissing', label: 'Missing SRP UK (£)', toneClass: 'border-cyan-400/30 bg-cyan-400/15 text-cyan-100', positiveIsGood: false, drilldownFocus: 'srpUkMissing' },
|
||||||
|
{ key: 'unitsOuterMissing', label: 'Missing Units/Outer', toneClass: 'border-sky-400/30 bg-sky-400/15 text-sky-100', positiveIsGood: false, drilldownFocus: 'unitsOuterMissing' },
|
||||||
|
{ key: 'outerWMissing', label: 'Missing Outer W', toneClass: 'border-violet-400/30 bg-violet-400/15 text-violet-100', positiveIsGood: false, drilldownFocus: 'outerWMissing' },
|
||||||
|
{ key: 'outerLMissing', label: 'Missing Outer L', toneClass: 'border-rose-400/30 bg-rose-400/15 text-rose-100', positiveIsGood: false, drilldownFocus: 'outerLMissing' },
|
||||||
|
{ key: 'outerHMissing', label: 'Missing Outer H', toneClass: 'border-pink-400/30 bg-pink-400/15 text-pink-100', positiveIsGood: false, drilldownFocus: 'outerHMissing' },
|
||||||
|
{ key: 'units40fMissing', label: 'Missing Units 40F', toneClass: 'border-teal-400/30 bg-teal-400/15 text-teal-100', positiveIsGood: false, drilldownFocus: 'units40fMissing' },
|
||||||
|
{ key: 'moqMissing', label: 'Missing MOQ', toneClass: 'border-orange-400/30 bg-orange-400/15 text-orange-100', positiveIsGood: false, drilldownFocus: 'moqMissing' },
|
||||||
|
{ key: 'weightIssues', label: 'Weight Issues', toneClass: 'border-red-400/30 bg-red-400/15 text-red-100', positiveIsGood: false, drilldownFocus: 'weightIssues' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COSMETIC_METRICS: Array<{
|
||||||
|
key: CosmeticMetricKey;
|
||||||
|
label: string;
|
||||||
|
toneClass: string;
|
||||||
|
positiveIsGood: boolean;
|
||||||
|
drilldownFocus: string;
|
||||||
|
}> = [
|
||||||
|
{ key: 'ok', label: 'CPNP present', toneClass: 'border-fuchsia-400/30 bg-fuchsia-400/15 text-fuchsia-100', positiveIsGood: true, drilldownFocus: 'present' },
|
||||||
|
{ key: 'cpnpMissing', label: 'CPNP missing', toneClass: 'border-rose-400/30 bg-rose-400/15 text-rose-100', positiveIsGood: false, drilldownFocus: 'missing' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return new Intl.NumberFormat('en-GB').format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDelta(current: number, historical: number, positiveIsGood: boolean): { text: string; className: string } {
|
||||||
|
const delta = current - historical;
|
||||||
|
|
||||||
|
if (delta === 0) {
|
||||||
|
return { text: '0 change', className: 'text-slate-400' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (positiveIsGood) {
|
||||||
|
return delta > 0
|
||||||
|
? { text: `↑${delta} improved`, className: 'text-emerald-400' }
|
||||||
|
: { text: `↓${Math.abs(delta)} worse`, className: 'text-rose-400' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta < 0
|
||||||
|
? { text: `↓${Math.abs(delta)} resolved`, className: 'text-emerald-400' }
|
||||||
|
: { text: `↑${delta} new`, className: 'text-rose-400' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricTile({
|
||||||
|
label,
|
||||||
|
current,
|
||||||
|
historical,
|
||||||
|
toneClass,
|
||||||
|
positiveIsGood,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
current: number;
|
||||||
|
historical?: number;
|
||||||
|
toneClass: string;
|
||||||
|
positiveIsGood: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
const delta = historical === undefined ? null : formatDelta(current, historical, positiveIsGood);
|
||||||
|
const tileClassName = cn(
|
||||||
|
'rounded-2xl border p-3 shadow-inner shadow-black/20 transition-all duration-200 bg-slate-950/70 text-left',
|
||||||
|
onClick && 'cursor-pointer hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300/70',
|
||||||
|
toneClass
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
onClick ? (
|
||||||
|
<button type="button" onClick={onClick} className={tileClassName}>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.16em] leading-none text-slate-400">{label}</p>
|
||||||
|
<div className="mt-2.5 flex items-end justify-between gap-2.5">
|
||||||
|
<div className="text-2xl font-semibold text-white tabular-nums leading-none">
|
||||||
|
{formatNumber(current)}
|
||||||
|
</div>
|
||||||
|
{historical === undefined ? (
|
||||||
|
<div className="text-right text-[10px] text-slate-500 leading-tight">
|
||||||
|
No historical data available
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-slate-500">7 days ago</div>
|
||||||
|
<div className="text-xs font-medium text-slate-300 tabular-nums">
|
||||||
|
{formatNumber(historical)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{delta && (
|
||||||
|
<div className={cn('mt-1.5 text-[11px] font-semibold', delta.className)}>
|
||||||
|
{delta.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className={tileClassName}>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.16em] leading-none text-slate-400">{label}</p>
|
||||||
|
<div className="mt-2.5 flex items-end justify-between gap-2.5">
|
||||||
|
<div className="text-2xl font-semibold text-white tabular-nums leading-none">
|
||||||
|
{formatNumber(current)}
|
||||||
|
</div>
|
||||||
|
{historical === undefined ? (
|
||||||
|
<div className="text-right text-[10px] text-slate-500 leading-tight">
|
||||||
|
No historical data available
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-slate-500">7 days ago</div>
|
||||||
|
<div className="text-xs font-medium text-slate-300 tabular-nums">
|
||||||
|
{formatNumber(historical)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{delta && (
|
||||||
|
<div className={cn('mt-1.5 text-[11px] font-semibold', delta.className)}>
|
||||||
|
{delta.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardSnapshotLike = {
|
||||||
|
total: number;
|
||||||
|
ok: number;
|
||||||
|
[key: string]: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DashboardCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
accentClass,
|
||||||
|
accentBarClass,
|
||||||
|
badgeClass,
|
||||||
|
badgeLabel,
|
||||||
|
titleClass,
|
||||||
|
current,
|
||||||
|
historical,
|
||||||
|
metrics,
|
||||||
|
metricGridClassName,
|
||||||
|
tabId,
|
||||||
|
onDrillDown,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
accentClass: string;
|
||||||
|
accentBarClass: string;
|
||||||
|
badgeClass: string;
|
||||||
|
badgeLabel: string;
|
||||||
|
titleClass: string;
|
||||||
|
current: DashboardSnapshotLike;
|
||||||
|
historical?: DashboardSnapshotLike;
|
||||||
|
metricGridClassName?: string;
|
||||||
|
metrics: Array<{
|
||||||
|
key: SnapshotKey;
|
||||||
|
label: string;
|
||||||
|
toneClass: string;
|
||||||
|
positiveIsGood: boolean;
|
||||||
|
drilldownFocus: string;
|
||||||
|
}>;
|
||||||
|
tabId: DashboardDrilldownRequest['tabId'];
|
||||||
|
onDrillDown?: (request: DashboardDrilldownRequest) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={cn(
|
||||||
|
'rounded-3xl border bg-gradient-to-br from-slate-900 via-slate-950 to-black shadow-2xl backdrop-blur-sm overflow-hidden',
|
||||||
|
accentClass
|
||||||
|
)}>
|
||||||
|
<div className={cn('h-1 w-full opacity-100 shadow-[0_0_18px_rgba(255,255,255,0.18)]', accentBarClass)} />
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-slate-700/80 bg-black/20 px-4 py-3.5">
|
||||||
|
<div>
|
||||||
|
<div className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-semibold uppercase tracking-[0.22em]', badgeClass)}>
|
||||||
|
{badgeLabel}
|
||||||
|
</div>
|
||||||
|
<h2 className={cn('mt-2 text-base font-semibold', titleClass)}>{title}</h2>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-300">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<span className="rounded-full border border-slate-600/70 bg-slate-800/80 px-2.5 py-1 text-[10px] font-medium text-slate-200">
|
||||||
|
Total {formatNumber(current.total)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] font-semibold text-emerald-200">
|
||||||
|
All OK {formatNumber(current.ok)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn('grid gap-2.5 px-4 py-4 sm:grid-cols-2 lg:grid-cols-3', metricGridClassName)}>
|
||||||
|
{metrics.map(metric => (
|
||||||
|
<React.Fragment key={metric.key}>
|
||||||
|
<MetricTile
|
||||||
|
label={metric.label}
|
||||||
|
current={current[metric.key]}
|
||||||
|
historical={historical?.[metric.key]}
|
||||||
|
toneClass={metric.toneClass}
|
||||||
|
positiveIsGood={metric.positiveIsGood}
|
||||||
|
onClick={onDrillDown ? () => onDrillDown({
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
tabId: tabId as DashboardDrilldownRequest['tabId'],
|
||||||
|
focus: metric.drilldownFocus,
|
||||||
|
}) : undefined}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-700/70 bg-black/20 px-4 py-3 text-[11px] text-slate-300">
|
||||||
|
{historical ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<ArrowUpRight className="h-3 w-3 text-emerald-400" />
|
||||||
|
Improvements are shown in green
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<ArrowDownRight className="h-3 w-3 text-rose-400" />
|
||||||
|
Regressions are shown in red
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>No historical data available</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ControlDashboardView({
|
||||||
|
headers,
|
||||||
|
data,
|
||||||
|
pendingRows,
|
||||||
|
rowStatuses,
|
||||||
|
onDrillDown,
|
||||||
|
}: ControlDashboardViewProps) {
|
||||||
|
const [dashboardSnapshots, setDashboardSnapshots] = useState<SnapshotStore>({});
|
||||||
|
const [snapshotsLoading, setSnapshotsLoading] = useState(true);
|
||||||
|
|
||||||
|
const currentSnapshot = useMemo(() => {
|
||||||
|
return computeDescriptionsDashboardSnapshot(headers, {
|
||||||
|
data,
|
||||||
|
pendingRows,
|
||||||
|
rowStatuses,
|
||||||
|
historyEntries: [],
|
||||||
|
});
|
||||||
|
}, [headers, data, pendingRows, rowStatuses]);
|
||||||
|
|
||||||
|
const currentArticleSnapshot = useMemo(() => {
|
||||||
|
return computeArticleDetailsDashboardSnapshot(headers, {
|
||||||
|
data,
|
||||||
|
pendingRows,
|
||||||
|
rowStatuses,
|
||||||
|
historyEntries: [],
|
||||||
|
});
|
||||||
|
}, [headers, data, pendingRows, rowStatuses]);
|
||||||
|
|
||||||
|
const currentPricingSnapshot = useMemo(() => {
|
||||||
|
return computePricingDashboardSnapshot(headers, {
|
||||||
|
data,
|
||||||
|
pendingRows,
|
||||||
|
rowStatuses,
|
||||||
|
historyEntries: [],
|
||||||
|
});
|
||||||
|
}, [headers, data, pendingRows, rowStatuses]);
|
||||||
|
|
||||||
|
const currentCosmeticSnapshot = useMemo(() => {
|
||||||
|
return computeCosmeticDashboardSnapshot(headers, {
|
||||||
|
data,
|
||||||
|
pendingRows,
|
||||||
|
rowStatuses,
|
||||||
|
historyEntries: [],
|
||||||
|
});
|
||||||
|
}, [headers, data, pendingRows, rowStatuses]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const loadSnapshots = async () => {
|
||||||
|
setSnapshotsLoading(true);
|
||||||
|
try {
|
||||||
|
const store = await loadDashboardSnapshots();
|
||||||
|
if (!cancelled) {
|
||||||
|
setDashboardSnapshots(store);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSnapshotsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSnapshots();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (snapshotsLoading) return;
|
||||||
|
void ensureDailyDashboardSnapshot(new Date(), {
|
||||||
|
descriptions: currentSnapshot,
|
||||||
|
articleDetails: currentArticleSnapshot,
|
||||||
|
pricing: currentPricingSnapshot,
|
||||||
|
cosmeticItems: currentCosmeticSnapshot,
|
||||||
|
});
|
||||||
|
}, [snapshotsLoading, currentSnapshot, currentArticleSnapshot, currentPricingSnapshot, currentCosmeticSnapshot]);
|
||||||
|
|
||||||
|
const historicalSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.descriptions;
|
||||||
|
const historicalArticleSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.articleDetails;
|
||||||
|
const historicalPricingSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.pricing;
|
||||||
|
const historicalCosmeticSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.cosmeticItems;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-full gap-4 overflow-auto xl:grid-cols-2">
|
||||||
|
<div className="xl:col-span-2 flex items-center justify-between gap-4 rounded-3xl border border-slate-700/60 bg-slate-900/80 px-4 py-3.5 shadow-xl">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-slate-300">
|
||||||
|
<LayoutDashboard className="h-4 w-4 text-blue-400" />
|
||||||
|
<h1 className="text-lg font-semibold text-white">Control Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
|
Compact overview of Product Descriptions, Article Details and Pricing & Units.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-slate-600/70 bg-slate-800/80 px-3 py-2 text-[11px] font-medium text-slate-200 hover:border-slate-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DashboardCard
|
||||||
|
title="Product Descriptions"
|
||||||
|
subtitle="Long DE, Long EN, Short DE and Short EN."
|
||||||
|
accentClass="border-emerald-400/40 bg-emerald-500/8 shadow-[0_0_0_1px_rgba(52,211,153,0.12)]"
|
||||||
|
accentBarClass="bg-gradient-to-r from-emerald-300 via-lime-300 to-cyan-300"
|
||||||
|
badgeClass="bg-emerald-500/20 text-emerald-100 ring-1 ring-emerald-300/30"
|
||||||
|
badgeLabel="Descriptions"
|
||||||
|
titleClass="text-emerald-100"
|
||||||
|
current={currentSnapshot}
|
||||||
|
historical={historicalSnapshot}
|
||||||
|
metrics={METRICS}
|
||||||
|
metricGridClassName="sm:grid-cols-2 lg:grid-cols-3"
|
||||||
|
tabId="descriptions"
|
||||||
|
onDrillDown={onDrillDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DashboardCard
|
||||||
|
title="Article Details"
|
||||||
|
subtitle="DETAILS DE and DETAILS EN."
|
||||||
|
accentClass="border-indigo-400/40 bg-indigo-500/12 shadow-[0_0_0_1px_rgba(129,140,248,0.12)]"
|
||||||
|
accentBarClass="bg-gradient-to-r from-indigo-300 via-violet-300 to-cyan-300"
|
||||||
|
badgeClass="bg-indigo-500/20 text-indigo-100 ring-1 ring-indigo-300/30"
|
||||||
|
badgeLabel="Details"
|
||||||
|
titleClass="text-indigo-100"
|
||||||
|
current={currentArticleSnapshot}
|
||||||
|
historical={historicalArticleSnapshot}
|
||||||
|
metrics={ARTICLE_METRICS}
|
||||||
|
metricGridClassName="sm:grid-cols-2 lg:grid-cols-3"
|
||||||
|
tabId="article_details"
|
||||||
|
onDrillDown={onDrillDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DashboardCard
|
||||||
|
title="Pricing & Units"
|
||||||
|
subtitle="Item to Logistic, pricing, Units 40F and Weight Issues."
|
||||||
|
accentClass="border-amber-400/40 bg-amber-500/12 shadow-[0_0_0_1px_rgba(251,191,36,0.12)]"
|
||||||
|
accentBarClass="bg-gradient-to-r from-amber-300 via-orange-300 to-red-300"
|
||||||
|
badgeClass="bg-amber-500/20 text-amber-100 ring-1 ring-amber-300/30"
|
||||||
|
badgeLabel="Pricing"
|
||||||
|
titleClass="text-amber-100"
|
||||||
|
current={currentPricingSnapshot}
|
||||||
|
historical={historicalPricingSnapshot}
|
||||||
|
metrics={PRICING_METRICS}
|
||||||
|
metricGridClassName="sm:grid-cols-2 xl:grid-cols-4"
|
||||||
|
tabId="pricing"
|
||||||
|
onDrillDown={onDrillDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DashboardCard
|
||||||
|
title="Cosmetic Items"
|
||||||
|
subtitle="CPNP present vs missing, with 7-day evolution."
|
||||||
|
accentClass="border-fuchsia-400/40 bg-fuchsia-500/12 shadow-[0_0_0_1px_rgba(232,121,249,0.12)]"
|
||||||
|
accentBarClass="bg-gradient-to-r from-fuchsia-300 via-pink-300 to-rose-300"
|
||||||
|
badgeClass="bg-fuchsia-500/20 text-fuchsia-100 ring-1 ring-fuchsia-300/30"
|
||||||
|
badgeLabel="Cosmetic"
|
||||||
|
titleClass="text-fuchsia-100"
|
||||||
|
current={currentCosmeticSnapshot}
|
||||||
|
historical={historicalCosmeticSnapshot}
|
||||||
|
metrics={COSMETIC_METRICS}
|
||||||
|
metricGridClassName="grid-cols-1 sm:grid-cols-2"
|
||||||
|
tabId="cosmetic_items"
|
||||||
|
onDrillDown={onDrillDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { ColumnFilterPopover } from './ColumnFilterPopover';
|
|||||||
import { usePersistentState } from '../contexts/FilterContext';
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
import { saveRowToSupabase } from '../lib/supabase';
|
import { saveRowToSupabase } from '../lib/supabase';
|
||||||
import { SyncStatusPill } from './SyncStatusPill';
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
import { type DashboardDrilldownRequest } from '../lib/controlDashboard';
|
||||||
|
|
||||||
const COSMETIC_LINES = ['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS'];
|
const COSMETIC_LINES = ['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS'];
|
||||||
|
|
||||||
@@ -17,9 +18,11 @@ interface CosmeticItemsViewProps {
|
|||||||
onCaptureState: (message: string) => void;
|
onCaptureState: (message: string) => void;
|
||||||
rowStatuses: Record<string, string>;
|
rowStatuses: Record<string, string>;
|
||||||
onQueueBcSync: (articleNo: string, rowIndex: number, originalData: ExcelRow, newData: ExcelRow, articleName: string) => void;
|
onQueueBcSync: (articleNo: string, rowIndex: number, originalData: ExcelRow, newData: ExcelRow, articleName: string) => void;
|
||||||
|
dashboardDrilldown?: DashboardDrilldownRequest | null;
|
||||||
|
onDashboardDrilldownApplied?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, rowStatuses, onQueueBcSync }: CosmeticItemsViewProps) {
|
export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, rowStatuses, onQueueBcSync, dashboardDrilldown, onDashboardDrilldownApplied }: CosmeticItemsViewProps) {
|
||||||
const COLUMNS = useColumns();
|
const COLUMNS = useColumns();
|
||||||
const [search, setSearch] = usePersistentState('cosmeticItems-search', '');
|
const [search, setSearch] = usePersistentState('cosmeticItems-search', '');
|
||||||
const [sortCol, setSortCol] = usePersistentState<number | null>('cosmeticItems-sortCol', null);
|
const [sortCol, setSortCol] = usePersistentState<number | null>('cosmeticItems-sortCol', null);
|
||||||
@@ -29,12 +32,24 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
|
|||||||
const [openFilter, setOpenFilter] = useState<number | null>(null);
|
const [openFilter, setOpenFilter] = useState<number | null>(null);
|
||||||
const [editingCpnp, setEditingCpnp] = useState<{ rowIndex: number; value: string } | null>(null);
|
const [editingCpnp, setEditingCpnp] = useState<{ rowIndex: number; value: string } | null>(null);
|
||||||
const [savingCpnp, setSavingCpnp] = useState<number | null>(null);
|
const [savingCpnp, setSavingCpnp] = useState<number | null>(null);
|
||||||
|
const [dashboardFocus, setDashboardFocus] = useState<'present' | 'missing' | null>(null);
|
||||||
const cpnpInputRef = useRef<HTMLInputElement>(null);
|
const cpnpInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingCpnp !== null) cpnpInputRef.current?.focus();
|
if (editingCpnp !== null) cpnpInputRef.current?.focus();
|
||||||
}, [editingCpnp]);
|
}, [editingCpnp]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'cosmetic_items') return;
|
||||||
|
|
||||||
|
const focus = dashboardDrilldown.focus === 'missing' ? 'missing' : 'present';
|
||||||
|
setDashboardFocus(focus);
|
||||||
|
setSearch('');
|
||||||
|
setColumnFilters({});
|
||||||
|
setPage(1);
|
||||||
|
onDashboardDrilldownApplied?.();
|
||||||
|
}, [dashboardDrilldown?.id, dashboardDrilldown?.tabId, dashboardDrilldown?.focus, onDashboardDrilldownApplied, setSearch, setColumnFilters]);
|
||||||
|
|
||||||
const pageSize = 100;
|
const pageSize = 100;
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -77,6 +92,13 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
|
|||||||
return COSMETIC_LINES.some(l => lineVal === l);
|
return COSMETIC_LINES.some(l => lineVal === l);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (dashboardFocus) {
|
||||||
|
result = result.filter(({ row }) => {
|
||||||
|
const cpnp = String(row[COLUMNS.CPNP_NO] ?? '').trim();
|
||||||
|
return dashboardFocus === 'present' ? cpnp !== '' : cpnp === '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Global search
|
// Global search
|
||||||
if (search) {
|
if (search) {
|
||||||
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
@@ -114,7 +136,7 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [data, search, sortCol, sortDesc, columnFilters, COLUMNS, columnUniqueValues]);
|
}, [data, search, sortCol, sortDesc, columnFilters, COLUMNS, columnUniqueValues, dashboardFocus]);
|
||||||
|
|
||||||
const paginatedData = useMemo(() => {
|
const paginatedData = useMemo(() => {
|
||||||
const start = (page - 1) * pageSize;
|
const start = (page - 1) * pageSize;
|
||||||
@@ -191,7 +213,9 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-xs text-slate-500">
|
<div className="flex items-center gap-3 text-xs text-slate-500">
|
||||||
<span className="text-slate-400 font-medium">{filteredData.length} items</span>
|
<span className="rounded-full border border-fuchsia-500/20 bg-fuchsia-500/10 px-3 py-1 font-medium text-fuchsia-200">
|
||||||
|
Filtered {filteredData.length}
|
||||||
|
</span>
|
||||||
<span className="text-slate-600">·</span>
|
<span className="text-slate-600">·</span>
|
||||||
<span>Lines: {COSMETIC_LINES.join(', ')}</span>
|
<span>Lines: {COSMETIC_LINES.join(', ')}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -370,7 +394,9 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
|
|||||||
</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} items</div>
|
<div>
|
||||||
|
Showing {paginatedData.length} of {filteredData.length} filtered items
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
const [bcValidationLoading, setBcValidationLoading] = useState(false);
|
const [bcValidationLoading, setBcValidationLoading] = useState(false);
|
||||||
const [bcApplyLoading, setBcApplyLoading] = useState(false);
|
const [bcApplyLoading, setBcApplyLoading] = useState(false);
|
||||||
const [bcValidationError, setBcValidationError] = useState<string | null>(null);
|
const [bcValidationError, setBcValidationError] = useState<string | null>(null);
|
||||||
|
const [bcValidationWarning, setBcValidationWarning] = useState<string | null>(null);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
let result = data.map((row, index) => ({ row, index }));
|
let result = data.map((row, index) => ({ row, index }));
|
||||||
@@ -91,6 +92,7 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBcValidationResult(null);
|
setBcValidationResult(null);
|
||||||
setBcValidationError(null);
|
setBcValidationError(null);
|
||||||
|
setBcValidationWarning(null);
|
||||||
}, [bcSku]);
|
}, [bcSku]);
|
||||||
|
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({});
|
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({});
|
||||||
@@ -148,6 +150,7 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
if (!bcPreviewRow) return;
|
if (!bcPreviewRow) return;
|
||||||
setBcValidationLoading(true);
|
setBcValidationLoading(true);
|
||||||
setBcValidationError(null);
|
setBcValidationError(null);
|
||||||
|
setBcValidationWarning(null);
|
||||||
try {
|
try {
|
||||||
const result = await previewBusinessCentralSync(headers, bcPreviewRow);
|
const result = await previewBusinessCentralSync(headers, bcPreviewRow);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -165,7 +168,15 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
if (!bcPreviewRow || !bcValidationResult?.previewToken) return;
|
if (!bcPreviewRow || !bcValidationResult?.previewToken) return;
|
||||||
setBcApplyLoading(true);
|
setBcApplyLoading(true);
|
||||||
setBcValidationError(null);
|
setBcValidationError(null);
|
||||||
|
setBcValidationWarning(null);
|
||||||
try {
|
try {
|
||||||
|
const hasWritableItems = bcValidationResult.items.changes.some((change: any) => change.changed);
|
||||||
|
const hasOnlyUomChanges = !hasWritableItems && bcValidationResult.itemUnitsOfMeasure.changes.some((change: any) => change.changed);
|
||||||
|
if (hasOnlyUomChanges) {
|
||||||
|
setBcValidationWarning(bcValidationResult.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is preview only');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await applyBusinessCentralSync(headers, bcPreviewRow, bcValidationResult.previewToken);
|
const result = await applyBusinessCentralSync(headers, bcPreviewRow, bcValidationResult.previewToken);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
if (isPreviewTokenMismatchError(result.error)) {
|
if (isPreviewTokenMismatchError(result.error)) {
|
||||||
@@ -175,6 +186,9 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
const retry = await applyBusinessCentralSync(headers, bcPreviewRow, refreshedPreview.previewToken);
|
const retry = await applyBusinessCentralSync(headers, bcPreviewRow, refreshedPreview.previewToken);
|
||||||
if (retry.success) {
|
if (retry.success) {
|
||||||
setBcValidationResult(prev => retry.preview ? { ...retry.preview, hasChanges: false } : prev);
|
setBcValidationResult(prev => retry.preview ? { ...retry.preview, hasChanges: false } : prev);
|
||||||
|
if (retry.warning) {
|
||||||
|
setBcValidationWarning(retry.warning);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBcValidationError(retry.error || 'Apply failed');
|
setBcValidationError(retry.error || 'Apply failed');
|
||||||
@@ -182,9 +196,15 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setBcValidationError(result.error || 'Apply failed');
|
setBcValidationError(result.error || 'Apply failed');
|
||||||
|
if (result.warning) {
|
||||||
|
setBcValidationWarning(result.warning);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBcValidationResult(prev => result.preview ? { ...result.preview, hasChanges: false } : prev);
|
setBcValidationResult(prev => result.preview ? { ...result.preview, hasChanges: false } : prev);
|
||||||
|
if (result.warning) {
|
||||||
|
setBcValidationWarning(result.warning);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBcApplyLoading(false);
|
setBcApplyLoading(false);
|
||||||
}
|
}
|
||||||
@@ -370,6 +390,12 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{bcValidationWarning && !bcValidationError && (
|
||||||
|
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
|
||||||
|
{bcValidationWarning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{bcValidationResult && (
|
{bcValidationResult && (
|
||||||
<div className="rounded-lg border border-slate-700 bg-slate-950/40 p-4">
|
<div className="rounded-lg border border-slate-700 bg-slate-950/40 p-4">
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
@@ -407,6 +433,11 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
{changed.length} changed field{changed.length === 1 ? '' : 's'}
|
{changed.length} changed field{changed.length === 1 ? '' : 's'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{section.supported === false && (
|
||||||
|
<span className="text-[10px] px-2 py-1 rounded-full border border-amber-500/20 bg-amber-500/10 text-amber-300">
|
||||||
|
preview only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!section.writeConfigured && (
|
{!section.writeConfigured && (
|
||||||
<span className="text-[10px] px-2 py-1 rounded-full border border-slate-600 text-slate-400">
|
<span className="text-[10px] px-2 py-1 rounded-full border border-slate-600 text-slate-400">
|
||||||
write not configured
|
write not configured
|
||||||
@@ -418,6 +449,11 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
<div className="text-xs text-slate-500">No changes for this endpoint.</div>
|
<div className="text-xs text-slate-500">No changes for this endpoint.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{section.supported === false && section.supportReason && (
|
||||||
|
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-200">
|
||||||
|
{section.supportReason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{changed.map((change: any) => (
|
{changed.map((change: any) => (
|
||||||
<div key={change.targetField} className="grid grid-cols-3 gap-3 text-xs">
|
<div key={change.targetField} className="grid grid-cols-3 gap-3 text-xs">
|
||||||
<div className="text-slate-400 truncate">{change.sourceLabel}</div>
|
<div className="text-slate-400 truncate">{change.sourceLabel}</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { cn } from '../lib/utils';
|
|||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
import { usePersistentState } from '../contexts/FilterContext';
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
import { SyncStatusPill } from './SyncStatusPill';
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
import { type DashboardDrilldownRequest } from '../lib/controlDashboard';
|
||||||
|
|
||||||
interface PricingViewProps {
|
interface PricingViewProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
@@ -32,6 +33,8 @@ interface PricingViewProps {
|
|||||||
onCaptureState: (message: string) => void;
|
onCaptureState: (message: string) => void;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
rowStatuses: Record<string, string>;
|
rowStatuses: Record<string, string>;
|
||||||
|
dashboardDrilldown?: DashboardDrilldownRequest | null;
|
||||||
|
onDashboardDrilldownApplied?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DetectedCol {
|
interface DetectedCol {
|
||||||
@@ -54,7 +57,7 @@ function findCol(headers: string[], ...keywords: string[]): number {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses }: PricingViewProps) {
|
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: PricingViewProps) {
|
||||||
const COLUMNS = useColumns();
|
const COLUMNS = useColumns();
|
||||||
const [filterMode, setFilterMode] = usePersistentState<FilterMode>('pricing-filterMode', 'all_errors');
|
const [filterMode, setFilterMode] = usePersistentState<FilterMode>('pricing-filterMode', 'all_errors');
|
||||||
const [search, setSearch] = usePersistentState('pricing-search', '');
|
const [search, setSearch] = usePersistentState('pricing-search', '');
|
||||||
@@ -108,6 +111,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
const [dynamicColFilters, setDynamicColFilters] = usePersistentState<Record<number, string[]>>('pricing-dynamicColFilters', {});
|
const [dynamicColFilters, setDynamicColFilters] = usePersistentState<Record<number, string[]>>('pricing-dynamicColFilters', {});
|
||||||
const [weightIssueFilter, setWeightIssueFilter] = usePersistentState<string[]>('pricing-weightIssueFilter', []);
|
const [weightIssueFilter, setWeightIssueFilter] = usePersistentState<string[]>('pricing-weightIssueFilter', []);
|
||||||
const [selectedSearchItems, setSelectedSearchItems] = usePersistentState<Set<string>>('pricing-selectedSearchItems', new Set());
|
const [selectedSearchItems, setSelectedSearchItems] = usePersistentState<Set<string>>('pricing-selectedSearchItems', new Set());
|
||||||
|
const [dashboardFocus, setDashboardFocus] = useState<string | null>(null);
|
||||||
const [openFilter, setOpenFilter] = useState<string | null>(null);
|
const [openFilter, setOpenFilter] = useState<string | null>(null);
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
const searchDropdownRef = useRef<HTMLDivElement>(null);
|
const searchDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -240,7 +244,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
}, [resizingColumn, resizeStartX, resizeStartWidth]);
|
}, [resizingColumn, resizeStartX, resizeStartWidth]);
|
||||||
|
|
||||||
// ── Dynamic column detection ──────────────────────────────────────────────
|
// ── Dynamic column detection ──────────────────────────────────────────────
|
||||||
const { uvpIdx, srpCols, containerCols, nwIdx, gwIdx, unitsOuterIdx } = useMemo(() => {
|
const { uvpIdx, srpCols, containerCols, nwIdx, gwIdx, unitsOuterIdx, units40fIdx } = useMemo(() => {
|
||||||
const uvpIdx = findCol(headers, 'uvp');
|
const uvpIdx = findCol(headers, 'uvp');
|
||||||
|
|
||||||
// All SRP columns, sorted: INT first, UK second, then alphabetically
|
// All SRP columns, sorted: INT first, UK second, then alphabetically
|
||||||
@@ -267,9 +271,66 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
|
|
||||||
const unitsOuterIdx = COLUMNS.UNITS_OUTER;
|
const unitsOuterIdx = COLUMNS.UNITS_OUTER;
|
||||||
|
|
||||||
return { uvpIdx, srpCols: srp, containerCols: container, nwIdx, gwIdx, unitsOuterIdx };
|
const units40fIdx = findCol(headers, '40f');
|
||||||
|
|
||||||
|
return { uvpIdx, srpCols: srp, containerCols: container, nwIdx, gwIdx, unitsOuterIdx, units40fIdx };
|
||||||
}, [headers, COLUMNS]);
|
}, [headers, COLUMNS]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'pricing') return;
|
||||||
|
|
||||||
|
const focus = dashboardDrilldown.focus;
|
||||||
|
const generalModes = new Set<FilterMode>(['all', 'all_errors', 'pricing_errors', 'units_errors']);
|
||||||
|
|
||||||
|
setFilterMode(generalModes.has(focus as FilterMode) ? (focus as FilterMode) : 'all_errors');
|
||||||
|
setDashboardFocus(generalModes.has(focus as FilterMode) ? null : focus);
|
||||||
|
setSearch('');
|
||||||
|
setSelectedSearchItems(new Set());
|
||||||
|
setLineMultiFilter([]);
|
||||||
|
setClassificationFilter([]);
|
||||||
|
setProductTypeFilter([]);
|
||||||
|
setNameColFilter({ terms: [''], op: 'and' });
|
||||||
|
setArticleNoColFilter({ terms: [''], op: 'and' });
|
||||||
|
setGlobalAdvancedFilter({ terms: [''], op: 'and' });
|
||||||
|
setUnitsOuterFilter([]);
|
||||||
|
setItemToLogisticFilter([]);
|
||||||
|
setOuterWFilter([]);
|
||||||
|
setOuterLFilter([]);
|
||||||
|
setOuterHFilter([]);
|
||||||
|
setMoqFilter([]);
|
||||||
|
setCheckYingFilter([]);
|
||||||
|
setCheckAnnaFilter([]);
|
||||||
|
setDynamicColFilters({});
|
||||||
|
setWeightIssueFilter([]);
|
||||||
|
setCurrentPage(1);
|
||||||
|
onDashboardDrilldownApplied?.();
|
||||||
|
}, [
|
||||||
|
dashboardDrilldown?.id,
|
||||||
|
dashboardDrilldown?.tabId,
|
||||||
|
dashboardDrilldown?.focus,
|
||||||
|
onDashboardDrilldownApplied,
|
||||||
|
setFilterMode,
|
||||||
|
setSearch,
|
||||||
|
setSelectedSearchItems,
|
||||||
|
setLineMultiFilter,
|
||||||
|
setClassificationFilter,
|
||||||
|
setProductTypeFilter,
|
||||||
|
setNameColFilter,
|
||||||
|
setArticleNoColFilter,
|
||||||
|
setGlobalAdvancedFilter,
|
||||||
|
setUnitsOuterFilter,
|
||||||
|
setItemToLogisticFilter,
|
||||||
|
setOuterWFilter,
|
||||||
|
setOuterLFilter,
|
||||||
|
setOuterHFilter,
|
||||||
|
setMoqFilter,
|
||||||
|
setCheckYingFilter,
|
||||||
|
setCheckAnnaFilter,
|
||||||
|
setDynamicColFilters,
|
||||||
|
setWeightIssueFilter,
|
||||||
|
setCurrentPage,
|
||||||
|
]);
|
||||||
|
|
||||||
// ── Error analysis per row ────────────────────────────────────────────────
|
// ── Error analysis per row ────────────────────────────────────────────────
|
||||||
const analyzedRows = useMemo(() => {
|
const analyzedRows = useMemo(() => {
|
||||||
return data.map((row, dataIndex) => {
|
return data.map((row, dataIndex) => {
|
||||||
@@ -503,8 +564,40 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dashboardFocus) {
|
||||||
|
result = result.filter(r => {
|
||||||
|
const row = r.row;
|
||||||
|
switch (dashboardFocus) {
|
||||||
|
case 'itemToLogisticMissing':
|
||||||
|
return !String(row[COLUMNS.ITEM_TO_LOGISTIC] ?? '').trim();
|
||||||
|
case 'uvpMissing':
|
||||||
|
return uvpIdx < 0 || !String(row[uvpIdx] ?? '').trim();
|
||||||
|
case 'srpIntMissing':
|
||||||
|
return !String(row[srpCols.find(({ name }) => /int/i.test(name))?.index ?? -1] ?? '').trim();
|
||||||
|
case 'srpUkMissing':
|
||||||
|
return !String(row[srpCols.find(({ name }) => /uk/i.test(name))?.index ?? -1] ?? '').trim();
|
||||||
|
case 'unitsOuterMissing':
|
||||||
|
return !String(row[unitsOuterIdx] ?? '').trim() || Number(row[unitsOuterIdx]) === 0;
|
||||||
|
case 'outerWMissing':
|
||||||
|
return !String(row[COLUMNS.OUTER_W] ?? '').trim() || Number(row[COLUMNS.OUTER_W]) === 0;
|
||||||
|
case 'outerLMissing':
|
||||||
|
return !String(row[COLUMNS.OUTER_L] ?? '').trim() || Number(row[COLUMNS.OUTER_L]) === 0;
|
||||||
|
case 'outerHMissing':
|
||||||
|
return !String(row[COLUMNS.OUTER_H] ?? '').trim() || Number(row[COLUMNS.OUTER_H]) === 0;
|
||||||
|
case 'units40fMissing':
|
||||||
|
return units40fIdx < 0 || !String(row[units40fIdx] ?? '').trim() || Number(row[units40fIdx]) === 0;
|
||||||
|
case 'moqMissing':
|
||||||
|
return !String(row[COLUMNS.MOQ] ?? '').trim() || Number(row[COLUMNS.MOQ]) === 0;
|
||||||
|
case 'weightIssues':
|
||||||
|
return r.unitErrors.some(e => e.startsWith('NW'));
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [analyzedRows, filterMode, search, nameColFilter, articleNoColFilter, globalAdvancedFilter, lineMultiFilter, classificationFilter, productTypeFilter, unitsOuterFilter, outerWFilter, outerLFilter, outerHFilter, moqFilter, dynamicColFilters, weightIssueFilter, selectedSearchItems]);
|
}, [analyzedRows, filterMode, search, nameColFilter, articleNoColFilter, globalAdvancedFilter, lineMultiFilter, classificationFilter, productTypeFilter, unitsOuterFilter, outerWFilter, outerLFilter, outerHFilter, moqFilter, dynamicColFilters, weightIssueFilter, selectedSearchItems, dashboardFocus, uvpIdx, srpCols, unitsOuterIdx, units40fIdx, COLUMNS]);
|
||||||
|
|
||||||
// ── Sorted rows ──────────────────────────────────────────────────────────
|
// ── Sorted rows ──────────────────────────────────────────────────────────
|
||||||
const sortedRows = useMemo(() => {
|
const sortedRows = useMemo(() => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { ExcelRow } from '../types';
|
import { ExcelRow } from '../types';
|
||||||
import { useColumns } from '../contexts/ColumnsContext';
|
import { useColumns } from '../contexts/ColumnsContext';
|
||||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X, Maximize2 } from 'lucide-react';
|
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X, Maximize2 } from 'lucide-react';
|
||||||
@@ -6,6 +6,7 @@ import { cn } from '../lib/utils';
|
|||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
import { usePersistentState } from '../contexts/FilterContext';
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
import { SyncStatusPill } from './SyncStatusPill';
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
import { type DashboardDrilldownRequest } from '../lib/controlDashboard';
|
||||||
|
|
||||||
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingShortDE' | 'missingShortEN' | 'complete' | 'incomplete';
|
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingShortDE' | 'missingShortEN' | 'complete' | 'incomplete';
|
||||||
|
|
||||||
@@ -15,9 +16,11 @@ interface ProductDescriptionsProps {
|
|||||||
asinColumnIndex: number | null;
|
asinColumnIndex: number | null;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
rowStatuses: Record<string, string>;
|
rowStatuses: Record<string, string>;
|
||||||
|
dashboardDrilldown?: DashboardDrilldownRequest | null;
|
||||||
|
onDashboardDrilldownApplied?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, rowStatuses }: ProductDescriptionsProps) {
|
export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: ProductDescriptionsProps) {
|
||||||
const COLUMNS = useColumns();
|
const COLUMNS = useColumns();
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
@@ -46,6 +49,19 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
[COLUMNS.SHORT_EN]: 110,
|
[COLUMNS.SHORT_EN]: 110,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'descriptions') return;
|
||||||
|
|
||||||
|
const focus = dashboardDrilldown.focus as TabType;
|
||||||
|
setActiveTab(focus);
|
||||||
|
setSearch('');
|
||||||
|
setLineFilter('');
|
||||||
|
setLicenseFilter('');
|
||||||
|
setColumnFilters({});
|
||||||
|
setPage(1);
|
||||||
|
onDashboardDrilldownApplied?.();
|
||||||
|
}, [dashboardDrilldown?.id, dashboardDrilldown?.tabId, dashboardDrilldown?.focus, onDashboardDrilldownApplied, setActiveTab, setSearch, setLineFilter, setLicenseFilter, setColumnFilters]);
|
||||||
|
|
||||||
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
||||||
const licenses = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LICENSE]).filter(Boolean))), [data]);
|
const licenses = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LICENSE]).filter(Boolean))), [data]);
|
||||||
|
|
||||||
@@ -249,7 +265,7 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800 p-4 rounded-xl border border-slate-700 shadow-sm">
|
<div className="flex flex-wrap items-center gap-4 mb-6 bg-slate-800 p-4 rounded-xl border border-slate-700 shadow-sm">
|
||||||
<div className="flex-1 min-w-[200px] relative">
|
<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-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
@@ -296,6 +312,10 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
<option value="">All Licenses</option>
|
<option value="">All Licenses</option>
|
||||||
{licenses.map(l => <option key={l} value={String(l)}>{String(l)}</option>)}
|
{licenses.map(l => <option key={l} value={String(l)}>{String(l)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
<div className="ml-auto flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-200">
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.18em] text-emerald-300/80">In view</span>
|
||||||
|
<span className="font-semibold tabular-nums">{filteredData.length}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
||||||
@@ -442,6 +462,10 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
<option value={100}>100 per page</option>
|
<option value={100}>100 per page</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="hidden md:flex items-center gap-2 rounded-full border border-slate-700 bg-slate-800 px-3 py-1 text-xs text-slate-300">
|
||||||
|
<span className="text-slate-500 uppercase tracking-[0.18em]">Filtered</span>
|
||||||
|
<span className="font-semibold tabular-nums text-white">{filteredData.length}</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle, Sparkles } from 'lucide-react';
|
import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle, Sparkles, LayoutDashboard, Users } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import { ControlTabId } from '../lib/controlDashboard';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeModule: string;
|
activeModule: ControlTabId;
|
||||||
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data' | 'cosmetic_items') => void;
|
setActiveModule: (m: ControlTabId) => void;
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,9 +16,8 @@ export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarPro
|
|||||||
]);
|
]);
|
||||||
const isMasterUser = MASTER_USERS.has(userEmail?.toLowerCase());
|
const isMasterUser = MASTER_USERS.has(userEmail?.toLowerCase());
|
||||||
|
|
||||||
type ModuleId = 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data' | 'cosmetic_items';
|
const navItems: { id: ControlTabId; label: string; icon: React.ElementType }[] = [
|
||||||
|
{ id: 'control_dashboard', label: 'Control Dashboard', icon: LayoutDashboard },
|
||||||
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 },
|
||||||
@@ -26,8 +26,9 @@ export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarPro
|
|||||||
{ id: 'missing_data', label: 'Missing Data', icon: AlertTriangle },
|
{ id: 'missing_data', label: 'Missing Data', icon: AlertTriangle },
|
||||||
{ id: 'cosmetic_items', label: 'Cosmetic Items', icon: Sparkles },
|
{ id: 'cosmetic_items', label: 'Cosmetic Items', icon: Sparkles },
|
||||||
...(isMasterUser ? [
|
...(isMasterUser ? [
|
||||||
{ id: 'pending_validation' as ModuleId, label: 'Pending Validation', icon: Clock },
|
{ id: 'pending_validation' as ControlTabId, label: 'Pending Validation', icon: Clock },
|
||||||
{ id: 'history' as ModuleId, label: 'Change History', icon: History }
|
{ id: 'history' as ControlTabId, label: 'Change History', icon: History },
|
||||||
|
{ id: 'user_management' as ControlTabId, label: 'User Validation', icon: Users }
|
||||||
] : [])
|
] : [])
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export function SyncStatusPill({ status, className }: SyncStatusPillProps) {
|
|||||||
if (normalized === 'previewed') {
|
if (normalized === 'previewed') {
|
||||||
return { label: 'Previewed', tone: 'bg-indigo-500/10 text-indigo-300 border-indigo-500/20' };
|
return { label: 'Previewed', tone: 'bg-indigo-500/10 text-indigo-300 border-indigo-500/20' };
|
||||||
}
|
}
|
||||||
|
if (normalized === 'preview_only' || normalized === 'preview only') {
|
||||||
|
return { label: 'Preview only', tone: 'bg-amber-500/10 text-amber-300 border-amber-500/20' };
|
||||||
|
}
|
||||||
if (normalized === 'syncing') {
|
if (normalized === 'syncing') {
|
||||||
return { label: 'Syncing BC', tone: 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20' };
|
return { label: 'Syncing BC', tone: 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function TopBar({ stats, activeModule, onExport, onDownloadBCExcel, onRef
|
|||||||
}, [bcQueueCount]);
|
}, [bcQueueCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 z-10 shadow-2xl">
|
<header className="relative z-[200] bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 shadow-2xl">
|
||||||
<div className="flex items-center -ml-4">
|
<div className="flex items-center -ml-4">
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
@@ -163,7 +163,7 @@ export function TopBar({ stats, activeModule, onExport, onDownloadBCExcel, onRef
|
|||||||
|
|
||||||
{/* Dropdown: list of pending changes */}
|
{/* Dropdown: list of pending changes */}
|
||||||
{showPending && pendingCount > 0 && (
|
{showPending && pendingCount > 0 && (
|
||||||
<div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 overflow-hidden">
|
<div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-[9999] overflow-hidden">
|
||||||
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between">
|
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between">
|
||||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Pending changes</span>
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Pending changes</span>
|
||||||
<span className="text-xs text-slate-500">{pendingCount} unsaved</span>
|
<span className="text-xs text-slate-500">{pendingCount} unsaved</span>
|
||||||
@@ -234,7 +234,7 @@ export function TopBar({ stats, activeModule, onExport, onDownloadBCExcel, onRef
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showBcQueue && bcQueueCount > 0 && (
|
{showBcQueue && bcQueueCount > 0 && (
|
||||||
<div className="absolute right-0 top-full mt-2 w-96 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 overflow-hidden">
|
<div className="absolute right-0 top-full mt-2 w-96 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-[9999] overflow-hidden">
|
||||||
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between gap-2">
|
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between gap-2">
|
||||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">BC sync queue</span>
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">BC sync queue</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Users, Search, CheckCircle, XCircle, Trash2, Shield, Loader2, AlertCircle, Clock } from 'lucide-react';
|
||||||
|
import { AuthSession } from '../lib/auth';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
status: 'Master' | 'Validated' | 'Pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserManagementViewProps {
|
||||||
|
session: AuthSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserManagementView({ session }: UserManagementViewProps) {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||||
|
const [deleteConfirmUser, setDeleteConfirmUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users-admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${session.access_token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ action: 'list' })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || 'Failed to fetch users.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setUsers(data.users || []);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[UserManagement] fetch error:', err);
|
||||||
|
setError(err.message || 'Error fetching user list.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const handleToggleValidation = async (user: User, approve: boolean) => {
|
||||||
|
setProcessingId(user.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users-admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${session.access_token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'validate',
|
||||||
|
targetUserId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
validated: approve
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || 'Failed to update user approval status.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setUsers(prev => prev.map(u => {
|
||||||
|
if (u.id === user.id) {
|
||||||
|
return { ...u, status: approve ? 'Validated' : 'Pending' };
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
}));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Error updating approval status.');
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (userId: string) => {
|
||||||
|
setProcessingId(userId);
|
||||||
|
setError(null);
|
||||||
|
setDeleteConfirmUser(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users-admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${session.access_token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'delete',
|
||||||
|
targetUserId: userId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || 'Failed to delete user.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from local list
|
||||||
|
setUsers(prev => prev.filter(u => u.id !== userId));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Error deleting user.');
|
||||||
|
} finally {
|
||||||
|
setProcessingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUsers = users.filter(user =>
|
||||||
|
user.email.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString('es-ES', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col h-full overflow-hidden bg-[#041021]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-slate-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-500/10 rounded-lg">
|
||||||
|
<Users className="w-6 h-6 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white">Validación de Usuarios</h2>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Administra los accesos y los registros de usuarios en la plataforma.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por email..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="bg-slate-900 border border-slate-800 rounded-md pl-9 pr-4 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 w-64 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-950/30 border border-red-500/30 rounded-lg flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 text-sm text-red-300">
|
||||||
|
<span className="font-semibold">Error:</span> {error}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="text-red-400 hover:text-red-300 text-xs font-semibold px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
Descartar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center text-slate-400">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-500 mb-3" />
|
||||||
|
<p className="text-sm">Cargando lista de usuarios...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredUsers.length === 0 ? (
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center border border-dashed border-slate-800 rounded-xl text-slate-500">
|
||||||
|
<Users className="w-12 h-12 mb-3 opacity-20" />
|
||||||
|
{search ? (
|
||||||
|
<p className="text-sm">No se encontraron usuarios que coincidan con la búsqueda.</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">No hay registros de usuarios registrados.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-[#08152c] border border-slate-800/60 rounded-xl overflow-hidden shadow-xl">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800/80 bg-slate-900/30 text-slate-400 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<th className="py-4 px-6">Email</th>
|
||||||
|
<th className="py-4 px-6">Fecha de Registro</th>
|
||||||
|
<th className="py-4 px-6">Estado</th>
|
||||||
|
<th className="py-4 px-6 text-right">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/50 text-slate-300 text-sm">
|
||||||
|
{filteredUsers.map(user => {
|
||||||
|
const isProcessing = processingId === user.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={user.id} className="hover:bg-slate-900/10 transition-colors">
|
||||||
|
<td className="py-4 px-6 font-medium text-white max-w-xs truncate">
|
||||||
|
{user.email}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6 text-slate-400">
|
||||||
|
{formatDate(user.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6">
|
||||||
|
{user.status === 'Master' ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">
|
||||||
|
<Shield className="w-3.5 h-3.5" />
|
||||||
|
Administrador Principal
|
||||||
|
</span>
|
||||||
|
) : user.status === 'Validated' ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
Validado
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
Pendiente Validación
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6 text-right">
|
||||||
|
{user.status === 'Master' ? (
|
||||||
|
<span className="text-slate-500 text-xs italic">Protegido</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{user.status === 'Pending' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleValidation(user, true)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white disabled:opacity-50 text-xs font-semibold rounded-md transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Validar
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleValidation(user, false)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 disabled:opacity-50 text-xs font-semibold rounded-md border border-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Revocar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirmUser(user)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="inline-flex items-center justify-center p-1.5 bg-red-950/30 hover:bg-red-900/50 text-red-400 hover:text-red-300 border border-red-900/20 disabled:opacity-50 rounded-md transition-colors"
|
||||||
|
title="Eliminar usuario"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
{deleteConfirmUser && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-[#08152c] border border-slate-800 rounded-xl max-w-md w-full overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 text-red-400 mb-4">
|
||||||
|
<Trash2 className="w-6 h-6 shrink-0" />
|
||||||
|
<h3 className="text-lg font-bold text-white">¿Eliminar usuario definitivamente?</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-300 mb-2">
|
||||||
|
Estás a punto de eliminar la cuenta del usuario:
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-mono bg-slate-900/60 border border-slate-800/80 p-2.5 rounded text-blue-400 break-all mb-4">
|
||||||
|
{deleteConfirmUser.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-red-400/90 leading-relaxed">
|
||||||
|
Esta acción no se puede deshacer. Se eliminarán sus accesos y toda su información asociada al servicio de autenticación.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900/50 border-t border-slate-800/60 px-6 py-4 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirmUser(null)}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm font-semibold rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(deleteConfirmUser.id)}
|
||||||
|
className="px-4 py-2 bg-red-600 hover:bg-red-500 text-white text-sm font-semibold rounded-md transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
Eliminar Cuenta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,31 @@ export async function signIn(email: string, password: string): Promise<AuthSessi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Validate status before signing in
|
||||||
|
try {
|
||||||
|
const statusRes = await fetch('/api/users-admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${data.access_token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ action: 'check-status' })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!statusRes.ok) {
|
||||||
|
const err = await statusRes.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Failed to verify account validation status.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusData = await statusRes.json();
|
||||||
|
if (!statusData.validated) {
|
||||||
|
throw new Error('Tu usuario aún no ha sido validado por un administrador.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(err.message || 'Error de validación del usuario.');
|
||||||
|
}
|
||||||
|
|
||||||
const session: AuthSession = {
|
const session: AuthSession = {
|
||||||
access_token: data.access_token,
|
access_token: data.access_token,
|
||||||
refresh_token: data.refresh_token,
|
refresh_token: data.refresh_token,
|
||||||
|
|||||||
@@ -0,0 +1,974 @@
|
|||||||
|
import { ExcelRow, COLUMNS, resolveColumnIndices } from '../types';
|
||||||
|
import {
|
||||||
|
HistoryEntry,
|
||||||
|
DashboardDescriptionsSnapshot,
|
||||||
|
DashboardArticleDetailsSnapshot,
|
||||||
|
DashboardPricingSnapshot,
|
||||||
|
DashboardCosmeticSnapshot,
|
||||||
|
DashboardSnapshotTabs,
|
||||||
|
getDashboardSnapshotStore,
|
||||||
|
ensureDashboardSnapshot,
|
||||||
|
} from './supabase';
|
||||||
|
|
||||||
|
export type ControlTabId =
|
||||||
|
| 'control_dashboard'
|
||||||
|
| 'matrix'
|
||||||
|
| 'descriptions'
|
||||||
|
| 'article_details'
|
||||||
|
| 'dimensions'
|
||||||
|
| 'pricing'
|
||||||
|
| 'missing_data'
|
||||||
|
| 'cosmetic_items'
|
||||||
|
| 'pending_validation'
|
||||||
|
| 'history'
|
||||||
|
| 'user_management';
|
||||||
|
|
||||||
|
export type DashboardDrilldownTabId = Exclude<ControlTabId, 'control_dashboard'>;
|
||||||
|
|
||||||
|
export interface DashboardDrilldownRequest {
|
||||||
|
id: string;
|
||||||
|
tabId: DashboardDrilldownTabId;
|
||||||
|
focus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDashboardDrilldownRequest(tabId: DashboardDrilldownTabId, focus: string): DashboardDrilldownRequest {
|
||||||
|
return {
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
tabId,
|
||||||
|
focus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabMetricSnapshot {
|
||||||
|
total: number;
|
||||||
|
ok: number;
|
||||||
|
pending: number;
|
||||||
|
empty: number;
|
||||||
|
error: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DescriptionsDashboardSnapshot {
|
||||||
|
total: number;
|
||||||
|
ok: number;
|
||||||
|
longDeMissing: number;
|
||||||
|
longEnMissing: number;
|
||||||
|
shortDeMissing: number;
|
||||||
|
shortEnMissing: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleDetailsDashboardSnapshot {
|
||||||
|
total: number;
|
||||||
|
ok: number;
|
||||||
|
detailsDeMissing: number;
|
||||||
|
detailsEnMissing: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingDashboardSnapshot {
|
||||||
|
total: number;
|
||||||
|
ok: number;
|
||||||
|
itemToLogisticMissing: number;
|
||||||
|
uvpMissing: number;
|
||||||
|
srpIntMissing: number;
|
||||||
|
srpUkMissing: number;
|
||||||
|
unitsOuterMissing: number;
|
||||||
|
outerWMissing: number;
|
||||||
|
outerLMissing: number;
|
||||||
|
outerHMissing: number;
|
||||||
|
units40fMissing: number;
|
||||||
|
moqMissing: number;
|
||||||
|
weightIssues: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CosmeticDashboardSnapshot {
|
||||||
|
total: number;
|
||||||
|
ok: number;
|
||||||
|
cpnpMissing: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabCardSummary {
|
||||||
|
id: ControlTabId;
|
||||||
|
label: string;
|
||||||
|
accentClass: string;
|
||||||
|
current: TabMetricSnapshot;
|
||||||
|
historical?: TabMetricSnapshot;
|
||||||
|
delta?: TabMetricSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingRowInfo {
|
||||||
|
rowIndex: number;
|
||||||
|
originalData: ExcelRow;
|
||||||
|
newData: ExcelRow;
|
||||||
|
articleName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistorySyncRecord {
|
||||||
|
selected: boolean;
|
||||||
|
status: 'bc_pending' | 'previewed' | 'preview_only' | 'syncing' | 'synced' | 'failed';
|
||||||
|
previewToken?: string;
|
||||||
|
error?: string;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HistorySyncMap = Record<string, HistorySyncRecord>;
|
||||||
|
|
||||||
|
export interface DashboardContext {
|
||||||
|
data: ExcelRow[];
|
||||||
|
pendingRows: Record<string, PendingRowInfo>;
|
||||||
|
rowStatuses: Record<string, string>;
|
||||||
|
historyEntries: HistoryEntry[];
|
||||||
|
historySyncMap?: HistorySyncMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapshotStore {
|
||||||
|
[dateKey: string]: DashboardSnapshotTabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTROL_TABS: Array<{
|
||||||
|
id: Exclude<ControlTabId, 'control_dashboard'>;
|
||||||
|
label: string;
|
||||||
|
accentClass: string;
|
||||||
|
}> = [
|
||||||
|
{ id: 'matrix', label: 'Matrix', accentClass: 'border-sky-500/30 bg-sky-500/5' },
|
||||||
|
{ id: 'descriptions', label: 'Product Descriptions', accentClass: 'border-emerald-500/30 bg-emerald-500/5' },
|
||||||
|
{ id: 'article_details', label: 'Article Details', accentClass: 'border-indigo-500/30 bg-indigo-500/5' },
|
||||||
|
{ id: 'dimensions', label: 'Dimensions', accentClass: 'border-violet-500/30 bg-violet-500/5' },
|
||||||
|
{ id: 'pricing', label: 'Pricing & Units', accentClass: 'border-amber-500/30 bg-amber-500/5' },
|
||||||
|
{ id: 'missing_data', label: 'Missing Data', accentClass: 'border-rose-500/30 bg-rose-500/5' },
|
||||||
|
{ id: 'cosmetic_items', label: 'Cosmetic Items', accentClass: 'border-fuchsia-500/30 bg-fuchsia-500/5' },
|
||||||
|
{ id: 'pending_validation', label: 'Pending Validation', accentClass: 'border-orange-500/30 bg-orange-500/5' },
|
||||||
|
{ id: 'history', label: 'Change History', accentClass: 'border-cyan-500/30 bg-cyan-500/5' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const APP_TABS: Array<{
|
||||||
|
id: ControlTabId;
|
||||||
|
label: string;
|
||||||
|
accentClass: string;
|
||||||
|
}> = [
|
||||||
|
{ id: 'control_dashboard', label: 'Control Dashboard', accentClass: 'border-slate-500/30 bg-slate-500/5' },
|
||||||
|
...CONTROL_TABS,
|
||||||
|
];
|
||||||
|
|
||||||
|
const HISTORY_SYNC_STORAGE_KEY = 'history-bcSync';
|
||||||
|
|
||||||
|
const COSMETIC_LINES = new Set(['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS']);
|
||||||
|
const STATUS_PENDING = new Set(['pending', 'bc_pending', 'queued', 'previewed', 'preview_only', 'syncing']);
|
||||||
|
const STATUS_ERROR = new Set(['failed', 'error']);
|
||||||
|
|
||||||
|
function normalize(value: unknown): string {
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
return String(value).replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeLocalStorageGet(key: string): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlank(value: unknown): boolean {
|
||||||
|
return normalize(value) === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateLikeEmpty(value: unknown): boolean {
|
||||||
|
if (value === undefined || value === null || value === '') return true;
|
||||||
|
if (typeof value === 'number') return value === 0 || value === 1;
|
||||||
|
|
||||||
|
const text = normalize(value);
|
||||||
|
if (text === '' || text === '0' || text === '1') return true;
|
||||||
|
if (text === '0001-01-01' || text.startsWith('0001-01-01T')) return true;
|
||||||
|
if (text.endsWith('/1900')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumericLikeEmpty(value: unknown): boolean {
|
||||||
|
if (value === undefined || value === null || value === '') return true;
|
||||||
|
const n = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
|
||||||
|
return Number.isNaN(n) || n === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTruthyNumeric(value: unknown): boolean {
|
||||||
|
if (value === undefined || value === null || value === '') return false;
|
||||||
|
const n = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
|
||||||
|
return !Number.isNaN(n) && n !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDate(value: unknown): Date | null {
|
||||||
|
if (isDateLikeEmpty(value)) return null;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (value >= 25569 && value <= 60000) {
|
||||||
|
const excelEpoch = new Date(1899, 11, 30);
|
||||||
|
return new Date(excelEpoch.getTime() + value * 86400000);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = normalize(value);
|
||||||
|
if (!text) return null;
|
||||||
|
const iso = new Date(text);
|
||||||
|
if (!Number.isNaN(iso.getTime())) return iso;
|
||||||
|
|
||||||
|
const parts = text.split('/');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const [dd, mm, yyyy] = parts;
|
||||||
|
const parsed = new Date(Number(yyyy), Number(mm) - 1, Number(dd));
|
||||||
|
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDateKey(date: Date): string {
|
||||||
|
const y = date.getUTCFullYear();
|
||||||
|
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftDate(date: Date, days: number): Date {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setDate(next.getDate() - days);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findIndicesByPatterns(headers: string[], patterns: string[][]): number[] {
|
||||||
|
const lower = headers.map(header => normalize(header).toLowerCase());
|
||||||
|
const indices = new Set<number>();
|
||||||
|
|
||||||
|
patterns.forEach(pattern => {
|
||||||
|
lower.forEach((header, index) => {
|
||||||
|
if (pattern.every(token => header.includes(token))) {
|
||||||
|
indices.add(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(indices).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unionIndices(...groups: number[][]): number[] {
|
||||||
|
const result = new Set<number>();
|
||||||
|
groups.forEach(group => group.forEach(index => result.add(index)));
|
||||||
|
return Array.from(result).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowKey(row: ExcelRow): string {
|
||||||
|
return normalize(row[COLUMNS.ARTICLE_NO]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistorySyncMapFromStorage(): HistorySyncMap {
|
||||||
|
try {
|
||||||
|
const raw = safeLocalStorageGet(HISTORY_SYNC_STORAGE_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||||
|
return parsed as HistorySyncMap;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTabRows(tabId: ControlTabId, headers: string[], ctx: DashboardContext): ExcelRow[] {
|
||||||
|
if (tabId === 'pending_validation') {
|
||||||
|
return Object.values(ctx.pendingRows).map(row => row.newData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabId === 'history') {
|
||||||
|
return ctx.historyEntries.map(entry => entry.new_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedRows = ctx.data || [];
|
||||||
|
|
||||||
|
if (tabId === 'cosmetic_items') {
|
||||||
|
return resolvedRows.filter(row => COSMETIC_LINES.has(normalize(row[COLUMNS.LINE]).toUpperCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabId === 'missing_data') {
|
||||||
|
return resolvedRows.filter(row => isMissingDataRow(row, headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDescriptionRows(headers: string[], ctx: DashboardContext): ExcelRow[] {
|
||||||
|
const resolvedRows = ctx.data || [];
|
||||||
|
void headers;
|
||||||
|
return resolvedRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArticleDetailsRows(headers: string[], ctx: DashboardContext): ExcelRow[] {
|
||||||
|
const resolvedRows = ctx.data || [];
|
||||||
|
void headers;
|
||||||
|
return resolvedRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPricingRows(headers: string[], ctx: DashboardContext): ExcelRow[] {
|
||||||
|
const resolvedRows = ctx.data || [];
|
||||||
|
void headers;
|
||||||
|
return resolvedRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCosmeticRows(headers: string[], ctx: DashboardContext): ExcelRow[] {
|
||||||
|
const resolvedRows = ctx.data || [];
|
||||||
|
const columns = resolveColumnIndices(headers);
|
||||||
|
return resolvedRows.filter(row => COSMETIC_LINES.has(normalize(row[columns.LINE]).toUpperCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingDataRow(row: ExcelRow, headers: string[]): boolean {
|
||||||
|
const launchIdx = findHeaderIndexFromHeaders(headers, [['launch', 'date']]);
|
||||||
|
const readyIdx = findHeaderIndexFromHeaders(headers, [['ready', 'to', 'order', 'date']]);
|
||||||
|
const classificationIdx = findHeaderIndexFromHeaders(headers, [['classification']]);
|
||||||
|
|
||||||
|
const launch = launchIdx >= 0 ? row[launchIdx] : undefined;
|
||||||
|
const ready = readyIdx >= 0 ? row[readyIdx] : undefined;
|
||||||
|
const classification = classificationIdx >= 0 ? row[classificationIdx] : undefined;
|
||||||
|
|
||||||
|
const missingBasics = isDateLikeEmpty(launch) || isBlank(classification);
|
||||||
|
const readyBeforeLaunch = (() => {
|
||||||
|
const launchDate = toDate(launch);
|
||||||
|
const readyDate = toDate(ready);
|
||||||
|
if (!launchDate || !readyDate) return false;
|
||||||
|
return readyDate.getTime() > launchDate.getTime();
|
||||||
|
})();
|
||||||
|
|
||||||
|
const upcomingLaunch = (() => {
|
||||||
|
const launchDate = toDate(launch);
|
||||||
|
if (!launchDate) return false;
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const diff = Math.ceil((launchDate.getTime() - today.getTime()) / 86400000);
|
||||||
|
return diff > 0 && diff <= 180;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return missingBasics || readyBeforeLaunch || upcomingLaunch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHeaderIndexFromHeaders(headers: string[], patterns: string[][]): number {
|
||||||
|
return findIndicesByPatterns(headers, patterns)[0] ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditableIndices(tabId: ControlTabId, headers: string[]): number[] {
|
||||||
|
const columns = resolveColumnIndices(headers);
|
||||||
|
|
||||||
|
const descriptions = unionIndices(
|
||||||
|
[columns.LONG_DE, columns.LONG_EN, columns.SHORT_DE, columns.SHORT_EN].filter(i => typeof i === 'number' && i >= 0),
|
||||||
|
findIndicesByPatterns(headers, [['long', 'description']]),
|
||||||
|
findIndicesByPatterns(headers, [['short', 'description']]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const articleDetails = unionIndices(
|
||||||
|
[columns.DETAILS_EN, columns.DETAILS_DE, columns.SHORT_DE, columns.SHORT_EN, columns.MOQ, columns.CPNP_NO].filter(i => typeof i === 'number' && i >= 0),
|
||||||
|
findIndicesByPatterns(headers, [['article', 'details', 'english']]),
|
||||||
|
findIndicesByPatterns(headers, [['article', 'details', 'german']]),
|
||||||
|
findIndicesByPatterns(headers, [['launch', 'date']]),
|
||||||
|
findIndicesByPatterns(headers, [['ready', 'to', 'order', 'date']]),
|
||||||
|
findIndicesByPatterns(headers, [['moq']]),
|
||||||
|
findIndicesByPatterns(headers, [['cpnp']])
|
||||||
|
);
|
||||||
|
|
||||||
|
const dimensions = unionIndices(
|
||||||
|
[columns.UNITS_OUTER, columns.INNER_W, columns.INNER_L, columns.INNER_H, columns.OUTER_W, columns.OUTER_L, columns.OUTER_H, columns.MOQ].filter(i => typeof i === 'number' && i >= 0),
|
||||||
|
findIndicesByPatterns(headers, [['inner', 'w']]),
|
||||||
|
findIndicesByPatterns(headers, [['inner', 'l']]),
|
||||||
|
findIndicesByPatterns(headers, [['inner', 'h']]),
|
||||||
|
findIndicesByPatterns(headers, [['outer', 'w']]),
|
||||||
|
findIndicesByPatterns(headers, [['outer', 'l']]),
|
||||||
|
findIndicesByPatterns(headers, [['outer', 'h']]),
|
||||||
|
findIndicesByPatterns(headers, [['units', 'outer']]),
|
||||||
|
findIndicesByPatterns(headers, [['moq']])
|
||||||
|
);
|
||||||
|
|
||||||
|
const pricing = unionIndices(
|
||||||
|
[columns.UNITS_OUTER, columns.OUTER_W, columns.OUTER_L, columns.OUTER_H, columns.MOQ].filter(i => typeof i === 'number' && i >= 0),
|
||||||
|
findIndicesByPatterns(headers, [['uvp']]),
|
||||||
|
findIndicesByPatterns(headers, [['srp']]),
|
||||||
|
findIndicesByPatterns(headers, [['price']]),
|
||||||
|
findIndicesByPatterns(headers, [['cost']]),
|
||||||
|
findIndicesByPatterns(headers, [['net']]),
|
||||||
|
findIndicesByPatterns(headers, [['gross']]),
|
||||||
|
findIndicesByPatterns(headers, [['units', 'outer']]),
|
||||||
|
findIndicesByPatterns(headers, [['outer', 'w']]),
|
||||||
|
findIndicesByPatterns(headers, [['outer', 'l']]),
|
||||||
|
findIndicesByPatterns(headers, [['outer', 'h']]),
|
||||||
|
findIndicesByPatterns(headers, [['moq']])
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingData = unionIndices(
|
||||||
|
findIndicesByPatterns(headers, [['classification']]),
|
||||||
|
findIndicesByPatterns(headers, [['launch', 'date']]),
|
||||||
|
findIndicesByPatterns(headers, [['ready', 'to', 'order', 'date']])
|
||||||
|
);
|
||||||
|
|
||||||
|
const cosmetic = findIndicesByPatterns(headers, [['cpnp']]);
|
||||||
|
|
||||||
|
const allEditable = unionIndices(descriptions, articleDetails, dimensions, pricing, missingData, cosmetic);
|
||||||
|
|
||||||
|
switch (tabId) {
|
||||||
|
case 'descriptions':
|
||||||
|
return descriptions;
|
||||||
|
case 'article_details':
|
||||||
|
return articleDetails;
|
||||||
|
case 'dimensions':
|
||||||
|
return dimensions;
|
||||||
|
case 'pricing':
|
||||||
|
return pricing;
|
||||||
|
case 'missing_data':
|
||||||
|
return missingData;
|
||||||
|
case 'cosmetic_items':
|
||||||
|
return cosmetic;
|
||||||
|
case 'pending_validation':
|
||||||
|
case 'history':
|
||||||
|
case 'matrix':
|
||||||
|
default:
|
||||||
|
return allEditable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFieldEmptyForTab(tabId: ControlTabId, index: number, value: unknown): boolean {
|
||||||
|
if (tabId === 'descriptions' || tabId === 'article_details' || tabId === 'cosmetic_items' || tabId === 'history' || tabId === 'pending_validation' || tabId === 'matrix') {
|
||||||
|
if (index === COLUMNS.CPNP_NO) return isBlank(value);
|
||||||
|
if (index === COLUMNS.MOQ || index === COLUMNS.UNITS_OUTER || index === COLUMNS.INNER_W || index === COLUMNS.INNER_L || index === COLUMNS.INNER_H || index === COLUMNS.OUTER_W || index === COLUMNS.OUTER_L || index === COLUMNS.OUTER_H) {
|
||||||
|
return isNumericLikeEmpty(value);
|
||||||
|
}
|
||||||
|
if (tabId === 'descriptions' && (index === COLUMNS.LONG_DE || index === COLUMNS.LONG_EN || index === COLUMNS.SHORT_DE || index === COLUMNS.SHORT_EN)) {
|
||||||
|
return isBlank(value);
|
||||||
|
}
|
||||||
|
if (tabId === 'article_details' && (index === COLUMNS.DETAILS_DE || index === COLUMNS.DETAILS_EN || index === COLUMNS.SHORT_DE || index === COLUMNS.SHORT_EN || index === COLUMNS.MOQ || index === COLUMNS.CPNP_NO)) {
|
||||||
|
return isBlank(value);
|
||||||
|
}
|
||||||
|
if (index === COLUMNS.ARTICLE_NO || index === COLUMNS.ARTICLE_NAME || index === COLUMNS.LINE || index === COLUMNS.CLASSIFICATION) {
|
||||||
|
return isBlank(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabId === 'missing_data') {
|
||||||
|
return index === COLUMNS.CLASSIFICATION || index === COLUMNS.CPNP_NO ? isBlank(value) : isDateLikeEmpty(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabId === 'pricing' || tabId === 'dimensions') {
|
||||||
|
return isNumericLikeEmpty(value) || isBlank(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isBlank(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countEmptyRows(tabId: ControlTabId, rows: ExcelRow[], headers: string[]): number {
|
||||||
|
const indices = getEditableIndices(tabId, headers);
|
||||||
|
return rows.filter(row => indices.some(index => isFieldEmptyForTab(tabId, index, row[index]))).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowHasPendingStatus(articleNo: string, ctx: DashboardContext): boolean {
|
||||||
|
const normalized = normalize(ctx.rowStatuses[articleNo]).toLowerCase();
|
||||||
|
return STATUS_PENDING.has(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countPendingRows(tabId: ControlTabId, rows: ExcelRow[], ctx: DashboardContext): number {
|
||||||
|
if (tabId === 'pending_validation') {
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabId === 'history') {
|
||||||
|
return ctx.historyEntries.filter(entry => {
|
||||||
|
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
|
||||||
|
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
|
||||||
|
return STATUS_PENDING.has(status);
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.filter(row => {
|
||||||
|
const articleNo = getRowKey(row);
|
||||||
|
return rowHasPendingStatus(articleNo, ctx) || Object.prototype.hasOwnProperty.call(ctx.pendingRows, articleNo);
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPendingArticles(tabId: ControlTabId, rows: ExcelRow[], ctx: DashboardContext): Set<string> {
|
||||||
|
const articles = new Set<string>();
|
||||||
|
|
||||||
|
if (tabId === 'pending_validation') {
|
||||||
|
rows.forEach(row => {
|
||||||
|
const articleNo = getRowKey(row);
|
||||||
|
if (articleNo) articles.add(articleNo);
|
||||||
|
});
|
||||||
|
return articles;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabId === 'history') {
|
||||||
|
ctx.historyEntries.forEach(entry => {
|
||||||
|
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
|
||||||
|
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
|
||||||
|
if (STATUS_PENDING.has(status)) {
|
||||||
|
articles.add(normalize(entry.product_id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return articles;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const articleNo = getRowKey(row);
|
||||||
|
if (!articleNo) return;
|
||||||
|
if (rowHasPendingStatus(articleNo, ctx) || Object.prototype.hasOwnProperty.call(ctx.pendingRows, articleNo)) {
|
||||||
|
articles.add(articleNo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return articles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectEmptyArticles(tabId: ControlTabId, rows: ExcelRow[], headers: string[]): Set<string> {
|
||||||
|
const indices = getEditableIndices(tabId, headers);
|
||||||
|
const articles = new Set<string>();
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const articleNo = getRowKey(row);
|
||||||
|
if (!articleNo) return;
|
||||||
|
if (indices.some(index => isFieldEmptyForTab(tabId, index, row[index]))) {
|
||||||
|
articles.add(articleNo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return articles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectErrorArticles(tabId: ControlTabId, rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set<string> {
|
||||||
|
switch (tabId) {
|
||||||
|
case 'dimensions':
|
||||||
|
return collectDimensionErrorArticles(rows, headers, ctx);
|
||||||
|
case 'pricing':
|
||||||
|
return collectPricingErrorArticles(rows, headers, ctx);
|
||||||
|
case 'missing_data':
|
||||||
|
return collectMissingDataErrorArticles(rows, headers, ctx);
|
||||||
|
case 'history':
|
||||||
|
return new Set(
|
||||||
|
ctx.historyEntries
|
||||||
|
.filter(entry => {
|
||||||
|
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
|
||||||
|
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
|
||||||
|
return status === 'failed';
|
||||||
|
})
|
||||||
|
.map(entry => normalize(entry.product_id))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
case 'matrix':
|
||||||
|
return new Set([
|
||||||
|
...collectPricingErrorArticles(rows, headers, ctx),
|
||||||
|
...collectDimensionErrorArticles(rows, headers, ctx),
|
||||||
|
...collectMissingDataErrorArticles(rows, headers, ctx),
|
||||||
|
...rows.filter(row => hasRowStatusError(getRowKey(row), ctx)).map(row => getRowKey(row)),
|
||||||
|
]);
|
||||||
|
default:
|
||||||
|
return new Set(
|
||||||
|
rows
|
||||||
|
.filter(row => hasRowStatusError(getRowKey(row), ctx))
|
||||||
|
.map(row => getRowKey(row))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDescriptionsSnapshot(headers: string[], ctx: DashboardContext): DescriptionsDashboardSnapshot {
|
||||||
|
const rows = getDescriptionRows(headers, ctx);
|
||||||
|
const columns = resolveColumnIndices(headers);
|
||||||
|
|
||||||
|
const longDeMissing = rows.reduce((count, row) => count + (isBlank(row[columns.LONG_DE]) ? 1 : 0), 0);
|
||||||
|
const longEnMissing = rows.reduce((count, row) => count + (isBlank(row[columns.LONG_EN]) ? 1 : 0), 0);
|
||||||
|
const shortDeMissing = rows.reduce((count, row) => count + (isBlank(row[columns.SHORT_DE]) ? 1 : 0), 0);
|
||||||
|
const shortEnMissing = rows.reduce((count, row) => count + (isBlank(row[columns.SHORT_EN]) ? 1 : 0), 0);
|
||||||
|
const ok = rows.reduce((count, row) => {
|
||||||
|
const complete = !isBlank(row[columns.LONG_DE])
|
||||||
|
&& !isBlank(row[columns.LONG_EN])
|
||||||
|
&& !isBlank(row[columns.SHORT_DE])
|
||||||
|
&& !isBlank(row[columns.SHORT_EN]);
|
||||||
|
return count + (complete ? 1 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: rows.length,
|
||||||
|
ok,
|
||||||
|
longDeMissing,
|
||||||
|
longEnMissing,
|
||||||
|
shortDeMissing,
|
||||||
|
shortEnMissing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeArticleDetailsSnapshot(headers: string[], ctx: DashboardContext): ArticleDetailsDashboardSnapshot {
|
||||||
|
const rows = getArticleDetailsRows(headers, ctx);
|
||||||
|
const columns = resolveColumnIndices(headers);
|
||||||
|
|
||||||
|
const detailsDeMissing = rows.reduce((count, row) => count + (isBlank(row[columns.DETAILS_DE]) ? 1 : 0), 0);
|
||||||
|
const detailsEnMissing = rows.reduce((count, row) => count + (isBlank(row[columns.DETAILS_EN]) ? 1 : 0), 0);
|
||||||
|
const ok = rows.reduce((count, row) => {
|
||||||
|
const complete = !isBlank(row[columns.DETAILS_DE]) && !isBlank(row[columns.DETAILS_EN]);
|
||||||
|
return count + (complete ? 1 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: rows.length,
|
||||||
|
ok,
|
||||||
|
detailsDeMissing,
|
||||||
|
detailsEnMissing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHeaderIndexByName(headers: string[], predicate: (name: string) => boolean): number {
|
||||||
|
return headers.findIndex(header => predicate(normalize(header).toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWeightIssueValue(value: unknown): boolean {
|
||||||
|
return !isBlank(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePricingSnapshot(headers: string[], ctx: DashboardContext): PricingDashboardSnapshot {
|
||||||
|
const rows = getPricingRows(headers, ctx);
|
||||||
|
const columns = resolveColumnIndices(headers);
|
||||||
|
const articleIndex = columns.ARTICLE_NO;
|
||||||
|
const skuForRow = (row: ExcelRow) => normalize(row[articleIndex]);
|
||||||
|
|
||||||
|
const uvpIdx = findHeaderIndexFromHeaders(headers, [['uvp']]);
|
||||||
|
const srpHeaders = headers
|
||||||
|
.map((header, index) => ({ index, text: normalize(header).toLowerCase() }))
|
||||||
|
.filter(({ text }) => text.includes('srp'));
|
||||||
|
const srpIntIdx = srpHeaders.find(({ text }) => text.includes('int'))?.index ?? -1;
|
||||||
|
const srpUkIdx = srpHeaders.find(({ text }) => text.includes('uk'))?.index ?? -1;
|
||||||
|
const units40fIdx = findHeaderIndexFromHeaders(headers, [['40f']]);
|
||||||
|
const itemToLogisticIdx = columns.ITEM_TO_LOGISTIC;
|
||||||
|
const unitsOuterIdx = columns.UNITS_OUTER;
|
||||||
|
const outerWIdx = columns.OUTER_W;
|
||||||
|
const outerLIdx = columns.OUTER_L;
|
||||||
|
const outerHIdx = columns.OUTER_H;
|
||||||
|
const moqIdx = columns.MOQ;
|
||||||
|
const nwIdx = findHeaderIndexFromHeaders(headers, [['nw']]);
|
||||||
|
const gwIdx = findHeaderIndexFromHeaders(headers, [['gw']]);
|
||||||
|
|
||||||
|
const rowIssues = new Set<string>();
|
||||||
|
|
||||||
|
const itemToLogisticMissing = rows.reduce((count, row) => {
|
||||||
|
const missing = isBlank(row[itemToLogisticIdx]);
|
||||||
|
if (missing) rowIssues.add(skuForRow(row));
|
||||||
|
return count + (missing ? 1 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const uvpMissing = rows.reduce((count, row) => {
|
||||||
|
const missing = uvpIdx < 0 ? true : isBlank(row[uvpIdx]);
|
||||||
|
if (missing) rowIssues.add(skuForRow(row));
|
||||||
|
return count + (missing ? 1 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const srpIntMissing = rows.reduce((count, row) => {
|
||||||
|
const missing = srpIntIdx < 0 ? true : isBlank(row[srpIntIdx]);
|
||||||
|
if (missing) rowIssues.add(skuForRow(row));
|
||||||
|
return count + (missing ? 1 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const srpUkMissing = rows.reduce((count, row) => {
|
||||||
|
const missing = srpUkIdx < 0 ? true : isBlank(row[srpUkIdx]);
|
||||||
|
if (missing) rowIssues.add(skuForRow(row));
|
||||||
|
return count + (missing ? 1 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const unitsOuterMissing = rows.reduce((count, row) => {
|
||||||
|
const missing = isNumericLikeEmpty(row[unitsOuterIdx]);
|
||||||
|
if (missing) rowIssues.add(skuForRow(row));
|
||||||
|
return count + (missing ? 1 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const outerWMissing = rows.reduce((count, row) => {
|
||||||
|
const missing = isNumericLikeEmpty(row[outerWIdx]);
|
||||||
|
if (missing) rowIssues.add(skuForRow(row));
|
||||||
|
return count + (missing ? 1 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const outerLMissing = rows.reduce((count, row) => {
|
||||||
|
const missing = isNumericLikeEmpty(row[outerLIdx]);
|
||||||
|
if (missing) rowIssues.add(skuForRow(row));
|
||||||
|
return count + (missing ? 1 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const outerHMissing = rows.reduce((count, row) => {
|
||||||
|
const missing = isNumericLikeEmpty(row[outerHIdx]);
|
||||||
|
if (missing) rowIssues.add(skuForRow(row));
|
||||||
|
return count + (missing ? 1 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const units40fMissing = rows.reduce((count, row) => {
|
||||||
|
const missing = units40fIdx < 0 ? true : isNumericLikeEmpty(row[units40fIdx]);
|
||||||
|
if (missing) rowIssues.add(skuForRow(row));
|
||||||
|
return count + (missing ? 1 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const moqMissing = rows.reduce((count, row) => {
|
||||||
|
const missing = isNumericLikeEmpty(row[moqIdx]);
|
||||||
|
if (missing) rowIssues.add(skuForRow(row));
|
||||||
|
return count + (missing ? 1 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const weightIssues = rows.reduce((count, row) => {
|
||||||
|
let issue = false;
|
||||||
|
if (nwIdx >= 0 && gwIdx >= 0) {
|
||||||
|
const nw = parseFloat(String(row[nwIdx] ?? '').replace(',', '.'));
|
||||||
|
const gw = parseFloat(String(row[gwIdx] ?? '').replace(',', '.'));
|
||||||
|
issue = !Number.isNaN(nw) && !Number.isNaN(gw) && nw > gw;
|
||||||
|
}
|
||||||
|
if (issue) rowIssues.add(skuForRow(row));
|
||||||
|
return count + (issue ? 1 : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const total = rows.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
ok: Math.max(total - rowIssues.size, 0),
|
||||||
|
itemToLogisticMissing,
|
||||||
|
uvpMissing,
|
||||||
|
srpIntMissing,
|
||||||
|
srpUkMissing,
|
||||||
|
unitsOuterMissing,
|
||||||
|
outerWMissing,
|
||||||
|
outerLMissing,
|
||||||
|
outerHMissing,
|
||||||
|
units40fMissing,
|
||||||
|
moqMissing,
|
||||||
|
weightIssues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeCosmeticSnapshot(headers: string[], ctx: DashboardContext): CosmeticDashboardSnapshot {
|
||||||
|
const rows = getCosmeticRows(headers, ctx);
|
||||||
|
const columns = resolveColumnIndices(headers);
|
||||||
|
const cpnpPresent = rows.reduce((count, row) => count + (!isBlank(row[columns.CPNP_NO]) ? 1 : 0), 0);
|
||||||
|
const cpnpMissing = rows.length - cpnpPresent;
|
||||||
|
return {
|
||||||
|
total: rows.length,
|
||||||
|
ok: cpnpPresent,
|
||||||
|
cpnpMissing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRowStatusError(articleNo: string, ctx: DashboardContext): boolean {
|
||||||
|
const status = normalize(ctx.rowStatuses[articleNo]).toLowerCase();
|
||||||
|
return STATUS_ERROR.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countDimensionErrors(rows: ExcelRow[], headers: string[], ctx: DashboardContext): number {
|
||||||
|
return collectDimensionErrorArticles(rows, headers, ctx).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDimensionErrorArticles(rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set<string> {
|
||||||
|
const columns = resolveColumnIndices(headers);
|
||||||
|
const groups = new Map<string, ExcelRow[]>();
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const innerValues = [row[columns.INNER_L], row[columns.INNER_W], row[columns.INNER_H]];
|
||||||
|
if (innerValues.every(value => isNumericLikeEmpty(value) || isBlank(value))) return;
|
||||||
|
|
||||||
|
const innerKey = innerValues
|
||||||
|
.map(value => normalize(value) || '0')
|
||||||
|
.join('x');
|
||||||
|
if (!groups.has(innerKey)) groups.set(innerKey, []);
|
||||||
|
groups.get(innerKey)!.push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorArticles = new Set<string>();
|
||||||
|
groups.forEach(groupRows => {
|
||||||
|
if (groupRows.length <= 1) return;
|
||||||
|
const signature = (row: ExcelRow) => [
|
||||||
|
row[columns.OUTER_L],
|
||||||
|
row[columns.OUTER_W],
|
||||||
|
row[columns.OUTER_H],
|
||||||
|
row[columns.UNITS_OUTER],
|
||||||
|
row[columns.MOQ],
|
||||||
|
].map(value => normalize(value) || '0').join('|');
|
||||||
|
|
||||||
|
const firstSignature = signature(groupRows[0]);
|
||||||
|
const inconsistent = groupRows.some(row => signature(row) !== firstSignature);
|
||||||
|
if (!inconsistent) return;
|
||||||
|
|
||||||
|
groupRows.forEach(row => {
|
||||||
|
const articleNo = getRowKey(row);
|
||||||
|
if (articleNo) errorArticles.add(articleNo);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return errorArticles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countPricingErrors(rows: ExcelRow[], headers: string[], ctx: DashboardContext): number {
|
||||||
|
return collectPricingErrorArticles(rows, headers, ctx).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPricingErrorArticles(rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set<string> {
|
||||||
|
const columns = resolveColumnIndices(headers);
|
||||||
|
const uvpIdx = findHeaderIndexFromHeaders(headers, [['uvp']]);
|
||||||
|
const srpIndices = findIndicesByPatterns(headers, [['srp']]);
|
||||||
|
const netIdx = findHeaderIndexFromHeaders(headers, [['net']]);
|
||||||
|
const grossIdx = findHeaderIndexFromHeaders(headers, [['gross']]);
|
||||||
|
|
||||||
|
const articles = new Set<string>();
|
||||||
|
rows.forEach(row => {
|
||||||
|
const articleNo = getRowKey(row);
|
||||||
|
if (hasRowStatusError(articleNo, ctx)) {
|
||||||
|
if (articleNo) articles.add(articleNo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricingMissing = uvpIdx >= 0 && isBlank(row[uvpIdx]);
|
||||||
|
const srpMissing = srpIndices.some(index => isBlank(row[index]));
|
||||||
|
const unitsOuter = row[columns.UNITS_OUTER];
|
||||||
|
const outerW = row[columns.OUTER_W];
|
||||||
|
const outerL = row[columns.OUTER_L];
|
||||||
|
const outerH = row[columns.OUTER_H];
|
||||||
|
const unitsError = isNumericLikeEmpty(unitsOuter) || normalize(unitsOuter) === '1';
|
||||||
|
const outerError = [outerW, outerL, outerH].some(value => isNumericLikeEmpty(value) || normalize(value) === '1');
|
||||||
|
const weightError = netIdx >= 0 && grossIdx >= 0 && !isBlank(row[netIdx]) && !isBlank(row[grossIdx]) && Number(String(row[netIdx]).replace(',', '.')) > Number(String(row[grossIdx]).replace(',', '.'));
|
||||||
|
|
||||||
|
if (pricingMissing || srpMissing || unitsError || outerError || weightError) {
|
||||||
|
if (articleNo) articles.add(articleNo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return articles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countMissingDataErrors(rows: ExcelRow[], headers: string[], ctx: DashboardContext): number {
|
||||||
|
return collectMissingDataErrorArticles(rows, headers, ctx).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMissingDataErrorArticles(rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set<string> {
|
||||||
|
const columns = resolveColumnIndices(headers);
|
||||||
|
const launchIdx = findHeaderIndexFromHeaders(headers, [['launch', 'date']]);
|
||||||
|
const readyIdx = findHeaderIndexFromHeaders(headers, [['ready', 'to', 'order', 'date']]);
|
||||||
|
|
||||||
|
const articles = new Set<string>();
|
||||||
|
rows.forEach(row => {
|
||||||
|
const articleNo = getRowKey(row);
|
||||||
|
if (hasRowStatusError(articleNo, ctx)) {
|
||||||
|
if (articleNo) articles.add(articleNo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (launchIdx < 0 || readyIdx < 0) return;
|
||||||
|
const launch = toDate(row[launchIdx]);
|
||||||
|
const ready = toDate(row[readyIdx]);
|
||||||
|
if (!launch || !ready) return;
|
||||||
|
if (ready.getTime() > launch.getTime() || isNumericLikeEmpty(row[columns.MOQ])) {
|
||||||
|
if (articleNo) articles.add(articleNo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return articles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countGenericErrors(rows: ExcelRow[], ctx: DashboardContext, extraPredicate?: (row: ExcelRow) => boolean): number {
|
||||||
|
return rows.filter(row => {
|
||||||
|
const articleNo = getRowKey(row);
|
||||||
|
if (hasRowStatusError(articleNo, ctx)) return true;
|
||||||
|
return extraPredicate ? extraPredicate(row) : false;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countHistoryErrors(entries: HistoryEntry[], ctx: DashboardContext): number {
|
||||||
|
return entries.filter(entry => {
|
||||||
|
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
|
||||||
|
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
|
||||||
|
return status === 'failed';
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countHistoryEmptyRows(entries: HistoryEntry[], headers: string[]): number {
|
||||||
|
const indices = getEditableIndices('history', headers);
|
||||||
|
return entries.filter(entry => indices.some(index => isFieldEmptyForTab('history', index, entry.new_data?.[index]))).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countHistoryPendingRows(entries: HistoryEntry[], ctx: DashboardContext): number {
|
||||||
|
return entries.filter(entry => {
|
||||||
|
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
|
||||||
|
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
|
||||||
|
return STATUS_PENDING.has(status);
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCurrentSnapshot(tabId: ControlTabId, rows: ExcelRow[], headers: string[], ctx: DashboardContext): TabMetricSnapshot {
|
||||||
|
const total = rows.length;
|
||||||
|
const pendingSet = collectPendingArticles(tabId, rows, ctx);
|
||||||
|
const emptySet = collectEmptyArticles(tabId, rows, headers);
|
||||||
|
const errorSet = collectErrorArticles(tabId, rows, headers, ctx);
|
||||||
|
const issueSet = new Set<string>([...pendingSet, ...emptySet, ...errorSet]);
|
||||||
|
|
||||||
|
switch (tabId) {
|
||||||
|
case 'dimensions':
|
||||||
|
case 'pricing':
|
||||||
|
case 'missing_data':
|
||||||
|
case 'history':
|
||||||
|
case 'pending_validation':
|
||||||
|
case 'matrix':
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
ok: Math.max(total - issueSet.size, 0),
|
||||||
|
pending: pendingSet.size,
|
||||||
|
empty: emptySet.size,
|
||||||
|
error: errorSet.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeControlDashboardSummaries(headers: string[], ctx: DashboardContext): TabCardSummary[] {
|
||||||
|
const historySyncMap = ctx.historySyncMap || getHistorySyncMapFromStorage();
|
||||||
|
const effectiveCtx: DashboardContext = { ...ctx, historySyncMap };
|
||||||
|
|
||||||
|
return CONTROL_TABS.map(tab => {
|
||||||
|
const rows = getTabRows(tab.id, headers, effectiveCtx);
|
||||||
|
const current = createCurrentSnapshot(tab.id, rows, headers, effectiveCtx);
|
||||||
|
return {
|
||||||
|
id: tab.id,
|
||||||
|
label: tab.label,
|
||||||
|
accentClass: tab.accentClass,
|
||||||
|
current,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeDescriptionsDashboardSnapshot(headers: string[], ctx: DashboardContext): DescriptionsDashboardSnapshot {
|
||||||
|
return computeDescriptionsSnapshot(headers, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeArticleDetailsDashboardSnapshot(headers: string[], ctx: DashboardContext): ArticleDetailsDashboardSnapshot {
|
||||||
|
return computeArticleDetailsSnapshot(headers, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePricingDashboardSnapshot(headers: string[], ctx: DashboardContext): PricingDashboardSnapshot {
|
||||||
|
return computePricingSnapshot(headers, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeCosmeticDashboardSnapshot(headers: string[], ctx: DashboardContext): CosmeticDashboardSnapshot {
|
||||||
|
return computeCosmeticSnapshot(headers, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDaysAgoKey(days: number, date = new Date()): string {
|
||||||
|
return localDateKey(shiftDate(date, days));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffSnapshots(current: TabMetricSnapshot, historical?: TabMetricSnapshot): TabMetricSnapshot | undefined {
|
||||||
|
if (!historical) return undefined;
|
||||||
|
return {
|
||||||
|
total: current.total - historical.total,
|
||||||
|
ok: current.ok - historical.ok,
|
||||||
|
pending: current.pending - historical.pending,
|
||||||
|
empty: current.empty - historical.empty,
|
||||||
|
error: current.error - historical.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDashboardSnapshots(): Promise<Record<string, DashboardSnapshotTabs>> {
|
||||||
|
const store = await getDashboardSnapshotStore();
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDailyDashboardSnapshot(date: Date, snapshot: DashboardSnapshotTabs): Promise<void> {
|
||||||
|
const key = localDateKey(date);
|
||||||
|
await ensureDashboardSnapshot(key, {
|
||||||
|
...snapshot,
|
||||||
|
});
|
||||||
|
}
|
||||||
+199
-7
@@ -3,7 +3,7 @@ import { refreshSession, getStoredSession } from './auth';
|
|||||||
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||||
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||||
|
|
||||||
async function safeFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
export async function safeFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||||
const session = getStoredSession();
|
const session = getStoredSession();
|
||||||
const token = session?.access_token || SUPABASE_ANON_KEY;
|
const token = session?.access_token || SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ export interface ExcelRow extends Array<any> {}
|
|||||||
export interface SyncedRow {
|
export interface SyncedRow {
|
||||||
data: ExcelRow;
|
data: ExcelRow;
|
||||||
status?: 'pending' | 'edited' | 'synced' | 'excel';
|
status?: 'pending' | 'edited' | 'synced' | 'excel';
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
|
export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
|
||||||
@@ -49,7 +50,7 @@ export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
|
|||||||
// Safety cap at 20 pages (20k products) to avoid infinite loops.
|
// Safety cap at 20 pages (20k products) to avoid infinite loops.
|
||||||
for (let page = 0; page < 20; page++) {
|
for (let page = 0; page < 20; page++) {
|
||||||
const response = await safeFetch(
|
const response = await safeFetch(
|
||||||
`${SUPABASE_URL}/rest/v1/products?select=product_id,data,status&order=product_id.asc&limit=${PAGE_SIZE}&offset=${offset}`,
|
`${SUPABASE_URL}/rest/v1/products?select=product_id,data,status,updated_at&order=product_id.asc&limit=${PAGE_SIZE}&offset=${offset}`,
|
||||||
{ cache: 'no-store' }
|
{ cache: 'no-store' }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,7 +61,12 @@ export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
|
|||||||
}
|
}
|
||||||
const rows = await response.json();
|
const rows = await response.json();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
result[row.product_id] = { data: row.data, status: row.status };
|
const current = result[row.product_id];
|
||||||
|
const currentUpdatedAt = current?.updated_at ? Date.parse(current.updated_at) : -1;
|
||||||
|
const nextUpdatedAt = row.updated_at ? Date.parse(row.updated_at) : -1;
|
||||||
|
if (!current || nextUpdatedAt >= currentUpdatedAt) {
|
||||||
|
result[row.product_id] = { data: row.data, status: row.status, updated_at: row.updated_at };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (rows.length < PAGE_SIZE) break;
|
if (rows.length < PAGE_SIZE) break;
|
||||||
offset += PAGE_SIZE;
|
offset += PAGE_SIZE;
|
||||||
@@ -75,12 +81,12 @@ export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
|
|||||||
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, status: 'pending' | 'edited' | 'synced' = 'pending'): Promise<{ success: boolean; error?: string }> {
|
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, status: 'pending' | 'edited' | 'synced' = 'pending'): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await safeFetch(
|
const response = await safeFetch(
|
||||||
`${SUPABASE_URL}/rest/v1/products`,
|
`${SUPABASE_URL}/rest/v1/products?on_conflict=product_id`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Prefer': 'resolution=merge-duplicates',
|
'Prefer': 'resolution=merge-duplicates,return=minimal',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
product_id: articleNo,
|
product_id: articleNo,
|
||||||
@@ -116,6 +122,53 @@ export interface HistoryEntry {
|
|||||||
changed_at: string;
|
changed_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardDescriptionsSnapshot {
|
||||||
|
total: number;
|
||||||
|
ok: number;
|
||||||
|
longDeMissing: number;
|
||||||
|
longEnMissing: number;
|
||||||
|
shortDeMissing: number;
|
||||||
|
shortEnMissing: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardArticleDetailsSnapshot {
|
||||||
|
total: number;
|
||||||
|
ok: number;
|
||||||
|
detailsDeMissing: number;
|
||||||
|
detailsEnMissing: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardPricingSnapshot {
|
||||||
|
total: number;
|
||||||
|
ok: number;
|
||||||
|
itemToLogisticMissing: number;
|
||||||
|
uvpMissing: number;
|
||||||
|
srpIntMissing: number;
|
||||||
|
srpUkMissing: number;
|
||||||
|
unitsOuterMissing: number;
|
||||||
|
outerWMissing: number;
|
||||||
|
outerLMissing: number;
|
||||||
|
outerHMissing: number;
|
||||||
|
units40fMissing: number;
|
||||||
|
moqMissing: number;
|
||||||
|
weightIssues: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardCosmeticSnapshot {
|
||||||
|
total: number;
|
||||||
|
ok: number;
|
||||||
|
cpnpMissing: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSnapshotTabs {
|
||||||
|
descriptions?: DashboardDescriptionsSnapshot;
|
||||||
|
articleDetails?: DashboardArticleDetailsSnapshot;
|
||||||
|
pricing?: DashboardPricingSnapshot;
|
||||||
|
cosmeticItems?: DashboardCosmeticSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID = '__control_dashboard__';
|
||||||
|
|
||||||
function normalizeHistoryValue(value: any): any {
|
function normalizeHistoryValue(value: any): any {
|
||||||
if (value === undefined || value === null || value === '') return null;
|
if (value === undefined || value === null || value === '') return null;
|
||||||
return value;
|
return value;
|
||||||
@@ -205,7 +258,7 @@ export async function getHistory(): Promise<HistoryEntry[]> {
|
|||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
const batch: HistoryEntry[] = await response.json();
|
const batch: HistoryEntry[] = await response.json();
|
||||||
allRows.push(...batch);
|
allRows.push(...batch.filter(entry => entry.product_id !== CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID));
|
||||||
if (batch.length < PAGE_SIZE) break;
|
if (batch.length < PAGE_SIZE) break;
|
||||||
}
|
}
|
||||||
// Return oldest-first so index+1 = natural chronological number
|
// Return oldest-first so index+1 = natural chronological number
|
||||||
@@ -235,7 +288,7 @@ export async function getHistoryDataForMerge(): Promise<Record<string, ExcelRow>
|
|||||||
console.error('[getHistoryDataForMerge] Error:', response.status);
|
console.error('[getHistoryDataForMerge] Error:', response.status);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
const entries: HistoryEntry[] = await response.json();
|
const entries: HistoryEntry[] = (await response.json()).filter((entry: HistoryEntry) => entry.product_id !== CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID);
|
||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime();
|
const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime();
|
||||||
if (timeDelta !== 0) return timeDelta;
|
if (timeDelta !== 0) return timeDelta;
|
||||||
@@ -266,6 +319,145 @@ export async function getHistoryDataForMerge(): Promise<Record<string, ExcelRow>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDashboardSnapshotStore(): Promise<Record<string, DashboardSnapshotTabs>> {
|
||||||
|
try {
|
||||||
|
const PAGE_SIZE = 1000;
|
||||||
|
const rows: Array<{ changed_at: string; new_data: any }> = [];
|
||||||
|
|
||||||
|
for (let page = 0; page < 20; page++) {
|
||||||
|
const offset = page * PAGE_SIZE;
|
||||||
|
const response = await safeFetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products_history?select=changed_at,new_data,product_id&product_id=eq.${encodeURIComponent(CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID)}&order=changed_at.asc&limit=${PAGE_SIZE}&offset=${offset}`,
|
||||||
|
{ cache: 'no-store' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
console.error('[getDashboardSnapshotStore] Error:', response.status, errText.substring(0, 200));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch: Array<{ changed_at: string; new_data: any }> = await response.json();
|
||||||
|
rows.push(...batch);
|
||||||
|
if (batch.length < PAGE_SIZE) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store: Record<string, DashboardSnapshotTabs> = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
const snapshotDate = normalizeSnapshotDate(row.new_data?.snapshot_date || row.changed_at);
|
||||||
|
const tabs = row.new_data?.tabs;
|
||||||
|
if (!snapshotDate || !tabs || typeof tabs !== 'object') return;
|
||||||
|
store[snapshotDate] = tabs as DashboardSnapshotTabs;
|
||||||
|
});
|
||||||
|
return store;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[getDashboardSnapshotStore] Exception:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDashboardSnapshot(dateKey: string, tabs: DashboardSnapshotTabs): Promise<{ success: boolean; error?: string; created?: boolean }> {
|
||||||
|
try {
|
||||||
|
const existingRes = await safeFetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products_history?select=id&product_id=eq.${encodeURIComponent(CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID)}&changed_at=gte.${encodeURIComponent(`${dateKey}T00:00:00.000Z`)}&changed_at=lt.${encodeURIComponent(nextUtcDateKey(dateKey))}&limit=1`,
|
||||||
|
{ cache: 'no-store' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingRes.ok) {
|
||||||
|
const errText = await existingRes.text();
|
||||||
|
return { success: false, error: `Snapshot lookup failed: ${existingRes.status} ${errText.substring(0, 200)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await existingRes.json();
|
||||||
|
if (Array.isArray(existing) && existing.length > 0) {
|
||||||
|
const existingId = existing[0]?.id;
|
||||||
|
const currentTabs = existing[0]?.new_data?.tabs ?? {};
|
||||||
|
const mergedTabs = {
|
||||||
|
...currentTabs,
|
||||||
|
...tabs,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (JSON.stringify(currentTabs) === JSON.stringify(mergedTabs)) {
|
||||||
|
return { success: true, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingId) {
|
||||||
|
return { success: true, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRes = await safeFetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(existingId)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Prefer': 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
new_data: {
|
||||||
|
snapshot_date: dateKey,
|
||||||
|
tabs: mergedTabs,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updateRes.ok) {
|
||||||
|
const errText = await updateRes.text();
|
||||||
|
return { success: false, error: `Snapshot update failed: ${updateRes.status} ${errText.substring(0, 200)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
product_id: CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID,
|
||||||
|
article_name: 'Control Dashboard Snapshot',
|
||||||
|
old_data: [],
|
||||||
|
new_data: {
|
||||||
|
snapshot_date: dateKey,
|
||||||
|
tabs,
|
||||||
|
},
|
||||||
|
changed_by: 'system-control-dashboard',
|
||||||
|
changed_at: `${dateKey}T00:00:00.000Z`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertRes = await safeFetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products_history`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Prefer': 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!insertRes.ok) {
|
||||||
|
const errText = await insertRes.text();
|
||||||
|
return { success: false, error: `Snapshot save failed: ${insertRes.status} ${errText.substring(0, 200)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, created: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[ensureDashboardSnapshot] Exception:', error);
|
||||||
|
return { success: false, error: error?.message || 'Network error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSnapshotDate(value: string): string | null {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextUtcDateKey(dateKey: string): string {
|
||||||
|
const date = new Date(`${dateKey}T00:00:00.000Z`);
|
||||||
|
date.setUTCDate(date.getUTCDate() + 1);
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteHistoryEntry(id: string): Promise<boolean> {
|
export async function deleteHistoryEntry(id: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await safeFetch(
|
const response = await safeFetch(
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export interface BCSyncPreviewSection {
|
|||||||
writeUrlTemplate: string | null;
|
writeUrlTemplate: string | null;
|
||||||
writeBodyTemplate: string | null;
|
writeBodyTemplate: string | null;
|
||||||
canApply: boolean;
|
canApply: boolean;
|
||||||
|
supported?: boolean;
|
||||||
|
supportReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BCSyncPreviewResult {
|
export interface BCSyncPreviewResult {
|
||||||
@@ -59,6 +61,7 @@ export interface BCSyncApplyResult {
|
|||||||
itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string };
|
itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string };
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
|
warning?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readResponsePayload(res: Response): Promise<{ data: any; rawText: string }> {
|
async function readResponsePayload(res: Response): Promise<{ data: any; rawText: string }> {
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
{ "source": "/api/dropbox-sync", "destination": "api/dropbox-sync.js" },
|
{ "source": "/api/dropbox-sync", "destination": "api/dropbox-sync.js" },
|
||||||
{ "source": "/api/bc-proxy", "destination": "api/bc-proxy.js" },
|
{ "source": "/api/bc-proxy", "destination": "api/bc-proxy.js" },
|
||||||
{ "source": "/api/bc-export", "destination": "api/bc-export.js" },
|
{ "source": "/api/bc-export", "destination": "api/bc-export.js" },
|
||||||
{ "source": "/api/backup", "destination": "api/backup.js" }
|
{ "source": "/api/backup", "destination": "api/backup.js" },
|
||||||
|
{ "source": "/api/users-admin", "destination": "api/users-admin.js" }
|
||||||
],
|
],
|
||||||
"headers": [
|
"headers": [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user