diff --git a/.env.example b/.env.example index cfb0350..2195d22 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,12 @@ BC_UOM_CODE="OUTER" BC_UOM_WRITE_URL_TEMPLATE="{{itemUnitsOfMeasureUrl}}(itemNo='{{itemNo}}',code='{{itemUnitsCode}}')" 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. # AI Studio automatically injects this at runtime with the Cloud Run service URL. # Used for self-referential links, OAuth callbacks, and API endpoints. diff --git a/api/users-admin.js b/api/users-admin.js index 8796396..a838cf4 100644 --- a/api/users-admin.js +++ b/api/users-admin.js @@ -106,21 +106,31 @@ export default async function handler(req, res) { } const token = authHeader.split(' ')[1]; - const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, { - headers: { - 'apikey': SUPABASE_ANON_KEY, - 'Authorization': `Bearer ${token}` + let callerEmail; + let callerId; + let user; + + 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) { - return res.status(401).json({ error: 'Unauthorized: Invalid token' }); + user = await userRes.json(); + callerEmail = user.email; + callerId = user.id; } - const user = await userRes.json(); - const callerEmail = user.email; - const callerId = user.id; - if (!callerEmail) { return res.status(401).json({ error: 'Unauthorized: Invalid user payload' }); } diff --git a/bc-runtime.js b/bc-runtime.js index 44a6c2b..5e0d8c4 100644 --- a/bc-runtime.js +++ b/bc-runtime.js @@ -20,6 +20,12 @@ export function getBcConfig(env = process.env) { 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}}')`, 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}}', + }), }; } diff --git a/bc-sync-runtime.js b/bc-sync-runtime.js index 28142b9..d9ae17c 100644 --- a/bc-sync-runtime.js +++ b/bc-sync-runtime.js @@ -436,10 +436,32 @@ async function renderAndPatchRecord({ token, url, method, body, etag }) { if (!res.ok) { 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) { return String(template).replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, 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) { const changedFields = preview.items.changes.filter(change => change.changed); if (changedFields.length === 0) { @@ -521,13 +588,46 @@ async function applyItemsSection(config, token, snapshot, preview, context) { 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'] || '*', - }); + try { + await renderAndPatchRecord({ + token, + url, + method: preview.items.writeMethod || 'PATCH', + 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 }; } @@ -644,8 +744,9 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev const verificationSnapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo); if (results.items.applied) { + const skippedItemFields = new Set(results.items.skippedFields || []); const itemMismatches = preview.items.changes - .filter(change => change.changed) + .filter(change => change.changed && !skippedItemFields.has(change.targetField)) .map(change => ({ ...change, 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 { success: true, articleNo: mapping.articleNo, previewToken: preview.previewToken, results, preview, - warning: (preview.itemUnitsOfMeasure.changes.some(change => change.changed) && !preview.itemUnitsOfMeasure.supported) - ? (preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only') - : (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, + warning: warnings.length > 0 ? warnings.join('\n') : undefined, }; } diff --git a/src/App.tsx b/src/App.tsx index 3d6dfbb..2f8d1b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -110,6 +110,14 @@ export default function App() { asinColumnIndex: null }); const [activeModule, setActiveModule] = useState('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 [editingRowIndex, setEditingRowIndex] = useState(null); const [isLoadingDefault, setIsLoadingDefault] = useState(true); @@ -1160,7 +1168,7 @@ export default function App() { pendingRows={pendingRows} rowStatuses={rowStatuses} activeModule={activeModule} - onDrillDown={(request) => { + onDrillDown={isOriol ? undefined : (request) => { setDashboardDrilldown(request); setActiveModule(request.tabId); }} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 98bbcd9..2d508b6 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -15,22 +15,27 @@ export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarPro 'jingying.shi@craze-group.com', ]); 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 }[] = [ - { id: 'control_dashboard', label: 'Control Dashboard', icon: LayoutDashboard }, - { id: 'matrix', label: 'Matrix', icon: Table }, - { id: 'descriptions', label: 'Product Descriptions', icon: FileText }, - { id: 'article_details', label: 'Article Details', icon: Package }, - { id: 'dimensions', label: 'Dimensions', icon: Box }, - { id: 'pricing', label: 'Pricing & Units', icon: DollarSign }, - { id: 'missing_data', label: 'Missing Data', icon: AlertTriangle }, - { 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 } - ] : []) - ]; + const navItems: { id: ControlTabId; label: string; icon: React.ElementType }[] = isOriol + ? [ + { id: 'control_dashboard', label: 'Control Dashboard', icon: LayoutDashboard }, + ] + : [ + { id: 'control_dashboard', label: 'Control Dashboard', icon: LayoutDashboard }, + { id: 'matrix', label: 'Matrix', icon: Table }, + { id: 'descriptions', label: 'Product Descriptions', icon: FileText }, + { id: 'article_details', label: 'Article Details', icon: Package }, + { id: 'dimensions', label: 'Dimensions', icon: Box }, + { id: 'pricing', label: 'Pricing & Units', icon: DollarSign }, + { id: 'missing_data', label: 'Missing Data', icon: AlertTriangle }, + { 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 (