diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..5bf3079 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +agentdb.rvf +agentdb.rvf.lock +ruvector.db +scratch diff --git a/api/bc-export.js b/api/bc-export.js new file mode 100644 index 0000000..8ba26e9 --- /dev/null +++ b/api/bc-export.js @@ -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) }); + } +} diff --git a/api/bc-proxy.js b/api/bc-proxy.js new file mode 100644 index 0000000..1d40a9e --- /dev/null +++ b/api/bc-proxy.js @@ -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 }); + } +} diff --git a/bc-runtime.js b/bc-runtime.js new file mode 100644 index 0000000..793b687 --- /dev/null +++ b/bc-runtime.js @@ -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; +} diff --git a/src/App.tsx b/src/App.tsx index 9401f12..5c8677f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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>({}); const [pendingRows, setPendingRows] = useState>({}); 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)} /> diff --git a/src/components/CosmeticItemsView.tsx b/src/components/CosmeticItemsView.tsx index fea2388..f5e7b87 100644 --- a/src/components/CosmeticItemsView.tsx +++ b/src/components/CosmeticItemsView.tsx @@ -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(null); const [editingCpnp, setEditingCpnp] = useState<{ rowIndex: number; value: string } | null>(null); const [savingCpnp, setSavingCpnp] = useState(null); + const cpnpInputRef = useRef(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 >
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 ( setEditingCpnp(prev => prev ? { ...prev, value: e.target.value } : prev)} onKeyDown={e => { if (e.key === 'Enter') saveCpnp(index); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b9a7741..7610769 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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'; diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 427c05a..f157212 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -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; 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(null); @@ -170,6 +172,21 @@ export function TopBar({ stats, activeModule, onExport, onRefresh, hasData, hasU )} + + {onRefresh && (