mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 10:55:25 +02:00
Compare commits
2
Commits
ea4592b695
...
10d3dadb5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d3dadb5d | ||
|
|
56593f942b |
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user