From 47b9303202b09e2d21c87931fa748fb2614d11fb Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Wed, 20 May 2026 13:33:37 +0200 Subject: [PATCH] feat: implement manual user validation and user deletion flow --- .env.example | 15 + .gitignore | 1 + README.md | 9 +- api/backup.js | 13 +- api/users-admin.js | 231 ++++++ bc-sync-runtime.js | 33 +- src/App.tsx | 120 ++- src/components/ArticleDetails.tsx | 19 +- src/components/ControlDashboardView.tsx | 474 ++++++++++++ src/components/CosmeticItemsView.tsx | 34 +- src/components/MatrixView.tsx | 58 +- src/components/PricingView.tsx | 101 ++- src/components/ProductDescriptions.tsx | 30 +- src/components/Sidebar.tsx | 17 +- src/components/SyncStatusPill.tsx | 3 + src/components/TopBar.tsx | 6 +- src/components/UserManagementView.tsx | 331 ++++++++ src/lib/auth.ts | 25 + src/lib/controlDashboard.ts | 974 ++++++++++++++++++++++++ src/lib/supabase.ts | 206 ++++- src/services/businessCentral.ts | 3 + vercel.json | 3 +- 22 files changed, 2613 insertions(+), 93 deletions(-) create mode 100644 api/users-admin.js create mode 100644 src/components/ControlDashboardView.tsx create mode 100644 src/components/UserManagementView.tsx create mode 100644 src/lib/controlDashboard.ts diff --git a/.env.example b/.env.example index 7a550fe..dcc2a9e 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,21 @@ # Users configure this via the Secrets panel in the AI Studio UI. GEMINI_API_KEY="MY_GEMINI_API_KEY" +# Business Central OAuth client credentials +BC_TENANT_ID="MY_BC_TENANT_ID" +BC_CLIENT_ID="MY_BC_CLIENT_ID" +BC_CLIENT_SECRET="MY_BC_CLIENT_SECRET" +BC_COMPANY_ID="MY_BC_COMPANY_ID" +BC_WRITE_METHOD="PATCH" +BC_WRITE_URL_TEMPLATE="{{itemsUrl}}('{{itemNo}}')" +BC_WRITE_BODY_TEMPLATE='{"cpnpNo":"{{cpnpNo}}"}' +BC_ITEMS_WRITE_METHOD="PATCH" +BC_ITEMS_WRITE_URL_TEMPLATE="{{itemsUrl}}('{{itemNo}}')" +BC_ITEMS_WRITE_BODY_TEMPLATE='' +BC_UOM_WRITE_METHOD="PATCH" +BC_UOM_WRITE_URL_TEMPLATE="{{itemUnitsOfMeasureUrl}}('{{itemNo}}')" +BC_UOM_WRITE_BODY_TEMPLATE='' + # 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. diff --git a/.gitignore b/.gitignore index c727d26..adc741c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ coverage/ agentdb.rvf* ruvector.db claude-flow.config.json +scratch/ diff --git a/README.md b/README.md index 043b8c2..958f40b 100644 --- a/README.md +++ b/README.md @@ -16,5 +16,12 @@ View your app in AI Studio: https://ai.studio/apps/bac9908e-4996-4e4c-8ec7-3add5 1. Install dependencies: `npm install` 2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key -3. Run the app: +3. To use the Business Central download button, set: + `BC_TENANT_ID`, `BC_CLIENT_ID`, `BC_CLIENT_SECRET`, `BC_COMPANY_ID` +4. If your Business Central writable endpoint is not the default `PATCH`, also set: + `BC_WRITE_METHOD`, `BC_WRITE_URL_TEMPLATE`, `BC_WRITE_BODY_TEMPLATE` +5. For the safer BC sync preview/apply flow, you can also set: + `BC_ITEMS_WRITE_METHOD`, `BC_ITEMS_WRITE_URL_TEMPLATE`, `BC_ITEMS_WRITE_BODY_TEMPLATE`, + `BC_UOM_WRITE_METHOD`, `BC_UOM_WRITE_URL_TEMPLATE`, `BC_UOM_WRITE_BODY_TEMPLATE` +6. Run the app: `npm run dev` diff --git a/api/backup.js b/api/backup.js index 253ab50..59a2f43 100644 --- a/api/backup.js +++ b/api/backup.js @@ -1,5 +1,5 @@ const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co'; -const SUPABASE_ANON_KEY = process.env.SUPABASE_SERVICE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv'; +const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv'; const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY; const DROPBOX_APP_SECRET = process.env.DROPBOX_APP_SECRET; @@ -32,9 +32,12 @@ async function fetchSupabaseTable(table) { for (let page = 0; page < 50; page++) { const res = await fetch( `${SUPABASE_URL}/rest/v1/${table}?select=*&limit=${PAGE_SIZE}&offset=${offset}`, - { headers: { apikey: SUPABASE_ANON_KEY, Authorization: `Bearer ${SUPABASE_ANON_KEY}` } } + { headers: { apikey: SUPABASE_SERVICE_KEY, Authorization: `Bearer ${SUPABASE_SERVICE_KEY}` } } ); - if (!res.ok) throw new Error(`Supabase ${table} fetch failed: ${res.status}`); + if (!res.ok) { + const txt = await res.text(); + throw new Error(`Supabase ${table} fetch failed (${res.status}): ${txt}`); + } const batch = await res.json(); rows.push(...batch); if (batch.length < PAGE_SIZE) break; @@ -99,8 +102,8 @@ export default async function handler(req, res) { try { const [syncedRows, history] = await Promise.all([ - fetchSupabaseTable('synced_rows'), - fetchSupabaseTable('item_history'), + fetchSupabaseTable('products'), + fetchSupabaseTable('products_history'), ]); const now = new Date(); diff --git a/api/users-admin.js b/api/users-admin.js new file mode 100644 index 0000000..4b338dc --- /dev/null +++ b/api/users-admin.js @@ -0,0 +1,231 @@ +const SUPABASE_URL = process.env.SUPABASE_URL || 'https://hwithddwaapyhnfwcesj.supabase.co'; +const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY; +const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv'; +const ALLOWED_ORIGIN = 'https://craze-data-check.vercel.app'; + +const MASTER_USERS = new Set([ + 'christian.vidal@craze-group.com', + 'jingying.shi@craze-group.com', +]); + +function setCors(req, res) { + const origin = req.headers.origin; + if (origin === ALLOWED_ORIGIN || (origin && (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')))) { + res.setHeader('Access-Control-Allow-Origin', origin); + } + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, apikey'); + res.setHeader('Access-Control-Max-Age', '86400'); + res.setHeader('Vary', 'Origin'); +} + +export default async function handler(req, res) { + setCors(req, res); + + if (req.method === 'OPTIONS') { + return res.status(204).end(); + } + + try { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + // 1. Authenticate caller + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized: Missing token' }); + } + const token = authHeader.split(' ')[1]; + + 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' }); + } + + 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' }); + } + + const isMaster = MASTER_USERS.has(callerEmail.toLowerCase()); + const { action } = req.body; + + if (!action) { + return res.status(400).json({ error: 'Missing action' }); + } + + // --- Action: Check Validation Status --- + if (action === 'check-status') { + if (isMaster) { + 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, + 'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}` + } + }); + + if (!approvalsRes.ok) { + const errText = await approvalsRes.text(); + console.error('Failed to query user approvals:', errText); + return res.status(500).json({ error: 'Failed to query database' }); + } + + const approvals = await approvalsRes.json(); + const isApproved = approvals.length > 0 && approvals[0].validated === true; + return res.json({ validated: isApproved }); + } + + // --- Admin-only Actions --- + if (!isMaster) { + return res.status(403).json({ error: 'Forbidden: Admin access required' }); + } + + if (action === 'list') { + // Fetch all users from GoTrue Admin API + const usersRes = await fetch(`${SUPABASE_URL}/auth/v1/admin/users`, { + headers: { + 'apikey': SUPABASE_SERVICE_KEY, + 'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}` + } + }); + + if (!usersRes.ok) { + const errText = await usersRes.text(); + console.error('Failed to fetch auth users:', errText); + return res.status(500).json({ error: 'Failed to fetch users from authentication' }); + } + + const usersData = await usersRes.json(); + const authUsers = usersData.users || []; + + // Fetch validation mappings from public.user_approvals + const approvalsRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?select=*`, { + headers: { + 'apikey': SUPABASE_SERVICE_KEY, + 'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}` + } + }); + + if (!approvalsRes.ok) { + const errText = await approvalsRes.text(); + console.error('Failed to fetch approvals:', errText); + return res.status(500).json({ error: 'Failed to fetch user approvals' }); + } + + const approvals = await approvalsRes.json(); + const approvalMap = new Map(approvals.map(a => [a.id, a.validated])); + + const mergedUsers = authUsers.map(u => { + const email = u.email; + const id = u.id; + const createdAt = u.created_at; + + let status = 'Pending'; + if (MASTER_USERS.has(email?.toLowerCase())) { + status = 'Master'; + } else if (approvalMap.has(id)) { + status = approvalMap.get(id) ? 'Validated' : 'Pending'; + } + + return { id, email, created_at: createdAt, status }; + }); + + return res.json({ users: mergedUsers }); + } + + if (action === 'validate') { + const { targetUserId, email, validated } = req.body; + if (!targetUserId || !email) { + return res.status(400).json({ error: 'Missing targetUserId or email' }); + } + + // Upsert into user_approvals table + const upsertRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals`, { + method: 'POST', + headers: { + 'apikey': SUPABASE_SERVICE_KEY, + 'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`, + 'Content-Type': 'application/json', + 'Prefer': 'resolution=merge-duplicates,return=representation' + }, + body: JSON.stringify({ + id: targetUserId, + email, + validated, + created_at: new Date().toISOString() + }) + }); + + if (!upsertRes.ok) { + const errText = await upsertRes.text(); + console.error('Failed to upsert approval:', errText); + return res.status(500).json({ error: 'Failed to update approval status' }); + } + + return res.json({ success: true }); + } + + if (action === 'delete') { + const { targetUserId } = req.body; + if (!targetUserId) { + return res.status(400).json({ error: 'Missing targetUserId' }); + } + + // Prevent master user self-deletion via API + const { data: targetUserRes } = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${targetUserId}`, { + headers: { + 'apikey': SUPABASE_SERVICE_KEY, + 'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}` + } + }).then(r => r.json().catch(() => ({}))); + + if (targetUserRes && MASTER_USERS.has(targetUserRes.email?.toLowerCase())) { + return res.status(400).json({ error: 'Cannot delete a master user account' }); + } + + // 1. Delete user from auth + const deleteAuthRes = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${targetUserId}`, { + method: 'DELETE', + headers: { + 'apikey': SUPABASE_SERVICE_KEY, + 'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}` + } + }); + + if (!deleteAuthRes.ok) { + const errText = await deleteAuthRes.text(); + console.error('Failed to delete auth user:', errText); + return res.status(500).json({ error: 'Failed to delete user from authentication' }); + } + + // 2. Delete user approval record from public.user_approvals if exists + await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?id=eq.${targetUserId}`, { + method: 'DELETE', + headers: { + 'apikey': SUPABASE_SERVICE_KEY, + 'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}` + } + }); + + return res.json({ success: true }); + } + + return res.status(400).json({ error: 'Invalid action' }); + } catch (err) { + console.error('Error in users-admin function:', err); + return res.status(500).json({ error: err.message || 'Internal server error' }); + } +} diff --git a/bc-sync-runtime.js b/bc-sync-runtime.js index 1a428ee..3c48bd6 100644 --- a/bc-sync-runtime.js +++ b/bc-sync-runtime.js @@ -133,12 +133,17 @@ function normalizeForComparison(field, value) { return '0'; } - if (value === null || value === undefined || value === '') return null; - if (strField.includes('date')) { - return formatDateForBc(value); + if (value === null || value === undefined || value === '') { + return '0001-01-01'; + } + + const normalizedDate = formatDateForBc(value); + return normalizedDate === '0001-01-01' ? '0001-01-01' : normalizedDate; } + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'string') { return value.replace(/\r\n/g, '\n').trimEnd(); } @@ -286,6 +291,8 @@ function makePreviewSection({ writeMethod, writeUrlTemplate, writeBodyTemplate, + supported = true, + supportReason = null, }) { const changes = buildFieldChanges(fieldPreviews, currentRecord, desiredPayload); return { @@ -298,7 +305,9 @@ function makePreviewSection({ writeMethod: writeMethod || null, writeUrlTemplate: writeUrlTemplate || null, writeBodyTemplate: writeBodyTemplate || null, - canApply: Boolean(writeUrlTemplate), + canApply: Boolean(writeUrlTemplate) && supported, + supported, + supportReason, }; } @@ -310,7 +319,7 @@ export function buildBusinessCentralSyncPreview(config, mapping, snapshot) { fieldPreviews: mapping.itemsFields, writeMethod: config.itemsWriteMethod || config.writeMethod, writeUrlTemplate: config.itemsWriteUrlTemplate || config.writeUrlTemplate, - writeBodyTemplate: config.itemsWriteBodyTemplate || config.writeBodyTemplate || null, + writeBodyTemplate: config.itemsWriteBodyTemplate || null, }); const itemUnitsSection = makePreviewSection({ @@ -464,6 +473,10 @@ async function applyItemUnitsSection(config, token, snapshot, preview, context) return { applied: false, reason: 'No itemUnitsOfMeasure changes' }; } + if (!preview.itemUnitsOfMeasure.supported) { + return { applied: false, reason: preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only' }; + } + if (!config.itemUnitsWriteUrlTemplate) { return { applied: false, reason: 'itemUnitsOfMeasure write template not configured' }; } @@ -487,6 +500,13 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo); const preview = buildBusinessCentralSyncPreview(config, mapping, snapshot); + const hasItemUnitsChanges = preview.itemUnitsOfMeasure.changes.some(change => change.changed); + if (hasItemUnitsChanges && !snapshot.itemUnitsOfMeasure) { + const error = new Error(`BC itemUnitsOfMeasure row missing for ${mapping.articleNo}. This BC API cannot update these fields until the row exists or BC exposes an upsert action.`); + error.statusCode = 409; + throw error; + } + if (previewToken && previewToken !== preview.previewToken) { const error = new Error('Preview token mismatch. BC data changed or preview is stale.'); error.statusCode = 409; @@ -551,6 +571,9 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev 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') + : undefined, }; } diff --git a/src/App.tsx b/src/App.tsx index d7b1bee..06d5b65 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,7 +21,11 @@ import { UndoToast } from './components/UndoToast'; import { PendingValidationView } from './components/PendingValidationView'; import { MissingDataView } from './components/MissingDataView'; import { CosmeticItemsView } from './components/CosmeticItemsView'; +import { ControlDashboardView } from './components/ControlDashboardView'; +import { UserManagementView } from './components/UserManagementView'; import { downloadBusinessCentralItemsExcel, previewBusinessCentralSync, applyBusinessCentralSync, isPreviewTokenMismatchError } from './services/businessCentral'; +import { ControlTabId, type DashboardDrilldownRequest } from './lib/controlDashboard'; + const FORCED_ZERO_STOCK_SKUS = new Set([ '11631VC', '1237VC', '1238VC', '1652VC', '1653VC', '1684VC', '1688VC', '1717VC', '1718VC', '180VC', '1832VC', '2025VC', '2027VC', '2180VC', '2181VC', '2210VC', @@ -92,7 +96,7 @@ export default function App() { hasUnsavedChanges: false, asinColumnIndex: null }); - const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data' | 'cosmetic_items'>('descriptions'); + const [activeModule, setActiveModule] = useState('control_dashboard'); const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]); const [editingRowIndex, setEditingRowIndex] = useState(null); const [isLoadingDefault, setIsLoadingDefault] = useState(true); @@ -104,11 +108,59 @@ export default function App() { const [isSyncingBC, setIsSyncingBC] = useState(false); const [isDownloadingBCExcel, setIsDownloadingBCExcel] = useState(false); const [refreshTrigger, setRefreshTrigger] = useState(0); + const [dashboardDrilldown, setDashboardDrilldown] = useState(null); useEffect(() => { console.log('[App] session changed:', session ? 'logged in' : 'logged out'); }, [session]); + // Background user validation status check + useEffect(() => { + if (!session) return; + + let isMounted = true; + + const checkUserStatus = async () => { + try { + const res = await fetch('/api/users-admin', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.access_token}` + }, + body: JSON.stringify({ action: 'check-status' }) + }); + + if (!isMounted) return; + + if (!res.ok) { + console.warn('[App] Validation check failed, logging out.'); + handleSignOut(); + return; + } + + const data = await res.json(); + if (!data.validated) { + alert('Tu usuario ya no está autorizado o está pendiente de validación.'); + handleSignOut(); + } + } catch (err) { + console.error('[App] Background validation check error:', err); + } + }; + + // Check immediately on mount/session change + checkUserStatus(); + + // Check periodically every 5 minutes + const interval = setInterval(checkUserStatus, 5 * 60 * 1000); + + return () => { + isMounted = false; + clearInterval(interval); + }; + }, [session]); + useEffect(() => { try { localStorage.setItem(BC_SYNC_QUEUE_STORAGE_KEY, JSON.stringify(bcSyncQueue)); @@ -235,30 +287,7 @@ export default function App() { } const resolvedCols = resolveColumnIndices(extendedHeaders); - const editableColumns = new Set([ - resolvedCols.CLASSIFICATION, - resolvedCols.LONG_DE, resolvedCols.LONG_EN, - resolvedCols.SHORT_DE, resolvedCols.SHORT_EN, - resolvedCols.DETAILS_DE, resolvedCols.DETAILS_EN, - resolvedCols.INNER_L, resolvedCols.INNER_W, resolvedCols.INNER_H, - resolvedCols.OUTER_L, resolvedCols.OUTER_W, resolvedCols.OUTER_H, - resolvedCols.UNITS_OUTER, resolvedCols.MOQ, - resolvedCols.VERIFIED_DIMS, - resolvedCols.VALIDATED_CHECK, - resolvedCols.VALIDATED_NOTE, - resolvedCols.PRODUCT_TYPE, - resolvedCols.ITEM_TO_LOGISTIC, - resolvedCols.CPNP_NO, - ]); - - headers.forEach((h: any, i: number) => { - const hl = String(h || '').toLowerCase(); - if (hl.includes('srp') || hl.includes('uvp') || hl.includes('40') || hl.includes('price')) { - editableColumns.add(i); - } - }); - -const articleNoIdx = resolvedCols.ARTICLE_NO; + const articleNoIdx = resolvedCols.ARTICLE_NO; const processedRows = rows.map(row => { const articleNo = String(row[articleNoIdx]); const synced = syncedData[articleNo]; @@ -300,17 +329,6 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; } }); - // 2. For Master Data (indices < 100, like UVP/SRP), only restore if it's an active edit (pending) - // This protects against the "Column Shift" bug where old saved indices might be wrong. - if (synced?.status === 'pending' || synced?.status === 'edited') { - editableColumns.forEach(idx => { - const value = synced?.data?.[idx]; - if (idx < 100 && value !== undefined && value !== null) { - finalRow[idx] = value; - } - }); - } - // Update row status in UI if it's not the default 'excel' if (synced?.status && synced.status !== 'excel') { setRowStatuses(prev => ({ ...prev, [articleNo]: synced.status! })); @@ -372,7 +390,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; hasUnsavedChanges: false, asinColumnIndex: asinIdx !== -1 ? asinIdx : null }); - setActiveModule('descriptions'); + setActiveModule('control_dashboard'); } } catch (err) { console.error('Failed to load from Dropbox:', err); @@ -465,7 +483,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; hasUnsavedChanges: false, asinColumnIndex: null }); - setActiveModule('descriptions'); + setActiveModule('control_dashboard'); } }; reader.readAsBinaryString(file); @@ -694,7 +712,7 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; }; const handleDiscardSelectedBcQueue = () => { - const selectedCount = Object.values(bcSyncQueue).filter(e => e.selected).length; + const selectedCount = (Object.values(bcSyncQueue) as BcSyncQueueEntry[]).filter(e => e.selected).length; if (selectedCount === 0) return; if (!window.confirm(`Discard ${selectedCount} pending BC sync(s)? They will be removed from the queue.`)) return; setBcSyncQueue(prev => { @@ -1055,6 +1073,21 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; asinColumnIndex={appState.asinColumnIndex} onEdit={(index) => setEditingRowIndex(index)} rowStatuses={rowStatuses} + dashboardDrilldown={dashboardDrilldown} + onDashboardDrilldownApplied={() => setDashboardDrilldown(null)} + /> + )} + {activeModule === 'control_dashboard' && ( + { + setDashboardDrilldown(request); + setActiveModule(request.tabId); + }} /> )} {activeModule === 'matrix' && ( @@ -1081,6 +1114,8 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; onCaptureState={captureState} onEdit={(index) => setEditingRowIndex(index)} rowStatuses={rowStatuses} + dashboardDrilldown={dashboardDrilldown} + onDashboardDrilldownApplied={() => setDashboardDrilldown(null)} /> )} {activeModule === 'article_details' && ( @@ -1088,6 +1123,8 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; data={appState.data} onEdit={(index) => setEditingRowIndex(index)} rowStatuses={rowStatuses} + dashboardDrilldown={dashboardDrilldown} + onDashboardDrilldownApplied={() => setDashboardDrilldown(null)} /> )} {activeModule === 'pending_validation' && ( @@ -1115,6 +1152,8 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; onCaptureState={captureState} rowStatuses={rowStatuses} onQueueBcSync={handleQueueBcSync} + dashboardDrilldown={dashboardDrilldown} + onDashboardDrilldownApplied={() => setDashboardDrilldown(null)} /> )} {activeModule === 'history' && ( @@ -1150,6 +1189,9 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; }} /> )} + {activeModule === 'user_management' && session && ( + + )} )} diff --git a/src/components/ArticleDetails.tsx b/src/components/ArticleDetails.tsx index dcb343c..5e7e354 100644 --- a/src/components/ArticleDetails.tsx +++ b/src/components/ArticleDetails.tsx @@ -1,20 +1,23 @@ -import React, { useState, useMemo } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { ExcelRow } from '../types'; import { useColumns } from '../contexts/ColumnsContext'; import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X, Maximize2 } from 'lucide-react'; import { cn } from '../lib/utils'; import { ColumnFilterPopover } from './ColumnFilterPopover'; import { SyncStatusPill } from './SyncStatusPill'; +import { type DashboardDrilldownRequest } from '../lib/controlDashboard'; interface ArticleDetailsProps { data: ExcelRow[]; onEdit: (index: number) => void; rowStatuses: Record; + dashboardDrilldown?: DashboardDrilldownRequest | null; + onDashboardDrilldownApplied?: () => void; } type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock'; -export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProps) { +export function ArticleDetails({ data, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: ArticleDetailsProps) { const COLUMNS = useColumns(); const [activeTab, setActiveTab] = useState('all'); const [search, setSearch] = useState(''); @@ -35,6 +38,18 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp [COLUMNS.DETAILS_EN]: 150, }); + useEffect(() => { + if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'article_details') return; + + const focus = dashboardDrilldown.focus as TabType; + setActiveTab(focus); + setSearch(''); + setLineFilter(''); + setColumnFilters({}); + setPage(1); + onDashboardDrilldownApplied?.(); + }, [dashboardDrilldown?.id, dashboardDrilldown?.tabId, dashboardDrilldown?.focus, onDashboardDrilldownApplied]); + const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]); const filteredData = useMemo(() => { diff --git a/src/components/ControlDashboardView.tsx b/src/components/ControlDashboardView.tsx new file mode 100644 index 0000000..4287179 --- /dev/null +++ b/src/components/ControlDashboardView.tsx @@ -0,0 +1,474 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { ArrowDownRight, ArrowUpRight, LayoutDashboard, RefreshCw } from 'lucide-react'; +import { ExcelRow } from '../types'; +import { cn } from '../lib/utils'; +import { + ArticleDetailsDashboardSnapshot, + CosmeticDashboardSnapshot, + DashboardDrilldownRequest, + DescriptionsDashboardSnapshot, + PendingRowInfo, + SnapshotStore, + computeArticleDetailsDashboardSnapshot, + computeCosmeticDashboardSnapshot, + computeDescriptionsDashboardSnapshot, + computePricingDashboardSnapshot, + ensureDailyDashboardSnapshot, + getDaysAgoKey, + loadDashboardSnapshots, +} from '../lib/controlDashboard'; + +interface ControlDashboardViewProps { + headers: string[]; + data: ExcelRow[]; + pendingRows: Record; + rowStatuses: Record; + activeModule?: string; + onOpenTab?: (tabId: string) => void; + onDrillDown?: (request: DashboardDrilldownRequest) => void; +} + +type MetricKey = keyof Pick; +type ArticleMetricKey = keyof Pick; +type CosmeticMetricKey = keyof Pick; +type PricingMetricKey = 'ok' | 'itemToLogisticMissing' | 'uvpMissing' | 'srpIntMissing' | 'srpUkMissing' | 'unitsOuterMissing' | 'outerWMissing' | 'outerLMissing' | 'outerHMissing' | 'units40fMissing' | 'moqMissing' | 'weightIssues'; +type SnapshotKey = MetricKey | ArticleMetricKey | CosmeticMetricKey | PricingMetricKey; + +const METRICS: Array<{ + key: SnapshotKey; + label: string; + toneClass: string; + positiveIsGood: boolean; + drilldownFocus: string; +}> = [ + { key: 'ok', label: 'All OK', toneClass: 'border-emerald-400/30 bg-emerald-400/15 text-emerald-100', positiveIsGood: true, drilldownFocus: 'complete' }, + { key: 'longDeMissing', label: 'Missing Long DE', toneClass: 'border-sky-400/30 bg-sky-400/15 text-sky-100', positiveIsGood: false, drilldownFocus: 'missingLongDE' }, + { key: 'longEnMissing', label: 'Missing Long EN', toneClass: 'border-indigo-400/30 bg-indigo-400/15 text-indigo-100', positiveIsGood: false, drilldownFocus: 'missingLongEN' }, + { key: 'shortDeMissing', label: 'Missing Short DE', toneClass: 'border-fuchsia-400/30 bg-fuchsia-400/15 text-fuchsia-100', positiveIsGood: false, drilldownFocus: 'missingShortDE' }, + { key: 'shortEnMissing', label: 'Missing Short EN', toneClass: 'border-rose-400/30 bg-rose-400/15 text-rose-100', positiveIsGood: false, drilldownFocus: 'missingShortEN' }, +]; + +const ARTICLE_METRICS: Array<{ + key: ArticleMetricKey; + label: string; + toneClass: string; + positiveIsGood: boolean; + drilldownFocus: string; +}> = [ + { key: 'ok', label: 'All OK', toneClass: 'border-indigo-400/30 bg-indigo-400/15 text-indigo-100', positiveIsGood: true, drilldownFocus: 'all' }, + { key: 'detailsDeMissing', label: 'Missing Details DE', toneClass: 'border-cyan-400/30 bg-cyan-400/15 text-cyan-100', positiveIsGood: false, drilldownFocus: 'missingDetailsDE' }, + { key: 'detailsEnMissing', label: 'Missing Details EN', toneClass: 'border-amber-400/30 bg-amber-400/15 text-amber-100', positiveIsGood: false, drilldownFocus: 'missingDetailsEN' }, +]; + +const PRICING_METRICS: Array<{ + key: PricingMetricKey; + label: string; + toneClass: string; + positiveIsGood: boolean; + drilldownFocus: string; +}> = [ + { key: 'ok', label: 'All OK', toneClass: 'border-amber-400/30 bg-amber-400/15 text-amber-100', positiveIsGood: true, drilldownFocus: 'all' }, + { key: 'itemToLogisticMissing', label: 'Missing Item to Logistic', toneClass: 'border-fuchsia-400/30 bg-fuchsia-400/15 text-fuchsia-100', positiveIsGood: false, drilldownFocus: 'itemToLogisticMissing' }, + { key: 'uvpMissing', label: 'Missing UVP (€)', toneClass: 'border-emerald-400/30 bg-emerald-400/15 text-emerald-100', positiveIsGood: false, drilldownFocus: 'uvpMissing' }, + { key: 'srpIntMissing', label: 'Missing SRP INT', toneClass: 'border-indigo-400/30 bg-indigo-400/15 text-indigo-100', positiveIsGood: false, drilldownFocus: 'srpIntMissing' }, + { key: 'srpUkMissing', label: 'Missing SRP UK (£)', toneClass: 'border-cyan-400/30 bg-cyan-400/15 text-cyan-100', positiveIsGood: false, drilldownFocus: 'srpUkMissing' }, + { key: 'unitsOuterMissing', label: 'Missing Units/Outer', toneClass: 'border-sky-400/30 bg-sky-400/15 text-sky-100', positiveIsGood: false, drilldownFocus: 'unitsOuterMissing' }, + { key: 'outerWMissing', label: 'Missing Outer W', toneClass: 'border-violet-400/30 bg-violet-400/15 text-violet-100', positiveIsGood: false, drilldownFocus: 'outerWMissing' }, + { key: 'outerLMissing', label: 'Missing Outer L', toneClass: 'border-rose-400/30 bg-rose-400/15 text-rose-100', positiveIsGood: false, drilldownFocus: 'outerLMissing' }, + { key: 'outerHMissing', label: 'Missing Outer H', toneClass: 'border-pink-400/30 bg-pink-400/15 text-pink-100', positiveIsGood: false, drilldownFocus: 'outerHMissing' }, + { key: 'units40fMissing', label: 'Missing Units 40F', toneClass: 'border-teal-400/30 bg-teal-400/15 text-teal-100', positiveIsGood: false, drilldownFocus: 'units40fMissing' }, + { key: 'moqMissing', label: 'Missing MOQ', toneClass: 'border-orange-400/30 bg-orange-400/15 text-orange-100', positiveIsGood: false, drilldownFocus: 'moqMissing' }, + { key: 'weightIssues', label: 'Weight Issues', toneClass: 'border-red-400/30 bg-red-400/15 text-red-100', positiveIsGood: false, drilldownFocus: 'weightIssues' }, +]; + +const COSMETIC_METRICS: Array<{ + key: CosmeticMetricKey; + label: string; + toneClass: string; + positiveIsGood: boolean; + drilldownFocus: string; +}> = [ + { key: 'ok', label: 'CPNP present', toneClass: 'border-fuchsia-400/30 bg-fuchsia-400/15 text-fuchsia-100', positiveIsGood: true, drilldownFocus: 'present' }, + { key: 'cpnpMissing', label: 'CPNP missing', toneClass: 'border-rose-400/30 bg-rose-400/15 text-rose-100', positiveIsGood: false, drilldownFocus: 'missing' }, +]; + +function formatNumber(value: number): string { + return new Intl.NumberFormat('en-GB').format(value); +} + +function formatDelta(current: number, historical: number, positiveIsGood: boolean): { text: string; className: string } { + const delta = current - historical; + + if (delta === 0) { + return { text: '0 change', className: 'text-slate-400' }; + } + + if (positiveIsGood) { + return delta > 0 + ? { text: `↑${delta} improved`, className: 'text-emerald-400' } + : { text: `↓${Math.abs(delta)} worse`, className: 'text-rose-400' }; + } + + return delta < 0 + ? { text: `↓${Math.abs(delta)} resolved`, className: 'text-emerald-400' } + : { text: `↑${delta} new`, className: 'text-rose-400' }; +} + +function MetricTile({ + label, + current, + historical, + toneClass, + positiveIsGood, + onClick, +}: { + label: string; + current: number; + historical?: number; + toneClass: string; + positiveIsGood: boolean; + onClick?: () => void; +}) { + const delta = historical === undefined ? null : formatDelta(current, historical, positiveIsGood); + const tileClassName = cn( + 'rounded-2xl border p-3 shadow-inner shadow-black/20 transition-all duration-200 bg-slate-950/70 text-left', + onClick && 'cursor-pointer hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300/70', + toneClass + ); + + return ( + onClick ? ( + + ) : ( +
+

