mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 11:55:23 +02:00
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:
co-authored by
claude-flow
parent
2961d3f86a
commit
9b5932920e
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
agentdb.rvf
|
||||||
|
agentdb.rvf.lock
|
||||||
|
ruvector.db
|
||||||
|
scratch
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { getBcConfig, getBCToken, fetchAllItems, buildWorkbook } from '../bc-runtime.js';
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://localhost:4173',
|
||||||
|
'http://localhost:5173',
|
||||||
|
'https://craze-data-check.vercel.app',
|
||||||
|
];
|
||||||
|
|
||||||
|
function setCors(req, res) {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
res.setHeader('Vary', 'Origin');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && !ALLOWED_ORIGINS.includes(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 items = await fetchAllItems(config, token);
|
||||||
|
|
||||||
|
if (req.query?.format === 'json') {
|
||||||
|
return res.json({ success: true, count: items.length, items });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = buildWorkbook(items);
|
||||||
|
const buffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' });
|
||||||
|
const dateStr = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="BusinessCentral_Items_${dateStr}.xlsx"`);
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
return res.status(200).send(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bc-export] error:', err?.message || err);
|
||||||
|
return res.status(500).json({ success: false, error: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { getBcConfig, getBCToken, findItem, patchItemCpnpNo } from '../bc-runtime.js';
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://localhost:4173',
|
||||||
|
'http://localhost:5173',
|
||||||
|
'https://craze-data-check.vercel.app',
|
||||||
|
];
|
||||||
|
|
||||||
|
function setCors(req, res) {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
res.setHeader('Vary', 'Origin');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && !ALLOWED_ORIGINS.includes(origin)) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { articleNo, cpnpNo } = req.body || {};
|
||||||
|
if (!articleNo || cpnpNo === undefined) {
|
||||||
|
return res.status(400).json({ error: 'Missing articleNo or cpnpNo' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = getBcConfig();
|
||||||
|
const token = await getBCToken(config);
|
||||||
|
const item = await findItem(config, token, articleNo);
|
||||||
|
await patchItemCpnpNo(config, token, item, String(cpnpNo));
|
||||||
|
return res.json({ success: true, articleNo, cpnpNo });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bc-proxy] error:', err.message);
|
||||||
|
return res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
|
export function getBcConfig(env = process.env) {
|
||||||
|
return {
|
||||||
|
tenantId: env.BC_TENANT_ID || 'fab724f7-6b6d-4e3b-86e3-8c1e05e36b2a',
|
||||||
|
clientId: env.BC_CLIENT_ID || '6f832138-cb48-43e7-8601-efca120b45dc',
|
||||||
|
clientSecret: env.BC_CLIENT_SECRET,
|
||||||
|
companyId: env.BC_COMPANY_ID || '2acec35c-7d06-ed11-82f8-0022485ceea3',
|
||||||
|
writeMethod: String(env.BC_WRITE_METHOD || 'PATCH').toUpperCase(),
|
||||||
|
writeUrlTemplate: env.BC_WRITE_URL_TEMPLATE || `{{itemsUrl}}('{{itemNo}}')`,
|
||||||
|
writeBodyTemplate: env.BC_WRITE_BODY_TEMPLATE || JSON.stringify({ cpnpNo: '{{cpnpNo}}' }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTokenUrl(config) {
|
||||||
|
return `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItemsUrl(config) {
|
||||||
|
return `https://api.businesscentral.dynamics.com/v2.0/${config.tenantId}/production/api/craze/integrations/v1.0/companies(${config.companyId})/items`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokenCache = { token: null, expiresAt: 0 };
|
||||||
|
|
||||||
|
export async function getBCToken(config) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (tokenCache.token && now < tokenCache.expiresAt) {
|
||||||
|
return tokenCache.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.clientSecret) {
|
||||||
|
throw new Error('BC_CLIENT_SECRET env var not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
scope: 'https://api.businesscentral.dynamics.com/.default',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(getTokenUrl(config), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.access_token) {
|
||||||
|
throw new Error('BC token error: ' + JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenCache = { token: data.access_token, expiresAt: now + 55 * 60 * 1000 };
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllItems(config, token) {
|
||||||
|
const items = [];
|
||||||
|
let url = `${getItemsUrl(config)}?$top=1000`;
|
||||||
|
|
||||||
|
while (url) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(`BC GET items failed (${res.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (Array.isArray(json.value)) {
|
||||||
|
items.push(...json.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
url = json['@odata.nextLink'] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findItem(config, token, articleNo) {
|
||||||
|
const filter = encodeURIComponent(`no eq '${articleNo}'`);
|
||||||
|
const url = `${getItemsUrl(config)}?$filter=${filter}&$select=systemId,no,cpnpNo`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(`BC GET items failed (${res.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
const items = json.value || [];
|
||||||
|
console.log('[bc-proxy] GET items result:', JSON.stringify(items));
|
||||||
|
if (items.length === 0) throw new Error(`Item not found in BC: ${articleNo}`);
|
||||||
|
return items[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchItemCpnpNo(config, token, item, cpnpNo) {
|
||||||
|
const etag = item['@odata.etag'] || '*';
|
||||||
|
const itemsUrl = getItemsUrl(config);
|
||||||
|
const url = config.writeUrlTemplate
|
||||||
|
.replaceAll('{{itemsUrl}}', itemsUrl)
|
||||||
|
.replaceAll('{{tenantId}}', config.tenantId)
|
||||||
|
.replaceAll('{{companyId}}', config.companyId)
|
||||||
|
.replaceAll('{{itemNo}}', encodeURIComponent(String(item.no)))
|
||||||
|
.replaceAll('{{systemId}}', encodeURIComponent(String(item.systemId || '')))
|
||||||
|
.replaceAll('{{cpnpNo}}', String(cpnpNo));
|
||||||
|
const bodyText = config.writeBodyTemplate
|
||||||
|
.replaceAll('{{itemsUrl}}', itemsUrl)
|
||||||
|
.replaceAll('{{tenantId}}', config.tenantId)
|
||||||
|
.replaceAll('{{companyId}}', config.companyId)
|
||||||
|
.replaceAll('{{itemNo}}', String(item.no))
|
||||||
|
.replaceAll('{{systemId}}', String(item.systemId || ''))
|
||||||
|
.replaceAll('{{cpnpNo}}', String(cpnpNo));
|
||||||
|
console.log('[bc-proxy] WRITE url:', url);
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if ((config.writeMethod || 'PATCH') === 'PATCH') {
|
||||||
|
headers['If-Match'] = etag;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: config.writeMethod || 'PATCH',
|
||||||
|
headers,
|
||||||
|
body: bodyText,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(`BC write failed (${res.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeValue(value) {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkbook(items) {
|
||||||
|
const headers = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
Object.keys(item || {}).forEach(key => {
|
||||||
|
if (key.startsWith('@odata.')) return;
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
headers.push(key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = items.map(item => {
|
||||||
|
const row = {};
|
||||||
|
headers.forEach(key => {
|
||||||
|
row[key] = normalizeValue(item?.[key]);
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ws = XLSX.utils.json_to_sheet(rows, { header: headers });
|
||||||
|
ws['!autofilter'] = {
|
||||||
|
ref: XLSX.utils.encode_range({
|
||||||
|
s: { c: 0, r: 0 },
|
||||||
|
e: { c: Math.max(headers.length - 1, 0), r: Math.max(rows.length, 0) },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Items');
|
||||||
|
return wb;
|
||||||
|
}
|
||||||
+29
-5
@@ -21,7 +21,7 @@ import { UndoToast } from './components/UndoToast';
|
|||||||
import { PendingValidationView } from './components/PendingValidationView';
|
import { PendingValidationView } from './components/PendingValidationView';
|
||||||
import { MissingDataView } from './components/MissingDataView';
|
import { MissingDataView } from './components/MissingDataView';
|
||||||
import { CosmeticItemsView } from './components/CosmeticItemsView';
|
import { CosmeticItemsView } from './components/CosmeticItemsView';
|
||||||
|
import { downloadBusinessCentralItemsExcel, updateCpnpNoInBC } from './services/businessCentral';
|
||||||
const FORCED_ZERO_STOCK_SKUS = new Set([
|
const FORCED_ZERO_STOCK_SKUS = new Set([
|
||||||
'11631VC', '1237VC', '1238VC', '1652VC', '1653VC', '1684VC', '1688VC', '1717VC',
|
'11631VC', '1237VC', '1238VC', '1652VC', '1653VC', '1684VC', '1688VC', '1717VC',
|
||||||
'1718VC', '180VC', '1832VC', '2025VC', '2027VC', '2180VC', '2181VC', '2210VC',
|
'1718VC', '180VC', '1832VC', '2025VC', '2027VC', '2180VC', '2181VC', '2210VC',
|
||||||
@@ -69,6 +69,7 @@ export default function App() {
|
|||||||
const [rowStatuses, setRowStatuses] = useState<Record<string, string>>({});
|
const [rowStatuses, setRowStatuses] = useState<Record<string, string>>({});
|
||||||
const [pendingRows, setPendingRows] = useState<Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }>>({});
|
const [pendingRows, setPendingRows] = useState<Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }>>({});
|
||||||
const [isSavingAll, setIsSavingAll] = useState(false);
|
const [isSavingAll, setIsSavingAll] = useState(false);
|
||||||
|
const [isDownloadingBCExcel, setIsDownloadingBCExcel] = useState(false);
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -543,17 +544,26 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
let sessionIssue = false;
|
let sessionIssue = false;
|
||||||
|
|
||||||
try {
|
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);
|
console.log('[handleSaveAll] Saving article:', articleNo);
|
||||||
const result = await saveRowToSupabase(articleNo, newData);
|
const result = await saveRowToSupabase(articleNo, newData);
|
||||||
console.log('[handleSaveAll] Save result for', articleNo, ':', result);
|
console.log('[handleSaveAll] Save result for', articleNo, ':', result);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Also save to history
|
const cpnpValue = String(newData[COLUMNS.CPNP_NO] ?? '').trim();
|
||||||
const histRes = await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown');
|
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) {
|
if (!histRes.success) {
|
||||||
console.warn(`[handleSaveAll] History save failed for ${articleNo}:`, histRes.error);
|
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' }));
|
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
|
||||||
setPendingRows(prev => {
|
setPendingRows(prev => {
|
||||||
@@ -568,7 +578,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
sessionIssue = true;
|
sessionIssue = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}));
|
||||||
|
|
||||||
console.log('[handleSaveAll] Finished loop. Failed:', failedArticles.length);
|
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) => {
|
const captureState = (message: string) => {
|
||||||
setUndoHistory(prev => {
|
setUndoHistory(prev => {
|
||||||
const newState = {
|
const newState = {
|
||||||
@@ -672,6 +694,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
stats={stats}
|
stats={stats}
|
||||||
activeModule={activeModule}
|
activeModule={activeModule}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
|
onDownloadBCExcel={handleDownloadBCExcel}
|
||||||
onRefresh={() => { setRefreshTrigger(t => t + 1); }}
|
onRefresh={() => { setRefreshTrigger(t => t + 1); }}
|
||||||
hasData={appState.data.length > 0}
|
hasData={appState.data.length > 0}
|
||||||
hasUnsavedChanges={appState.hasUnsavedChanges}
|
hasUnsavedChanges={appState.hasUnsavedChanges}
|
||||||
@@ -686,6 +709,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO;
|
|||||||
onSaveAll={handleSaveAll}
|
onSaveAll={handleSaveAll}
|
||||||
onRevertRow={handleRevertRow}
|
onRevertRow={handleRevertRow}
|
||||||
isSavingAll={isSavingAll}
|
isSavingAll={isSavingAll}
|
||||||
|
isDownloadingBCExcel={isDownloadingBCExcel}
|
||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
onToggleMaximize={() => setIsMaximized(true)}
|
onToggleMaximize={() => setIsMaximized(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||||
import { ExcelRow } from '../types';
|
import { ExcelRow } from '../types';
|
||||||
import { useColumns } from '../contexts/ColumnsContext';
|
import { useColumns } from '../contexts/ColumnsContext';
|
||||||
import { Search, ChevronDown, ChevronUp, X, Save, Check, Loader2 } from 'lucide-react';
|
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 { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
import { usePersistentState } from '../contexts/FilterContext';
|
import { usePersistentState } from '../contexts/FilterContext';
|
||||||
import { saveRowToSupabase } from '../lib/supabase';
|
import { saveRowToSupabase } from '../lib/supabase';
|
||||||
|
import { updateCpnpNoInBC } from '../services/businessCentral';
|
||||||
|
|
||||||
const COSMETIC_LINES = ['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS'];
|
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 [openFilter, setOpenFilter] = useState<number | null>(null);
|
||||||
const [editingCpnp, setEditingCpnp] = useState<{ rowIndex: number; value: string } | null>(null);
|
const [editingCpnp, setEditingCpnp] = useState<{ rowIndex: number; value: string } | null>(null);
|
||||||
const [savingCpnp, setSavingCpnp] = useState<number | 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;
|
const pageSize = 100;
|
||||||
|
|
||||||
@@ -129,16 +135,26 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
|
|||||||
if (!editingCpnp || editingCpnp.rowIndex !== rowIndex) return;
|
if (!editingCpnp || editingCpnp.rowIndex !== rowIndex) return;
|
||||||
const row = data[rowIndex];
|
const row = data[rowIndex];
|
||||||
const articleNo = String(row[COLUMNS.ARTICLE_NO]);
|
const articleNo = String(row[COLUMNS.ARTICLE_NO]);
|
||||||
|
const cpnpValue = editingCpnp.value.trim();
|
||||||
const newRow = [...row];
|
const newRow = [...row];
|
||||||
newRow[COLUMNS.CPNP_NO] = editingCpnp.value.trim();
|
newRow[COLUMNS.CPNP_NO] = cpnpValue;
|
||||||
setSavingCpnp(rowIndex);
|
setSavingCpnp(rowIndex);
|
||||||
setEditingCpnp(null);
|
setEditingCpnp(null);
|
||||||
onCaptureState(`Updated CPNP No. for ${articleNo}`);
|
onCaptureState(`Updated CPNP No. for ${articleNo}`);
|
||||||
onSaveRow(rowIndex, newRow);
|
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);
|
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
|
<div
|
||||||
className="flex items-center gap-1 cursor-pointer select-none hover:text-white"
|
className="flex items-center gap-1 cursor-pointer select-none hover:text-white"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => handleSort(col)}
|
onClick={() => handleSort(col)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') handleSort(col); }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{sortCol === col && (
|
{sortCol === col && (
|
||||||
@@ -251,7 +270,7 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={articleNo || index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-slate-700/20 transition-colors",
|
"hover:bg-slate-700/20 transition-colors",
|
||||||
status === 'pending' && "bg-amber-500/5"
|
status === 'pending' && "bg-amber-500/5"
|
||||||
@@ -283,9 +302,9 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
|
|||||||
) : isEditing ? (
|
) : isEditing ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
|
ref={cpnpInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={editingCpnp.value}
|
value={editingCpnp.value}
|
||||||
autoFocus
|
|
||||||
onChange={e => setEditingCpnp(prev => prev ? { ...prev, value: e.target.value } : prev)}
|
onChange={e => setEditingCpnp(prev => prev ? { ...prev, value: e.target.value } : prev)}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter') saveCpnp(index);
|
if (e.key === 'Enter') saveCpnp(index);
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ activeModule, setActiveModule, userEmail }: 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';
|
type ModuleId = 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data' | 'cosmetic_items';
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
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';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
stats: any;
|
stats: any;
|
||||||
activeModule?: string;
|
activeModule?: string;
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
|
onDownloadBCExcel: () => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
hasData: boolean;
|
hasData: boolean;
|
||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
@@ -20,11 +21,12 @@ interface TopBarProps {
|
|||||||
onSaveAll: () => Promise<void>;
|
onSaveAll: () => Promise<void>;
|
||||||
onRevertRow: (articleNo: string) => void;
|
onRevertRow: (articleNo: string) => void;
|
||||||
isSavingAll: boolean;
|
isSavingAll: boolean;
|
||||||
|
isDownloadingBCExcel: boolean;
|
||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
onToggleMaximize: () => void;
|
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 [showPending, setShowPending] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -170,6 +172,21 @@ export function TopBar({ stats, activeModule, onExport, onRefresh, hasData, hasU
|
|||||||
</button>
|
</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 && (
|
{onRefresh && (
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
{ "source": "/api/dropbox-proxy", "destination": "api/dropbox-proxy.js" },
|
{ "source": "/api/dropbox-proxy", "destination": "api/dropbox-proxy.js" },
|
||||||
{ "source": "/api/dropbox-sync", "destination": "api/dropbox-sync.js" }
|
{ "source": "/api/dropbox-sync", "destination": "api/dropbox-sync.js" },
|
||||||
|
{ "source": "/api/bc-proxy", "destination": "api/bc-proxy.js" },
|
||||||
|
{ "source": "/api/bc-export", "destination": "api/bc-export.js" }
|
||||||
],
|
],
|
||||||
"headers": [
|
"headers": [
|
||||||
{
|
{
|
||||||
|
|||||||
+107
-1
@@ -2,11 +2,117 @@ import tailwindcss from '@tailwindcss/vite';
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {defineConfig, loadEnv} from 'vite';
|
import {defineConfig, loadEnv} from 'vite';
|
||||||
|
import { getBcConfig, getBCToken, findItem, patchItemCpnpNo, fetchAllItems, buildWorkbook } from './bc-runtime.js';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
|
async function readJsonBody(req: any): Promise<any> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
let raw = '';
|
||||||
|
req.on('data', (chunk: Buffer) => { raw += chunk.toString('utf8'); });
|
||||||
|
req.on('end', () => {
|
||||||
|
if (!raw.trim()) return resolve({});
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(raw));
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig(({mode}) => {
|
export default defineConfig(({mode}) => {
|
||||||
const env = loadEnv(mode, '.', '');
|
const env = loadEnv(mode, '.', '');
|
||||||
return {
|
return {
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
{
|
||||||
|
name: 'bc-local-api',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url || '/', 'http://localhost');
|
||||||
|
const config = getBcConfig(env);
|
||||||
|
|
||||||
|
if (url.pathname === '/api/bc-proxy') {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.statusCode = 204;
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const { articleNo, cpnpNo } = body || {};
|
||||||
|
if (!articleNo || cpnpNo === undefined) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ error: 'Missing articleNo or cpnpNo' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getBCToken(config);
|
||||||
|
const item = await findItem(config, token, articleNo);
|
||||||
|
await patchItemCpnpNo(config, token, item, String(cpnpNo));
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ success: true, articleNo, cpnpNo }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/bc-export') {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.statusCode = 204;
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getBCToken(config);
|
||||||
|
const items = await fetchAllItems(config, token);
|
||||||
|
|
||||||
|
if (url.searchParams.get('format') === 'json') {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ success: true, count: items.length, items }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = buildWorkbook(items);
|
||||||
|
const buffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' });
|
||||||
|
const dateStr = new Date().toISOString().split('T')[0];
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="BusinessCentral_Items_${dateStr}.xlsx"`);
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.end(buffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.message || String(err);
|
||||||
|
if ((req.url || '').startsWith('/api/bc-')) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ success: false, error: message }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('[bc-local-api]', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
define: {
|
define: {
|
||||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user