mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 12:35:25 +02:00
fix: commit all BC sync files missing from repo
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,14 +1,24 @@
|
|||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
export function getBcConfig(env = process.env) {
|
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 {
|
return {
|
||||||
tenantId: env.BC_TENANT_ID || 'fab724f7-6b6d-4e3b-86e3-8c1e05e36b2a',
|
tenantId: env.BC_TENANT_ID || 'fab724f7-6b6d-4e3b-86e3-8c1e05e36b2a',
|
||||||
clientId: env.BC_CLIENT_ID || '6f832138-cb48-43e7-8601-efca120b45dc',
|
clientId: env.BC_CLIENT_ID || '6f832138-cb48-43e7-8601-efca120b45dc',
|
||||||
clientSecret: env.BC_CLIENT_SECRET,
|
clientSecret: env.BC_CLIENT_SECRET,
|
||||||
companyId: env.BC_COMPANY_ID || '2acec35c-7d06-ed11-82f8-0022485ceea3',
|
companyId: env.BC_COMPANY_ID || '2acec35c-7d06-ed11-82f8-0022485ceea3',
|
||||||
writeMethod: String(env.BC_WRITE_METHOD || 'PATCH').toUpperCase(),
|
writeMethod: legacyWriteMethod,
|
||||||
writeUrlTemplate: env.BC_WRITE_URL_TEMPLATE || `{{itemsUrl}}('{{itemNo}}')`,
|
writeUrlTemplate: legacyWriteUrlTemplate,
|
||||||
writeBodyTemplate: env.BC_WRITE_BODY_TEMPLATE || JSON.stringify({ cpnpNo: '{{cpnpNo}}' }),
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -21,7 +21,7 @@ import { UndoToast } from './components/UndoToast';
|
|||||||
import { PendingValidationView } from './components/PendingValidationView';
|
import { PendingValidationView } from './components/PendingValidationView';
|
||||||
import { MissingDataView } from './components/MissingDataView';
|
import { MissingDataView } from './components/MissingDataView';
|
||||||
import { CosmeticItemsView } from './components/CosmeticItemsView';
|
import { CosmeticItemsView } from './components/CosmeticItemsView';
|
||||||
import { downloadBusinessCentralItemsExcel, updateCpnpNoInBC } from './services/businessCentral';
|
import { downloadBusinessCentralItemsExcel, previewBusinessCentralSync, applyBusinessCentralSync, isPreviewTokenMismatchError } from './services/businessCentral';
|
||||||
const FORCED_ZERO_STOCK_SKUS = new Set([
|
const FORCED_ZERO_STOCK_SKUS = new Set([
|
||||||
'11631VC', '1237VC', '1238VC', '1652VC', '1653VC', '1684VC', '1688VC', '1717VC',
|
'11631VC', '1237VC', '1238VC', '1652VC', '1653VC', '1684VC', '1688VC', '1717VC',
|
||||||
'1718VC', '180VC', '1832VC', '2025VC', '2027VC', '2180VC', '2181VC', '2210VC',
|
'1718VC', '180VC', '1832VC', '2025VC', '2027VC', '2180VC', '2181VC', '2210VC',
|
||||||
@@ -50,6 +50,36 @@ const FORCED_ZERO_STOCK_SKUS = new Set([
|
|||||||
'6471VC', '5658VCI', '2915244CLM', '30512912CLM', '2861262CLM', '41683MDRG', '41891MDRG'
|
'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() {
|
export default function App() {
|
||||||
const [session, setSession] = useState<AuthSession | null>(() => getStoredSession());
|
const [session, setSession] = useState<AuthSession | null>(() => getStoredSession());
|
||||||
|
|
||||||
@@ -68,7 +98,9 @@ export default function App() {
|
|||||||
const [defaultLoadError, setDefaultLoadError] = useState<string | null>(null);
|
const [defaultLoadError, setDefaultLoadError] = useState<string | null>(null);
|
||||||
const [rowStatuses, setRowStatuses] = useState<Record<string, string>>({});
|
const [rowStatuses, setRowStatuses] = useState<Record<string, string>>({});
|
||||||
const [pendingRows, setPendingRows] = useState<Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: 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 [isSavingAll, setIsSavingAll] = useState(false);
|
||||||
|
const [isSyncingBC, setIsSyncingBC] = useState(false);
|
||||||
const [isDownloadingBCExcel, setIsDownloadingBCExcel] = useState(false);
|
const [isDownloadingBCExcel, setIsDownloadingBCExcel] = useState(false);
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
@@ -76,6 +108,14 @@ export default function App() {
|
|||||||
console.log('[App] session changed:', session ? 'logged in' : 'logged out');
|
console.log('[App] session changed:', session ? 'logged in' : 'logged out');
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(BC_SYNC_QUEUE_STORAGE_KEY, JSON.stringify(bcSyncQueue));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage quota or serialization errors.
|
||||||
|
}
|
||||||
|
}, [bcSyncQueue]);
|
||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = () => {
|
||||||
signOut();
|
signOut();
|
||||||
setSession(null);
|
setSession(null);
|
||||||
@@ -231,18 +271,31 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
finalRow.push(null);
|
finalRow.push(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge logic: Prioritize internal control columns from syncedData or historyData
|
// Merge logic:
|
||||||
// Use synced.data only if it has internal cols (>= 100), otherwise use hist
|
// - Use the Dropbox row as the base for master data and prices.
|
||||||
const syncedHasInternalCols = synced?.data && (synced.data.length > 100 || Object.values(COLUMNS).some(idx => idx >= 100 && synced.data[idx] !== undefined && synced.data[idx] !== null));
|
// - Restore internal control columns from history when available.
|
||||||
const sourceForInternal = syncedHasInternalCols ? synced!.data : (hist || synced?.data);
|
// 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) {
|
if (mergedInternalSource) {
|
||||||
// 1. ALWAYS restore Internal Control Columns (indices >= 100)
|
// 1. Restore internal control columns (indices >= 100)
|
||||||
// These are the "TYPE", "Item to Logistic", "Checking", etc.
|
// 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 => {
|
Object.values(COLUMNS).forEach(idx => {
|
||||||
if (idx >= 100 && sourceForInternal[idx] !== undefined && sourceForInternal[idx] !== null) {
|
if (idx < 100) return;
|
||||||
finalRow[idx] = sourceForInternal[idx];
|
|
||||||
|
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.
|
// This protects against the "Column Shift" bug where old saved indices might be wrong.
|
||||||
if (synced?.status === 'pending' || synced?.status === 'edited') {
|
if (synced?.status === 'pending' || synced?.status === 'edited') {
|
||||||
editableColumns.forEach(idx => {
|
editableColumns.forEach(idx => {
|
||||||
if (idx < 100 && sourceForInternal[idx] !== undefined && sourceForInternal[idx] !== null) {
|
const value = synced?.data?.[idx];
|
||||||
finalRow[idx] = sourceForInternal[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; });
|
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
|
||||||
setRowStatuses(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 () => {
|
const handleSaveAll = async () => {
|
||||||
@@ -544,28 +604,32 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
let sessionIssue = false;
|
let sessionIssue = false;
|
||||||
|
|
||||||
try {
|
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);
|
console.log('[handleSaveAll] Saving article:', articleNo);
|
||||||
const result = await saveRowToSupabase(articleNo, newData);
|
const result = await saveRowToSupabase(articleNo, newData);
|
||||||
console.log('[handleSaveAll] Save result for', articleNo, ':', result);
|
console.log('[handleSaveAll] Save result for', articleNo, ':', result);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const cpnpValue = String(newData[COLUMNS.CPNP_NO] ?? '').trim();
|
const histRes = await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown');
|
||||||
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 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!histRes.success) {
|
if (!histRes.success) {
|
||||||
console.warn(`[handleSaveAll] History save failed for ${articleNo}:`, histRes.error);
|
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 => {
|
setPendingRows(prev => {
|
||||||
const n = { ...prev };
|
const n = { ...prev };
|
||||||
delete n[articleNo];
|
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 () => {
|
const handleDownloadBCExcel = async () => {
|
||||||
setIsDownloadingBCExcel(true);
|
setIsDownloadingBCExcel(true);
|
||||||
try {
|
try {
|
||||||
@@ -616,6 +863,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
data: JSON.parse(JSON.stringify(appState.data)), // Deep copy
|
data: JSON.parse(JSON.stringify(appState.data)), // Deep copy
|
||||||
rowStatuses: { ...rowStatuses },
|
rowStatuses: { ...rowStatuses },
|
||||||
pendingRows: { ...pendingRows },
|
pendingRows: { ...pendingRows },
|
||||||
|
bcSyncQueue: JSON.parse(JSON.stringify(bcSyncQueue)) as BcSyncQueue,
|
||||||
message
|
message
|
||||||
};
|
};
|
||||||
// Keep last 50 steps
|
// Keep last 50 steps
|
||||||
@@ -639,6 +887,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
// Restore statuses and pending state
|
// Restore statuses and pending state
|
||||||
if (lastAction.rowStatuses) setRowStatuses(lastAction.rowStatuses);
|
if (lastAction.rowStatuses) setRowStatuses(lastAction.rowStatuses);
|
||||||
if (lastAction.pendingRows) setPendingRows(lastAction.pendingRows);
|
if (lastAction.pendingRows) setPendingRows(lastAction.pendingRows);
|
||||||
|
if (lastAction.bcSyncQueue) setBcSyncQueue(lastAction.bcSyncQueue);
|
||||||
|
|
||||||
setUndoHistory(remainingHistory);
|
setUndoHistory(remainingHistory);
|
||||||
};
|
};
|
||||||
@@ -709,6 +958,13 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
onSaveAll={handleSaveAll}
|
onSaveAll={handleSaveAll}
|
||||||
onRevertRow={handleRevertRow}
|
onRevertRow={handleRevertRow}
|
||||||
isSavingAll={isSavingAll}
|
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}
|
isDownloadingBCExcel={isDownloadingBCExcel}
|
||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
onToggleMaximize={() => setIsMaximized(true)}
|
onToggleMaximize={() => setIsMaximized(true)}
|
||||||
@@ -823,6 +1079,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
onSaveRow={handleSaveRow}
|
onSaveRow={handleSaveRow}
|
||||||
onCaptureState={captureState}
|
onCaptureState={captureState}
|
||||||
rowStatuses={rowStatuses}
|
rowStatuses={rowStatuses}
|
||||||
|
onQueueBcSync={handleQueueBcSync}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeModule === 'history' && (
|
{activeModule === 'history' && (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useColumns } from '../contexts/ColumnsContext';
|
|||||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X, Maximize2 } from 'lucide-react';
|
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X, Maximize2 } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
|
||||||
interface ArticleDetailsProps {
|
interface ArticleDetailsProps {
|
||||||
data: ExcelRow[];
|
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]}>
|
<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]}
|
{row[COLUMNS.ARTICLE_NAME]}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { cn } from '../lib/utils';
|
|||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
import { usePersistentState } from '../contexts/FilterContext';
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
import { saveRowToSupabase } from '../lib/supabase';
|
import { saveRowToSupabase } from '../lib/supabase';
|
||||||
import { updateCpnpNoInBC } from '../services/businessCentral';
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
|
||||||
const COSMETIC_LINES = ['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS'];
|
const COSMETIC_LINES = ['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS'];
|
||||||
|
|
||||||
@@ -16,9 +16,10 @@ interface CosmeticItemsViewProps {
|
|||||||
onSaveRow: (rowIndex: number, updatedRow: ExcelRow) => void;
|
onSaveRow: (rowIndex: number, updatedRow: ExcelRow) => void;
|
||||||
onCaptureState: (message: string) => void;
|
onCaptureState: (message: string) => void;
|
||||||
rowStatuses: Record<string, string>;
|
rowStatuses: Record<string, string>;
|
||||||
|
onQueueBcSync: (articleNo: string, rowIndex: number, originalData: ExcelRow, newData: ExcelRow, articleName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, rowStatuses }: CosmeticItemsViewProps) {
|
export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, rowStatuses, onQueueBcSync }: CosmeticItemsViewProps) {
|
||||||
const COLUMNS = useColumns();
|
const COLUMNS = useColumns();
|
||||||
const [search, setSearch] = usePersistentState('cosmeticItems-search', '');
|
const [search, setSearch] = usePersistentState('cosmeticItems-search', '');
|
||||||
const [sortCol, setSortCol] = usePersistentState<number | null>('cosmeticItems-sortCol', null);
|
const [sortCol, setSortCol] = usePersistentState<number | null>('cosmeticItems-sortCol', null);
|
||||||
@@ -143,16 +144,15 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
|
|||||||
onCaptureState(`Updated CPNP No. for ${articleNo}`);
|
onCaptureState(`Updated CPNP No. for ${articleNo}`);
|
||||||
onSaveRow(rowIndex, newRow);
|
onSaveRow(rowIndex, newRow);
|
||||||
|
|
||||||
const [supabaseResult, bcResult] = await Promise.all([
|
const supabaseResult = await saveRowToSupabase(articleNo, newRow, 'edited');
|
||||||
saveRowToSupabase(articleNo, newRow, 'edited'),
|
if (supabaseResult.success) {
|
||||||
updateCpnpNoInBC(articleNo, cpnpValue),
|
onQueueBcSync(articleNo, rowIndex, row, newRow, String(row[COLUMNS.ARTICLE_NAME] || articleNo));
|
||||||
]);
|
}
|
||||||
|
|
||||||
setSavingCpnp(null);
|
setSavingCpnp(null);
|
||||||
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
if (!supabaseResult.success) errors.push(`Supabase: ${supabaseResult.error}`);
|
if (!supabaseResult.success) errors.push(`Supabase: ${supabaseResult.error}`);
|
||||||
if (!bcResult.success) errors.push(`Business Central: ${bcResult.error}`);
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
alert(`Error saving CPNP No. for ${articleNo}:\n${errors.join('\n')}`);
|
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 }}>
|
<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>
|
||||||
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: 240 }} title={row[COLUMNS.ARTICLE_NAME]}>
|
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: 240 }} title={row[COLUMNS.ARTICLE_NAME]}>
|
||||||
{row[COLUMNS.ARTICLE_NAME]}
|
{row[COLUMNS.ARTICLE_NAME]}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { cn } from '../lib/utils';
|
|||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
import { usePersistentState } from '../contexts/FilterContext';
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
|
||||||
interface DimensionsViewProps {
|
interface DimensionsViewProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
@@ -802,6 +803,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
<tbody className="divide-y divide-slate-700/50">
|
<tbody className="divide-y divide-slate-700/50">
|
||||||
{group.rows.map(({ row, index }) => {
|
{group.rows.map(({ row, index }) => {
|
||||||
const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
|
const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
|
||||||
|
const syncStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
@@ -813,6 +815,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="font-medium text-slate-200">{row[COLUMNS.ARTICLE_NO]}</div>
|
<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>
|
<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>
|
||||||
<td className="px-4 py-3 text-slate-400 font-mono">
|
<td className="px-4 py-3 text-slate-400 font-mono">
|
||||||
{row[COLUMNS.INNER_L] !== 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] : '-'}
|
{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] : '-'}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { ExcelRow } from '../types';
|
import { ExcelRow, COLUMNS } from '../types';
|
||||||
import { Search, Filter, ChevronDown, ChevronUp, X, Maximize2 } from 'lucide-react';
|
import { useColumns } from '../contexts/ColumnsContext';
|
||||||
|
import { Search, Filter, ChevronDown, ChevronUp, X, Maximize2, Check, Loader2 } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
import { usePersistentState } from '../contexts/FilterContext';
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
|
import { buildBusinessCentralMappingPreview } from '../services/businessCentralMapping';
|
||||||
|
import { applyBusinessCentralSync, previewBusinessCentralSync, isPreviewTokenMismatchError } from '../services/businessCentral';
|
||||||
|
|
||||||
interface MatrixViewProps {
|
interface MatrixViewProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
@@ -12,14 +15,20 @@ interface MatrixViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||||
|
const resolvedCols = useColumns();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [search, setSearch] = usePersistentState('matrix-search', '');
|
const [search, setSearch] = usePersistentState('matrix-search', '');
|
||||||
|
const [bcSku, setBcSku] = usePersistentState('matrix-bcSku', '');
|
||||||
const [columnFilters, setColumnFilters] = usePersistentState<Record<number, string[]>>('matrix-columnFilters', {});
|
const [columnFilters, setColumnFilters] = usePersistentState<Record<number, string[]>>('matrix-columnFilters', {});
|
||||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||||
const [sortCol, setSortCol] = usePersistentState<number | null>('matrix-sortCol', null);
|
const [sortCol, setSortCol] = usePersistentState<number | null>('matrix-sortCol', null);
|
||||||
const [sortDesc, setSortDesc] = usePersistentState('matrix-sortDesc', false);
|
const [sortDesc, setSortDesc] = usePersistentState('matrix-sortDesc', false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(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(() => {
|
const filteredData = useMemo(() => {
|
||||||
let result = data.map((row, index) => ({ row, index }));
|
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);
|
return filteredData.slice(start, start + pageSize);
|
||||||
}, [filteredData, page, 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 [columnWidths, setColumnWidths] = useState<Record<number, number>>({});
|
||||||
|
|
||||||
const handleSort = (col: number) => {
|
const handleSort = (col: number) => {
|
||||||
@@ -119,6 +144,52 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
setPage(1);
|
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 = '') => {
|
const formatCellValue = (val: any, header: string = '') => {
|
||||||
if (val === undefined || val === null || val === '') return '';
|
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>
|
<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>
|
<p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p>
|
||||||
</div>
|
</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]">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
@@ -235,6 +324,165 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-auto flex-1">
|
<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' }}>
|
<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">
|
<thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useColumns } from '../contexts/ColumnsContext';
|
|||||||
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter, X, Maximize2 } from 'lucide-react';
|
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter, X, Maximize2 } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { usePersistentState } from '../contexts/FilterContext';
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
|
||||||
interface PendingValidationViewProps {
|
interface PendingValidationViewProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
@@ -144,6 +145,7 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert
|
|||||||
{articleNo}
|
{articleNo}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-slate-300">{articleName}</div>
|
<div className="text-sm text-slate-300">{articleName}</div>
|
||||||
|
{status && <SyncStatusPill status={status} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
import { usePersistentState } from '../contexts/FilterContext';
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
|
import { SyncStatusPill } from './SyncStatusPill';
|
||||||
|
|
||||||
interface PricingViewProps {
|
interface PricingViewProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
@@ -1934,7 +1935,10 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
>
|
>
|
||||||
{/* Art. No. */}
|
{/* 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 } : {}}>
|
<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>
|
</td>
|
||||||
|
|
||||||
{/* Article Name */}
|
{/* Article Name */}
|
||||||
|
|||||||
+118
-4
@@ -21,19 +21,29 @@ interface TopBarProps {
|
|||||||
onSaveAll: () => Promise<void>;
|
onSaveAll: () => Promise<void>;
|
||||||
onRevertRow: (articleNo: string) => void;
|
onRevertRow: (articleNo: string) => void;
|
||||||
isSavingAll: boolean;
|
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;
|
isDownloadingBCExcel: boolean;
|
||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
onToggleMaximize: () => void;
|
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 [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(() => {
|
useEffect(() => {
|
||||||
if (!showPending) return;
|
if (!showPending) return;
|
||||||
const handler = (e: MouseEvent) => {
|
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);
|
setShowPending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -41,11 +51,26 @@ export function TopBar({ stats, activeModule, onExport, onDownloadBCExcel, onRef
|
|||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, [showPending]);
|
}, [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
|
// Close dropdown when all changes are saved/reverted
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingCount === 0) setShowPending(false);
|
if (pendingCount === 0) setShowPending(false);
|
||||||
}, [pendingCount]);
|
}, [pendingCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bcQueueCount === 0) setShowBcQueue(false);
|
||||||
|
}, [bcQueueCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 z-10 shadow-2xl">
|
<header className="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">
|
<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="flex items-center gap-3">
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={pendingDropdownRef}>
|
||||||
{/* Split button: Save All + dropdown toggle */}
|
{/* Split button: Save All + dropdown toggle */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex items-center rounded-md overflow-hidden shadow-lg transition-all",
|
"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>
|
||||||
|
|
||||||
|
<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 && (
|
{hasData && (
|
||||||
<button
|
<button
|
||||||
onClick={onExport}
|
onClick={onExport}
|
||||||
|
|||||||
@@ -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';
|
const BC_EXPORT_URL = '/api/bc-export';
|
||||||
|
|
||||||
export interface BCUpdateResult {
|
export interface BCUpdateResult {
|
||||||
@@ -6,12 +7,60 @@ export interface BCUpdateResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPreviewTokenMismatchError(error?: string): boolean {
|
||||||
|
return Boolean(error && error.toLowerCase().includes('preview token mismatch'));
|
||||||
|
}
|
||||||
|
|
||||||
export interface BCDownloadResult {
|
export interface BCDownloadResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
filename?: 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 }> {
|
async function readResponsePayload(res: Response): Promise<{ data: any; rawText: string }> {
|
||||||
const rawText = await res.text();
|
const rawText = await res.text();
|
||||||
if (!rawText.trim()) {
|
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> {
|
export async function updateCpnpNoInBC(articleNo: string, cpnpNo: string): Promise<BCUpdateResult> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(BC_PROXY_URL, {
|
const res = await fetch(BC_SYNC_APPLY_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ articleNo, cpnpNo }),
|
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 {
|
function getFilenameFromDisposition(contentDisposition: string | null): string | null {
|
||||||
if (!contentDisposition) return null;
|
if (!contentDisposition) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user