2 Commits
8 changed files with 225 additions and 44 deletions
+6
View File
@@ -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.
+36 -11
View File
@@ -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' });
}
@@ -138,6 +148,10 @@ export default async function handler(req, res) {
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`, {
headers: {
'apikey': SUPABASE_SERVICE_KEY,
@@ -200,6 +214,17 @@ export default async function handler(req, res) {
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 });
}
+6
View File
@@ -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}}',
}),
};
}
+123 -14
View File
@@ -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,
};
}
+9 -1
View File
@@ -110,6 +110,14 @@ export default function App() {
asinColumnIndex: null
});
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 [editingRowIndex, setEditingRowIndex] = useState<number | null>(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);
}}
+20 -15
View File
@@ -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 (
<aside className="w-60 bg-slate-800 border-r border-slate-700 flex flex-col shrink-0">
+22
View File
@@ -25,6 +25,17 @@ export async function signUp(email: string, password: string): Promise<void> {
}
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`, {
method: 'POST',
headers: {
@@ -79,6 +90,17 @@ export async function signIn(email: string, password: string): Promise<AuthSessi
let activeRefreshPromise: Promise<AuthSession> | null = null;
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) {
return activeRefreshPromise;
}
+3 -3
View File
@@ -58,9 +58,9 @@ export interface BCSyncApplyResult {
previewToken?: string;
preview?: BCSyncPreviewResult;
results?: {
items: { applied: boolean; reason?: string; url?: string };
itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string };
itemUnits40HC: { 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; skippedFields?: string[]; createdRelatedRecords?: string[]; warning?: string };
itemUnits40HC: { applied: boolean; reason?: string; url?: string; skippedFields?: string[]; createdRelatedRecords?: string[]; warning?: string };
};
error?: string;
warning?: string;