From 143f655f3cc5c505dd2eb045bbb5a40249c64697 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Fri, 15 May 2026 11:00:21 +0200 Subject: [PATCH] fix: commit all BC sync files missing from repo --- api/bc-sync-apply.js | 56 ++ api/bc-sync-preview.js | 56 ++ bc-runtime.js | 16 +- bc-sync-runtime.js | 656 +++++++++++++++++++++++ src/App.tsx | 307 ++++++++++- src/components/ArticleDetails.tsx | 8 +- src/components/CosmeticItemsView.tsx | 19 +- src/components/DimensionsView.tsx | 3 + src/components/MatrixView.tsx | 256 ++++++++- src/components/PendingValidationView.tsx | 4 +- src/components/PricingView.tsx | 6 +- src/components/TopBar.tsx | 122 ++++- src/services/businessCentral.ts | 99 +++- src/services/businessCentralMapping.ts | 142 +++++ 14 files changed, 1701 insertions(+), 49 deletions(-) create mode 100644 api/bc-sync-apply.js create mode 100644 api/bc-sync-preview.js create mode 100644 bc-sync-runtime.js create mode 100644 src/services/businessCentralMapping.ts diff --git a/api/bc-sync-apply.js b/api/bc-sync-apply.js new file mode 100644 index 0000000..a656cfe --- /dev/null +++ b/api/bc-sync-apply.js @@ -0,0 +1,56 @@ +import { getBcConfig, getBCToken } from '../bc-runtime.js'; +import { applyBusinessCentralCpnp, applyBusinessCentralSync } from '../bc-sync-runtime.js'; + +const ALLOWED_ORIGINS = [ + 'http://localhost:3000', + 'http://localhost:4173', + 'http://localhost:5173', + 'https://craze-data-check.vercel.app', +]; + +function setCors(req, res) { + const origin = req.headers.origin; + if (origin && ALLOWED_ORIGINS.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + } + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.setHeader('Vary', 'Origin'); +} + +export default async function handler(req, res) { + setCors(req, res); + + if (req.method === 'OPTIONS') return res.status(204).end(); + + const origin = req.headers.origin; + if (origin && !ALLOWED_ORIGINS.includes(origin)) { + return res.status(403).json({ error: 'Forbidden' }); + } + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const { headers, row, articleNo, cpnpNo, previewToken } = req.body || {}; + + try { + const config = getBcConfig(); + const token = await getBCToken(config); + + if (Array.isArray(headers) && Array.isArray(row)) { + const result = await applyBusinessCentralSync(config, token, headers, row, previewToken); + return res.json({ success: true, ...result }); + } + + if (articleNo && cpnpNo !== undefined) { + const result = await applyBusinessCentralCpnp(config, token, articleNo, cpnpNo, previewToken); + return res.json(result); + } + + return res.status(400).json({ error: 'Missing headers/row or articleNo/cpnpNo' }); + } catch (err) { + console.error('[bc-sync-apply] error:', err.message); + return res.status(err.statusCode || 500).json({ success: false, error: err.message }); + } +} diff --git a/api/bc-sync-preview.js b/api/bc-sync-preview.js new file mode 100644 index 0000000..f69083e --- /dev/null +++ b/api/bc-sync-preview.js @@ -0,0 +1,56 @@ +import { getBcConfig, getBCToken } from '../bc-runtime.js'; +import { previewBusinessCentralCpnp, previewBusinessCentralSync } from '../bc-sync-runtime.js'; + +const ALLOWED_ORIGINS = [ + 'http://localhost:3000', + 'http://localhost:4173', + 'http://localhost:5173', + 'https://craze-data-check.vercel.app', +]; + +function setCors(req, res) { + const origin = req.headers.origin; + if (origin && ALLOWED_ORIGINS.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + } + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.setHeader('Vary', 'Origin'); +} + +export default async function handler(req, res) { + setCors(req, res); + + if (req.method === 'OPTIONS') return res.status(204).end(); + + const origin = req.headers.origin; + if (origin && !ALLOWED_ORIGINS.includes(origin)) { + return res.status(403).json({ error: 'Forbidden' }); + } + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const { headers, row, articleNo, cpnpNo } = req.body || {}; + + try { + const config = getBcConfig(); + const token = await getBCToken(config); + + if (Array.isArray(headers) && Array.isArray(row)) { + const preview = await previewBusinessCentralSync(config, token, headers, row); + return res.json({ success: true, ...preview }); + } + + if (articleNo && cpnpNo !== undefined) { + const preview = await previewBusinessCentralCpnp(config, token, articleNo, cpnpNo); + return res.json({ success: true, ...preview }); + } + + return res.status(400).json({ error: 'Missing headers/row or articleNo/cpnpNo' }); + } catch (err) { + console.error('[bc-sync-preview] error:', err.message); + return res.status(500).json({ success: false, error: err.message }); + } +} diff --git a/bc-runtime.js b/bc-runtime.js index 793b687..cb41c18 100644 --- a/bc-runtime.js +++ b/bc-runtime.js @@ -1,14 +1,24 @@ import * as XLSX from 'xlsx'; export function getBcConfig(env = process.env) { + const legacyWriteMethod = String(env.BC_WRITE_METHOD || 'PATCH').toUpperCase(); + const legacyWriteUrlTemplate = env.BC_WRITE_URL_TEMPLATE || `{{itemsUrl}}('{{itemNo}}')`; + const legacyWriteBodyTemplate = env.BC_WRITE_BODY_TEMPLATE || JSON.stringify({ cpnpNo: '{{cpnpNo}}' }); + return { tenantId: env.BC_TENANT_ID || 'fab724f7-6b6d-4e3b-86e3-8c1e05e36b2a', clientId: env.BC_CLIENT_ID || '6f832138-cb48-43e7-8601-efca120b45dc', clientSecret: env.BC_CLIENT_SECRET, companyId: env.BC_COMPANY_ID || '2acec35c-7d06-ed11-82f8-0022485ceea3', - writeMethod: String(env.BC_WRITE_METHOD || 'PATCH').toUpperCase(), - writeUrlTemplate: env.BC_WRITE_URL_TEMPLATE || `{{itemsUrl}}('{{itemNo}}')`, - writeBodyTemplate: env.BC_WRITE_BODY_TEMPLATE || JSON.stringify({ cpnpNo: '{{cpnpNo}}' }), + writeMethod: legacyWriteMethod, + writeUrlTemplate: legacyWriteUrlTemplate, + writeBodyTemplate: legacyWriteBodyTemplate, + itemsWriteMethod: String(env.BC_ITEMS_WRITE_METHOD || legacyWriteMethod).toUpperCase(), + itemsWriteUrlTemplate: env.BC_ITEMS_WRITE_URL_TEMPLATE || legacyWriteUrlTemplate, + itemsWriteBodyTemplate: env.BC_ITEMS_WRITE_BODY_TEMPLATE || null, + itemUnitsWriteMethod: env.BC_UOM_WRITE_METHOD ? String(env.BC_UOM_WRITE_METHOD).toUpperCase() : 'PATCH', + itemUnitsWriteUrlTemplate: env.BC_UOM_WRITE_URL_TEMPLATE || `{{itemUnitsOfMeasureUrl}}('{{itemNo}}')`, + itemUnitsWriteBodyTemplate: env.BC_UOM_WRITE_BODY_TEMPLATE || null, }; } diff --git a/bc-sync-runtime.js b/bc-sync-runtime.js new file mode 100644 index 0000000..1a428ee --- /dev/null +++ b/bc-sync-runtime.js @@ -0,0 +1,656 @@ +import crypto from 'crypto'; +import { getBcConfig, getBCToken, getItemsUrl } from './bc-runtime.js'; + +function getItemUnitsOfMeasureUrl(config) { + return `https://api.businesscentral.dynamics.com/v2.0/${config.tenantId}/production/api/craze/integrations/v1.0/companies(${config.companyId})/itemUnitsOfMeasure`; +} + +function findHeaderIndex(headers, patterns) { + const normalized = headers.map(h => String(h || '').toLowerCase()); + return normalized.findIndex(header => + patterns.every(pattern => header.includes(pattern.toLowerCase())) + ); +} + +function formatDateForBc(value) { + if (value === null || value === undefined || value === '') return null; + + if (typeof value === 'number' && value >= 25569 && value <= 60000) { + const excelEpoch = new Date(1899, 11, 30); + const date = new Date(excelEpoch.getTime() + value * 86400000); + return date.toISOString().split('T')[0]; + } + + const str = String(value).trim(); + if (!str) return null; + if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str; + + const parsed = new Date(str); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString().split('T')[0]; + } + + return str; +} + +function getValue(row, index) { + if (index === null || index < 0) return null; + const value = row[index]; + return value === undefined || value === '' ? null : value; +} + +function toBcDecimal(value) { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'number' && Number.isFinite(value)) return value; + + const normalized = String(value).trim().replace(/\s+/g, '').replace(',', '.'); + if (!normalized) return null; + + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +} + +function makeFieldPreview(sourceLabel, sourceIndex, targetField, value) { + return { sourceLabel, sourceIndex, targetField, value }; +} + +export function buildBusinessCentralMappingPreview(headers, row) { + const articleNoIdx = findHeaderIndex(headers, ['article', 'no']); + const articleDetailsEnIdx = findHeaderIndex(headers, ['article', 'details', 'english']); + const articleDetailsDeIdx = findHeaderIndex(headers, ['article', 'details', 'german']); + const launchDateIdx = findHeaderIndex(headers, ['launch']); + const readyToOrderDateIdx = findHeaderIndex(headers, ['ready']); + const moqIdx = findHeaderIndex(headers, ['moq']); + const shortDeIdx = findHeaderIndex(headers, ['short', 'description', 'german']); + const shortEnIdx = findHeaderIndex(headers, ['short', 'description', 'english']); + const cpnpIdx = findHeaderIndex(headers, ['cpnp']); + + const unitsOuterIdx = findHeaderIndex(headers, ['units', 'outer']); + const innerWIdx = findHeaderIndex(headers, ['inner', 'w']); + const innerLIdx = findHeaderIndex(headers, ['inner', 'l']); + const innerHIdx = findHeaderIndex(headers, ['inner', 'h']); + const outerWIdx = findHeaderIndex(headers, ['outer', 'w']); + const outerLIdx = findHeaderIndex(headers, ['outer', 'l']); + const outerHIdx = findHeaderIndex(headers, ['outer', 'h']); + + const articleNo = String(getValue(row, articleNoIdx) ?? ''); + + const itemsPayload = { + no: getValue(row, articleNoIdx), + articleDetailsEnglish: getValue(row, articleDetailsEnIdx), + articleDetailsGerman: getValue(row, articleDetailsDeIdx), + launchDate: formatDateForBc(getValue(row, launchDateIdx)), + readyToOrderDate: formatDateForBc(getValue(row, readyToOrderDateIdx)), + minimumOrderQuantity: toBcDecimal(getValue(row, moqIdx)), + shortDescriptionInGerman: getValue(row, shortDeIdx), + shortDescriptionInEnglish: getValue(row, shortEnIdx), + cpnpNo: getValue(row, cpnpIdx), + }; + + const itemUnitsOfMeasurePayload = { + itemNo: getValue(row, articleNoIdx), + qtyPerUnitOfMeasure6: toBcDecimal(getValue(row, unitsOuterIdx)), + width4: toBcDecimal(getValue(row, innerWIdx)), + length4: toBcDecimal(getValue(row, innerLIdx)), + height4: toBcDecimal(getValue(row, innerHIdx)), + width6: toBcDecimal(getValue(row, outerWIdx)), + length6: toBcDecimal(getValue(row, outerLIdx)), + height6: toBcDecimal(getValue(row, outerHIdx)), + }; + + return { + articleNo, + itemsPayload, + itemUnitsOfMeasurePayload, + itemsFields: [ + makeFieldPreview('Article No.', articleNoIdx, 'no', itemsPayload.no), + makeFieldPreview('Article Details - English', articleDetailsEnIdx, 'articleDetailsEnglish', itemsPayload.articleDetailsEnglish), + makeFieldPreview('Article Details - German', articleDetailsDeIdx, 'articleDetailsGerman', itemsPayload.articleDetailsGerman), + makeFieldPreview('Launch Date', launchDateIdx, 'launchDate', itemsPayload.launchDate), + makeFieldPreview('Ready to Order Date', readyToOrderDateIdx, 'readyToOrderDate', itemsPayload.readyToOrderDate), + makeFieldPreview('MOQ', moqIdx, 'minimumOrderQuantity', itemsPayload.minimumOrderQuantity), + makeFieldPreview('Short Description - German', shortDeIdx, 'shortDescriptionInGerman', itemsPayload.shortDescriptionInGerman), + makeFieldPreview('Short Description - English', shortEnIdx, 'shortDescriptionInEnglish', itemsPayload.shortDescriptionInEnglish), + makeFieldPreview('CPNP', cpnpIdx, 'cpnpNo', itemsPayload.cpnpNo), + ], + itemUnitsFields: [ + makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnitsOfMeasurePayload.itemNo), + makeFieldPreview('Units Outer', unitsOuterIdx, 'qtyPerUnitOfMeasure6', itemUnitsOfMeasurePayload.qtyPerUnitOfMeasure6), + makeFieldPreview('CDU/Inner W (cm)', innerWIdx, 'width4', itemUnitsOfMeasurePayload.width4), + makeFieldPreview('CDU/Inner L (cm)', innerLIdx, 'length4', itemUnitsOfMeasurePayload.length4), + makeFieldPreview('CDU/Inner H (cm)', innerHIdx, 'height4', itemUnitsOfMeasurePayload.height4), + makeFieldPreview('Outer W (cm)', outerWIdx, 'width6', itemUnitsOfMeasurePayload.width6), + makeFieldPreview('Outer L (cm)', outerLIdx, 'length6', itemUnitsOfMeasurePayload.length6), + makeFieldPreview('Outer H (cm)', outerHIdx, 'height6', itemUnitsOfMeasurePayload.height6), + ], + }; +} + +function normalizeForComparison(field, value) { + const strField = String(field || '').toLowerCase(); + + if (DECIMAL_FIELDS.has(field) && (value === null || value === undefined || value === '')) { + return '0'; + } + + if (value === null || value === undefined || value === '') return null; + + if (strField.includes('date')) { + return formatDateForBc(value); + } + + if (typeof value === 'string') { + return value.replace(/\r\n/g, '\n').trimEnd(); + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + + return String(value).trim(); +} + +function buildFieldChanges(fields, currentRecord, desiredPayload) { + return fields + .filter(field => field.targetField !== 'itemNo' && field.targetField !== 'no') + .map(field => { + const before = currentRecord ? currentRecord[field.targetField] : undefined; + const after = desiredPayload[field.targetField]; + const normalizedBefore = normalizeForComparison(field.targetField, before); + const normalizedAfter = normalizeForComparison(field.targetField, after); + + return { + sourceLabel: field.sourceLabel, + sourceIndex: field.sourceIndex, + targetField: field.targetField, + before, + after, + changed: normalizedBefore !== normalizedAfter, + }; + }); +} + +function describeUnappliedChanges(sectionLabel, changes) { + return changes + .filter(change => change.changed) + .map(change => `${change.sourceLabel} (${change.targetField}): expected ${JSON.stringify(change.after)} but BC has ${JSON.stringify(change.before)}`) + .join('; '); +} + +function buildChangedPayload(changes) { + const payload = {}; + + changes + .filter(change => change.changed) + .forEach(change => { + const field = change.targetField; + const value = change.after; + + if (DECIMAL_FIELDS.has(field)) { + payload[field] = toBcDecimal(value); + return; + } + + if (EMPTY_STRING_FIELDS.has(field)) { + payload[field] = value === null || value === undefined ? '' : String(value); + return; + } + + if (String(field || '').toLowerCase().includes('date')) { + payload[field] = formatDateForBc(value); + return; + } + + payload[field] = value; + }); + + return payload; +} + +function buildPreviewHash(payload) { + return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); +} + +function getItemsSelect() { + return [ + 'systemId', + 'no', + 'articleDetailsEnglish', + 'articleDetailsGerman', + 'launchDate', + 'readyToOrderDate', + 'minimumOrderQuantity', + 'shortDescriptionInGerman', + 'shortDescriptionInEnglish', + 'cpnpNo', + ].join(','); +} + +function getItemUnitsSelect() { + return [ + 'itemNo', + 'qtyPerUnitOfMeasure6', + 'width4', + 'length4', + 'height4', + 'width6', + 'length6', + 'height6', + ].join(','); +} + +async function fetchJsonOrThrow(url, token, label) { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + }); + + if (!res.ok) { + const txt = await res.text(); + throw new Error(`${label} failed (${res.status}): ${txt}`); + } + + return { + json: await res.json(), + etag: res.headers.get('etag') || null, + }; +} + +export async function fetchBusinessCentralSnapshot(config, token, articleNo) { + const itemsFilter = encodeURIComponent(`no eq '${articleNo}'`); + const itemsUrl = `${getItemsUrl(config)}?$filter=${itemsFilter}&$select=${getItemsSelect()}`; + const { json: itemsJson, etag: itemsEtag } = await fetchJsonOrThrow(itemsUrl, token, 'BC GET items'); + const item = Array.isArray(itemsJson.value) && itemsJson.value.length > 0 ? itemsJson.value[0] : null; + + const uomFilter = encodeURIComponent(`itemNo eq '${articleNo}'`); + const uomUrl = `${getItemUnitsOfMeasureUrl(config)}?$filter=${uomFilter}&$select=${getItemUnitsSelect()}`; + const { json: uomJson, etag: uomEtag } = await fetchJsonOrThrow(uomUrl, token, 'BC GET itemUnitsOfMeasure'); + const itemUnitsOfMeasure = Array.isArray(uomJson.value) && uomJson.value.length > 0 ? uomJson.value[0] : null; + + return { + item, + itemUnitsOfMeasure, + itemEtag: item?.['@odata.etag'] || itemsEtag || '*', + itemUnitsEtag: itemUnitsOfMeasure?.['@odata.etag'] || uomEtag || '*', + }; +} + +function makePreviewSection({ + type, + desiredPayload, + currentRecord, + fieldPreviews, + writeMethod, + writeUrlTemplate, + writeBodyTemplate, +}) { + const changes = buildFieldChanges(fieldPreviews, currentRecord, desiredPayload); + return { + type, + desired: desiredPayload, + current: currentRecord || null, + changes, + changedFields: changes.filter(change => change.changed).map(change => change.targetField), + writeConfigured: Boolean(writeUrlTemplate), + writeMethod: writeMethod || null, + writeUrlTemplate: writeUrlTemplate || null, + writeBodyTemplate: writeBodyTemplate || null, + canApply: Boolean(writeUrlTemplate), + }; +} + +export function buildBusinessCentralSyncPreview(config, mapping, snapshot) { + const itemsSection = makePreviewSection({ + type: 'items', + desiredPayload: mapping.itemsPayload, + currentRecord: snapshot.item, + fieldPreviews: mapping.itemsFields, + writeMethod: config.itemsWriteMethod || config.writeMethod, + writeUrlTemplate: config.itemsWriteUrlTemplate || config.writeUrlTemplate, + writeBodyTemplate: config.itemsWriteBodyTemplate || config.writeBodyTemplate || null, + }); + + const itemUnitsSection = makePreviewSection({ + type: 'itemUnitsOfMeasure', + desiredPayload: mapping.itemUnitsOfMeasurePayload, + currentRecord: snapshot.itemUnitsOfMeasure, + fieldPreviews: mapping.itemUnitsFields, + writeMethod: config.itemUnitsWriteMethod || null, + writeUrlTemplate: config.itemUnitsWriteUrlTemplate || null, + writeBodyTemplate: config.itemUnitsWriteBodyTemplate || null, + }); + + const previewPayload = { + articleNo: mapping.articleNo, + items: itemsSection, + itemUnitsOfMeasure: itemUnitsSection, + }; + + return { + ...previewPayload, + hasChanges: itemsSection.changes.some(change => change.changed) || itemUnitsSection.changes.some(change => change.changed), + previewToken: buildPreviewHash({ + articleNo: mapping.articleNo, + items: itemsSection.changes, + itemUnitsOfMeasure: itemUnitsSection.changes, + }), + }; +} + +export async function previewBusinessCentralSync(config, token, headers, row) { + const mapping = buildBusinessCentralMappingPreview(headers, row); + const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo); + return buildBusinessCentralSyncPreview(config, mapping, snapshot); +} + +async function renderAndPatchRecord({ token, url, method, body, etag }) { + const headers = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; + + if (String(method || 'PATCH').toUpperCase() === 'PATCH') { + headers['If-Match'] = etag || '*'; + } + + const res = await fetch(url, { + method: method || 'PATCH', + headers, + body, + }); + + if (!res.ok) { + const txt = await res.text(); + throw new Error(`BC write failed (${res.status}): ${txt}`); + } +} + +function renderTemplate(template, context) { + return String(template).replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, key) => { + const value = context[key]; + if (value === null || value === undefined) return ''; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + }); +} + +const DECIMAL_FIELDS = new Set([ + 'minimumOrderQuantity', + 'qtyPerUnitOfMeasure6', + 'width4', + 'length4', + 'height4', + 'width6', + 'length6', + 'height6', +]); + +const EMPTY_STRING_FIELDS = new Set([ + 'articleDetailsEnglish', + 'articleDetailsGerman', + 'shortDescriptionInGerman', + 'shortDescriptionInEnglish', + 'cpnpNo', +]); + +function normalizeDecimalPayload(value) { + if (Array.isArray(value)) { + return value.map(normalizeDecimalPayload); + } + + if (!value || typeof value !== 'object') { + return value; + } + + const next = {}; + for (const [key, nested] of Object.entries(value)) { + if (EMPTY_STRING_FIELDS.has(key) && (nested === null || nested === undefined)) { + next[key] = ''; + continue; + } + if (DECIMAL_FIELDS.has(key)) { + next[key] = toBcDecimal(nested); + continue; + } + next[key] = normalizeDecimalPayload(nested); + } + return next; +} + +function renderJsonBody(template, context, fallbackPayload) { + const rendered = template ? renderTemplate(template, context) : JSON.stringify(fallbackPayload); + if (typeof rendered !== 'string') { + return JSON.stringify(normalizeDecimalPayload(rendered)); + } + + try { + const parsed = JSON.parse(rendered); + return JSON.stringify(normalizeDecimalPayload(parsed)); + } catch { + return rendered; + } +} + +async function applyItemsSection(config, token, snapshot, preview, context) { + const changedFields = preview.items.changes.filter(change => change.changed); + if (changedFields.length === 0) { + return { applied: false, reason: 'No item changes' }; + } + + if (!config.itemsWriteUrlTemplate) { + return { applied: false, reason: 'Items write template not configured' }; + } + + const url = renderTemplate(config.itemsWriteUrlTemplate, context); + const payload = renderJsonBody(preview.items.writeBodyTemplate, context, buildChangedPayload(preview.items.changes)); + + await renderAndPatchRecord({ + token, + url, + method: preview.items.writeMethod || 'PATCH', + body: payload, + etag: snapshot.itemEtag || snapshot.item?.['@odata.etag'] || '*', + }); + + return { applied: true, url }; +} + +async function applyItemUnitsSection(config, token, snapshot, preview, context) { + const changedFields = preview.itemUnitsOfMeasure.changes.filter(change => change.changed); + if (changedFields.length === 0) { + return { applied: false, reason: 'No itemUnitsOfMeasure changes' }; + } + + if (!config.itemUnitsWriteUrlTemplate) { + return { applied: false, reason: 'itemUnitsOfMeasure write template not configured' }; + } + + const url = renderTemplate(config.itemUnitsWriteUrlTemplate, context); + const payload = renderJsonBody(preview.itemUnitsOfMeasure.writeBodyTemplate, context, buildChangedPayload(preview.itemUnitsOfMeasure.changes)); + + await renderAndPatchRecord({ + token, + url, + method: preview.itemUnitsOfMeasure.writeMethod || 'PATCH', + body: payload, + etag: snapshot.itemUnitsEtag || snapshot.itemUnitsOfMeasure?.['@odata.etag'] || '*', + }); + + return { applied: true, url }; +} + +export async function applyBusinessCentralSync(config, token, headers, row, previewToken) { + const mapping = buildBusinessCentralMappingPreview(headers, row); + const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo); + const preview = buildBusinessCentralSyncPreview(config, mapping, snapshot); + + if (previewToken && previewToken !== preview.previewToken) { + const error = new Error('Preview token mismatch. BC data changed or preview is stale.'); + error.statusCode = 409; + throw error; + } + + const context = { + itemsUrl: getItemsUrl(config), + itemUnitsOfMeasureUrl: getItemUnitsOfMeasureUrl(config), + articleNo: mapping.articleNo, + itemNo: mapping.articleNo, + systemId: snapshot.item?.systemId || '', + cpnpNo: mapping.itemsPayload.cpnpNo, + itemsPayload: preview.items.desired, + itemUnitsPayload: preview.itemUnitsOfMeasure.desired, + ...preview.items.desired, + ...preview.itemUnitsOfMeasure.desired, + }; + + const results = { + items: await applyItemsSection(config, token, snapshot, preview, context), + itemUnitsOfMeasure: await applyItemUnitsSection(config, token, snapshot, preview, context), + }; + + if (results.items.applied || results.itemUnitsOfMeasure.applied) { + const verificationSnapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo); + + if (results.items.applied) { + const itemMismatches = preview.items.changes + .filter(change => change.changed) + .map(change => ({ + ...change, + before: verificationSnapshot.item ? verificationSnapshot.item[change.targetField] : undefined, + })) + .filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after)); + if (itemMismatches.length > 0) { + const error = new Error(`BC verification failed for items: ${describeUnappliedChanges('items', itemMismatches)}`); + error.statusCode = 409; + throw error; + } + } + + if (results.itemUnitsOfMeasure.applied) { + const uomMismatches = preview.itemUnitsOfMeasure.changes + .filter(change => change.changed) + .map(change => ({ + ...change, + before: verificationSnapshot.itemUnitsOfMeasure ? verificationSnapshot.itemUnitsOfMeasure[change.targetField] : undefined, + })) + .filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after)); + if (uomMismatches.length > 0) { + const error = new Error(`BC verification failed for itemUnitsOfMeasure: ${describeUnappliedChanges('itemUnitsOfMeasure', uomMismatches)}`); + error.statusCode = 409; + throw error; + } + } + } + + return { + success: true, + articleNo: mapping.articleNo, + previewToken: preview.previewToken, + results, + preview, + }; +} + +export async function previewBusinessCentralCpnp(config, token, articleNo, cpnpNo) { + const { json } = await fetchJsonOrThrow( + `${getItemsUrl(config)}?$filter=${encodeURIComponent(`no eq '${articleNo}'`)}&$select=${getItemsSelect()}`, + token, + 'BC GET items' + ); + const item = Array.isArray(json.value) && json.value.length > 0 ? json.value[0] : null; + + if (!item) { + throw new Error(`Item not found in BC: ${articleNo}`); + } + + const desired = { cpnpNo: String(cpnpNo) }; + const current = { cpnpNo: item.cpnpNo }; + const changes = buildFieldChanges( + [{ sourceLabel: 'CPNP', sourceIndex: null, targetField: 'cpnpNo' }], + current, + desired + ); + + return { + articleNo, + items: { + type: 'items', + desired, + current, + changes, + changedFields: changes.filter(change => change.changed).map(change => change.targetField), + writeConfigured: Boolean(config.writeUrlTemplate), + writeMethod: config.writeMethod || 'PATCH', + writeUrlTemplate: config.writeUrlTemplate || null, + writeBodyTemplate: config.writeBodyTemplate || null, + canApply: Boolean(config.writeUrlTemplate), + }, + itemUnitsOfMeasure: { + type: 'itemUnitsOfMeasure', + desired: {}, + current: null, + changes: [], + changedFields: [], + writeConfigured: Boolean(config.itemUnitsWriteUrlTemplate), + writeMethod: config.itemUnitsWriteMethod || null, + writeUrlTemplate: config.itemUnitsWriteUrlTemplate || null, + writeBodyTemplate: config.itemUnitsWriteBodyTemplate || null, + canApply: false, + }, + hasChanges: changes.some(change => change.changed), + previewToken: buildPreviewHash({ + articleNo, + cpnpNo: String(cpnpNo), + current: item.cpnpNo, + }), + }; +} + +export async function applyBusinessCentralCpnp(config, token, articleNo, cpnpNo, previewToken) { + const preview = await previewBusinessCentralCpnp(config, token, articleNo, cpnpNo); + if (previewToken && previewToken !== preview.previewToken) { + const error = new Error('Preview token mismatch. BC data changed or preview is stale.'); + error.statusCode = 409; + throw error; + } + + const item = preview.items.current; + const url = renderTemplate(config.writeUrlTemplate || `{{itemsUrl}}('{{itemNo}}')`, { + itemsUrl: getItemsUrl(config), + articleNo, + itemNo: articleNo, + cpnpNo: String(cpnpNo), + systemId: item?.systemId || '', + itemsPayload: { cpnpNo: String(cpnpNo) }, + }); + const body = config.writeBodyTemplate + ? renderTemplate(config.writeBodyTemplate, { + itemsUrl: getItemsUrl(config), + articleNo, + itemNo: articleNo, + cpnpNo: String(cpnpNo), + systemId: item?.systemId || '', + itemsPayload: { cpnpNo: String(cpnpNo) }, + }) + : JSON.stringify({ cpnpNo: String(cpnpNo) }); + + await renderAndPatchRecord({ + token, + url, + method: config.writeMethod || 'PATCH', + body, + etag: item?.['@odata.etag'] || '*', + }); + + return { + success: true, + articleNo, + cpnpNo: String(cpnpNo), + previewToken: preview.previewToken, + }; +} + +export { getBcConfig, getBCToken, getItemsUrl, getItemUnitsOfMeasureUrl }; diff --git a/src/App.tsx b/src/App.tsx index 5c8677f..c12b53a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,7 +21,7 @@ import { UndoToast } from './components/UndoToast'; import { PendingValidationView } from './components/PendingValidationView'; import { MissingDataView } from './components/MissingDataView'; import { CosmeticItemsView } from './components/CosmeticItemsView'; -import { downloadBusinessCentralItemsExcel, updateCpnpNoInBC } from './services/businessCentral'; +import { downloadBusinessCentralItemsExcel, previewBusinessCentralSync, applyBusinessCentralSync, isPreviewTokenMismatchError } from './services/businessCentral'; const FORCED_ZERO_STOCK_SKUS = new Set([ '11631VC', '1237VC', '1238VC', '1652VC', '1653VC', '1684VC', '1688VC', '1717VC', '1718VC', '180VC', '1832VC', '2025VC', '2027VC', '2180VC', '2181VC', '2210VC', @@ -50,6 +50,36 @@ const FORCED_ZERO_STOCK_SKUS = new Set([ '6471VC', '5658VCI', '2915244CLM', '30512912CLM', '2861262CLM', '41683MDRG', '41891MDRG' ]); +const BC_SYNC_QUEUE_STORAGE_KEY = 'craze_bc_sync_queue'; + +type BcSyncStatus = 'queued' | 'previewed' | 'syncing' | 'synced' | 'failed'; + +interface BcSyncQueueEntry { + rowIndex: number; + originalData: ExcelRow; + newData: ExcelRow; + articleName: string; + selected: boolean; + status: BcSyncStatus; + previewToken?: string; + error?: string; + source: 'save_all' | 'cosmetic_items' | 'manual'; +} + +type BcSyncQueue = Record; + +function readStoredBcSyncQueue(): BcSyncQueue { + try { + const raw = localStorage.getItem(BC_SYNC_QUEUE_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return parsed as BcSyncQueue; + } catch { + return {}; + } +} + export default function App() { const [session, setSession] = useState(() => getStoredSession()); @@ -68,7 +98,9 @@ export default function App() { const [defaultLoadError, setDefaultLoadError] = useState(null); const [rowStatuses, setRowStatuses] = useState>({}); const [pendingRows, setPendingRows] = useState>({}); + const [bcSyncQueue, setBcSyncQueue] = useState(() => readStoredBcSyncQueue()); const [isSavingAll, setIsSavingAll] = useState(false); + const [isSyncingBC, setIsSyncingBC] = useState(false); const [isDownloadingBCExcel, setIsDownloadingBCExcel] = useState(false); const [refreshTrigger, setRefreshTrigger] = useState(0); @@ -76,6 +108,14 @@ export default function App() { console.log('[App] session changed:', session ? 'logged in' : 'logged out'); }, [session]); + useEffect(() => { + try { + localStorage.setItem(BC_SYNC_QUEUE_STORAGE_KEY, JSON.stringify(bcSyncQueue)); + } catch { + // Ignore storage quota or serialization errors. + } + }, [bcSyncQueue]); + const handleSignOut = () => { signOut(); setSession(null); @@ -231,18 +271,31 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; finalRow.push(null); } - // Merge logic: Prioritize internal control columns from syncedData or historyData - // Use synced.data only if it has internal cols (>= 100), otherwise use hist - const syncedHasInternalCols = synced?.data && (synced.data.length > 100 || Object.values(COLUMNS).some(idx => idx >= 100 && synced.data[idx] !== undefined && synced.data[idx] !== null)); - const sourceForInternal = syncedHasInternalCols ? synced!.data : (hist || synced?.data); - - if (sourceForInternal) { - // 1. ALWAYS restore Internal Control Columns (indices >= 100) + // Merge logic: + // - Use the Dropbox row as the base for master data and prices. + // - Restore internal control columns from history when available. + // This is critical because some synced rows still have empty internal + // slots even when the change history already contains the corrected value. + const mergedInternalSource = hist || synced?.data; + + if (mergedInternalSource) { + // 1. Restore internal control columns (indices >= 100) // These are the "TYPE", "Item to Logistic", "Checking", etc. - // We use hardcoded indices from COLUMNS to ensure they stay at the end. + // We use hardcoded indices from COLUMNS to keep them stable. Object.values(COLUMNS).forEach(idx => { - if (idx >= 100 && sourceForInternal[idx] !== undefined && sourceForInternal[idx] !== null) { - finalRow[idx] = sourceForInternal[idx]; + if (idx < 100) return; + + const historyValue = hist?.[idx]; + const syncedValue = synced?.data?.[idx]; + const value = + historyValue !== undefined && historyValue !== null && historyValue !== '' + ? historyValue + : syncedValue !== undefined && syncedValue !== null && syncedValue !== '' + ? syncedValue + : undefined; + + if (value !== undefined) { + finalRow[idx] = value; } }); @@ -250,8 +303,9 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; // This protects against the "Column Shift" bug where old saved indices might be wrong. if (synced?.status === 'pending' || synced?.status === 'edited') { editableColumns.forEach(idx => { - if (idx < 100 && sourceForInternal[idx] !== undefined && sourceForInternal[idx] !== null) { - finalRow[idx] = sourceForInternal[idx]; + const value = synced?.data?.[idx]; + if (idx < 100 && value !== undefined && value !== null) { + finalRow[idx] = value; } }); } @@ -529,6 +583,12 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; }); setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; }); setRowStatuses(prev => { const n = { ...prev }; delete n[articleNo]; return n; }); + setBcSyncQueue(prev => { + if (!prev[articleNo]) return prev; + const n = { ...prev }; + delete n[articleNo]; + return n; + }); }; const handleSaveAll = async () => { @@ -544,28 +604,32 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; let sessionIssue = false; try { - await Promise.all(entries.map(async ([articleNo, { newData, originalData, articleName }]) => { + await Promise.all(entries.map(async ([articleNo, { rowIndex, newData, originalData, articleName }]) => { console.log('[handleSaveAll] Saving article:', articleNo); const result = await saveRowToSupabase(articleNo, newData); console.log('[handleSaveAll] Save result for', articleNo, ':', result); if (result.success) { - const cpnpValue = String(newData[COLUMNS.CPNP_NO] ?? '').trim(); - const cpnpChanged = cpnpValue !== String(originalData[COLUMNS.CPNP_NO] ?? '').trim(); - - const [histRes, bcResult] = await Promise.all([ - saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown'), - cpnpValue && cpnpChanged ? updateCpnpNoInBC(articleNo, cpnpValue) : Promise.resolve({ success: true }), - ]); + const histRes = await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown'); if (!histRes.success) { console.warn(`[handleSaveAll] History save failed for ${articleNo}:`, histRes.error); } - if (!bcResult.success) { - console.warn(`[handleSaveAll] BC sync failed for ${articleNo}:`, (bcResult as any).error); - } - setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' })); + setBcSyncQueue(prev => ({ + ...prev, + [articleNo]: { + rowIndex, + originalData, + newData, + articleName, + selected: true, + status: 'queued', + source: 'save_all', + }, + })); + + setRowStatuses(prev => ({ ...prev, [articleNo]: 'bc_pending' })); setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; @@ -598,6 +662,189 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; } }; + const handleQueueBcSync = (articleNo: string, rowIndex: number, originalData: ExcelRow, newData: ExcelRow, articleName: string) => { + setBcSyncQueue(prev => ({ + ...prev, + [articleNo]: { + rowIndex, + originalData, + newData, + articleName, + selected: true, + status: 'queued', + source: 'cosmetic_items', + }, + })); + setRowStatuses(prev => ({ ...prev, [articleNo]: 'bc_pending' })); + }; + + const handleToggleBcQueueSelection = (articleNo: string) => { + setBcSyncQueue(prev => { + const item = prev[articleNo]; + if (!item) return prev; + return { + ...prev, + [articleNo]: { + ...item, + selected: !item.selected, + }, + }; + }); + }; + + const handleSelectAllBcQueue = (selected: boolean) => { + setBcSyncQueue(prev => { + const next: BcSyncQueue = { ...prev }; + (Object.keys(next) as string[]).forEach(articleNo => { + const item = next[articleNo]; + next[articleNo] = { ...item, selected }; + }); + return next; + }); + }; + + const bcQueueEntries = useMemo>( + () => Object.entries(bcSyncQueue) as Array<[string, BcSyncQueueEntry]>, + [bcSyncQueue] + ); + + const selectedBcQueueEntries = useMemo>( + () => bcQueueEntries.filter(([, item]) => item.selected), + [bcQueueEntries] + ); + + useEffect(() => { + if (appState.data.length === 0) return; + const validArticles = new Set(appState.data.map(row => String(row[COLUMNS.ARTICLE_NO] ?? '').trim()).filter(Boolean)); + setBcSyncQueue(prev => { + let changed = false; + const next: BcSyncQueue = {}; + for (const [articleNo, item] of Object.entries(prev) as Array<[string, BcSyncQueueEntry]>) { + if (!validArticles.has(articleNo)) { + changed = true; + continue; + } + next[articleNo] = item; + } + return changed ? next : prev; + }); + }, [appState.data]); + + const handlePreviewSelectedBcSync = async () => { + if (selectedBcQueueEntries.length === 0) return; + setIsSyncingBC(true); + try { + const results = await Promise.all(selectedBcQueueEntries.map(async ([articleNo, item]) => { + const preview = await previewBusinessCentralSync(appState.headers, item.newData); + return [articleNo, preview] as const; + })); + + setBcSyncQueue(prev => { + const next: BcSyncQueue = { ...prev }; + results.forEach(([articleNo, preview]) => { + const item = next[articleNo]; + if (!item || !preview.success) { + if (item) { + next[articleNo] = { + ...item, + status: 'failed', + error: preview.error || 'Preview failed', + }; + } + return; + } + + next[articleNo] = { + ...item, + status: 'previewed', + previewToken: preview.previewToken, + error: undefined, + }; + }); + return next; + }); + } finally { + setIsSyncingBC(false); + } + }; + + const handleSyncSelectedBcSync = async () => { + if (selectedBcQueueEntries.length === 0) return; + setIsSyncingBC(true); + try { + const selectedArticles = new Set(selectedBcQueueEntries.map(([articleNo]) => articleNo)); + + setBcSyncQueue(prev => { + const next: BcSyncQueue = { ...prev }; + (Object.entries(next) as Array<[string, BcSyncQueueEntry]>).forEach(([articleNo, item]) => { + if (selectedArticles.has(articleNo)) { + next[articleNo] = { ...item, status: 'syncing', error: undefined }; + } + }); + return next; + }); + + for (const [articleNo, item] of selectedBcQueueEntries) { + const result = await applyBusinessCentralSync(appState.headers, item.newData, item.previewToken); + if (!result.success) { + if (isPreviewTokenMismatchError(result.error)) { + const refreshedPreview = await previewBusinessCentralSync(appState.headers, item.newData); + if (refreshedPreview.success) { + setBcSyncQueue(prev => ({ + ...prev, + [articleNo]: { + ...prev[articleNo], + status: 'previewed', + previewToken: refreshedPreview.previewToken, + error: undefined, + }, + })); + + const retryResult = await applyBusinessCentralSync(appState.headers, item.newData, refreshedPreview.previewToken); + if (retryResult.success) { + setBcSyncQueue(prev => { + const next = { ...prev }; + delete next[articleNo]; + return next; + }); + setRowStatuses(prev => ({ ...prev, [articleNo]: 'synced' })); + continue; + } + + setBcSyncQueue(prev => ({ + ...prev, + [articleNo]: { + ...prev[articleNo], + status: 'failed', + error: retryResult.error || 'BC sync failed', + }, + })); + continue; + } + } + setBcSyncQueue(prev => ({ + ...prev, + [articleNo]: { + ...prev[articleNo], + status: 'failed', + error: result.error || 'BC sync failed', + }, + })); + continue; + } + + setBcSyncQueue(prev => { + const next = { ...prev }; + delete next[articleNo]; + return next; + }); + setRowStatuses(prev => ({ ...prev, [articleNo]: 'synced' })); + } + } finally { + setIsSyncingBC(false); + } + }; + const handleDownloadBCExcel = async () => { setIsDownloadingBCExcel(true); try { @@ -616,6 +863,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; data: JSON.parse(JSON.stringify(appState.data)), // Deep copy rowStatuses: { ...rowStatuses }, pendingRows: { ...pendingRows }, + bcSyncQueue: JSON.parse(JSON.stringify(bcSyncQueue)) as BcSyncQueue, message }; // Keep last 50 steps @@ -639,6 +887,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; // Restore statuses and pending state if (lastAction.rowStatuses) setRowStatuses(lastAction.rowStatuses); if (lastAction.pendingRows) setPendingRows(lastAction.pendingRows); + if (lastAction.bcSyncQueue) setBcSyncQueue(lastAction.bcSyncQueue); setUndoHistory(remainingHistory); }; @@ -709,6 +958,13 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; onSaveAll={handleSaveAll} onRevertRow={handleRevertRow} isSavingAll={isSavingAll} + bcQueueCount={Object.keys(bcSyncQueue).length} + bcQueueEntries={Object.fromEntries((Object.entries(bcSyncQueue) as Array<[string, BcSyncQueueEntry]>).map(([k, v]) => [k, { articleName: v.articleName, selected: v.selected, status: v.status, error: v.error }]))} + onToggleBcQueueSelection={handleToggleBcQueueSelection} + onSelectAllBcQueue={handleSelectAllBcQueue} + onPreviewSelectedBcSync={handlePreviewSelectedBcSync} + onSyncSelectedBcSync={handleSyncSelectedBcSync} + isSyncingBC={isSyncingBC} isDownloadingBCExcel={isDownloadingBCExcel} isMaximized={isMaximized} onToggleMaximize={() => setIsMaximized(true)} @@ -823,6 +1079,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; onSaveRow={handleSaveRow} onCaptureState={captureState} rowStatuses={rowStatuses} + onQueueBcSync={handleQueueBcSync} /> )} {activeModule === 'history' && ( diff --git a/src/components/ArticleDetails.tsx b/src/components/ArticleDetails.tsx index 7a8fcb8..dcb343c 100644 --- a/src/components/ArticleDetails.tsx +++ b/src/components/ArticleDetails.tsx @@ -4,6 +4,7 @@ import { useColumns } from '../contexts/ColumnsContext'; import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X, Maximize2 } from 'lucide-react'; import { cn } from '../lib/utils'; import { ColumnFilterPopover } from './ColumnFilterPopover'; +import { SyncStatusPill } from './SyncStatusPill'; interface ArticleDetailsProps { data: ExcelRow[]; @@ -319,7 +320,12 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp "" )} > - {row[COLUMNS.ARTICLE_NO]} + +
+ {row[COLUMNS.ARTICLE_NO]} + {saveStatus && } +
+ {row[COLUMNS.ARTICLE_NAME]} diff --git a/src/components/CosmeticItemsView.tsx b/src/components/CosmeticItemsView.tsx index 317da17..eeb1b3f 100644 --- a/src/components/CosmeticItemsView.tsx +++ b/src/components/CosmeticItemsView.tsx @@ -6,7 +6,7 @@ import { cn } from '../lib/utils'; import { ColumnFilterPopover } from './ColumnFilterPopover'; import { usePersistentState } from '../contexts/FilterContext'; import { saveRowToSupabase } from '../lib/supabase'; -import { updateCpnpNoInBC } from '../services/businessCentral'; +import { SyncStatusPill } from './SyncStatusPill'; const COSMETIC_LINES = ['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS']; @@ -16,9 +16,10 @@ interface CosmeticItemsViewProps { onSaveRow: (rowIndex: number, updatedRow: ExcelRow) => void; onCaptureState: (message: string) => void; rowStatuses: Record; + onQueueBcSync: (articleNo: string, rowIndex: number, originalData: ExcelRow, newData: ExcelRow, articleName: string) => void; } -export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, rowStatuses }: CosmeticItemsViewProps) { +export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, rowStatuses, onQueueBcSync }: CosmeticItemsViewProps) { const COLUMNS = useColumns(); const [search, setSearch] = usePersistentState('cosmeticItems-search', ''); const [sortCol, setSortCol] = usePersistentState('cosmeticItems-sortCol', null); @@ -143,16 +144,15 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro onCaptureState(`Updated CPNP No. for ${articleNo}`); onSaveRow(rowIndex, newRow); - const [supabaseResult, bcResult] = await Promise.all([ - saveRowToSupabase(articleNo, newRow, 'edited'), - updateCpnpNoInBC(articleNo, cpnpValue), - ]); + const supabaseResult = await saveRowToSupabase(articleNo, newRow, 'edited'); + if (supabaseResult.success) { + onQueueBcSync(articleNo, rowIndex, row, newRow, String(row[COLUMNS.ARTICLE_NAME] || articleNo)); + } setSavingCpnp(null); const errors: string[] = []; if (!supabaseResult.success) errors.push(`Supabase: ${supabaseResult.error}`); - if (!bcResult.success) errors.push(`Business Central: ${bcResult.error}`); if (errors.length > 0) { alert(`Error saving CPNP No. for ${articleNo}:\n${errors.join('\n')}`); } @@ -277,7 +277,10 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro )} > - {articleNo} +
+ {articleNo} + {status && } +
{row[COLUMNS.ARTICLE_NAME]} diff --git a/src/components/DimensionsView.tsx b/src/components/DimensionsView.tsx index 2ed8729..36983cc 100644 --- a/src/components/DimensionsView.tsx +++ b/src/components/DimensionsView.tsx @@ -6,6 +6,7 @@ import { cn } from '../lib/utils'; import { ConfirmModal } from './ConfirmModal'; import { ColumnFilterPopover } from './ColumnFilterPopover'; import { usePersistentState } from '../contexts/FilterContext'; +import { SyncStatusPill } from './SyncStatusPill'; interface DimensionsViewProps { data: ExcelRow[]; @@ -802,6 +803,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat {group.rows.map(({ row, index }) => { const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending'; + const syncStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])]; return (
{row[COLUMNS.ARTICLE_NO]}
{row[COLUMNS.ARTICLE_NAME]}
+ {syncStatus && } {row[COLUMNS.INNER_L] !== undefined && row[COLUMNS.INNER_L] !== null ? row[COLUMNS.INNER_L] : '-'} × {row[COLUMNS.INNER_W] !== undefined && row[COLUMNS.INNER_W] !== null ? row[COLUMNS.INNER_W] : '-'} × {row[COLUMNS.INNER_H] !== undefined && row[COLUMNS.INNER_H] !== null ? row[COLUMNS.INNER_H] : '-'} diff --git a/src/components/MatrixView.tsx b/src/components/MatrixView.tsx index 6d08f64..d69ed04 100644 --- a/src/components/MatrixView.tsx +++ b/src/components/MatrixView.tsx @@ -1,9 +1,12 @@ -import React, { useState, useMemo } from 'react'; -import { ExcelRow } from '../types'; -import { Search, Filter, ChevronDown, ChevronUp, X, Maximize2 } from 'lucide-react'; +import React, { useState, useMemo, useEffect } from 'react'; +import { ExcelRow, COLUMNS } from '../types'; +import { useColumns } from '../contexts/ColumnsContext'; +import { Search, Filter, ChevronDown, ChevronUp, X, Maximize2, Check, Loader2 } from 'lucide-react'; import { cn } from '../lib/utils'; import { ColumnFilterPopover } from './ColumnFilterPopover'; import { usePersistentState } from '../contexts/FilterContext'; +import { buildBusinessCentralMappingPreview } from '../services/businessCentralMapping'; +import { applyBusinessCentralSync, previewBusinessCentralSync, isPreviewTokenMismatchError } from '../services/businessCentral'; interface MatrixViewProps { data: ExcelRow[]; @@ -12,14 +15,20 @@ interface MatrixViewProps { } export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) { + const resolvedCols = useColumns(); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(25); const [search, setSearch] = usePersistentState('matrix-search', ''); + const [bcSku, setBcSku] = usePersistentState('matrix-bcSku', ''); const [columnFilters, setColumnFilters] = usePersistentState>('matrix-columnFilters', {}); const [openFilterCol, setOpenFilterCol] = useState(null); const [sortCol, setSortCol] = usePersistentState('matrix-sortCol', null); const [sortDesc, setSortDesc] = usePersistentState('matrix-sortDesc', false); const [isFullscreen, setIsFullscreen] = useState(false); + const [bcValidationResult, setBcValidationResult] = useState(null); + const [bcValidationLoading, setBcValidationLoading] = useState(false); + const [bcApplyLoading, setBcApplyLoading] = useState(false); + const [bcValidationError, setBcValidationError] = useState(null); const filteredData = useMemo(() => { let result = data.map((row, index) => ({ row, index })); @@ -68,6 +77,22 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) { return filteredData.slice(start, start + pageSize); }, [filteredData, page, pageSize]); + const bcPreviewRow = useMemo(() => { + const targetSku = String(bcSku || '').trim(); + if (!targetSku) return null; + return data.find(row => String(row[resolvedCols.ARTICLE_NO] ?? '') === targetSku) || null; + }, [bcSku, data, resolvedCols.ARTICLE_NO]); + + const bcMappingPreview = useMemo(() => { + if (!bcPreviewRow) return null; + return buildBusinessCentralMappingPreview(headers, bcPreviewRow); + }, [bcPreviewRow, headers]); + + useEffect(() => { + setBcValidationResult(null); + setBcValidationError(null); + }, [bcSku]); + const [columnWidths, setColumnWidths] = useState>({}); const handleSort = (col: number) => { @@ -119,6 +144,52 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) { setPage(1); }; + const handlePreviewBcSync = async () => { + if (!bcPreviewRow) return; + setBcValidationLoading(true); + setBcValidationError(null); + try { + const result = await previewBusinessCentralSync(headers, bcPreviewRow); + if (!result.success) { + setBcValidationResult(null); + setBcValidationError(result.error || 'Preview failed'); + return; + } + setBcValidationResult(result); + } finally { + setBcValidationLoading(false); + } + }; + + const handleApplyBcSync = async () => { + if (!bcPreviewRow || !bcValidationResult?.previewToken) return; + setBcApplyLoading(true); + setBcValidationError(null); + try { + const result = await applyBusinessCentralSync(headers, bcPreviewRow, bcValidationResult.previewToken); + if (!result.success) { + if (isPreviewTokenMismatchError(result.error)) { + const refreshedPreview = await previewBusinessCentralSync(headers, bcPreviewRow); + if (refreshedPreview.success) { + setBcValidationResult(refreshedPreview); + const retry = await applyBusinessCentralSync(headers, bcPreviewRow, refreshedPreview.previewToken); + if (retry.success) { + setBcValidationResult(prev => retry.preview ? { ...retry.preview, hasChanges: false } : prev); + return; + } + setBcValidationError(retry.error || 'Apply failed'); + return; + } + } + setBcValidationError(result.error || 'Apply failed'); + return; + } + setBcValidationResult(prev => result.preview ? { ...result.preview, hasChanges: false } : prev); + } finally { + setBcApplyLoading(false); + } + }; + const formatCellValue = (val: any, header: string = '') => { if (val === undefined || val === null || val === '') return ''; @@ -193,7 +264,25 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {

Matrix View

All data fields formatted to 2 decimal places for numbers/prices.

-
+
+
+ + setBcSku(e.target.value)} + className="w-full pl-9 pr-10 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-medium" + /> + {bcSku && ( + + )} +
+ {bcSku && ( +
+ {!bcMappingPreview ? ( +
+ No row found for SKU {bcSku}. +
+ ) : ( +
+
+ + + + Preview checks the current BC row before any write happens. + +
+ + {bcValidationError && ( +
+ {bcValidationError} +
+ )} + + {bcValidationResult && ( +
+
+
+

Business Central validation

+

+ {bcValidationResult.hasChanges + ? 'Changes detected and ready for apply when the BC endpoint is writable.' + : 'No differences detected against BC.'} +

+
+
+ {bcValidationResult.hasChanges ? 'Pending changes' : 'In sync'} +
+
+ +
+ {(['items', 'itemUnitsOfMeasure'] as const).map(sectionKey => { + const section = bcValidationResult[sectionKey]; + const changed = section.changes.filter((change: any) => change.changed); + + return ( +
+
+
+

+ API `{sectionKey}` +

+

+ {changed.length} changed field{changed.length === 1 ? '' : 's'} +

+
+ {!section.writeConfigured && ( + + write not configured + + )} +
+ + {changed.length === 0 ? ( +
No changes for this endpoint.
+ ) : ( +
+ {changed.map((change: any) => ( +
+
{change.sourceLabel}
+
{change.targetField}
+
+ {String(change.before ?? '—')} {String(change.after ?? '—')} +
+
+ ))} +
+ )} +
+ ); + })} +
+
+ )} + +
+
+
+
+

API `items` preview

+

SKU {bcMappingPreview.articleNo}

+
+
+
+ {bcMappingPreview.itemsFields.map(field => ( +
+
{field.sourceLabel}
+
{field.targetField}
+
{String(field.value ?? '—')}
+
+ ))} +
+
+                    {JSON.stringify(bcMappingPreview.itemsPayload, null, 2)}
+                  
+
+ +
+
+
+

API `itemUnitsOfMeasure` preview

+

SKU {bcMappingPreview.articleNo}

+
+
+
+ {bcMappingPreview.itemUnitsFields.map(field => ( +
+
{field.sourceLabel}
+
{field.targetField}
+
{String(field.value ?? '—')}
+
+ ))} +
+
+                    {JSON.stringify(bcMappingPreview.itemUnitsOfMeasurePayload, null, 2)}
+                  
+
+
+
+ )} +
+ )} diff --git a/src/components/PendingValidationView.tsx b/src/components/PendingValidationView.tsx index 5787d58..186e7ce 100644 --- a/src/components/PendingValidationView.tsx +++ b/src/components/PendingValidationView.tsx @@ -4,6 +4,7 @@ import { useColumns } from '../contexts/ColumnsContext'; import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter, X, Maximize2 } from 'lucide-react'; import { cn } from '../lib/utils'; import { usePersistentState } from '../contexts/FilterContext'; +import { SyncStatusPill } from './SyncStatusPill'; interface PendingValidationViewProps { data: ExcelRow[]; @@ -144,6 +145,7 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert {articleNo}
{articleName}
+ {status && }
{/* Article Name */} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index f157212..27133ab 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -21,19 +21,29 @@ interface TopBarProps { onSaveAll: () => Promise; onRevertRow: (articleNo: string) => void; isSavingAll: boolean; + bcQueueCount: number; + bcQueueEntries: Record; + onToggleBcQueueSelection: (articleNo: string) => void; + onSelectAllBcQueue: (selected: boolean) => void; + onPreviewSelectedBcSync: () => Promise; + onSyncSelectedBcSync: () => Promise; + isSyncingBC: boolean; isDownloadingBCExcel: boolean; isMaximized: boolean; onToggleMaximize: () => void; } -export function TopBar({ stats, activeModule, onExport, onDownloadBCExcel, onRefresh, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps, pendingCount, pendingChanges, onSaveAll, onRevertRow, isSavingAll, isDownloadingBCExcel, isMaximized, onToggleMaximize }: TopBarProps) { +export function TopBar({ stats, activeModule, onExport, onDownloadBCExcel, onRefresh, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps, pendingCount, pendingChanges, onSaveAll, onRevertRow, isSavingAll, bcQueueCount, bcQueueEntries, onToggleBcQueueSelection, onSelectAllBcQueue, onPreviewSelectedBcSync, onSyncSelectedBcSync, isSyncingBC, isDownloadingBCExcel, isMaximized, onToggleMaximize }: TopBarProps) { const [showPending, setShowPending] = useState(false); - const dropdownRef = useRef(null); + const [showBcQueue, setShowBcQueue] = useState(false); + const pendingDropdownRef = useRef(null); + const bcQueueDropdownRef = useRef(null); + const selectedBcQueueCount = Object.values(bcQueueEntries).filter(entry => entry.selected).length; useEffect(() => { if (!showPending) return; const handler = (e: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + if (pendingDropdownRef.current && !pendingDropdownRef.current.contains(e.target as Node)) { setShowPending(false); } }; @@ -41,11 +51,26 @@ export function TopBar({ stats, activeModule, onExport, onDownloadBCExcel, onRef return () => document.removeEventListener('mousedown', handler); }, [showPending]); + useEffect(() => { + if (!showBcQueue) return; + const handler = (e: MouseEvent) => { + if (bcQueueDropdownRef.current && !bcQueueDropdownRef.current.contains(e.target as Node)) { + setShowBcQueue(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [showBcQueue]); + // Close dropdown when all changes are saved/reverted useEffect(() => { if (pendingCount === 0) setShowPending(false); }, [pendingCount]); + useEffect(() => { + if (bcQueueCount === 0) setShowBcQueue(false); + }, [bcQueueCount]); + return (
@@ -86,7 +111,7 @@ export function TopBar({ stats, activeModule, onExport, onDownloadBCExcel, onRef )}
-
+
{/* Split button: Save All + dropdown toggle */}
+
+
0 ? "shadow-blue-900/30" : "shadow-none opacity-40" + )}> + + +
+ + {showBcQueue && bcQueueCount > 0 && ( +
+
+ BC sync queue +
+ + + +
+
+
+ {Object.entries(bcQueueEntries).map(([articleNo, entry]) => ( +
+ onToggleBcQueueSelection(articleNo)} + className="accent-blue-500" + /> +
+

{articleNo}

+

{entry.articleName}

+

{entry.status}{entry.error ? ` · ${entry.error}` : ''}

+
+
+ ))} +
+
+ )} +
+ {hasData && (
- {row[COLUMNS.ARTICLE_NO]} +
+ {row[COLUMNS.ARTICLE_NO]} + {saveStatus && } +