fix: commit all BC sync files missing from repo

This commit is contained in:
Christian Vidal Wolf
2026-05-15 11:00:21 +02:00
parent 85794b4b52
commit 143f655f3c
14 changed files with 1701 additions and 49 deletions
+56
View File
@@ -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 });
}
}
+56
View File
@@ -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 });
}
}
+13 -3
View File
@@ -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,
};
}
+656
View File
@@ -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 };
+281 -24
View File
@@ -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<string, BcSyncQueueEntry>;
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<AuthSession | null>(() => getStoredSession());
@@ -68,7 +98,9 @@ export default function App() {
const [defaultLoadError, setDefaultLoadError] = useState<string | null>(null);
const [rowStatuses, setRowStatuses] = useState<Record<string, string>>({});
const [pendingRows, setPendingRows] = useState<Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }>>({});
const [bcSyncQueue, setBcSyncQueue] = useState<BcSyncQueue>(() => 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);
// 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 (sourceForInternal) {
// 1. ALWAYS restore Internal Control Columns (indices >= 100)
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<Array<[string, BcSyncQueueEntry]>>(
() => Object.entries(bcSyncQueue) as Array<[string, BcSyncQueueEntry]>,
[bcSyncQueue]
);
const selectedBcQueueEntries = useMemo<Array<[string, BcSyncQueueEntry]>>(
() => 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' && (
+7 -1
View File
@@ -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
""
)}
>
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>
<div className="flex flex-col gap-1">
<span>{row[COLUMNS.ARTICLE_NO]}</span>
{saveStatus && <SyncStatusPill status={saveStatus} className="self-start" />}
</div>
</td>
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>
{row[COLUMNS.ARTICLE_NAME]}
</td>
+11 -8
View File
@@ -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<string, string>;
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<number | null>('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
)}
>
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: 110 }}>
{articleNo}
<div className="flex flex-col gap-1">
<span>{articleNo}</span>
{status && <SyncStatusPill status={status} className="self-start" />}
</div>
</td>
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: 240 }} title={row[COLUMNS.ARTICLE_NAME]}>
{row[COLUMNS.ARTICLE_NAME]}
+3
View File
@@ -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
<tbody className="divide-y divide-slate-700/50">
{group.rows.map(({ row, index }) => {
const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
const syncStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
return (
<tr
key={index}
@@ -813,6 +815,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
<td className="px-4 py-3">
<div className="font-medium text-slate-200">{row[COLUMNS.ARTICLE_NO]}</div>
<div className="text-[10px] text-slate-500 truncate max-w-[200px]">{row[COLUMNS.ARTICLE_NAME]}</div>
{syncStatus && <SyncStatusPill status={syncStatus} className="mt-1" />}
</td>
<td className="px-4 py-3 text-slate-400 font-mono">
{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] : '-'}
+252 -4
View File
@@ -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<Record<number, string[]>>('matrix-columnFilters', {});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
const [sortCol, setSortCol] = usePersistentState<number | null>('matrix-sortCol', null);
const [sortDesc, setSortDesc] = usePersistentState('matrix-sortDesc', false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [bcValidationResult, setBcValidationResult] = useState<any>(null);
const [bcValidationLoading, setBcValidationLoading] = useState(false);
const [bcApplyLoading, setBcApplyLoading] = useState(false);
const [bcValidationError, setBcValidationError] = useState<string | null>(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<Record<number, number>>({});
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) {
<h2 className="text-lg font-semibold text-white">Matrix View</h2>
<p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 flex-wrap justify-end">
<div className="relative min-w-[260px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Preview BC mapping by SKU..."
value={bcSku}
onChange={e => 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 && (
<button
onClick={() => setBcSku('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="relative min-w-[300px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
@@ -235,6 +324,165 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
</div>
<div className="overflow-auto flex-1">
{bcSku && (
<div className="mx-4 mt-4 mb-2 rounded-xl border border-slate-700 bg-slate-900/70 p-4">
{!bcMappingPreview ? (
<div className="text-sm text-slate-400">
No row found for SKU <span className="text-white font-semibold">{bcSku}</span>.
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<button
onClick={handlePreviewBcSync}
disabled={bcValidationLoading}
className={cn(
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors border",
bcValidationLoading
? "bg-slate-700 text-slate-400 border-slate-600 cursor-wait"
: "bg-blue-600 hover:bg-blue-500 text-white border-blue-500/30"
)}
>
{bcValidationLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
Preview BC sync
</button>
<button
onClick={handleApplyBcSync}
disabled={bcApplyLoading || !bcValidationResult?.previewToken}
className={cn(
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors border",
bcApplyLoading || !bcValidationResult?.previewToken
? "bg-slate-700 text-slate-400 border-slate-600 cursor-not-allowed"
: "bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-500/30"
)}
>
{bcApplyLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Apply to BC
</button>
<span className="text-xs text-slate-500">
Preview checks the current BC row before any write happens.
</span>
</div>
{bcValidationError && (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{bcValidationError}
</div>
)}
{bcValidationResult && (
<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>
<h3 className="text-sm font-semibold text-white">Business Central validation</h3>
<p className="text-xs text-slate-400">
{bcValidationResult.hasChanges
? 'Changes detected and ready for apply when the BC endpoint is writable.'
: 'No differences detected against BC.'}
</p>
</div>
<div className={cn(
"text-xs px-2 py-1 rounded-full border",
bcValidationResult.hasChanges
? "border-amber-500/20 bg-amber-500/10 text-amber-300"
: "border-emerald-500/20 bg-emerald-500/10 text-emerald-300"
)}>
{bcValidationResult.hasChanges ? 'Pending changes' : 'In sync'}
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{(['items', 'itemUnitsOfMeasure'] as const).map(sectionKey => {
const section = bcValidationResult[sectionKey];
const changed = section.changes.filter((change: any) => change.changed);
return (
<div key={sectionKey} className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-semibold text-white">
API `{sectionKey}`
</h4>
<p className="text-xs text-slate-400">
{changed.length} changed field{changed.length === 1 ? '' : 's'}
</p>
</div>
{!section.writeConfigured && (
<span className="text-[10px] px-2 py-1 rounded-full border border-slate-600 text-slate-400">
write not configured
</span>
)}
</div>
{changed.length === 0 ? (
<div className="text-xs text-slate-500">No changes for this endpoint.</div>
) : (
<div className="space-y-2">
{changed.map((change: any) => (
<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-500 truncate">{change.targetField}</div>
<div className="text-white truncate">
{String(change.before ?? '—')} <span className="text-slate-500"></span> {String(change.after ?? '—')}
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-white">API `items` preview</h3>
<p className="text-xs text-slate-400">SKU {bcMappingPreview.articleNo}</p>
</div>
</div>
<div className="space-y-2">
{bcMappingPreview.itemsFields.map(field => (
<div key={field.targetField} className="grid grid-cols-3 gap-3 text-xs">
<div className="text-slate-400">{field.sourceLabel}</div>
<div className="text-slate-500 truncate">{field.targetField}</div>
<div className="text-white truncate">{String(field.value ?? '—')}</div>
</div>
))}
</div>
<pre className="mt-4 text-[11px] leading-relaxed bg-slate-950 border border-slate-800 rounded p-3 overflow-auto text-slate-200">
{JSON.stringify(bcMappingPreview.itemsPayload, null, 2)}
</pre>
</div>
<div className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-white">API `itemUnitsOfMeasure` preview</h3>
<p className="text-xs text-slate-400">SKU {bcMappingPreview.articleNo}</p>
</div>
</div>
<div className="space-y-2">
{bcMappingPreview.itemUnitsFields.map(field => (
<div key={field.targetField} className="grid grid-cols-3 gap-3 text-xs">
<div className="text-slate-400">{field.sourceLabel}</div>
<div className="text-slate-500 truncate">{field.targetField}</div>
<div className="text-white truncate">{String(field.value ?? '—')}</div>
</div>
))}
</div>
<pre className="mt-4 text-[11px] leading-relaxed bg-slate-950 border border-slate-800 rounded p-3 overflow-auto text-slate-200">
{JSON.stringify(bcMappingPreview.itemUnitsOfMeasurePayload, null, 2)}
</pre>
</div>
</div>
</div>
)}
</div>
)}
<table className="w-full text-left text-sm whitespace-nowrap" style={{ tableLayout: 'fixed' }}>
<thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
<tr>
+2
View File
@@ -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}
</div>
<div className="text-sm text-slate-300">{articleName}</div>
{status && <SyncStatusPill status={status} />}
</div>
<div className="flex items-center gap-2">
<button
+5 -1
View File
@@ -23,6 +23,7 @@ import {
import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
import { usePersistentState } from '../contexts/FilterContext';
import { SyncStatusPill } from './SyncStatusPill';
interface PricingViewProps {
data: ExcelRow[];
@@ -1934,7 +1935,10 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
>
{/* Art. No. */}
<td className={cn("px-3 py-2.5 font-mono text-xs text-slate-300 whitespace-nowrap truncate", isPinned('articleNo') && "sticky bg-slate-800")} style={isPinned('articleNo') ? { left: getStickyLeft('articleNo') ?? 0, zIndex: getStickyRank('articleNo') ?? 0 } : {}}>
{row[COLUMNS.ARTICLE_NO]}
<div className="flex flex-col gap-1">
<span>{row[COLUMNS.ARTICLE_NO]}</span>
{saveStatus && <SyncStatusPill status={saveStatus} className="self-start" />}
</div>
</td>
{/* Article Name */}
+118 -4
View File
@@ -21,19 +21,29 @@ interface TopBarProps {
onSaveAll: () => Promise<void>;
onRevertRow: (articleNo: string) => void;
isSavingAll: boolean;
bcQueueCount: number;
bcQueueEntries: Record<string, { articleName: string; selected: boolean; status: string; error?: string }>;
onToggleBcQueueSelection: (articleNo: string) => void;
onSelectAllBcQueue: (selected: boolean) => void;
onPreviewSelectedBcSync: () => Promise<void>;
onSyncSelectedBcSync: () => Promise<void>;
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<HTMLDivElement>(null);
const [showBcQueue, setShowBcQueue] = useState(false);
const pendingDropdownRef = useRef<HTMLDivElement>(null);
const bcQueueDropdownRef = useRef<HTMLDivElement>(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 (
<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">
<div className="flex items-center -ml-4">
@@ -86,7 +111,7 @@ export function TopBar({ stats, activeModule, onExport, onDownloadBCExcel, onRef
)}
<div className="flex items-center gap-3">
<div className="relative" ref={dropdownRef}>
<div className="relative" ref={pendingDropdownRef}>
{/* Split button: Save All + dropdown toggle */}
<div className={cn(
"flex items-center rounded-md overflow-hidden shadow-lg transition-all",
@@ -157,6 +182,95 @@ export function TopBar({ stats, activeModule, onExport, onDownloadBCExcel, onRef
)}
</div>
<div className="relative" ref={bcQueueDropdownRef}>
<div className={cn(
"flex items-center rounded-md overflow-hidden shadow-lg transition-all",
bcQueueCount > 0 ? "shadow-blue-900/30" : "shadow-none opacity-40"
)}>
<button
onClick={onSyncSelectedBcSync}
disabled={isSyncingBC || selectedBcQueueCount === 0}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-bold transition-all text-white",
selectedBcQueueCount > 0
? "bg-blue-600 hover:bg-blue-500 disabled:opacity-60"
: "bg-slate-700 cursor-not-allowed"
)}
>
{isSyncingBC ? <Loader2 className="w-4 h-4 animate-spin" /> : <CloudUpload className="w-4 h-4" />}
{isSyncingBC
? 'Syncing...'
: selectedBcQueueCount > 0
? `Sync ${selectedBcQueueCount} to BC`
: bcQueueCount > 0
? 'No selected items'
: 'No BC queue'}
</button>
<button
onClick={() => bcQueueCount > 0 && setShowBcQueue(v => !v)}
disabled={isSyncingBC || bcQueueCount === 0}
className={cn(
"flex items-center px-2 py-2 text-white border-l transition-all",
bcQueueCount > 0
? "bg-blue-700 hover:bg-blue-600 border-blue-500/40"
: "bg-slate-700 cursor-not-allowed border-slate-600"
)}
title="View BC sync queue"
>
<ChevronDown className={cn("w-4 h-4 transition-transform", showBcQueue && "rotate-180")} />
</button>
</div>
{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="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>
<div className="flex items-center gap-2">
<button
onClick={() => onSelectAllBcQueue(true)}
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-200"
>
All
</button>
<button
onClick={() => onSelectAllBcQueue(false)}
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-200"
>
None
</button>
<button
onClick={onPreviewSelectedBcSync}
disabled={isSyncingBC}
className="text-xs px-2 py-1 rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-60"
>
Preview {selectedBcQueueCount > 0 ? `(${selectedBcQueueCount})` : ''}
</button>
</div>
</div>
<div className="max-h-64 overflow-y-auto">
{Object.entries(bcQueueEntries).map(([articleNo, entry]) => (
<div
key={articleNo}
className="flex items-center gap-2 px-3 py-2.5 hover:bg-slate-700/50 border-b border-slate-700/50 last:border-0"
>
<input
type="checkbox"
checked={entry.selected}
onChange={() => onToggleBcQueueSelection(articleNo)}
className="accent-blue-500"
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-mono text-slate-400">{articleNo}</p>
<p className="text-sm text-white truncate">{entry.articleName}</p>
<p className="text-[10px] text-slate-500 uppercase">{entry.status}{entry.error ? ` · ${entry.error}` : ''}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
{hasData && (
<button
onClick={onExport}
+97 -2
View File
@@ -1,4 +1,5 @@
const BC_PROXY_URL = '/api/bc-proxy';
const BC_SYNC_PREVIEW_URL = '/api/bc-sync-preview';
const BC_SYNC_APPLY_URL = '/api/bc-sync-apply';
const BC_EXPORT_URL = '/api/bc-export';
export interface BCUpdateResult {
@@ -6,12 +7,60 @@ export interface BCUpdateResult {
error?: string;
}
export function isPreviewTokenMismatchError(error?: string): boolean {
return Boolean(error && error.toLowerCase().includes('preview token mismatch'));
}
export interface BCDownloadResult {
success: boolean;
error?: string;
filename?: string;
}
export interface BCFieldChange {
sourceLabel: string;
sourceIndex: number | null;
targetField: string;
before: any;
after: any;
changed: boolean;
}
export interface BCSyncPreviewSection {
type: 'items' | 'itemUnitsOfMeasure';
desired: Record<string, any>;
current: Record<string, any> | null;
changes: BCFieldChange[];
changedFields: string[];
writeConfigured: boolean;
writeMethod: string | null;
writeUrlTemplate: string | null;
writeBodyTemplate: string | null;
canApply: boolean;
}
export interface BCSyncPreviewResult {
success: boolean;
articleNo: string;
items: BCSyncPreviewSection;
itemUnitsOfMeasure: BCSyncPreviewSection;
hasChanges: boolean;
previewToken: string;
error?: string;
}
export interface BCSyncApplyResult {
success: boolean;
articleNo: string;
previewToken?: string;
preview?: BCSyncPreviewResult;
results?: {
items: { applied: boolean; reason?: string; url?: string };
itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string };
};
error?: string;
}
async function readResponsePayload(res: Response): Promise<{ data: any; rawText: string }> {
const rawText = await res.text();
if (!rawText.trim()) {
@@ -27,7 +76,7 @@ async function readResponsePayload(res: Response): Promise<{ data: any; rawText:
export async function updateCpnpNoInBC(articleNo: string, cpnpNo: string): Promise<BCUpdateResult> {
try {
const res = await fetch(BC_PROXY_URL, {
const res = await fetch(BC_SYNC_APPLY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ articleNo, cpnpNo }),
@@ -48,6 +97,52 @@ export async function updateCpnpNoInBC(articleNo: string, cpnpNo: string): Promi
}
}
export async function previewBusinessCentralSync(headers: string[], row: any[]): Promise<BCSyncPreviewResult> {
try {
const res = await fetch(BC_SYNC_PREVIEW_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ headers, row }),
});
const { data, rawText } = await readResponsePayload(res);
if (!res.ok || !data?.success) {
const error =
data?.error ||
(typeof data === 'string' && data.trim()) ||
rawText ||
`HTTP ${res.status}`;
return { success: false, error } as BCSyncPreviewResult;
}
return data as BCSyncPreviewResult;
} catch (err: any) {
return { success: false, error: err.message } as BCSyncPreviewResult;
}
}
export async function applyBusinessCentralSync(headers: string[], row: any[], previewToken?: string): Promise<BCSyncApplyResult> {
try {
const res = await fetch(BC_SYNC_APPLY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ headers, row, previewToken }),
});
const { data, rawText } = await readResponsePayload(res);
if (!res.ok || !data?.success) {
const error =
data?.error ||
(typeof data === 'string' && data.trim()) ||
rawText ||
`HTTP ${res.status}`;
return { success: false, error } as BCSyncApplyResult;
}
return data as BCSyncApplyResult;
} catch (err: any) {
return { success: false, error: err.message } as BCSyncApplyResult;
}
}
function getFilenameFromDisposition(contentDisposition: string | null): string | null {
if (!contentDisposition) return null;
+142
View File
@@ -0,0 +1,142 @@
import { ExcelRow } from '../types';
export interface MappingFieldPreview {
sourceLabel: string;
sourceIndex: number | null;
targetField: string;
value: any;
}
export interface BusinessCentralMappingPreview {
articleNo: string;
itemsPayload: Record<string, any>;
itemUnitsOfMeasurePayload: Record<string, any>;
itemsFields: MappingFieldPreview[];
itemUnitsFields: MappingFieldPreview[];
}
function findHeaderIndex(headers: string[], patterns: string[]): number {
const normalized = headers.map(h => String(h || '').toLowerCase());
return normalized.findIndex(header =>
patterns.every(pattern => header.includes(pattern.toLowerCase()))
);
}
function formatDateForBc(value: any): string | null {
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: ExcelRow, index: number | null): any {
if (index === null || index < 0) return null;
const value = row[index];
return value === undefined || value === '' ? null : value;
}
function toBcDecimal(value: any): number | null {
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: string,
sourceIndex: number | null,
targetField: string,
value: any
): MappingFieldPreview {
return { sourceLabel, sourceIndex, targetField, value };
}
export function buildBusinessCentralMappingPreview(headers: string[], row: ExcelRow): BusinessCentralMappingPreview {
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),
],
};
}