{label}

+
+
+ {formatNumber(current)} +
+ {historical === undefined ? ( +
+ No historical data available +
+ ) : ( +
+
7 days ago
+
+ {formatNumber(historical)} +
+
+ )} +
+ {delta && ( +
+ {delta.text} +
+ )} +
+ ) + ); +} + +type DashboardSnapshotLike = { + total: number; + ok: number; + [key: string]: number; +}; + +function DashboardCard({ + title, + subtitle, + accentClass, + accentBarClass, + badgeClass, + badgeLabel, + titleClass, + current, + historical, + metrics, + metricGridClassName, + tabId, + onDrillDown, +}: { + title: string; + subtitle: string; + accentClass: string; + accentBarClass: string; + badgeClass: string; + badgeLabel: string; + titleClass: string; + current: DashboardSnapshotLike; + historical?: DashboardSnapshotLike; + metricGridClassName?: string; + metrics: Array<{ + key: SnapshotKey; + label: string; + toneClass: string; + positiveIsGood: boolean; + drilldownFocus: string; + }>; + tabId: DashboardDrilldownRequest['tabId']; + onDrillDown?: (request: DashboardDrilldownRequest) => void; +}) { + return ( +
+
+ +
+
+
+ {badgeLabel} +
+

{title}

+

{subtitle}

+
+
+ + Total {formatNumber(current.total)} + + + All OK {formatNumber(current.ok)} + +
+
+ +
+ {metrics.map(metric => ( + + onDrillDown({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + tabId: tabId as DashboardDrilldownRequest['tabId'], + focus: metric.drilldownFocus, + }) : undefined} + /> + + ))} +
+ +
+ {historical ? ( +
+ + + Improvements are shown in green + + + + Regressions are shown in red + +
+ ) : ( +
No historical data available
+ )} +
+
+ ); +} + +export function ControlDashboardView({ + headers, + data, + pendingRows, + rowStatuses, + onDrillDown, +}: ControlDashboardViewProps) { + const [dashboardSnapshots, setDashboardSnapshots] = useState({}); + const [snapshotsLoading, setSnapshotsLoading] = useState(true); + + const currentSnapshot = useMemo(() => { + return computeDescriptionsDashboardSnapshot(headers, { + data, + pendingRows, + rowStatuses, + historyEntries: [], + }); + }, [headers, data, pendingRows, rowStatuses]); + + const currentArticleSnapshot = useMemo(() => { + return computeArticleDetailsDashboardSnapshot(headers, { + data, + pendingRows, + rowStatuses, + historyEntries: [], + }); + }, [headers, data, pendingRows, rowStatuses]); + + const currentPricingSnapshot = useMemo(() => { + return computePricingDashboardSnapshot(headers, { + data, + pendingRows, + rowStatuses, + historyEntries: [], + }); + }, [headers, data, pendingRows, rowStatuses]); + + const currentCosmeticSnapshot = useMemo(() => { + return computeCosmeticDashboardSnapshot(headers, { + data, + pendingRows, + rowStatuses, + historyEntries: [], + }); + }, [headers, data, pendingRows, rowStatuses]); + + useEffect(() => { + let cancelled = false; + const loadSnapshots = async () => { + setSnapshotsLoading(true); + try { + const store = await loadDashboardSnapshots(); + if (!cancelled) { + setDashboardSnapshots(store); + } + } finally { + if (!cancelled) { + setSnapshotsLoading(false); + } + } + }; + + loadSnapshots(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (snapshotsLoading) return; + void ensureDailyDashboardSnapshot(new Date(), { + descriptions: currentSnapshot, + articleDetails: currentArticleSnapshot, + pricing: currentPricingSnapshot, + cosmeticItems: currentCosmeticSnapshot, + }); + }, [snapshotsLoading, currentSnapshot, currentArticleSnapshot, currentPricingSnapshot, currentCosmeticSnapshot]); + + const historicalSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.descriptions; + const historicalArticleSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.articleDetails; + const historicalPricingSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.pricing; + const historicalCosmeticSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.cosmeticItems; + + return ( +
+
+
+
+ +

Control Dashboard

+
+

+ Compact overview of Product Descriptions, Article Details and Pricing & Units. +

+
+ +
+ + + + + + + + +
+ ); +} diff --git a/src/components/CosmeticItemsView.tsx b/src/components/CosmeticItemsView.tsx index eeb1b3f..9839d43 100644 --- a/src/components/CosmeticItemsView.tsx +++ b/src/components/CosmeticItemsView.tsx @@ -7,6 +7,7 @@ import { ColumnFilterPopover } from './ColumnFilterPopover'; import { usePersistentState } from '../contexts/FilterContext'; import { saveRowToSupabase } from '../lib/supabase'; import { SyncStatusPill } from './SyncStatusPill'; +import { type DashboardDrilldownRequest } from '../lib/controlDashboard'; const COSMETIC_LINES = ['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS']; @@ -17,9 +18,11 @@ interface CosmeticItemsViewProps { onCaptureState: (message: string) => void; rowStatuses: Record; onQueueBcSync: (articleNo: string, rowIndex: number, originalData: ExcelRow, newData: ExcelRow, articleName: string) => void; + dashboardDrilldown?: DashboardDrilldownRequest | null; + onDashboardDrilldownApplied?: () => void; } -export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, rowStatuses, onQueueBcSync }: CosmeticItemsViewProps) { +export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, rowStatuses, onQueueBcSync, dashboardDrilldown, onDashboardDrilldownApplied }: CosmeticItemsViewProps) { const COLUMNS = useColumns(); const [search, setSearch] = usePersistentState('cosmeticItems-search', ''); const [sortCol, setSortCol] = usePersistentState('cosmeticItems-sortCol', null); @@ -29,12 +32,24 @@ 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 [dashboardFocus, setDashboardFocus] = useState<'present' | 'missing' | null>(null); const cpnpInputRef = useRef(null); useEffect(() => { if (editingCpnp !== null) cpnpInputRef.current?.focus(); }, [editingCpnp]); + useEffect(() => { + if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'cosmetic_items') return; + + const focus = dashboardDrilldown.focus === 'missing' ? 'missing' : 'present'; + setDashboardFocus(focus); + setSearch(''); + setColumnFilters({}); + setPage(1); + onDashboardDrilldownApplied?.(); + }, [dashboardDrilldown?.id, dashboardDrilldown?.tabId, dashboardDrilldown?.focus, onDashboardDrilldownApplied, setSearch, setColumnFilters]); + const pageSize = 100; const columns = [ @@ -77,6 +92,13 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro return COSMETIC_LINES.some(l => lineVal === l); }); + if (dashboardFocus) { + result = result.filter(({ row }) => { + const cpnp = String(row[COLUMNS.CPNP_NO] ?? '').trim(); + return dashboardFocus === 'present' ? cpnp !== '' : cpnp === ''; + }); + } + // Global search if (search) { const terms = search.toLowerCase().split(/\s+/).filter(Boolean); @@ -114,7 +136,7 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro } return result; - }, [data, search, sortCol, sortDesc, columnFilters, COLUMNS, columnUniqueValues]); + }, [data, search, sortCol, sortDesc, columnFilters, COLUMNS, columnUniqueValues, dashboardFocus]); const paginatedData = useMemo(() => { const start = (page - 1) * pageSize; @@ -191,7 +213,9 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro )}
- {filteredData.length} items + + Filtered {filteredData.length} + · Lines: {COSMETIC_LINES.join(', ')}
@@ -370,7 +394,9 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
-
Showing {paginatedData.length} of {filteredData.length} items
+
+ Showing {paginatedData.length} of {filteredData.length} filtered items +
)} + {bcValidationWarning && !bcValidationError && ( +
+ {bcValidationWarning} +
+ )} + {bcValidationResult && (
@@ -398,7 +424,7 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) { return (
-
+

API `{sectionKey}` @@ -407,6 +433,11 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) { {changed.length} changed field{changed.length === 1 ? '' : 's'}

+ {section.supported === false && ( + + preview only + + )} {!section.writeConfigured && ( write not configured @@ -418,6 +449,11 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
No changes for this endpoint.
) : (
+ {section.supported === false && section.supportReason && ( +
+ {section.supportReason} +
+ )} {changed.map((change: any) => (
{change.sourceLabel}
diff --git a/src/components/PricingView.tsx b/src/components/PricingView.tsx index 572bad3..9175f65 100644 --- a/src/components/PricingView.tsx +++ b/src/components/PricingView.tsx @@ -24,6 +24,7 @@ import { cn } from '../lib/utils'; import { ColumnFilterPopover } from './ColumnFilterPopover'; import { usePersistentState } from '../contexts/FilterContext'; import { SyncStatusPill } from './SyncStatusPill'; +import { type DashboardDrilldownRequest } from '../lib/controlDashboard'; interface PricingViewProps { data: ExcelRow[]; @@ -32,6 +33,8 @@ interface PricingViewProps { onCaptureState: (message: string) => void; onEdit: (index: number) => void; rowStatuses: Record; + dashboardDrilldown?: DashboardDrilldownRequest | null; + onDashboardDrilldownApplied?: () => void; } interface DetectedCol { @@ -54,7 +57,7 @@ function findCol(headers: string[], ...keywords: string[]): number { ); } -export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses }: PricingViewProps) { +export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: PricingViewProps) { const COLUMNS = useColumns(); const [filterMode, setFilterMode] = usePersistentState('pricing-filterMode', 'all_errors'); const [search, setSearch] = usePersistentState('pricing-search', ''); @@ -108,6 +111,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, const [dynamicColFilters, setDynamicColFilters] = usePersistentState>('pricing-dynamicColFilters', {}); const [weightIssueFilter, setWeightIssueFilter] = usePersistentState('pricing-weightIssueFilter', []); const [selectedSearchItems, setSelectedSearchItems] = usePersistentState>('pricing-selectedSearchItems', new Set()); + const [dashboardFocus, setDashboardFocus] = useState(null); const [openFilter, setOpenFilter] = useState(null); const [isSearchOpen, setIsSearchOpen] = useState(false); const searchDropdownRef = useRef(null); @@ -240,7 +244,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, }, [resizingColumn, resizeStartX, resizeStartWidth]); // ── Dynamic column detection ────────────────────────────────────────────── - const { uvpIdx, srpCols, containerCols, nwIdx, gwIdx, unitsOuterIdx } = useMemo(() => { + const { uvpIdx, srpCols, containerCols, nwIdx, gwIdx, unitsOuterIdx, units40fIdx } = useMemo(() => { const uvpIdx = findCol(headers, 'uvp'); // All SRP columns, sorted: INT first, UK second, then alphabetically @@ -267,9 +271,66 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, const unitsOuterIdx = COLUMNS.UNITS_OUTER; - return { uvpIdx, srpCols: srp, containerCols: container, nwIdx, gwIdx, unitsOuterIdx }; + const units40fIdx = findCol(headers, '40f'); + + return { uvpIdx, srpCols: srp, containerCols: container, nwIdx, gwIdx, unitsOuterIdx, units40fIdx }; }, [headers, COLUMNS]); + useEffect(() => { + if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'pricing') return; + + const focus = dashboardDrilldown.focus; + const generalModes = new Set(['all', 'all_errors', 'pricing_errors', 'units_errors']); + + setFilterMode(generalModes.has(focus as FilterMode) ? (focus as FilterMode) : 'all_errors'); + setDashboardFocus(generalModes.has(focus as FilterMode) ? null : focus); + setSearch(''); + setSelectedSearchItems(new Set()); + setLineMultiFilter([]); + setClassificationFilter([]); + setProductTypeFilter([]); + setNameColFilter({ terms: [''], op: 'and' }); + setArticleNoColFilter({ terms: [''], op: 'and' }); + setGlobalAdvancedFilter({ terms: [''], op: 'and' }); + setUnitsOuterFilter([]); + setItemToLogisticFilter([]); + setOuterWFilter([]); + setOuterLFilter([]); + setOuterHFilter([]); + setMoqFilter([]); + setCheckYingFilter([]); + setCheckAnnaFilter([]); + setDynamicColFilters({}); + setWeightIssueFilter([]); + setCurrentPage(1); + onDashboardDrilldownApplied?.(); + }, [ + dashboardDrilldown?.id, + dashboardDrilldown?.tabId, + dashboardDrilldown?.focus, + onDashboardDrilldownApplied, + setFilterMode, + setSearch, + setSelectedSearchItems, + setLineMultiFilter, + setClassificationFilter, + setProductTypeFilter, + setNameColFilter, + setArticleNoColFilter, + setGlobalAdvancedFilter, + setUnitsOuterFilter, + setItemToLogisticFilter, + setOuterWFilter, + setOuterLFilter, + setOuterHFilter, + setMoqFilter, + setCheckYingFilter, + setCheckAnnaFilter, + setDynamicColFilters, + setWeightIssueFilter, + setCurrentPage, + ]); + // ── Error analysis per row ──────────────────────────────────────────────── const analyzedRows = useMemo(() => { return data.map((row, dataIndex) => { @@ -503,8 +564,40 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, }); } + if (dashboardFocus) { + result = result.filter(r => { + const row = r.row; + switch (dashboardFocus) { + case 'itemToLogisticMissing': + return !String(row[COLUMNS.ITEM_TO_LOGISTIC] ?? '').trim(); + case 'uvpMissing': + return uvpIdx < 0 || !String(row[uvpIdx] ?? '').trim(); + case 'srpIntMissing': + return !String(row[srpCols.find(({ name }) => /int/i.test(name))?.index ?? -1] ?? '').trim(); + case 'srpUkMissing': + return !String(row[srpCols.find(({ name }) => /uk/i.test(name))?.index ?? -1] ?? '').trim(); + case 'unitsOuterMissing': + return !String(row[unitsOuterIdx] ?? '').trim() || Number(row[unitsOuterIdx]) === 0; + case 'outerWMissing': + return !String(row[COLUMNS.OUTER_W] ?? '').trim() || Number(row[COLUMNS.OUTER_W]) === 0; + case 'outerLMissing': + return !String(row[COLUMNS.OUTER_L] ?? '').trim() || Number(row[COLUMNS.OUTER_L]) === 0; + case 'outerHMissing': + return !String(row[COLUMNS.OUTER_H] ?? '').trim() || Number(row[COLUMNS.OUTER_H]) === 0; + case 'units40fMissing': + return units40fIdx < 0 || !String(row[units40fIdx] ?? '').trim() || Number(row[units40fIdx]) === 0; + case 'moqMissing': + return !String(row[COLUMNS.MOQ] ?? '').trim() || Number(row[COLUMNS.MOQ]) === 0; + case 'weightIssues': + return r.unitErrors.some(e => e.startsWith('NW')); + default: + return true; + } + }); + } + return result; - }, [analyzedRows, filterMode, search, nameColFilter, articleNoColFilter, globalAdvancedFilter, lineMultiFilter, classificationFilter, productTypeFilter, unitsOuterFilter, outerWFilter, outerLFilter, outerHFilter, moqFilter, dynamicColFilters, weightIssueFilter, selectedSearchItems]); + }, [analyzedRows, filterMode, search, nameColFilter, articleNoColFilter, globalAdvancedFilter, lineMultiFilter, classificationFilter, productTypeFilter, unitsOuterFilter, outerWFilter, outerLFilter, outerHFilter, moqFilter, dynamicColFilters, weightIssueFilter, selectedSearchItems, dashboardFocus, uvpIdx, srpCols, unitsOuterIdx, units40fIdx, COLUMNS]); // ── Sorted rows ────────────────────────────────────────────────────────── const sortedRows = useMemo(() => { diff --git a/src/components/ProductDescriptions.tsx b/src/components/ProductDescriptions.tsx index 00241a6..6c9a3f4 100644 --- a/src/components/ProductDescriptions.tsx +++ b/src/components/ProductDescriptions.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { ExcelRow } from '../types'; import { useColumns } from '../contexts/ColumnsContext'; import { Search, Filter, Edit2, ChevronDown, ChevronUp, X, Maximize2 } from 'lucide-react'; @@ -6,6 +6,7 @@ import { cn } from '../lib/utils'; import { ColumnFilterPopover } from './ColumnFilterPopover'; import { usePersistentState } from '../contexts/FilterContext'; import { SyncStatusPill } from './SyncStatusPill'; +import { type DashboardDrilldownRequest } from '../lib/controlDashboard'; type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingShortDE' | 'missingShortEN' | 'complete' | 'incomplete'; @@ -15,9 +16,11 @@ interface ProductDescriptionsProps { asinColumnIndex: number | null; onEdit: (index: number) => void; rowStatuses: Record; + dashboardDrilldown?: DashboardDrilldownRequest | null; + onDashboardDrilldownApplied?: () => void; } -export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, rowStatuses }: ProductDescriptionsProps) { +export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: ProductDescriptionsProps) { const COLUMNS = useColumns(); const [isFullscreen, setIsFullscreen] = useState(false); @@ -46,6 +49,19 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro [COLUMNS.SHORT_EN]: 110, }); + useEffect(() => { + if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'descriptions') return; + + const focus = dashboardDrilldown.focus as TabType; + setActiveTab(focus); + setSearch(''); + setLineFilter(''); + setLicenseFilter(''); + setColumnFilters({}); + setPage(1); + onDashboardDrilldownApplied?.(); + }, [dashboardDrilldown?.id, dashboardDrilldown?.tabId, dashboardDrilldown?.focus, onDashboardDrilldownApplied, setActiveTab, setSearch, setLineFilter, setLicenseFilter, setColumnFilters]); + const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]); const licenses = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LICENSE]).filter(Boolean))), [data]); @@ -249,7 +265,7 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
-
+
All Licenses {licenses.map(l => )} +
+ In view + {filteredData.length} +
@@ -442,6 +462,10 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
+
+ Filtered + {filteredData.length} +
+
+ )} + + {loading ? ( +
+ +

Cargando lista de usuarios...

+
+ ) : filteredUsers.length === 0 ? ( +
+ + {search ? ( +

No se encontraron usuarios que coincidan con la búsqueda.

+ ) : ( +

No hay registros de usuarios registrados.

+ )} +
+ ) : ( +
+ + + + + + + + + + + {filteredUsers.map(user => { + const isProcessing = processingId === user.id; + + return ( + + + + + + + ); + })} + +
EmailFecha de RegistroEstadoAcciones
+ {user.email} + + {formatDate(user.created_at)} + + {user.status === 'Master' ? ( + + + Administrador Principal + + ) : user.status === 'Validated' ? ( + + + Validado + + ) : ( + + + Pendiente Validación + + )} + + {user.status === 'Master' ? ( + Protegido + ) : ( +
+ {user.status === 'Pending' ? ( + + ) : ( + + )} + +
+ )} +
+
+ )} +
+ + {/* Confirmation Modal */} + {deleteConfirmUser && ( +
+
+
+
+ +

¿Eliminar usuario definitivamente?

+
+

+ Estás a punto de eliminar la cuenta del usuario: +

+

+ {deleteConfirmUser.email} +

+

+ Esta acción no se puede deshacer. Se eliminarán sus accesos y toda su información asociada al servicio de autenticación. +

+
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 3e7cde8..d05f401 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -40,6 +40,31 @@ export async function signIn(email: string, password: string): Promise ({})); + throw new Error(err.error || 'Failed to verify account validation status.'); + } + + const statusData = await statusRes.json(); + if (!statusData.validated) { + throw new Error('Tu usuario aún no ha sido validado por un administrador.'); + } + } catch (err: any) { + throw new Error(err.message || 'Error de validación del usuario.'); + } + const session: AuthSession = { access_token: data.access_token, refresh_token: data.refresh_token, diff --git a/src/lib/controlDashboard.ts b/src/lib/controlDashboard.ts new file mode 100644 index 0000000..6647a3a --- /dev/null +++ b/src/lib/controlDashboard.ts @@ -0,0 +1,974 @@ +import { ExcelRow, COLUMNS, resolveColumnIndices } from '../types'; +import { + HistoryEntry, + DashboardDescriptionsSnapshot, + DashboardArticleDetailsSnapshot, + DashboardPricingSnapshot, + DashboardCosmeticSnapshot, + DashboardSnapshotTabs, + getDashboardSnapshotStore, + ensureDashboardSnapshot, +} from './supabase'; + +export type ControlTabId = + | 'control_dashboard' + | 'matrix' + | 'descriptions' + | 'article_details' + | 'dimensions' + | 'pricing' + | 'missing_data' + | 'cosmetic_items' + | 'pending_validation' + | 'history' + | 'user_management'; + +export type DashboardDrilldownTabId = Exclude; + +export interface DashboardDrilldownRequest { + id: string; + tabId: DashboardDrilldownTabId; + focus: string; +} + +export function createDashboardDrilldownRequest(tabId: DashboardDrilldownTabId, focus: string): DashboardDrilldownRequest { + return { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + tabId, + focus, + }; +} + +export interface TabMetricSnapshot { + total: number; + ok: number; + pending: number; + empty: number; + error: number; +} + +export interface DescriptionsDashboardSnapshot { + total: number; + ok: number; + longDeMissing: number; + longEnMissing: number; + shortDeMissing: number; + shortEnMissing: number; +} + +export interface ArticleDetailsDashboardSnapshot { + total: number; + ok: number; + detailsDeMissing: number; + detailsEnMissing: number; +} + +export interface PricingDashboardSnapshot { + total: number; + ok: number; + itemToLogisticMissing: number; + uvpMissing: number; + srpIntMissing: number; + srpUkMissing: number; + unitsOuterMissing: number; + outerWMissing: number; + outerLMissing: number; + outerHMissing: number; + units40fMissing: number; + moqMissing: number; + weightIssues: number; +} + +export interface CosmeticDashboardSnapshot { + total: number; + ok: number; + cpnpMissing: number; +} + +export interface TabCardSummary { + id: ControlTabId; + label: string; + accentClass: string; + current: TabMetricSnapshot; + historical?: TabMetricSnapshot; + delta?: TabMetricSnapshot; +} + +export interface PendingRowInfo { + rowIndex: number; + originalData: ExcelRow; + newData: ExcelRow; + articleName: string; +} + +export interface HistorySyncRecord { + selected: boolean; + status: 'bc_pending' | 'previewed' | 'preview_only' | 'syncing' | 'synced' | 'failed'; + previewToken?: string; + error?: string; + warning?: string; +} + +export type HistorySyncMap = Record; + +export interface DashboardContext { + data: ExcelRow[]; + pendingRows: Record; + rowStatuses: Record; + historyEntries: HistoryEntry[]; + historySyncMap?: HistorySyncMap; +} + +export interface SnapshotStore { + [dateKey: string]: DashboardSnapshotTabs; +} + +export const CONTROL_TABS: Array<{ + id: Exclude; + label: string; + accentClass: string; +}> = [ + { id: 'matrix', label: 'Matrix', accentClass: 'border-sky-500/30 bg-sky-500/5' }, + { id: 'descriptions', label: 'Product Descriptions', accentClass: 'border-emerald-500/30 bg-emerald-500/5' }, + { id: 'article_details', label: 'Article Details', accentClass: 'border-indigo-500/30 bg-indigo-500/5' }, + { id: 'dimensions', label: 'Dimensions', accentClass: 'border-violet-500/30 bg-violet-500/5' }, + { id: 'pricing', label: 'Pricing & Units', accentClass: 'border-amber-500/30 bg-amber-500/5' }, + { id: 'missing_data', label: 'Missing Data', accentClass: 'border-rose-500/30 bg-rose-500/5' }, + { id: 'cosmetic_items', label: 'Cosmetic Items', accentClass: 'border-fuchsia-500/30 bg-fuchsia-500/5' }, + { id: 'pending_validation', label: 'Pending Validation', accentClass: 'border-orange-500/30 bg-orange-500/5' }, + { id: 'history', label: 'Change History', accentClass: 'border-cyan-500/30 bg-cyan-500/5' }, +]; + +export const APP_TABS: Array<{ + id: ControlTabId; + label: string; + accentClass: string; +}> = [ + { id: 'control_dashboard', label: 'Control Dashboard', accentClass: 'border-slate-500/30 bg-slate-500/5' }, + ...CONTROL_TABS, +]; + +const HISTORY_SYNC_STORAGE_KEY = 'history-bcSync'; + +const COSMETIC_LINES = new Set(['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS']); +const STATUS_PENDING = new Set(['pending', 'bc_pending', 'queued', 'previewed', 'preview_only', 'syncing']); +const STATUS_ERROR = new Set(['failed', 'error']); + +function normalize(value: unknown): string { + if (value === undefined || value === null) return ''; + return String(value).replace(/\s+/g, ' ').trim(); +} + +function safeLocalStorageGet(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function isBlank(value: unknown): boolean { + return normalize(value) === ''; +} + +function isDateLikeEmpty(value: unknown): boolean { + if (value === undefined || value === null || value === '') return true; + if (typeof value === 'number') return value === 0 || value === 1; + + const text = normalize(value); + if (text === '' || text === '0' || text === '1') return true; + if (text === '0001-01-01' || text.startsWith('0001-01-01T')) return true; + if (text.endsWith('/1900')) return true; + return false; +} + +function isNumericLikeEmpty(value: unknown): boolean { + if (value === undefined || value === null || value === '') return true; + const n = typeof value === 'number' ? value : Number(String(value).replace(',', '.')); + return Number.isNaN(n) || n === 0; +} + +function isTruthyNumeric(value: unknown): boolean { + if (value === undefined || value === null || value === '') return false; + const n = typeof value === 'number' ? value : Number(String(value).replace(',', '.')); + return !Number.isNaN(n) && n !== 0; +} + +function toDate(value: unknown): Date | null { + if (isDateLikeEmpty(value)) return null; + if (typeof value === 'number') { + if (value >= 25569 && value <= 60000) { + const excelEpoch = new Date(1899, 11, 30); + return new Date(excelEpoch.getTime() + value * 86400000); + } + return null; + } + + const text = normalize(value); + if (!text) return null; + const iso = new Date(text); + if (!Number.isNaN(iso.getTime())) return iso; + + const parts = text.split('/'); + if (parts.length === 3) { + const [dd, mm, yyyy] = parts; + const parsed = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); + if (!Number.isNaN(parsed.getTime())) return parsed; + } + + return null; +} + +function localDateKey(date: Date): string { + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, '0'); + const d = String(date.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function shiftDate(date: Date, days: number): Date { + const next = new Date(date); + next.setDate(next.getDate() - days); + return next; +} + +function findIndicesByPatterns(headers: string[], patterns: string[][]): number[] { + const lower = headers.map(header => normalize(header).toLowerCase()); + const indices = new Set(); + + patterns.forEach(pattern => { + lower.forEach((header, index) => { + if (pattern.every(token => header.includes(token))) { + indices.add(index); + } + }); + }); + + return Array.from(indices).sort((a, b) => a - b); +} + +function unionIndices(...groups: number[][]): number[] { + const result = new Set(); + groups.forEach(group => group.forEach(index => result.add(index))); + return Array.from(result).sort((a, b) => a - b); +} + +function getRowKey(row: ExcelRow): string { + return normalize(row[COLUMNS.ARTICLE_NO]); +} + +export function getHistorySyncMapFromStorage(): HistorySyncMap { + try { + const raw = safeLocalStorageGet(HISTORY_SYNC_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return parsed as HistorySyncMap; + } catch { + return {}; + } +} + +function getTabRows(tabId: ControlTabId, headers: string[], ctx: DashboardContext): ExcelRow[] { + if (tabId === 'pending_validation') { + return Object.values(ctx.pendingRows).map(row => row.newData); + } + + if (tabId === 'history') { + return ctx.historyEntries.map(entry => entry.new_data); + } + + const resolvedRows = ctx.data || []; + + if (tabId === 'cosmetic_items') { + return resolvedRows.filter(row => COSMETIC_LINES.has(normalize(row[COLUMNS.LINE]).toUpperCase())); + } + + if (tabId === 'missing_data') { + return resolvedRows.filter(row => isMissingDataRow(row, headers)); + } + + return resolvedRows; +} + +function getDescriptionRows(headers: string[], ctx: DashboardContext): ExcelRow[] { + const resolvedRows = ctx.data || []; + void headers; + return resolvedRows; +} + +function getArticleDetailsRows(headers: string[], ctx: DashboardContext): ExcelRow[] { + const resolvedRows = ctx.data || []; + void headers; + return resolvedRows; +} + +function getPricingRows(headers: string[], ctx: DashboardContext): ExcelRow[] { + const resolvedRows = ctx.data || []; + void headers; + return resolvedRows; +} + +function getCosmeticRows(headers: string[], ctx: DashboardContext): ExcelRow[] { + const resolvedRows = ctx.data || []; + const columns = resolveColumnIndices(headers); + return resolvedRows.filter(row => COSMETIC_LINES.has(normalize(row[columns.LINE]).toUpperCase())); +} + +function isMissingDataRow(row: ExcelRow, headers: string[]): boolean { + const launchIdx = findHeaderIndexFromHeaders(headers, [['launch', 'date']]); + const readyIdx = findHeaderIndexFromHeaders(headers, [['ready', 'to', 'order', 'date']]); + const classificationIdx = findHeaderIndexFromHeaders(headers, [['classification']]); + + const launch = launchIdx >= 0 ? row[launchIdx] : undefined; + const ready = readyIdx >= 0 ? row[readyIdx] : undefined; + const classification = classificationIdx >= 0 ? row[classificationIdx] : undefined; + + const missingBasics = isDateLikeEmpty(launch) || isBlank(classification); + const readyBeforeLaunch = (() => { + const launchDate = toDate(launch); + const readyDate = toDate(ready); + if (!launchDate || !readyDate) return false; + return readyDate.getTime() > launchDate.getTime(); + })(); + + const upcomingLaunch = (() => { + const launchDate = toDate(launch); + if (!launchDate) return false; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const diff = Math.ceil((launchDate.getTime() - today.getTime()) / 86400000); + return diff > 0 && diff <= 180; + })(); + + return missingBasics || readyBeforeLaunch || upcomingLaunch; +} + +function findHeaderIndexFromHeaders(headers: string[], patterns: string[][]): number { + return findIndicesByPatterns(headers, patterns)[0] ?? -1; +} + +function getEditableIndices(tabId: ControlTabId, headers: string[]): number[] { + const columns = resolveColumnIndices(headers); + + const descriptions = unionIndices( + [columns.LONG_DE, columns.LONG_EN, columns.SHORT_DE, columns.SHORT_EN].filter(i => typeof i === 'number' && i >= 0), + findIndicesByPatterns(headers, [['long', 'description']]), + findIndicesByPatterns(headers, [['short', 'description']]), + ); + + const articleDetails = unionIndices( + [columns.DETAILS_EN, columns.DETAILS_DE, columns.SHORT_DE, columns.SHORT_EN, columns.MOQ, columns.CPNP_NO].filter(i => typeof i === 'number' && i >= 0), + findIndicesByPatterns(headers, [['article', 'details', 'english']]), + findIndicesByPatterns(headers, [['article', 'details', 'german']]), + findIndicesByPatterns(headers, [['launch', 'date']]), + findIndicesByPatterns(headers, [['ready', 'to', 'order', 'date']]), + findIndicesByPatterns(headers, [['moq']]), + findIndicesByPatterns(headers, [['cpnp']]) + ); + + const dimensions = unionIndices( + [columns.UNITS_OUTER, columns.INNER_W, columns.INNER_L, columns.INNER_H, columns.OUTER_W, columns.OUTER_L, columns.OUTER_H, columns.MOQ].filter(i => typeof i === 'number' && i >= 0), + findIndicesByPatterns(headers, [['inner', 'w']]), + findIndicesByPatterns(headers, [['inner', 'l']]), + findIndicesByPatterns(headers, [['inner', 'h']]), + findIndicesByPatterns(headers, [['outer', 'w']]), + findIndicesByPatterns(headers, [['outer', 'l']]), + findIndicesByPatterns(headers, [['outer', 'h']]), + findIndicesByPatterns(headers, [['units', 'outer']]), + findIndicesByPatterns(headers, [['moq']]) + ); + + const pricing = unionIndices( + [columns.UNITS_OUTER, columns.OUTER_W, columns.OUTER_L, columns.OUTER_H, columns.MOQ].filter(i => typeof i === 'number' && i >= 0), + findIndicesByPatterns(headers, [['uvp']]), + findIndicesByPatterns(headers, [['srp']]), + findIndicesByPatterns(headers, [['price']]), + findIndicesByPatterns(headers, [['cost']]), + findIndicesByPatterns(headers, [['net']]), + findIndicesByPatterns(headers, [['gross']]), + findIndicesByPatterns(headers, [['units', 'outer']]), + findIndicesByPatterns(headers, [['outer', 'w']]), + findIndicesByPatterns(headers, [['outer', 'l']]), + findIndicesByPatterns(headers, [['outer', 'h']]), + findIndicesByPatterns(headers, [['moq']]) + ); + + const missingData = unionIndices( + findIndicesByPatterns(headers, [['classification']]), + findIndicesByPatterns(headers, [['launch', 'date']]), + findIndicesByPatterns(headers, [['ready', 'to', 'order', 'date']]) + ); + + const cosmetic = findIndicesByPatterns(headers, [['cpnp']]); + + const allEditable = unionIndices(descriptions, articleDetails, dimensions, pricing, missingData, cosmetic); + + switch (tabId) { + case 'descriptions': + return descriptions; + case 'article_details': + return articleDetails; + case 'dimensions': + return dimensions; + case 'pricing': + return pricing; + case 'missing_data': + return missingData; + case 'cosmetic_items': + return cosmetic; + case 'pending_validation': + case 'history': + case 'matrix': + default: + return allEditable; + } +} + +function isFieldEmptyForTab(tabId: ControlTabId, index: number, value: unknown): boolean { + if (tabId === 'descriptions' || tabId === 'article_details' || tabId === 'cosmetic_items' || tabId === 'history' || tabId === 'pending_validation' || tabId === 'matrix') { + if (index === COLUMNS.CPNP_NO) return isBlank(value); + if (index === COLUMNS.MOQ || index === COLUMNS.UNITS_OUTER || index === COLUMNS.INNER_W || index === COLUMNS.INNER_L || index === COLUMNS.INNER_H || index === COLUMNS.OUTER_W || index === COLUMNS.OUTER_L || index === COLUMNS.OUTER_H) { + return isNumericLikeEmpty(value); + } + if (tabId === 'descriptions' && (index === COLUMNS.LONG_DE || index === COLUMNS.LONG_EN || index === COLUMNS.SHORT_DE || index === COLUMNS.SHORT_EN)) { + return isBlank(value); + } + if (tabId === 'article_details' && (index === COLUMNS.DETAILS_DE || index === COLUMNS.DETAILS_EN || index === COLUMNS.SHORT_DE || index === COLUMNS.SHORT_EN || index === COLUMNS.MOQ || index === COLUMNS.CPNP_NO)) { + return isBlank(value); + } + if (index === COLUMNS.ARTICLE_NO || index === COLUMNS.ARTICLE_NAME || index === COLUMNS.LINE || index === COLUMNS.CLASSIFICATION) { + return isBlank(value); + } + } + + if (tabId === 'missing_data') { + return index === COLUMNS.CLASSIFICATION || index === COLUMNS.CPNP_NO ? isBlank(value) : isDateLikeEmpty(value); + } + + if (tabId === 'pricing' || tabId === 'dimensions') { + return isNumericLikeEmpty(value) || isBlank(value); + } + + return isBlank(value); +} + +function countEmptyRows(tabId: ControlTabId, rows: ExcelRow[], headers: string[]): number { + const indices = getEditableIndices(tabId, headers); + return rows.filter(row => indices.some(index => isFieldEmptyForTab(tabId, index, row[index]))).length; +} + +function rowHasPendingStatus(articleNo: string, ctx: DashboardContext): boolean { + const normalized = normalize(ctx.rowStatuses[articleNo]).toLowerCase(); + return STATUS_PENDING.has(normalized); +} + +function countPendingRows(tabId: ControlTabId, rows: ExcelRow[], ctx: DashboardContext): number { + if (tabId === 'pending_validation') { + return rows.length; + } + + if (tabId === 'history') { + return ctx.historyEntries.filter(entry => { + const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`); + const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase(); + return STATUS_PENDING.has(status); + }).length; + } + + return rows.filter(row => { + const articleNo = getRowKey(row); + return rowHasPendingStatus(articleNo, ctx) || Object.prototype.hasOwnProperty.call(ctx.pendingRows, articleNo); + }).length; +} + +function collectPendingArticles(tabId: ControlTabId, rows: ExcelRow[], ctx: DashboardContext): Set { + const articles = new Set(); + + if (tabId === 'pending_validation') { + rows.forEach(row => { + const articleNo = getRowKey(row); + if (articleNo) articles.add(articleNo); + }); + return articles; + } + + if (tabId === 'history') { + ctx.historyEntries.forEach(entry => { + const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`); + const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase(); + if (STATUS_PENDING.has(status)) { + articles.add(normalize(entry.product_id)); + } + }); + return articles; + } + + rows.forEach(row => { + const articleNo = getRowKey(row); + if (!articleNo) return; + if (rowHasPendingStatus(articleNo, ctx) || Object.prototype.hasOwnProperty.call(ctx.pendingRows, articleNo)) { + articles.add(articleNo); + } + }); + + return articles; +} + +function collectEmptyArticles(tabId: ControlTabId, rows: ExcelRow[], headers: string[]): Set { + const indices = getEditableIndices(tabId, headers); + const articles = new Set(); + + rows.forEach(row => { + const articleNo = getRowKey(row); + if (!articleNo) return; + if (indices.some(index => isFieldEmptyForTab(tabId, index, row[index]))) { + articles.add(articleNo); + } + }); + + return articles; +} + +function collectErrorArticles(tabId: ControlTabId, rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set { + switch (tabId) { + case 'dimensions': + return collectDimensionErrorArticles(rows, headers, ctx); + case 'pricing': + return collectPricingErrorArticles(rows, headers, ctx); + case 'missing_data': + return collectMissingDataErrorArticles(rows, headers, ctx); + case 'history': + return new Set( + ctx.historyEntries + .filter(entry => { + const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`); + const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase(); + return status === 'failed'; + }) + .map(entry => normalize(entry.product_id)) + .filter(Boolean) + ); + case 'matrix': + return new Set([ + ...collectPricingErrorArticles(rows, headers, ctx), + ...collectDimensionErrorArticles(rows, headers, ctx), + ...collectMissingDataErrorArticles(rows, headers, ctx), + ...rows.filter(row => hasRowStatusError(getRowKey(row), ctx)).map(row => getRowKey(row)), + ]); + default: + return new Set( + rows + .filter(row => hasRowStatusError(getRowKey(row), ctx)) + .map(row => getRowKey(row)) + .filter(Boolean) + ); + } +} + +function computeDescriptionsSnapshot(headers: string[], ctx: DashboardContext): DescriptionsDashboardSnapshot { + const rows = getDescriptionRows(headers, ctx); + const columns = resolveColumnIndices(headers); + + const longDeMissing = rows.reduce((count, row) => count + (isBlank(row[columns.LONG_DE]) ? 1 : 0), 0); + const longEnMissing = rows.reduce((count, row) => count + (isBlank(row[columns.LONG_EN]) ? 1 : 0), 0); + const shortDeMissing = rows.reduce((count, row) => count + (isBlank(row[columns.SHORT_DE]) ? 1 : 0), 0); + const shortEnMissing = rows.reduce((count, row) => count + (isBlank(row[columns.SHORT_EN]) ? 1 : 0), 0); + const ok = rows.reduce((count, row) => { + const complete = !isBlank(row[columns.LONG_DE]) + && !isBlank(row[columns.LONG_EN]) + && !isBlank(row[columns.SHORT_DE]) + && !isBlank(row[columns.SHORT_EN]); + return count + (complete ? 1 : 0); + }, 0); + + return { + total: rows.length, + ok, + longDeMissing, + longEnMissing, + shortDeMissing, + shortEnMissing, + }; +} + +function computeArticleDetailsSnapshot(headers: string[], ctx: DashboardContext): ArticleDetailsDashboardSnapshot { + const rows = getArticleDetailsRows(headers, ctx); + const columns = resolveColumnIndices(headers); + + const detailsDeMissing = rows.reduce((count, row) => count + (isBlank(row[columns.DETAILS_DE]) ? 1 : 0), 0); + const detailsEnMissing = rows.reduce((count, row) => count + (isBlank(row[columns.DETAILS_EN]) ? 1 : 0), 0); + const ok = rows.reduce((count, row) => { + const complete = !isBlank(row[columns.DETAILS_DE]) && !isBlank(row[columns.DETAILS_EN]); + return count + (complete ? 1 : 0); + }, 0); + + return { + total: rows.length, + ok, + detailsDeMissing, + detailsEnMissing, + }; +} + +function findHeaderIndexByName(headers: string[], predicate: (name: string) => boolean): number { + return headers.findIndex(header => predicate(normalize(header).toLowerCase())); +} + +function isWeightIssueValue(value: unknown): boolean { + return !isBlank(value); +} + +function computePricingSnapshot(headers: string[], ctx: DashboardContext): PricingDashboardSnapshot { + const rows = getPricingRows(headers, ctx); + const columns = resolveColumnIndices(headers); + const articleIndex = columns.ARTICLE_NO; + const skuForRow = (row: ExcelRow) => normalize(row[articleIndex]); + + const uvpIdx = findHeaderIndexFromHeaders(headers, [['uvp']]); + const srpHeaders = headers + .map((header, index) => ({ index, text: normalize(header).toLowerCase() })) + .filter(({ text }) => text.includes('srp')); + const srpIntIdx = srpHeaders.find(({ text }) => text.includes('int'))?.index ?? -1; + const srpUkIdx = srpHeaders.find(({ text }) => text.includes('uk'))?.index ?? -1; + const units40fIdx = findHeaderIndexFromHeaders(headers, [['40f']]); + const itemToLogisticIdx = columns.ITEM_TO_LOGISTIC; + const unitsOuterIdx = columns.UNITS_OUTER; + const outerWIdx = columns.OUTER_W; + const outerLIdx = columns.OUTER_L; + const outerHIdx = columns.OUTER_H; + const moqIdx = columns.MOQ; + const nwIdx = findHeaderIndexFromHeaders(headers, [['nw']]); + const gwIdx = findHeaderIndexFromHeaders(headers, [['gw']]); + + const rowIssues = new Set(); + + const itemToLogisticMissing = rows.reduce((count, row) => { + const missing = isBlank(row[itemToLogisticIdx]); + if (missing) rowIssues.add(skuForRow(row)); + return count + (missing ? 1 : 0); + }, 0); + + const uvpMissing = rows.reduce((count, row) => { + const missing = uvpIdx < 0 ? true : isBlank(row[uvpIdx]); + if (missing) rowIssues.add(skuForRow(row)); + return count + (missing ? 1 : 0); + }, 0); + + const srpIntMissing = rows.reduce((count, row) => { + const missing = srpIntIdx < 0 ? true : isBlank(row[srpIntIdx]); + if (missing) rowIssues.add(skuForRow(row)); + return count + (missing ? 1 : 0); + }, 0); + + const srpUkMissing = rows.reduce((count, row) => { + const missing = srpUkIdx < 0 ? true : isBlank(row[srpUkIdx]); + if (missing) rowIssues.add(skuForRow(row)); + return count + (missing ? 1 : 0); + }, 0); + + const unitsOuterMissing = rows.reduce((count, row) => { + const missing = isNumericLikeEmpty(row[unitsOuterIdx]); + if (missing) rowIssues.add(skuForRow(row)); + return count + (missing ? 1 : 0); + }, 0); + + const outerWMissing = rows.reduce((count, row) => { + const missing = isNumericLikeEmpty(row[outerWIdx]); + if (missing) rowIssues.add(skuForRow(row)); + return count + (missing ? 1 : 0); + }, 0); + + const outerLMissing = rows.reduce((count, row) => { + const missing = isNumericLikeEmpty(row[outerLIdx]); + if (missing) rowIssues.add(skuForRow(row)); + return count + (missing ? 1 : 0); + }, 0); + + const outerHMissing = rows.reduce((count, row) => { + const missing = isNumericLikeEmpty(row[outerHIdx]); + if (missing) rowIssues.add(skuForRow(row)); + return count + (missing ? 1 : 0); + }, 0); + + const units40fMissing = rows.reduce((count, row) => { + const missing = units40fIdx < 0 ? true : isNumericLikeEmpty(row[units40fIdx]); + if (missing) rowIssues.add(skuForRow(row)); + return count + (missing ? 1 : 0); + }, 0); + + const moqMissing = rows.reduce((count, row) => { + const missing = isNumericLikeEmpty(row[moqIdx]); + if (missing) rowIssues.add(skuForRow(row)); + return count + (missing ? 1 : 0); + }, 0); + + const weightIssues = rows.reduce((count, row) => { + let issue = false; + if (nwIdx >= 0 && gwIdx >= 0) { + const nw = parseFloat(String(row[nwIdx] ?? '').replace(',', '.')); + const gw = parseFloat(String(row[gwIdx] ?? '').replace(',', '.')); + issue = !Number.isNaN(nw) && !Number.isNaN(gw) && nw > gw; + } + if (issue) rowIssues.add(skuForRow(row)); + return count + (issue ? 1 : 0); + }, 0); + + const total = rows.length; + + return { + total, + ok: Math.max(total - rowIssues.size, 0), + itemToLogisticMissing, + uvpMissing, + srpIntMissing, + srpUkMissing, + unitsOuterMissing, + outerWMissing, + outerLMissing, + outerHMissing, + units40fMissing, + moqMissing, + weightIssues, + }; +} + +function computeCosmeticSnapshot(headers: string[], ctx: DashboardContext): CosmeticDashboardSnapshot { + const rows = getCosmeticRows(headers, ctx); + const columns = resolveColumnIndices(headers); + const cpnpPresent = rows.reduce((count, row) => count + (!isBlank(row[columns.CPNP_NO]) ? 1 : 0), 0); + const cpnpMissing = rows.length - cpnpPresent; + return { + total: rows.length, + ok: cpnpPresent, + cpnpMissing, + }; +} + +function hasRowStatusError(articleNo: string, ctx: DashboardContext): boolean { + const status = normalize(ctx.rowStatuses[articleNo]).toLowerCase(); + return STATUS_ERROR.has(status); +} + +function countDimensionErrors(rows: ExcelRow[], headers: string[], ctx: DashboardContext): number { + return collectDimensionErrorArticles(rows, headers, ctx).size; +} + +function collectDimensionErrorArticles(rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set { + const columns = resolveColumnIndices(headers); + const groups = new Map(); + + rows.forEach(row => { + const innerValues = [row[columns.INNER_L], row[columns.INNER_W], row[columns.INNER_H]]; + if (innerValues.every(value => isNumericLikeEmpty(value) || isBlank(value))) return; + + const innerKey = innerValues + .map(value => normalize(value) || '0') + .join('x'); + if (!groups.has(innerKey)) groups.set(innerKey, []); + groups.get(innerKey)!.push(row); + }); + + const errorArticles = new Set(); + groups.forEach(groupRows => { + if (groupRows.length <= 1) return; + const signature = (row: ExcelRow) => [ + row[columns.OUTER_L], + row[columns.OUTER_W], + row[columns.OUTER_H], + row[columns.UNITS_OUTER], + row[columns.MOQ], + ].map(value => normalize(value) || '0').join('|'); + + const firstSignature = signature(groupRows[0]); + const inconsistent = groupRows.some(row => signature(row) !== firstSignature); + if (!inconsistent) return; + + groupRows.forEach(row => { + const articleNo = getRowKey(row); + if (articleNo) errorArticles.add(articleNo); + }); + }); + + return errorArticles; +} + +function countPricingErrors(rows: ExcelRow[], headers: string[], ctx: DashboardContext): number { + return collectPricingErrorArticles(rows, headers, ctx).size; +} + +function collectPricingErrorArticles(rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set { + const columns = resolveColumnIndices(headers); + const uvpIdx = findHeaderIndexFromHeaders(headers, [['uvp']]); + const srpIndices = findIndicesByPatterns(headers, [['srp']]); + const netIdx = findHeaderIndexFromHeaders(headers, [['net']]); + const grossIdx = findHeaderIndexFromHeaders(headers, [['gross']]); + + const articles = new Set(); + rows.forEach(row => { + const articleNo = getRowKey(row); + if (hasRowStatusError(articleNo, ctx)) { + if (articleNo) articles.add(articleNo); + return; + } + + const pricingMissing = uvpIdx >= 0 && isBlank(row[uvpIdx]); + const srpMissing = srpIndices.some(index => isBlank(row[index])); + const unitsOuter = row[columns.UNITS_OUTER]; + const outerW = row[columns.OUTER_W]; + const outerL = row[columns.OUTER_L]; + const outerH = row[columns.OUTER_H]; + const unitsError = isNumericLikeEmpty(unitsOuter) || normalize(unitsOuter) === '1'; + const outerError = [outerW, outerL, outerH].some(value => isNumericLikeEmpty(value) || normalize(value) === '1'); + const weightError = netIdx >= 0 && grossIdx >= 0 && !isBlank(row[netIdx]) && !isBlank(row[grossIdx]) && Number(String(row[netIdx]).replace(',', '.')) > Number(String(row[grossIdx]).replace(',', '.')); + + if (pricingMissing || srpMissing || unitsError || outerError || weightError) { + if (articleNo) articles.add(articleNo); + } + }); + return articles; +} + +function countMissingDataErrors(rows: ExcelRow[], headers: string[], ctx: DashboardContext): number { + return collectMissingDataErrorArticles(rows, headers, ctx).size; +} + +function collectMissingDataErrorArticles(rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set { + const columns = resolveColumnIndices(headers); + const launchIdx = findHeaderIndexFromHeaders(headers, [['launch', 'date']]); + const readyIdx = findHeaderIndexFromHeaders(headers, [['ready', 'to', 'order', 'date']]); + + const articles = new Set(); + rows.forEach(row => { + const articleNo = getRowKey(row); + if (hasRowStatusError(articleNo, ctx)) { + if (articleNo) articles.add(articleNo); + return; + } + + if (launchIdx < 0 || readyIdx < 0) return; + const launch = toDate(row[launchIdx]); + const ready = toDate(row[readyIdx]); + if (!launch || !ready) return; + if (ready.getTime() > launch.getTime() || isNumericLikeEmpty(row[columns.MOQ])) { + if (articleNo) articles.add(articleNo); + } + }); + return articles; +} + +function countGenericErrors(rows: ExcelRow[], ctx: DashboardContext, extraPredicate?: (row: ExcelRow) => boolean): number { + return rows.filter(row => { + const articleNo = getRowKey(row); + if (hasRowStatusError(articleNo, ctx)) return true; + return extraPredicate ? extraPredicate(row) : false; + }).length; +} + +function countHistoryErrors(entries: HistoryEntry[], ctx: DashboardContext): number { + return entries.filter(entry => { + const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`); + const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase(); + return status === 'failed'; + }).length; +} + +function countHistoryEmptyRows(entries: HistoryEntry[], headers: string[]): number { + const indices = getEditableIndices('history', headers); + return entries.filter(entry => indices.some(index => isFieldEmptyForTab('history', index, entry.new_data?.[index]))).length; +} + +function countHistoryPendingRows(entries: HistoryEntry[], ctx: DashboardContext): number { + return entries.filter(entry => { + const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`); + const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase(); + return STATUS_PENDING.has(status); + }).length; +} + +function createCurrentSnapshot(tabId: ControlTabId, rows: ExcelRow[], headers: string[], ctx: DashboardContext): TabMetricSnapshot { + const total = rows.length; + const pendingSet = collectPendingArticles(tabId, rows, ctx); + const emptySet = collectEmptyArticles(tabId, rows, headers); + const errorSet = collectErrorArticles(tabId, rows, headers, ctx); + const issueSet = new Set([...pendingSet, ...emptySet, ...errorSet]); + + switch (tabId) { + case 'dimensions': + case 'pricing': + case 'missing_data': + case 'history': + case 'pending_validation': + case 'matrix': + break; + default: + break; + } + + return { + total, + ok: Math.max(total - issueSet.size, 0), + pending: pendingSet.size, + empty: emptySet.size, + error: errorSet.size, + }; +} + +export function computeControlDashboardSummaries(headers: string[], ctx: DashboardContext): TabCardSummary[] { + const historySyncMap = ctx.historySyncMap || getHistorySyncMapFromStorage(); + const effectiveCtx: DashboardContext = { ...ctx, historySyncMap }; + + return CONTROL_TABS.map(tab => { + const rows = getTabRows(tab.id, headers, effectiveCtx); + const current = createCurrentSnapshot(tab.id, rows, headers, effectiveCtx); + return { + id: tab.id, + label: tab.label, + accentClass: tab.accentClass, + current, + }; + }); +} + +export function computeDescriptionsDashboardSnapshot(headers: string[], ctx: DashboardContext): DescriptionsDashboardSnapshot { + return computeDescriptionsSnapshot(headers, ctx); +} + +export function computeArticleDetailsDashboardSnapshot(headers: string[], ctx: DashboardContext): ArticleDetailsDashboardSnapshot { + return computeArticleDetailsSnapshot(headers, ctx); +} + +export function computePricingDashboardSnapshot(headers: string[], ctx: DashboardContext): PricingDashboardSnapshot { + return computePricingSnapshot(headers, ctx); +} + +export function computeCosmeticDashboardSnapshot(headers: string[], ctx: DashboardContext): CosmeticDashboardSnapshot { + return computeCosmeticSnapshot(headers, ctx); +} + +export function getDaysAgoKey(days: number, date = new Date()): string { + return localDateKey(shiftDate(date, days)); +} + +export function diffSnapshots(current: TabMetricSnapshot, historical?: TabMetricSnapshot): TabMetricSnapshot | undefined { + if (!historical) return undefined; + return { + total: current.total - historical.total, + ok: current.ok - historical.ok, + pending: current.pending - historical.pending, + empty: current.empty - historical.empty, + error: current.error - historical.error, + }; +} + +export async function loadDashboardSnapshots(): Promise> { + const store = await getDashboardSnapshotStore(); + return store; +} + +export async function ensureDailyDashboardSnapshot(date: Date, snapshot: DashboardSnapshotTabs): Promise { + const key = localDateKey(date); + await ensureDashboardSnapshot(key, { + ...snapshot, + }); +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 03252c4..6f214ec 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -3,7 +3,7 @@ import { refreshSession, getStoredSession } from './auth'; const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co'; const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv'; -async function safeFetch(url: string, options: RequestInit = {}): Promise { +export async function safeFetch(url: string, options: RequestInit = {}): Promise { const session = getStoredSession(); const token = session?.access_token || SUPABASE_ANON_KEY; @@ -37,6 +37,7 @@ export interface ExcelRow extends Array {} export interface SyncedRow { data: ExcelRow; status?: 'pending' | 'edited' | 'synced' | 'excel'; + updated_at?: string; } export async function getAllSyncedRows(): Promise> { @@ -49,7 +50,7 @@ export async function getAllSyncedRows(): Promise> { // Safety cap at 20 pages (20k products) to avoid infinite loops. for (let page = 0; page < 20; page++) { const response = await safeFetch( - `${SUPABASE_URL}/rest/v1/products?select=product_id,data,status&order=product_id.asc&limit=${PAGE_SIZE}&offset=${offset}`, + `${SUPABASE_URL}/rest/v1/products?select=product_id,data,status,updated_at&order=product_id.asc&limit=${PAGE_SIZE}&offset=${offset}`, { cache: 'no-store' } ); @@ -60,7 +61,12 @@ export async function getAllSyncedRows(): Promise> { } const rows = await response.json(); for (const row of rows) { - result[row.product_id] = { data: row.data, status: row.status }; + const current = result[row.product_id]; + const currentUpdatedAt = current?.updated_at ? Date.parse(current.updated_at) : -1; + const nextUpdatedAt = row.updated_at ? Date.parse(row.updated_at) : -1; + if (!current || nextUpdatedAt >= currentUpdatedAt) { + result[row.product_id] = { data: row.data, status: row.status, updated_at: row.updated_at }; + } } if (rows.length < PAGE_SIZE) break; offset += PAGE_SIZE; @@ -75,12 +81,12 @@ export async function getAllSyncedRows(): Promise> { export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, status: 'pending' | 'edited' | 'synced' = 'pending'): Promise<{ success: boolean; error?: string }> { try { const response = await safeFetch( - `${SUPABASE_URL}/rest/v1/products`, + `${SUPABASE_URL}/rest/v1/products?on_conflict=product_id`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Prefer': 'resolution=merge-duplicates', + 'Prefer': 'resolution=merge-duplicates,return=minimal', }, body: JSON.stringify({ product_id: articleNo, @@ -116,6 +122,53 @@ export interface HistoryEntry { changed_at: string; } +export interface DashboardDescriptionsSnapshot { + total: number; + ok: number; + longDeMissing: number; + longEnMissing: number; + shortDeMissing: number; + shortEnMissing: number; +} + +export interface DashboardArticleDetailsSnapshot { + total: number; + ok: number; + detailsDeMissing: number; + detailsEnMissing: number; +} + +export interface DashboardPricingSnapshot { + total: number; + ok: number; + itemToLogisticMissing: number; + uvpMissing: number; + srpIntMissing: number; + srpUkMissing: number; + unitsOuterMissing: number; + outerWMissing: number; + outerLMissing: number; + outerHMissing: number; + units40fMissing: number; + moqMissing: number; + weightIssues: number; +} + +export interface DashboardCosmeticSnapshot { + total: number; + ok: number; + cpnpMissing: number; +} + +export interface DashboardSnapshotTabs { + descriptions?: DashboardDescriptionsSnapshot; + articleDetails?: DashboardArticleDetailsSnapshot; + pricing?: DashboardPricingSnapshot; + cosmeticItems?: DashboardCosmeticSnapshot; +} + +const CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID = '__control_dashboard__'; + function normalizeHistoryValue(value: any): any { if (value === undefined || value === null || value === '') return null; return value; @@ -205,7 +258,7 @@ export async function getHistory(): Promise { }]; } const batch: HistoryEntry[] = await response.json(); - allRows.push(...batch); + allRows.push(...batch.filter(entry => entry.product_id !== CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID)); if (batch.length < PAGE_SIZE) break; } // Return oldest-first so index+1 = natural chronological number @@ -235,7 +288,7 @@ export async function getHistoryDataForMerge(): Promise console.error('[getHistoryDataForMerge] Error:', response.status); return {}; } - const entries: HistoryEntry[] = await response.json(); + const entries: HistoryEntry[] = (await response.json()).filter((entry: HistoryEntry) => entry.product_id !== CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID); entries.sort((a, b) => { const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime(); if (timeDelta !== 0) return timeDelta; @@ -266,6 +319,145 @@ export async function getHistoryDataForMerge(): Promise } } +export async function getDashboardSnapshotStore(): Promise> { + try { + const PAGE_SIZE = 1000; + const rows: Array<{ changed_at: string; new_data: any }> = []; + + for (let page = 0; page < 20; page++) { + const offset = page * PAGE_SIZE; + const response = await safeFetch( + `${SUPABASE_URL}/rest/v1/products_history?select=changed_at,new_data,product_id&product_id=eq.${encodeURIComponent(CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID)}&order=changed_at.asc&limit=${PAGE_SIZE}&offset=${offset}`, + { cache: 'no-store' } + ); + + if (!response.ok) { + const errText = await response.text(); + console.error('[getDashboardSnapshotStore] Error:', response.status, errText.substring(0, 200)); + return {}; + } + + const batch: Array<{ changed_at: string; new_data: any }> = await response.json(); + rows.push(...batch); + if (batch.length < PAGE_SIZE) break; + } + + const store: Record = {}; + rows.forEach(row => { + const snapshotDate = normalizeSnapshotDate(row.new_data?.snapshot_date || row.changed_at); + const tabs = row.new_data?.tabs; + if (!snapshotDate || !tabs || typeof tabs !== 'object') return; + store[snapshotDate] = tabs as DashboardSnapshotTabs; + }); + return store; + } catch (error) { + console.error('[getDashboardSnapshotStore] Exception:', error); + return {}; + } +} + +export async function ensureDashboardSnapshot(dateKey: string, tabs: DashboardSnapshotTabs): Promise<{ success: boolean; error?: string; created?: boolean }> { + try { + const existingRes = await safeFetch( + `${SUPABASE_URL}/rest/v1/products_history?select=id&product_id=eq.${encodeURIComponent(CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID)}&changed_at=gte.${encodeURIComponent(`${dateKey}T00:00:00.000Z`)}&changed_at=lt.${encodeURIComponent(nextUtcDateKey(dateKey))}&limit=1`, + { cache: 'no-store' } + ); + + if (!existingRes.ok) { + const errText = await existingRes.text(); + return { success: false, error: `Snapshot lookup failed: ${existingRes.status} ${errText.substring(0, 200)}` }; + } + + const existing = await existingRes.json(); + if (Array.isArray(existing) && existing.length > 0) { + const existingId = existing[0]?.id; + const currentTabs = existing[0]?.new_data?.tabs ?? {}; + const mergedTabs = { + ...currentTabs, + ...tabs, + }; + + if (JSON.stringify(currentTabs) === JSON.stringify(mergedTabs)) { + return { success: true, created: false }; + } + + if (!existingId) { + return { success: true, created: false }; + } + + const updateRes = await safeFetch( + `${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(existingId)}`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Prefer': 'return=minimal', + }, + body: JSON.stringify({ + new_data: { + snapshot_date: dateKey, + tabs: mergedTabs, + }, + }), + } + ); + + if (!updateRes.ok) { + const errText = await updateRes.text(); + return { success: false, error: `Snapshot update failed: ${updateRes.status} ${errText.substring(0, 200)}` }; + } + + return { success: true, created: false }; + } + + const payload = { + product_id: CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID, + article_name: 'Control Dashboard Snapshot', + old_data: [], + new_data: { + snapshot_date: dateKey, + tabs, + }, + changed_by: 'system-control-dashboard', + changed_at: `${dateKey}T00:00:00.000Z`, + }; + + const insertRes = await safeFetch( + `${SUPABASE_URL}/rest/v1/products_history`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Prefer': 'return=minimal', + }, + body: JSON.stringify(payload), + } + ); + + if (!insertRes.ok) { + const errText = await insertRes.text(); + return { success: false, error: `Snapshot save failed: ${insertRes.status} ${errText.substring(0, 200)}` }; + } + + return { success: true, created: true }; + } catch (error: any) { + console.error('[ensureDashboardSnapshot] Exception:', error); + return { success: false, error: error?.message || 'Network error' }; + } +} + +function normalizeSnapshotDate(value: string): string | null { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString().slice(0, 10); +} + +function nextUtcDateKey(dateKey: string): string { + const date = new Date(`${dateKey}T00:00:00.000Z`); + date.setUTCDate(date.getUTCDate() + 1); + return date.toISOString(); +} + export async function deleteHistoryEntry(id: string): Promise { try { const response = await safeFetch( diff --git a/src/services/businessCentral.ts b/src/services/businessCentral.ts index d3c1def..1feccee 100644 --- a/src/services/businessCentral.ts +++ b/src/services/businessCentral.ts @@ -37,6 +37,8 @@ export interface BCSyncPreviewSection { writeUrlTemplate: string | null; writeBodyTemplate: string | null; canApply: boolean; + supported?: boolean; + supportReason?: string | null; } export interface BCSyncPreviewResult { @@ -59,6 +61,7 @@ export interface BCSyncApplyResult { itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string }; }; error?: string; + warning?: string; } async function readResponsePayload(res: Response): Promise<{ data: any; rawText: string }> { diff --git a/vercel.json b/vercel.json index c43e1c6..3b8ebac 100644 --- a/vercel.json +++ b/vercel.json @@ -4,7 +4,8 @@ { "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" }, - { "source": "/api/backup", "destination": "api/backup.js" } + { "source": "/api/backup", "destination": "api/backup.js" }, + { "source": "/api/users-admin", "destination": "api/users-admin.js" } ], "headers": [ {