feat: add Business Central CPNP sync and expand history access

- Add bc-proxy and bc-export serverless API handlers
- Add businessCentral service with updateCpnpNoInBC and downloadBusinessCentralItemsExcel
- Sync cpnpNo to BC on save (CosmeticItemsView and handleSaveAll)
- Parallelize handleSaveAll with Promise.all
- Fix array index key, a11y click/keyboard handlers, autoFocus
- Grant Change History and Pending Validation access to jingying.shi@craze-group.com

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Christian Vidal Wolf
2026-05-14 17:17:10 +02:00
co-authored by claude-flow
parent 2961d3f86a
commit 9b5932920e
11 changed files with 585 additions and 19 deletions
+31 -7
View File
@@ -21,7 +21,7 @@ import { UndoToast } from './components/UndoToast';
import { PendingValidationView } from './components/PendingValidationView';
import { MissingDataView } from './components/MissingDataView';
import { CosmeticItemsView } from './components/CosmeticItemsView';
import { downloadBusinessCentralItemsExcel, updateCpnpNoInBC } from './services/businessCentral';
const FORCED_ZERO_STOCK_SKUS = new Set([
'11631VC', '1237VC', '1238VC', '1652VC', '1653VC', '1684VC', '1688VC', '1717VC',
'1718VC', '180VC', '1832VC', '2025VC', '2027VC', '2180VC', '2181VC', '2210VC',
@@ -69,6 +69,7 @@ export default function App() {
const [rowStatuses, setRowStatuses] = useState<Record<string, string>>({});
const [pendingRows, setPendingRows] = useState<Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }>>({});
const [isSavingAll, setIsSavingAll] = useState(false);
const [isDownloadingBCExcel, setIsDownloadingBCExcel] = useState(false);
const [refreshTrigger, setRefreshTrigger] = useState(0);
useEffect(() => {
@@ -543,18 +544,27 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
let sessionIssue = false;
try {
for (const [articleNo, { newData, originalData, articleName }] of entries) {
await Promise.all(entries.map(async ([articleNo, { newData, originalData, articleName }]) => {
console.log('[handleSaveAll] Saving article:', articleNo);
const result = await saveRowToSupabase(articleNo, newData);
console.log('[handleSaveAll] Save result for', articleNo, ':', result);
if (result.success) {
// Also save to history
const histRes = await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown');
const cpnpValue = String(newData[COLUMNS.CPNP_NO] ?? '').trim();
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) {
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' }));
setPendingRows(prev => {
const n = { ...prev };
@@ -568,7 +578,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
sessionIssue = true;
}
}
}
}));
console.log('[handleSaveAll] Finished loop. Failed:', failedArticles.length);
@@ -588,6 +598,18 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
}
};
const handleDownloadBCExcel = async () => {
setIsDownloadingBCExcel(true);
try {
const result = await downloadBusinessCentralItemsExcel();
if (!result.success) {
alert(`Failed to download Business Central Excel:\n\n${result.error || 'Unknown error'}`);
}
} finally {
setIsDownloadingBCExcel(false);
}
};
const captureState = (message: string) => {
setUndoHistory(prev => {
const newState = {
@@ -672,6 +694,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
stats={stats}
activeModule={activeModule}
onExport={handleExport}
onDownloadBCExcel={handleDownloadBCExcel}
onRefresh={() => { setRefreshTrigger(t => t + 1); }}
hasData={appState.data.length > 0}
hasUnsavedChanges={appState.hasUnsavedChanges}
@@ -686,6 +709,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
onSaveAll={handleSaveAll}
onRevertRow={handleRevertRow}
isSavingAll={isSavingAll}
isDownloadingBCExcel={isDownloadingBCExcel}
isMaximized={isMaximized}
onToggleMaximize={() => setIsMaximized(true)}
/>
+26 -7
View File
@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { Search, ChevronDown, ChevronUp, X, Save, Check, Loader2 } from 'lucide-react';
@@ -6,6 +6,7 @@ import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
import { usePersistentState } from '../contexts/FilterContext';
import { saveRowToSupabase } from '../lib/supabase';
import { updateCpnpNoInBC } from '../services/businessCentral';
const COSMETIC_LINES = ['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS'];
@@ -27,6 +28,11 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
const [openFilter, setOpenFilter] = useState<number | null>(null);
const [editingCpnp, setEditingCpnp] = useState<{ rowIndex: number; value: string } | null>(null);
const [savingCpnp, setSavingCpnp] = useState<number | null>(null);
const cpnpInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingCpnp !== null) cpnpInputRef.current?.focus();
}, [editingCpnp]);
const pageSize = 100;
@@ -129,16 +135,26 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
if (!editingCpnp || editingCpnp.rowIndex !== rowIndex) return;
const row = data[rowIndex];
const articleNo = String(row[COLUMNS.ARTICLE_NO]);
const cpnpValue = editingCpnp.value.trim();
const newRow = [...row];
newRow[COLUMNS.CPNP_NO] = editingCpnp.value.trim();
newRow[COLUMNS.CPNP_NO] = cpnpValue;
setSavingCpnp(rowIndex);
setEditingCpnp(null);
onCaptureState(`Updated CPNP No. for ${articleNo}`);
onSaveRow(rowIndex, newRow);
const result = await saveRowToSupabase(articleNo, newRow, 'edited');
const [supabaseResult, bcResult] = await Promise.all([
saveRowToSupabase(articleNo, newRow, 'edited'),
updateCpnpNoInBC(articleNo, cpnpValue),
]);
setSavingCpnp(null);
if (!result.success) {
alert(`Error saving CPNP No. for ${articleNo}: ${result.error}`);
const errors: string[] = [];
if (!supabaseResult.success) errors.push(`Supabase: ${supabaseResult.error}`);
if (!bcResult.success) errors.push(`Business Central: ${bcResult.error}`);
if (errors.length > 0) {
alert(`Error saving CPNP No. for ${articleNo}:\n${errors.join('\n')}`);
}
};
@@ -198,7 +214,10 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
>
<div
className="flex items-center gap-1 cursor-pointer select-none hover:text-white"
role="button"
tabIndex={0}
onClick={() => handleSort(col)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') handleSort(col); }}
>
{label}
{sortCol === col && (
@@ -251,7 +270,7 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
return (
<tr
key={index}
key={articleNo || index}
className={cn(
"hover:bg-slate-700/20 transition-colors",
status === 'pending' && "bg-amber-500/5"
@@ -283,9 +302,9 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
) : isEditing ? (
<div className="flex items-center gap-1">
<input
ref={cpnpInputRef}
type="text"
value={editingCpnp.value}
autoFocus
onChange={e => setEditingCpnp(prev => prev ? { ...prev, value: e.target.value } : prev)}
onKeyDown={e => {
if (e.key === 'Enter') saveCpnp(index);
+5 -1
View File
@@ -9,7 +9,11 @@ interface SidebarProps {
}
export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarProps) {
const isMasterUser = userEmail?.toLowerCase() === 'christian.vidal@craze-group.com';
const MASTER_USERS = new Set([
'christian.vidal@craze-group.com',
'jingying.shi@craze-group.com',
]);
const isMasterUser = MASTER_USERS.has(userEmail?.toLowerCase());
type ModuleId = 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data' | 'cosmetic_items';
+19 -2
View File
@@ -1,11 +1,12 @@
import React, { useState, useRef, useEffect } from 'react';
import { Download, LogOut, Undo2, CloudUpload, Loader2, ChevronDown, RotateCcw, Maximize2, X } from 'lucide-react';
import { Download, LogOut, Undo2, CloudUpload, Loader2, ChevronDown, RotateCcw, Maximize2, X, CloudDownload } from 'lucide-react';
import { cn } from '../lib/utils';
interface TopBarProps {
stats: any;
activeModule?: string;
onExport: () => void;
onDownloadBCExcel: () => void;
onRefresh?: () => void;
hasData: boolean;
hasUnsavedChanges: boolean;
@@ -20,11 +21,12 @@ interface TopBarProps {
onSaveAll: () => Promise<void>;
onRevertRow: (articleNo: string) => void;
isSavingAll: boolean;
isDownloadingBCExcel: boolean;
isMaximized: boolean;
onToggleMaximize: () => void;
}
export function TopBar({ stats, activeModule, onExport, onRefresh, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps, pendingCount, pendingChanges, onSaveAll, onRevertRow, isSavingAll, isMaximized, onToggleMaximize }: TopBarProps) {
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) {
const [showPending, setShowPending] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -170,6 +172,21 @@ export function TopBar({ stats, activeModule, onExport, onRefresh, hasData, hasU
</button>
)}
<button
onClick={onDownloadBCExcel}
disabled={isDownloadingBCExcel}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors',
isDownloadingBCExcel
? 'bg-slate-700 text-slate-400 cursor-wait'
: 'bg-slate-700 hover:bg-slate-600 text-slate-200'
)}
title="Download the latest table directly from Business Central"
>
{isDownloadingBCExcel ? <Loader2 className="w-4 h-4 animate-spin" /> : <CloudDownload className="w-4 h-4" />}
Download BC Excel
</button>
{onRefresh && (
<button
onClick={onRefresh}
+107
View File
@@ -0,0 +1,107 @@
const BC_PROXY_URL = '/api/bc-proxy';
const BC_EXPORT_URL = '/api/bc-export';
export interface BCUpdateResult {
success: boolean;
error?: string;
}
export interface BCDownloadResult {
success: boolean;
error?: string;
filename?: string;
}
async function readResponsePayload(res: Response): Promise<{ data: any; rawText: string }> {
const rawText = await res.text();
if (!rawText.trim()) {
return { data: null, rawText };
}
try {
return { data: JSON.parse(rawText), rawText };
} catch {
return { data: rawText, rawText };
}
}
export async function updateCpnpNoInBC(articleNo: string, cpnpNo: string): Promise<BCUpdateResult> {
try {
const res = await fetch(BC_PROXY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ articleNo, cpnpNo }),
});
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 };
}
return { success: true };
} catch (err: any) {
return { success: false, error: err.message };
}
}
function getFilenameFromDisposition(contentDisposition: string | null): string | null {
if (!contentDisposition) return null;
const utf8Match = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1].trim().replace(/^"|"$/g, ''));
} catch {
return utf8Match[1].trim().replace(/^"|"$/g, '');
}
}
const filenameMatch = contentDisposition.match(/filename\s*=\s*([^;]+)/i);
if (filenameMatch?.[1]) {
return filenameMatch[1].trim().replace(/^"|"$/g, '');
}
return null;
}
export async function downloadBusinessCentralItemsExcel(): Promise<BCDownloadResult> {
try {
const res = await fetch(BC_EXPORT_URL, {
method: 'GET',
});
if (!res.ok) {
let error = `HTTP ${res.status}`;
try {
const { data, rawText } = await readResponsePayload(res);
error = data?.error || (typeof data === 'string' && data.trim()) || rawText || error;
} catch {
error = `HTTP ${res.status}`;
}
return { success: false, error };
}
const blob = await res.blob();
const filename =
getFilenameFromDisposition(res.headers.get('content-disposition')) ||
`BusinessCentral_Items_${new Date().toISOString().split('T')[0]}.xlsx`;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
return { success: true, filename };
} catch (err: any) {
return { success: false, error: err.message };
}
}