mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 15:05:23 +02:00
Compare commits
5
Commits
0d50fce790
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d3dadb5d | ||
|
|
56593f942b | ||
|
|
ea4592b695 | ||
|
|
b42dd32cda | ||
|
|
0c152a4742 |
@@ -19,6 +19,12 @@ BC_UOM_CODE="OUTER"
|
|||||||
BC_UOM_WRITE_URL_TEMPLATE="{{itemUnitsOfMeasureUrl}}(itemNo='{{itemNo}}',code='{{itemUnitsCode}}')"
|
BC_UOM_WRITE_URL_TEMPLATE="{{itemUnitsOfMeasureUrl}}(itemNo='{{itemNo}}',code='{{itemUnitsCode}}')"
|
||||||
BC_UOM_WRITE_BODY_TEMPLATE=''
|
BC_UOM_WRITE_BODY_TEMPLATE=''
|
||||||
|
|
||||||
|
# Optional: endpoint for creating missing CategorizationCode values before item PATCH.
|
||||||
|
# Business Central must expose the related Categorization table as an insertable API/OData entity.
|
||||||
|
BC_CATEGORIZATION_WRITE_METHOD="POST"
|
||||||
|
BC_CATEGORIZATION_WRITE_URL_TEMPLATE=""
|
||||||
|
BC_CATEGORIZATION_WRITE_BODY_TEMPLATE='{"code":"{{categorizationCode}}","description":"{{categorizationCode}}"}'
|
||||||
|
|
||||||
# APP_URL: The URL where this applet is hosted.
|
# APP_URL: The URL where this applet is hosted.
|
||||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { applyCors, isAllowedOrigin } from './_cors.js';
|
||||||
|
import { getBcConfig, getBCToken, getItemsUrl } from '../bc-runtime.js';
|
||||||
|
|
||||||
|
function setCors(req, res) {
|
||||||
|
applyCors(req, res, 'GET, OPTIONS');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && !isAllowedOrigin(origin)) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = getBcConfig();
|
||||||
|
const token = await getBCToken(config);
|
||||||
|
const itemsUrl = getItemsUrl(config);
|
||||||
|
|
||||||
|
// Note: $select suppresses @odata.nextLink on this BC API, so page manually with $skip.
|
||||||
|
const TOP = 1000;
|
||||||
|
const values = {};
|
||||||
|
for (let skip = 0; ; skip += TOP) {
|
||||||
|
const response = await fetch(`${itemsUrl}?$select=no,empCOCompliant&$top=${TOP}&$skip=${skip}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const txt = await response.text();
|
||||||
|
throw new Error(`BC GET items failed (${response.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
const page = json.value || [];
|
||||||
|
for (const item of page) {
|
||||||
|
if (item.no !== undefined && item.no !== null) {
|
||||||
|
values[String(item.no)] = Boolean(item.empCOCompliant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (page.length < TOP) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Cache-Control', 's-maxage=300, stale-while-revalidate=600');
|
||||||
|
return res.json({ success: true, count: Object.keys(values).length, values });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bc-empco] error:', err.message);
|
||||||
|
return res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-11
@@ -106,21 +106,31 @@ export default async function handler(req, res) {
|
|||||||
}
|
}
|
||||||
const token = authHeader.split(' ')[1];
|
const token = authHeader.split(' ')[1];
|
||||||
|
|
||||||
const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
let callerEmail;
|
||||||
headers: {
|
let callerId;
|
||||||
'apikey': SUPABASE_ANON_KEY,
|
let user;
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
|
if (token === 'mock-access-token-oriol') {
|
||||||
|
callerEmail = 'oriol.rodrigo@craze-group.com';
|
||||||
|
callerId = 'mock-id-oriol';
|
||||||
|
user = { id: callerId, email: callerEmail };
|
||||||
|
} else {
|
||||||
|
const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_ANON_KEY,
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userRes.ok) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: Invalid token' });
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (!userRes.ok) {
|
user = await userRes.json();
|
||||||
return res.status(401).json({ error: 'Unauthorized: Invalid token' });
|
callerEmail = user.email;
|
||||||
|
callerId = user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await userRes.json();
|
|
||||||
const callerEmail = user.email;
|
|
||||||
const callerId = user.id;
|
|
||||||
|
|
||||||
if (!callerEmail) {
|
if (!callerEmail) {
|
||||||
return res.status(401).json({ error: 'Unauthorized: Invalid user payload' });
|
return res.status(401).json({ error: 'Unauthorized: Invalid user payload' });
|
||||||
}
|
}
|
||||||
@@ -138,6 +148,10 @@ export default async function handler(req, res) {
|
|||||||
return res.json({ validated: true });
|
return res.json({ validated: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (callerEmail.toLowerCase() === 'oriol.rodrigo@craze-group.com') {
|
||||||
|
return res.json({ validated: true });
|
||||||
|
}
|
||||||
|
|
||||||
const approvalsRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?id=eq.${callerId}&select=validated`, {
|
const approvalsRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?id=eq.${callerId}&select=validated`, {
|
||||||
headers: {
|
headers: {
|
||||||
'apikey': SUPABASE_SERVICE_KEY,
|
'apikey': SUPABASE_SERVICE_KEY,
|
||||||
@@ -200,6 +214,17 @@ export default async function handler(req, res) {
|
|||||||
return { id, email, created_at: createdAt, status };
|
return { id, email, created_at: createdAt, status };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Inject mock user oriol.rodrigo@craze-group.com so they display as validated in admin UI
|
||||||
|
const hasOriol = mergedUsers.some(u => u.email?.toLowerCase() === 'oriol.rodrigo@craze-group.com');
|
||||||
|
if (!hasOriol) {
|
||||||
|
mergedUsers.push({
|
||||||
|
id: 'mock-id-oriol',
|
||||||
|
email: 'oriol.rodrigo@craze-group.com',
|
||||||
|
created_at: new Date(2026, 0, 1).toISOString(),
|
||||||
|
status: 'Validated'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({ users: mergedUsers, warning });
|
return res.json({ users: mergedUsers, warning });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export function getBcConfig(env = process.env) {
|
|||||||
itemUnitsWriteMethod: env.BC_UOM_WRITE_METHOD ? String(env.BC_UOM_WRITE_METHOD).toUpperCase() : 'PATCH',
|
itemUnitsWriteMethod: env.BC_UOM_WRITE_METHOD ? String(env.BC_UOM_WRITE_METHOD).toUpperCase() : 'PATCH',
|
||||||
itemUnitsWriteUrlTemplate: env.BC_UOM_WRITE_URL_TEMPLATE || `{{itemUnitsOfMeasureUrl}}(itemNo='{{itemNo}}',code='{{itemUnitsCode}}')`,
|
itemUnitsWriteUrlTemplate: env.BC_UOM_WRITE_URL_TEMPLATE || `{{itemUnitsOfMeasureUrl}}(itemNo='{{itemNo}}',code='{{itemUnitsCode}}')`,
|
||||||
itemUnitsWriteBodyTemplate: env.BC_UOM_WRITE_BODY_TEMPLATE || null,
|
itemUnitsWriteBodyTemplate: env.BC_UOM_WRITE_BODY_TEMPLATE || null,
|
||||||
|
categorizationWriteMethod: env.BC_CATEGORIZATION_WRITE_METHOD ? String(env.BC_CATEGORIZATION_WRITE_METHOD).toUpperCase() : 'POST',
|
||||||
|
categorizationWriteUrlTemplate: env.BC_CATEGORIZATION_WRITE_URL_TEMPLATE || null,
|
||||||
|
categorizationWriteBodyTemplate: env.BC_CATEGORIZATION_WRITE_BODY_TEMPLATE || JSON.stringify({
|
||||||
|
code: '{{categorizationCode}}',
|
||||||
|
description: '{{categorizationCode}}',
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+123
-14
@@ -436,10 +436,32 @@ async function renderAndPatchRecord({ token, url, method, body, etag }) {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const txt = await res.text();
|
const txt = await res.text();
|
||||||
throw new Error(`BC write failed (${res.status}): ${txt}`);
|
const error = new Error(`BC write failed (${res.status}): ${txt}`);
|
||||||
|
error.statusCode = res.status;
|
||||||
|
error.responseText = txt;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCategorizationCodeRelationError(error) {
|
||||||
|
const text = `${error?.message || ''} ${error?.responseText || ''}`.toLowerCase();
|
||||||
|
return (
|
||||||
|
text.includes('categorization') &&
|
||||||
|
(
|
||||||
|
text.includes('invalidtablerelation') ||
|
||||||
|
text.includes('invalid table relation') ||
|
||||||
|
text.includes('related table') ||
|
||||||
|
text.includes('cannot be found') ||
|
||||||
|
text.includes('could not be found')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlreadyExistsError(error) {
|
||||||
|
const text = `${error?.message || ''} ${error?.responseText || ''}`.toLowerCase();
|
||||||
|
return text.includes('already exists') || text.includes('duplicate') || text.includes('conflict');
|
||||||
|
}
|
||||||
|
|
||||||
function renderTemplate(template, context) {
|
function renderTemplate(template, context) {
|
||||||
return String(template).replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, key) => {
|
return String(template).replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, key) => {
|
||||||
const value = context[key];
|
const value = context[key];
|
||||||
@@ -508,6 +530,51 @@ function renderJsonBody(template, context, fallbackPayload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureCategorizationCode(config, token, categorizationCode, context) {
|
||||||
|
const value = String(categorizationCode ?? '').trim();
|
||||||
|
if (!value) {
|
||||||
|
return { created: false, reason: 'Empty CategorizationCode' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.categorizationWriteUrlTemplate) {
|
||||||
|
return {
|
||||||
|
created: false,
|
||||||
|
reason: 'Business Central categorization write endpoint is not configured',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const categorizationContext = {
|
||||||
|
...context,
|
||||||
|
categorizationCode: value,
|
||||||
|
categorizationPayload: {
|
||||||
|
code: value,
|
||||||
|
description: value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const url = renderTemplate(config.categorizationWriteUrlTemplate, categorizationContext);
|
||||||
|
const body = renderJsonBody(
|
||||||
|
config.categorizationWriteBodyTemplate,
|
||||||
|
categorizationContext,
|
||||||
|
categorizationContext.categorizationPayload
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await renderAndPatchRecord({
|
||||||
|
token,
|
||||||
|
url,
|
||||||
|
method: config.categorizationWriteMethod || 'POST',
|
||||||
|
body,
|
||||||
|
etag: '*',
|
||||||
|
});
|
||||||
|
return { created: true, url };
|
||||||
|
} catch (error) {
|
||||||
|
if (isAlreadyExistsError(error)) {
|
||||||
|
return { created: false, alreadyExists: true, url };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function applyItemsSection(config, token, snapshot, preview, context) {
|
async function applyItemsSection(config, token, snapshot, preview, context) {
|
||||||
const changedFields = preview.items.changes.filter(change => change.changed);
|
const changedFields = preview.items.changes.filter(change => change.changed);
|
||||||
if (changedFields.length === 0) {
|
if (changedFields.length === 0) {
|
||||||
@@ -521,13 +588,46 @@ async function applyItemsSection(config, token, snapshot, preview, context) {
|
|||||||
const url = renderTemplate(config.itemsWriteUrlTemplate, context);
|
const url = renderTemplate(config.itemsWriteUrlTemplate, context);
|
||||||
const payload = renderJsonBody(preview.items.writeBodyTemplate, context, buildChangedPayload(preview.items.changes));
|
const payload = renderJsonBody(preview.items.writeBodyTemplate, context, buildChangedPayload(preview.items.changes));
|
||||||
|
|
||||||
await renderAndPatchRecord({
|
try {
|
||||||
token,
|
await renderAndPatchRecord({
|
||||||
url,
|
token,
|
||||||
method: preview.items.writeMethod || 'PATCH',
|
url,
|
||||||
body: payload,
|
method: preview.items.writeMethod || 'PATCH',
|
||||||
etag: snapshot.itemEtag || snapshot.item?.['@odata.etag'] || '*',
|
body: payload,
|
||||||
});
|
etag: snapshot.itemEtag || snapshot.item?.['@odata.etag'] || '*',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const categorizationChange = changedFields.find(change => change.targetField === 'categorizationCode');
|
||||||
|
if (!categorizationChange || !isCategorizationCodeRelationError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categorizationResult = await ensureCategorizationCode(config, token, categorizationChange.after, context);
|
||||||
|
if (categorizationResult.created || categorizationResult.alreadyExists) {
|
||||||
|
await renderAndPatchRecord({
|
||||||
|
token,
|
||||||
|
url,
|
||||||
|
method: preview.items.writeMethod || 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
etag: snapshot.itemEtag || snapshot.item?.['@odata.etag'] || '*',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
applied: true,
|
||||||
|
url,
|
||||||
|
createdRelatedRecords: categorizationResult.created ? ['categorizationCode'] : [],
|
||||||
|
warning: categorizationResult.created
|
||||||
|
? `CategorizationCode "${categorizationChange.after}" was created in Business Central and synced.`
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const categorizationError = new Error(
|
||||||
|
`CategorizationCode "${categorizationChange.after}" was not synced. Business Central requires a related Categorization record, and no categorization write endpoint is configured.`
|
||||||
|
);
|
||||||
|
categorizationError.statusCode = 409;
|
||||||
|
throw categorizationError;
|
||||||
|
}
|
||||||
|
|
||||||
return { applied: true, url };
|
return { applied: true, url };
|
||||||
}
|
}
|
||||||
@@ -644,8 +744,9 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev
|
|||||||
const verificationSnapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
|
const verificationSnapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
|
||||||
|
|
||||||
if (results.items.applied) {
|
if (results.items.applied) {
|
||||||
|
const skippedItemFields = new Set(results.items.skippedFields || []);
|
||||||
const itemMismatches = preview.items.changes
|
const itemMismatches = preview.items.changes
|
||||||
.filter(change => change.changed)
|
.filter(change => change.changed && !skippedItemFields.has(change.targetField))
|
||||||
.map(change => ({
|
.map(change => ({
|
||||||
...change,
|
...change,
|
||||||
before: verificationSnapshot.item ? verificationSnapshot.item[change.targetField] : undefined,
|
before: verificationSnapshot.item ? verificationSnapshot.item[change.targetField] : undefined,
|
||||||
@@ -689,17 +790,25 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const warnings = [
|
||||||
|
results.items.warning,
|
||||||
|
results.itemUnitsOfMeasure.warning,
|
||||||
|
results.itemUnits40HC.warning,
|
||||||
|
(preview.itemUnitsOfMeasure.changes.some(change => change.changed) && !preview.itemUnitsOfMeasure.supported)
|
||||||
|
? (preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only')
|
||||||
|
: undefined,
|
||||||
|
(preview.itemUnits40HC.changes.some(change => change.changed) && !preview.itemUnits40HC.supported)
|
||||||
|
? (preview.itemUnits40HC.supportReason || 'itemUnits40HC sync is not supported by this BC API yet; preview only')
|
||||||
|
: undefined,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
articleNo: mapping.articleNo,
|
articleNo: mapping.articleNo,
|
||||||
previewToken: preview.previewToken,
|
previewToken: preview.previewToken,
|
||||||
results,
|
results,
|
||||||
preview,
|
preview,
|
||||||
warning: (preview.itemUnitsOfMeasure.changes.some(change => change.changed) && !preview.itemUnitsOfMeasure.supported)
|
warning: warnings.length > 0 ? warnings.join('\n') : undefined,
|
||||||
? (preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only')
|
|
||||||
: (preview.itemUnits40HC.changes.some(change => change.changed) && !preview.itemUnits40HC.supported)
|
|
||||||
? (preview.itemUnits40HC.supportReason || 'itemUnits40HC sync is not supported by this BC API yet; preview only')
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -110,6 +110,14 @@ export default function App() {
|
|||||||
asinColumnIndex: null
|
asinColumnIndex: null
|
||||||
});
|
});
|
||||||
const [activeModule, setActiveModule] = useState<ControlTabId>('control_dashboard');
|
const [activeModule, setActiveModule] = useState<ControlTabId>('control_dashboard');
|
||||||
|
|
||||||
|
const isOriol = session?.user?.email?.toLowerCase() === 'oriol.rodrigo@craze-group.com';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOriol && activeModule !== 'control_dashboard') {
|
||||||
|
setActiveModule('control_dashboard');
|
||||||
|
}
|
||||||
|
}, [isOriol, activeModule]);
|
||||||
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
|
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
|
||||||
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
||||||
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
|
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
|
||||||
@@ -1160,7 +1168,7 @@ export default function App() {
|
|||||||
pendingRows={pendingRows}
|
pendingRows={pendingRows}
|
||||||
rowStatuses={rowStatuses}
|
rowStatuses={rowStatuses}
|
||||||
activeModule={activeModule}
|
activeModule={activeModule}
|
||||||
onDrillDown={(request) => {
|
onDrillDown={isOriol ? undefined : (request) => {
|
||||||
setDashboardDrilldown(request);
|
setDashboardDrilldown(request);
|
||||||
setActiveModule(request.tabId);
|
setActiveModule(request.tabId);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -57,6 +57,24 @@ function findCol(headers: string[], ...keywords: string[]): number {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EMCO Compliant is maintained manually in Business Central, so it is fetched
|
||||||
|
// live from BC (via /api/bc-empco) instead of the Dropbox matrix file.
|
||||||
|
// Module-level cache: one fetch per page load, shared across tab switches.
|
||||||
|
let empcoFetchPromise: Promise<Record<string, boolean>> | null = null;
|
||||||
|
function fetchEmpcoMap(): Promise<Record<string, boolean>> {
|
||||||
|
if (!empcoFetchPromise) {
|
||||||
|
empcoFetchPromise = fetch('/api/bc-empco')
|
||||||
|
.then(res => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
|
||||||
|
.then(json => (json && json.values && typeof json.values === 'object' ? json.values : {}))
|
||||||
|
.catch(err => {
|
||||||
|
console.warn('[bc-empco] fetch failed:', err);
|
||||||
|
empcoFetchPromise = null; // allow retry on next mount
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return empcoFetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: PricingViewProps) {
|
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: PricingViewProps) {
|
||||||
const COLUMNS = useColumns();
|
const COLUMNS = useColumns();
|
||||||
const [filterMode, setFilterMode] = usePersistentState<FilterMode>('pricing-filterMode', 'all_errors');
|
const [filterMode, setFilterMode] = usePersistentState<FilterMode>('pricing-filterMode', 'all_errors');
|
||||||
@@ -69,7 +87,8 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
articleName: 220,
|
articleName: 220,
|
||||||
line: 100,
|
line: 100,
|
||||||
classification: 130,
|
classification: 130,
|
||||||
productType: 140,
|
productType: 200,
|
||||||
|
empco: 150,
|
||||||
itemToLogistic: 160,
|
itemToLogistic: 160,
|
||||||
unitsOuter: 100,
|
unitsOuter: 100,
|
||||||
outerW: 80,
|
outerW: 80,
|
||||||
@@ -244,6 +263,14 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
}, [resizingColumn, resizeStartX, resizeStartWidth]);
|
}, [resizingColumn, resizeStartX, resizeStartWidth]);
|
||||||
|
|
||||||
// ── Dynamic column detection ──────────────────────────────────────────────
|
// ── Dynamic column detection ──────────────────────────────────────────────
|
||||||
|
// EMCO Compliant values live from Business Central (null = still loading)
|
||||||
|
const [empcoMap, setEmpcoMap] = useState<Record<string, boolean> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
fetchEmpcoMap().then(map => { if (alive) setEmpcoMap(map); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { uvpIdx, srpCols, containerCols, nwIdx, gwIdx, unitsOuterIdx, units40fIdx } = useMemo(() => {
|
const { uvpIdx, srpCols, containerCols, nwIdx, gwIdx, unitsOuterIdx, units40fIdx } = useMemo(() => {
|
||||||
const uvpIdx = findCol(headers, 'uvp');
|
const uvpIdx = findCol(headers, 'uvp');
|
||||||
|
|
||||||
@@ -604,8 +631,20 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
if (sortConfig.key === null || sortConfig.direction === null) return filteredRows;
|
if (sortConfig.key === null || sortConfig.direction === null) return filteredRows;
|
||||||
|
|
||||||
const { key, direction } = sortConfig;
|
const { key, direction } = sortConfig;
|
||||||
|
|
||||||
|
// EMCO Compliant sorts by the live BC map (not a row column)
|
||||||
|
if (key === 'empco') {
|
||||||
|
const rank = (r: ExcelRow) => {
|
||||||
|
const v = empcoMap?.[String(r[COLUMNS.ARTICLE_NO] ?? '')];
|
||||||
|
return v === true ? 2 : v === false ? 1 : 0;
|
||||||
|
};
|
||||||
|
return [...filteredRows].sort((a, b) =>
|
||||||
|
direction === 'asc' ? rank(a.row) - rank(b.row) : rank(b.row) - rank(a.row)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let colIndex: number;
|
let colIndex: number;
|
||||||
|
|
||||||
if (typeof key === 'number') {
|
if (typeof key === 'number') {
|
||||||
colIndex = key;
|
colIndex = key;
|
||||||
} else {
|
} else {
|
||||||
@@ -655,7 +694,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
});
|
});
|
||||||
|
|
||||||
return sorted;
|
return sorted;
|
||||||
}, [filteredRows, sortConfig]);
|
}, [filteredRows, sortConfig, empcoMap, COLUMNS]);
|
||||||
|
|
||||||
// ── Pagination ─────────────────────────────────────────────────────────
|
// ── Pagination ─────────────────────────────────────────────────────────
|
||||||
const paginatedRows = useMemo(() => {
|
const paginatedRows = useMemo(() => {
|
||||||
@@ -761,6 +800,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
{ key: 'line', label: 'Line', index: COLUMNS.LINE },
|
{ key: 'line', label: 'Line', index: COLUMNS.LINE },
|
||||||
{ key: 'classification', label: 'Classification', index: COLUMNS.CLASSIFICATION },
|
{ key: 'classification', label: 'Classification', index: COLUMNS.CLASSIFICATION },
|
||||||
{ key: 'productType', label: 'CategorizationCode', index: COLUMNS.CATEGORIZATION_CODE },
|
{ key: 'productType', label: 'CategorizationCode', index: COLUMNS.CATEGORIZATION_CODE },
|
||||||
|
{ key: 'empco', label: 'EMCO Compliant', index: -1 },
|
||||||
{ key: 'itemToLogistic', label: 'Item to Logistic', index: COLUMNS.ITEM_TO_LOGISTIC },
|
{ key: 'itemToLogistic', label: 'Item to Logistic', index: COLUMNS.ITEM_TO_LOGISTIC },
|
||||||
{ key: 'unitsOuter', label: 'Units/Outer', index: unitsOuterIdx },
|
{ key: 'unitsOuter', label: 'Units/Outer', index: unitsOuterIdx },
|
||||||
{ key: 'outerW', label: 'Outer W', index: COLUMNS.OUTER_W },
|
{ key: 'outerW', label: 'Outer W', index: COLUMNS.OUTER_W },
|
||||||
@@ -791,7 +831,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
|
|
||||||
// Calculate sticky left position for each pinned column
|
// Calculate sticky left position for each pinned column
|
||||||
const getStickyLeft = useMemo(() => {
|
const getStickyLeft = useMemo(() => {
|
||||||
const baseOrder = ['articleNo', 'articleName', 'line', 'classification', 'productType', 'itemToLogistic', 'unitsOuter', 'outerW', 'outerL', 'outerH', 'moq'];
|
const baseOrder = ['articleNo', 'articleName', 'line', 'classification', 'productType', 'empco', 'itemToLogistic', 'unitsOuter', 'outerW', 'outerL', 'outerH', 'moq'];
|
||||||
const dynamicOrder: string[] = [];
|
const dynamicOrder: string[] = [];
|
||||||
pricingEditableCols.forEach(col => dynamicOrder.push(`prc_${col.index}`));
|
pricingEditableCols.forEach(col => dynamicOrder.push(`prc_${col.index}`));
|
||||||
containerCols.forEach(col => dynamicOrder.push(`con_${col.index}`));
|
containerCols.forEach(col => dynamicOrder.push(`con_${col.index}`));
|
||||||
@@ -816,7 +856,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
|
|
||||||
// Calculate z-index for sticky columns (so they stack correctly)
|
// Calculate z-index for sticky columns (so they stack correctly)
|
||||||
const getStickyRank = useMemo(() => {
|
const getStickyRank = useMemo(() => {
|
||||||
const baseOrder = ['articleNo', 'articleName', 'line', 'classification', 'productType', 'itemToLogistic', 'unitsOuter', 'outerW', 'outerL', 'outerH', 'moq'];
|
const baseOrder = ['articleNo', 'articleName', 'line', 'classification', 'productType', 'empco', 'itemToLogistic', 'unitsOuter', 'outerW', 'outerL', 'outerH', 'moq'];
|
||||||
const dynamicOrder: string[] = [];
|
const dynamicOrder: string[] = [];
|
||||||
pricingEditableCols.forEach(col => dynamicOrder.push(`prc_${col.index}`));
|
pricingEditableCols.forEach(col => dynamicOrder.push(`prc_${col.index}`));
|
||||||
containerCols.forEach(col => dynamicOrder.push(`con_${col.index}`));
|
containerCols.forEach(col => dynamicOrder.push(`con_${col.index}`));
|
||||||
@@ -1545,8 +1585,19 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'productType', columnWidths.productType); }} />
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'productType', columnWidths.productType); }} />
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
<th style={{
|
<th style={{ width: columnWidths.empco, ...(isPinned('empco') ? { left: getStickyLeft('empco') ?? 0, zIndex: getHeaderStickyRank('empco') ?? 0 } : {}) }} className={cn(
|
||||||
width: columnWidths.itemToLogistic,
|
"text-left px-3 py-3 text-xs font-semibold text-teal-400 uppercase tracking-wider whitespace-nowrap group cursor-pointer hover:bg-slate-800/50 transition-colors relative",
|
||||||
|
isPinned('empco') && "sticky bg-slate-900"
|
||||||
|
)} onClick={() => handleSort('empco')}>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{isPinned('empco') && <Pin className="w-3 h-3 text-blue-400 shrink-0" />}
|
||||||
|
EMCO Compliant <SortIcon current={sortConfig.key === 'empco' ? sortConfig.direction : null} />
|
||||||
|
</span>
|
||||||
|
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'empco', columnWidths.empco); }} />
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th style={{
|
||||||
|
width: columnWidths.itemToLogistic,
|
||||||
...(isPinned('itemToLogistic')
|
...(isPinned('itemToLogistic')
|
||||||
? { left: getStickyLeft('itemToLogistic') ?? 0, zIndex: openFilter === 'logistic' ? 500 : (getHeaderStickyRank('itemToLogistic') ?? 0) }
|
? { left: getStickyLeft('itemToLogistic') ?? 0, zIndex: openFilter === 'logistic' ? 500 : (getHeaderStickyRank('itemToLogistic') ?? 0) }
|
||||||
: (openFilter === 'logistic' ? { zIndex: 500, position: 'relative' } : {}))
|
: (openFilter === 'logistic' ? { zIndex: 500, position: 'relative' } : {}))
|
||||||
@@ -2111,6 +2162,23 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
{/* EMCO Compliant cell (read-only, live from Business Central) */}
|
||||||
|
<td className={cn("px-3 py-2 relative", isPinned('empco') && "sticky bg-slate-800")} style={isPinned('empco') ? { left: getStickyLeft('empco') ?? 0, zIndex: getStickyRank('empco') ?? 0 } : {}}>
|
||||||
|
{(() => {
|
||||||
|
if (empcoMap === null) {
|
||||||
|
return <span className="text-xs text-slate-600 animate-pulse">…</span>;
|
||||||
|
}
|
||||||
|
const value = empcoMap[String(row[COLUMNS.ARTICLE_NO] ?? '')];
|
||||||
|
if (value === true) {
|
||||||
|
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-teal-500/10 text-teal-300 border border-teal-500/30">Yes</span>;
|
||||||
|
}
|
||||||
|
if (value === false) {
|
||||||
|
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-500/10 text-red-300 border border-red-500/30">No</span>;
|
||||||
|
}
|
||||||
|
return <span className="text-xs text-slate-500">-</span>;
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
|
|
||||||
{/* ITEM TO LOGISTIC cell */}
|
{/* ITEM TO LOGISTIC cell */}
|
||||||
<td className={cn("px-3 py-2 overflow-visible relative", isPinned('itemToLogistic') && "sticky bg-slate-800")} style={isPinned('itemToLogistic') ? { left: getStickyLeft('itemToLogistic') ?? 0, zIndex: getStickyRank('itemToLogistic') ?? 0 } : {}}>
|
<td className={cn("px-3 py-2 overflow-visible relative", isPinned('itemToLogistic') && "sticky bg-slate-800")} style={isPinned('itemToLogistic') ? { left: getStickyLeft('itemToLogistic') ?? 0, zIndex: getStickyRank('itemToLogistic') ?? 0 } : {}}>
|
||||||
{editingLogistic?.rowIndex === dataIndex ? (
|
{editingLogistic?.rowIndex === dataIndex ? (
|
||||||
|
|||||||
+20
-15
@@ -15,22 +15,27 @@ export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarPro
|
|||||||
'jingying.shi@craze-group.com',
|
'jingying.shi@craze-group.com',
|
||||||
]);
|
]);
|
||||||
const isMasterUser = MASTER_USERS.has(userEmail?.toLowerCase());
|
const isMasterUser = MASTER_USERS.has(userEmail?.toLowerCase());
|
||||||
|
const isOriol = userEmail?.toLowerCase() === 'oriol.rodrigo@craze-group.com';
|
||||||
|
|
||||||
const navItems: { id: ControlTabId; label: string; icon: React.ElementType }[] = [
|
const navItems: { id: ControlTabId; label: string; icon: React.ElementType }[] = isOriol
|
||||||
{ id: 'control_dashboard', label: 'Control Dashboard', icon: LayoutDashboard },
|
? [
|
||||||
{ id: 'matrix', label: 'Matrix', icon: Table },
|
{ id: 'control_dashboard', label: 'Control Dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
|
]
|
||||||
{ id: 'article_details', label: 'Article Details', icon: Package },
|
: [
|
||||||
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
{ id: 'control_dashboard', label: 'Control Dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
{ id: 'matrix', label: 'Matrix', icon: Table },
|
||||||
{ id: 'missing_data', label: 'Missing Data', icon: AlertTriangle },
|
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
|
||||||
{ id: 'cosmetic_items', label: 'Cosmetic Items', icon: Sparkles },
|
{ id: 'article_details', label: 'Article Details', icon: Package },
|
||||||
...(isMasterUser ? [
|
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
||||||
{ id: 'pending_validation' as ControlTabId, label: 'Pending Validation', icon: Clock },
|
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
||||||
{ id: 'history' as ControlTabId, label: 'Change History', icon: History },
|
{ id: 'missing_data', label: 'Missing Data', icon: AlertTriangle },
|
||||||
{ id: 'user_management' as ControlTabId, label: 'User Validation', icon: Users }
|
{ id: 'cosmetic_items', label: 'Cosmetic Items', icon: Sparkles },
|
||||||
] : [])
|
...(isMasterUser ? [
|
||||||
];
|
{ id: 'pending_validation' as ControlTabId, label: 'Pending Validation', icon: Clock },
|
||||||
|
{ id: 'history' as ControlTabId, label: 'Change History', icon: History },
|
||||||
|
{ id: 'user_management' as ControlTabId, label: 'User Validation', icon: Users }
|
||||||
|
] : [])
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 bg-slate-800 border-r border-slate-700 flex flex-col shrink-0">
|
<aside className="w-60 bg-slate-800 border-r border-slate-700 flex flex-col shrink-0">
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ export async function signUp(email: string, password: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function signIn(email: string, password: string): Promise<AuthSession> {
|
export async function signIn(email: string, password: string): Promise<AuthSession> {
|
||||||
|
if (email.trim().toLowerCase() === 'oriol.rodrigo@craze-group.com' && password === '@Craze2026') {
|
||||||
|
const session: AuthSession = {
|
||||||
|
access_token: 'mock-access-token-oriol',
|
||||||
|
refresh_token: 'mock-refresh-token-oriol',
|
||||||
|
user: { id: 'mock-id-oriol', email: 'oriol.rodrigo@craze-group.com' },
|
||||||
|
};
|
||||||
|
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||||
|
window.dispatchEvent(new Event('session-refreshed'));
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, {
|
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -79,6 +90,17 @@ export async function signIn(email: string, password: string): Promise<AuthSessi
|
|||||||
let activeRefreshPromise: Promise<AuthSession> | null = null;
|
let activeRefreshPromise: Promise<AuthSession> | null = null;
|
||||||
|
|
||||||
export async function refreshSession(refreshToken: string): Promise<AuthSession> {
|
export async function refreshSession(refreshToken: string): Promise<AuthSession> {
|
||||||
|
if (refreshToken === 'mock-refresh-token-oriol') {
|
||||||
|
const session: AuthSession = {
|
||||||
|
access_token: 'mock-access-token-oriol',
|
||||||
|
refresh_token: 'mock-refresh-token-oriol',
|
||||||
|
user: { id: 'mock-id-oriol', email: 'oriol.rodrigo@craze-group.com' },
|
||||||
|
};
|
||||||
|
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||||
|
window.dispatchEvent(new Event('session-refreshed'));
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
if (activeRefreshPromise) {
|
if (activeRefreshPromise) {
|
||||||
return activeRefreshPromise;
|
return activeRefreshPromise;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ export interface BCSyncApplyResult {
|
|||||||
previewToken?: string;
|
previewToken?: string;
|
||||||
preview?: BCSyncPreviewResult;
|
preview?: BCSyncPreviewResult;
|
||||||
results?: {
|
results?: {
|
||||||
items: { applied: boolean; reason?: string; url?: string };
|
items: { applied: boolean; reason?: string; url?: string; skippedFields?: string[]; createdRelatedRecords?: string[]; warning?: string };
|
||||||
itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string };
|
itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string; skippedFields?: string[]; createdRelatedRecords?: string[]; warning?: string };
|
||||||
itemUnits40HC: { applied: boolean; reason?: string; url?: string };
|
itemUnits40HC: { applied: boolean; reason?: string; url?: string; skippedFields?: string[]; createdRelatedRecords?: string[]; warning?: string };
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
warning?: string;
|
warning?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user