feat: implement manual user validation and user deletion flow

This commit is contained in:
Christian Vidal Wolf
2026-05-20 13:33:37 +02:00
parent 1d105e19ae
commit 47b9303202
22 changed files with 2613 additions and 93 deletions
+15
View File
@@ -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.
+1
View File
@@ -20,3 +20,4 @@ coverage/
agentdb.rvf*
ruvector.db
claude-flow.config.json
scratch/
+8 -1
View File
@@ -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`
+8 -5
View File
@@ -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();
+231
View File
@@ -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' });
}
}
+28 -5
View File
@@ -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,
};
}
+81 -39
View File
@@ -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<ControlTabId>('control_dashboard');
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(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<DashboardDrilldownRequest | null>(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' && (
<ControlDashboardView
headers={appState.headers}
data={appState.data}
pendingRows={pendingRows}
rowStatuses={rowStatuses}
activeModule={activeModule}
onDrillDown={(request) => {
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 && (
<UserManagementView session={session} />
)}
</>
)}
</main>
+17 -2
View File
@@ -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<string, string>;
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<TabType>('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(() => {
+474
View File
@@ -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<string, PendingRowInfo>;
rowStatuses: Record<string, string>;
activeModule?: string;
onOpenTab?: (tabId: string) => void;
onDrillDown?: (request: DashboardDrilldownRequest) => void;
}
type MetricKey = keyof Pick<DescriptionsDashboardSnapshot, 'ok' | 'longDeMissing' | 'longEnMissing' | 'shortDeMissing' | 'shortEnMissing'>;
type ArticleMetricKey = keyof Pick<ArticleDetailsDashboardSnapshot, 'ok' | 'detailsDeMissing' | 'detailsEnMissing'>;
type CosmeticMetricKey = keyof Pick<CosmeticDashboardSnapshot, 'ok' | 'cpnpMissing'>;
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 ? (
<button type="button" onClick={onClick} className={tileClassName}>
<p className="text-[10px] uppercase tracking-[0.16em] leading-none text-slate-400">{label}</p>
<div className="mt-2.5 flex items-end justify-between gap-2.5">
<div className="text-2xl font-semibold text-white tabular-nums leading-none">
{formatNumber(current)}
</div>
{historical === undefined ? (
<div className="text-right text-[10px] text-slate-500 leading-tight">
No historical data available
</div>
) : (
<div className="text-right">
<div className="text-[10px] uppercase tracking-wide text-slate-500">7 days ago</div>
<div className="text-xs font-medium text-slate-300 tabular-nums">
{formatNumber(historical)}
</div>
</div>
)}
</div>
{delta && (
<div className={cn('mt-1.5 text-[11px] font-semibold', delta.className)}>
{delta.text}
</div>
)}
</button>
) : (
<div className={tileClassName}>
<p className="text-[10px] uppercase tracking-[0.16em] leading-none text-slate-400">{label}</p>
<div className="mt-2.5 flex items-end justify-between gap-2.5">
<div className="text-2xl font-semibold text-white tabular-nums leading-none">
{formatNumber(current)}
</div>
{historical === undefined ? (
<div className="text-right text-[10px] text-slate-500 leading-tight">
No historical data available
</div>
) : (
<div className="text-right">
<div className="text-[10px] uppercase tracking-wide text-slate-500">7 days ago</div>
<div className="text-xs font-medium text-slate-300 tabular-nums">
{formatNumber(historical)}
</div>
</div>
)}
</div>
{delta && (
<div className={cn('mt-1.5 text-[11px] font-semibold', delta.className)}>
{delta.text}
</div>
)}
</div>
)
);
}
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 (
<section className={cn(
'rounded-3xl border bg-gradient-to-br from-slate-900 via-slate-950 to-black shadow-2xl backdrop-blur-sm overflow-hidden',
accentClass
)}>
<div className={cn('h-1 w-full opacity-100 shadow-[0_0_18px_rgba(255,255,255,0.18)]', accentBarClass)} />
<div className="flex items-start justify-between gap-4 border-b border-slate-700/80 bg-black/20 px-4 py-3.5">
<div>
<div className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-semibold uppercase tracking-[0.22em]', badgeClass)}>
{badgeLabel}
</div>
<h2 className={cn('mt-2 text-base font-semibold', titleClass)}>{title}</h2>
<p className="mt-1 text-[11px] text-slate-300">{subtitle}</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="rounded-full border border-slate-600/70 bg-slate-800/80 px-2.5 py-1 text-[10px] font-medium text-slate-200">
Total {formatNumber(current.total)}
</span>
<span className="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] font-semibold text-emerald-200">
All OK {formatNumber(current.ok)}
</span>
</div>
</div>
<div className={cn('grid gap-2.5 px-4 py-4 sm:grid-cols-2 lg:grid-cols-3', metricGridClassName)}>
{metrics.map(metric => (
<React.Fragment key={metric.key}>
<MetricTile
label={metric.label}
current={current[metric.key]}
historical={historical?.[metric.key]}
toneClass={metric.toneClass}
positiveIsGood={metric.positiveIsGood}
onClick={onDrillDown ? () => onDrillDown({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
tabId: tabId as DashboardDrilldownRequest['tabId'],
focus: metric.drilldownFocus,
}) : undefined}
/>
</React.Fragment>
))}
</div>
<div className="border-t border-slate-700/70 bg-black/20 px-4 py-3 text-[11px] text-slate-300">
{historical ? (
<div className="flex flex-wrap items-center gap-3">
<span className="inline-flex items-center gap-1">
<ArrowUpRight className="h-3 w-3 text-emerald-400" />
Improvements are shown in green
</span>
<span className="inline-flex items-center gap-1">
<ArrowDownRight className="h-3 w-3 text-rose-400" />
Regressions are shown in red
</span>
</div>
) : (
<div>No historical data available</div>
)}
</div>
</section>
);
}
export function ControlDashboardView({
headers,
data,
pendingRows,
rowStatuses,
onDrillDown,
}: ControlDashboardViewProps) {
const [dashboardSnapshots, setDashboardSnapshots] = useState<SnapshotStore>({});
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 (
<div className="grid h-full gap-4 overflow-auto xl:grid-cols-2">
<div className="xl:col-span-2 flex items-center justify-between gap-4 rounded-3xl border border-slate-700/60 bg-slate-900/80 px-4 py-3.5 shadow-xl">
<div>
<div className="flex items-center gap-2 text-slate-300">
<LayoutDashboard className="h-4 w-4 text-blue-400" />
<h1 className="text-lg font-semibold text-white">Control Dashboard</h1>
</div>
<p className="mt-1 text-xs text-slate-400">
Compact overview of Product Descriptions, Article Details and Pricing & Units.
</p>
</div>
<button
onClick={() => {
if (typeof window !== 'undefined') {
window.location.reload();
}
}}
className="inline-flex items-center gap-2 rounded-full border border-slate-600/70 bg-slate-800/80 px-3 py-2 text-[11px] font-medium text-slate-200 hover:border-slate-500 hover:text-white transition-colors"
>
<RefreshCw className="h-3 w-3" />
Refresh
</button>
</div>
<DashboardCard
title="Product Descriptions"
subtitle="Long DE, Long EN, Short DE and Short EN."
accentClass="border-emerald-400/40 bg-emerald-500/8 shadow-[0_0_0_1px_rgba(52,211,153,0.12)]"
accentBarClass="bg-gradient-to-r from-emerald-300 via-lime-300 to-cyan-300"
badgeClass="bg-emerald-500/20 text-emerald-100 ring-1 ring-emerald-300/30"
badgeLabel="Descriptions"
titleClass="text-emerald-100"
current={currentSnapshot}
historical={historicalSnapshot}
metrics={METRICS}
metricGridClassName="sm:grid-cols-2 lg:grid-cols-3"
tabId="descriptions"
onDrillDown={onDrillDown}
/>
<DashboardCard
title="Article Details"
subtitle="DETAILS DE and DETAILS EN."
accentClass="border-indigo-400/40 bg-indigo-500/12 shadow-[0_0_0_1px_rgba(129,140,248,0.12)]"
accentBarClass="bg-gradient-to-r from-indigo-300 via-violet-300 to-cyan-300"
badgeClass="bg-indigo-500/20 text-indigo-100 ring-1 ring-indigo-300/30"
badgeLabel="Details"
titleClass="text-indigo-100"
current={currentArticleSnapshot}
historical={historicalArticleSnapshot}
metrics={ARTICLE_METRICS}
metricGridClassName="sm:grid-cols-2 lg:grid-cols-3"
tabId="article_details"
onDrillDown={onDrillDown}
/>
<DashboardCard
title="Pricing & Units"
subtitle="Item to Logistic, pricing, Units 40F and Weight Issues."
accentClass="border-amber-400/40 bg-amber-500/12 shadow-[0_0_0_1px_rgba(251,191,36,0.12)]"
accentBarClass="bg-gradient-to-r from-amber-300 via-orange-300 to-red-300"
badgeClass="bg-amber-500/20 text-amber-100 ring-1 ring-amber-300/30"
badgeLabel="Pricing"
titleClass="text-amber-100"
current={currentPricingSnapshot}
historical={historicalPricingSnapshot}
metrics={PRICING_METRICS}
metricGridClassName="sm:grid-cols-2 xl:grid-cols-4"
tabId="pricing"
onDrillDown={onDrillDown}
/>
<DashboardCard
title="Cosmetic Items"
subtitle="CPNP present vs missing, with 7-day evolution."
accentClass="border-fuchsia-400/40 bg-fuchsia-500/12 shadow-[0_0_0_1px_rgba(232,121,249,0.12)]"
accentBarClass="bg-gradient-to-r from-fuchsia-300 via-pink-300 to-rose-300"
badgeClass="bg-fuchsia-500/20 text-fuchsia-100 ring-1 ring-fuchsia-300/30"
badgeLabel="Cosmetic"
titleClass="text-fuchsia-100"
current={currentCosmeticSnapshot}
historical={historicalCosmeticSnapshot}
metrics={COSMETIC_METRICS}
metricGridClassName="grid-cols-1 sm:grid-cols-2"
tabId="cosmetic_items"
onDrillDown={onDrillDown}
/>
</div>
);
}
+30 -4
View File
@@ -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<string, string>;
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<number | null>('cosmeticItems-sortCol', null);
@@ -29,12 +32,24 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
const [openFilter, setOpenFilter] = useState<number | null>(null);
const [editingCpnp, setEditingCpnp] = useState<{ rowIndex: number; value: string } | null>(null);
const [savingCpnp, setSavingCpnp] = useState<number | null>(null);
const [dashboardFocus, setDashboardFocus] = useState<'present' | 'missing' | null>(null);
const cpnpInputRef = useRef<HTMLInputElement>(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
)}
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
<span className="text-slate-400 font-medium">{filteredData.length} items</span>
<span className="rounded-full border border-fuchsia-500/20 bg-fuchsia-500/10 px-3 py-1 font-medium text-fuchsia-200">
Filtered {filteredData.length}
</span>
<span className="text-slate-600">·</span>
<span>Lines: {COSMETIC_LINES.join(', ')}</span>
</div>
@@ -370,7 +394,9 @@ export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, ro
</div>
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-xs text-slate-500">
<div>Showing {paginatedData.length} of {filteredData.length} items</div>
<div>
Showing {paginatedData.length} of {filteredData.length} filtered items
</div>
<div className="flex items-center gap-2">
<button
disabled={page === 1}
+36
View File
@@ -29,6 +29,7 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
const [bcValidationLoading, setBcValidationLoading] = useState(false);
const [bcApplyLoading, setBcApplyLoading] = useState(false);
const [bcValidationError, setBcValidationError] = useState<string | null>(null);
const [bcValidationWarning, setBcValidationWarning] = useState<string | null>(null);
const filteredData = useMemo(() => {
let result = data.map((row, index) => ({ row, index }));
@@ -91,6 +92,7 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
useEffect(() => {
setBcValidationResult(null);
setBcValidationError(null);
setBcValidationWarning(null);
}, [bcSku]);
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({});
@@ -148,6 +150,7 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
if (!bcPreviewRow) return;
setBcValidationLoading(true);
setBcValidationError(null);
setBcValidationWarning(null);
try {
const result = await previewBusinessCentralSync(headers, bcPreviewRow);
if (!result.success) {
@@ -165,7 +168,15 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
if (!bcPreviewRow || !bcValidationResult?.previewToken) return;
setBcApplyLoading(true);
setBcValidationError(null);
setBcValidationWarning(null);
try {
const hasWritableItems = bcValidationResult.items.changes.some((change: any) => change.changed);
const hasOnlyUomChanges = !hasWritableItems && bcValidationResult.itemUnitsOfMeasure.changes.some((change: any) => change.changed);
if (hasOnlyUomChanges) {
setBcValidationWarning(bcValidationResult.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is preview only');
return;
}
const result = await applyBusinessCentralSync(headers, bcPreviewRow, bcValidationResult.previewToken);
if (!result.success) {
if (isPreviewTokenMismatchError(result.error)) {
@@ -175,6 +186,9 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
const retry = await applyBusinessCentralSync(headers, bcPreviewRow, refreshedPreview.previewToken);
if (retry.success) {
setBcValidationResult(prev => retry.preview ? { ...retry.preview, hasChanges: false } : prev);
if (retry.warning) {
setBcValidationWarning(retry.warning);
}
return;
}
setBcValidationError(retry.error || 'Apply failed');
@@ -182,9 +196,15 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
}
}
setBcValidationError(result.error || 'Apply failed');
if (result.warning) {
setBcValidationWarning(result.warning);
}
return;
}
setBcValidationResult(prev => result.preview ? { ...result.preview, hasChanges: false } : prev);
if (result.warning) {
setBcValidationWarning(result.warning);
}
} finally {
setBcApplyLoading(false);
}
@@ -370,6 +390,12 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
</div>
)}
{bcValidationWarning && !bcValidationError && (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
{bcValidationWarning}
</div>
)}
{bcValidationResult && (
<div className="rounded-lg border border-slate-700 bg-slate-950/40 p-4">
<div className="flex items-center justify-between gap-3 mb-3">
@@ -407,6 +433,11 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
{changed.length} changed field{changed.length === 1 ? '' : 's'}
</p>
</div>
{section.supported === false && (
<span className="text-[10px] px-2 py-1 rounded-full border border-amber-500/20 bg-amber-500/10 text-amber-300">
preview only
</span>
)}
{!section.writeConfigured && (
<span className="text-[10px] px-2 py-1 rounded-full border border-slate-600 text-slate-400">
write not configured
@@ -418,6 +449,11 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
<div className="text-xs text-slate-500">No changes for this endpoint.</div>
) : (
<div className="space-y-2">
{section.supported === false && section.supportReason && (
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-200">
{section.supportReason}
</div>
)}
{changed.map((change: any) => (
<div key={change.targetField} className="grid grid-cols-3 gap-3 text-xs">
<div className="text-slate-400 truncate">{change.sourceLabel}</div>
+97 -4
View File
@@ -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<string, string>;
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<FilterMode>('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<Record<number, string[]>>('pricing-dynamicColFilters', {});
const [weightIssueFilter, setWeightIssueFilter] = usePersistentState<string[]>('pricing-weightIssueFilter', []);
const [selectedSearchItems, setSelectedSearchItems] = usePersistentState<Set<string>>('pricing-selectedSearchItems', new Set());
const [dashboardFocus, setDashboardFocus] = useState<string | null>(null);
const [openFilter, setOpenFilter] = useState<string | null>(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const searchDropdownRef = useRef<HTMLDivElement>(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<FilterMode>(['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(() => {
+27 -3
View File
@@ -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<string, string>;
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
</button>
</div>
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800 p-4 rounded-xl border border-slate-700 shadow-sm">
<div className="flex flex-wrap items-center gap-4 mb-6 bg-slate-800 p-4 rounded-xl border border-slate-700 shadow-sm">
<div className="flex-1 min-w-[200px] relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
@@ -296,6 +312,10 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
<option value="">All Licenses</option>
{licenses.map(l => <option key={l} value={String(l)}>{String(l)}</option>)}
</select>
<div className="ml-auto flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-200">
<span className="text-[11px] uppercase tracking-[0.18em] text-emerald-300/80">In view</span>
<span className="font-semibold tabular-nums">{filteredData.length}</span>
</div>
</div>
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
@@ -442,6 +462,10 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
<option value={100}>100 per page</option>
</select>
</div>
<div className="hidden md:flex items-center gap-2 rounded-full border border-slate-700 bg-slate-800 px-3 py-1 text-xs text-slate-300">
<span className="text-slate-500 uppercase tracking-[0.18em]">Filtered</span>
<span className="font-semibold tabular-nums text-white">{filteredData.length}</span>
</div>
<div className="flex items-center gap-2">
<button
disabled={page === 1}
+9 -8
View File
@@ -1,10 +1,11 @@
import React from 'react';
import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle, Sparkles } from 'lucide-react';
import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle, Sparkles, LayoutDashboard, Users } from 'lucide-react';
import { cn } from '../lib/utils';
import { ControlTabId } from '../lib/controlDashboard';
interface SidebarProps {
activeModule: string;
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data' | 'cosmetic_items') => void;
activeModule: ControlTabId;
setActiveModule: (m: ControlTabId) => void;
userEmail: string;
}
@@ -15,9 +16,8 @@ export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarPro
]);
const isMasterUser = MASTER_USERS.has(userEmail?.toLowerCase());
type ModuleId = 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data' | 'cosmetic_items';
const navItems: { id: ModuleId; label: string; icon: React.ElementType }[] = [
const navItems: { id: ControlTabId; label: string; icon: React.ElementType }[] = [
{ id: 'control_dashboard', label: 'Control Dashboard', icon: LayoutDashboard },
{ id: 'matrix', label: 'Matrix', icon: Table },
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
{ id: 'article_details', label: 'Article Details', icon: Package },
@@ -26,8 +26,9 @@ export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarPro
{ id: 'missing_data', label: 'Missing Data', icon: AlertTriangle },
{ id: 'cosmetic_items', label: 'Cosmetic Items', icon: Sparkles },
...(isMasterUser ? [
{ id: 'pending_validation' as ModuleId, label: 'Pending Validation', icon: Clock },
{ id: 'history' as ModuleId, label: 'Change History', icon: History }
{ id: 'pending_validation' as ControlTabId, label: 'Pending Validation', icon: Clock },
{ id: 'history' as ControlTabId, label: 'Change History', icon: History },
{ id: 'user_management' as ControlTabId, label: 'User Validation', icon: Users }
] : [])
];
+3
View File
@@ -18,6 +18,9 @@ export function SyncStatusPill({ status, className }: SyncStatusPillProps) {
if (normalized === 'previewed') {
return { label: 'Previewed', tone: 'bg-indigo-500/10 text-indigo-300 border-indigo-500/20' };
}
if (normalized === 'preview_only' || normalized === 'preview only') {
return { label: 'Preview only', tone: 'bg-amber-500/10 text-amber-300 border-amber-500/20' };
}
if (normalized === 'syncing') {
return { label: 'Syncing BC', tone: 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20' };
}
+3 -3
View File
@@ -84,7 +84,7 @@ export function TopBar({ stats, activeModule, onExport, onDownloadBCExcel, onRef
}, [bcQueueCount]);
return (
<header className="bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 z-10 shadow-2xl">
<header className="relative z-[200] bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 shadow-2xl">
<div className="flex items-center -ml-4">
<img
src="/logo.png"
@@ -163,7 +163,7 @@ export function TopBar({ stats, activeModule, onExport, onDownloadBCExcel, onRef
{/* Dropdown: list of pending changes */}
{showPending && pendingCount > 0 && (
<div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 overflow-hidden">
<div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-[9999] overflow-hidden">
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Pending changes</span>
<span className="text-xs text-slate-500">{pendingCount} unsaved</span>
@@ -234,7 +234,7 @@ export function TopBar({ stats, activeModule, onExport, onDownloadBCExcel, onRef
</div>
{showBcQueue && bcQueueCount > 0 && (
<div className="absolute right-0 top-full mt-2 w-96 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 overflow-hidden">
<div className="absolute right-0 top-full mt-2 w-96 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-[9999] overflow-hidden">
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between gap-2">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">BC sync queue</span>
<div className="flex items-center gap-2">
+331
View File
@@ -0,0 +1,331 @@
import React, { useState, useEffect } from 'react';
import { Users, Search, CheckCircle, XCircle, Trash2, Shield, Loader2, AlertCircle, Clock } from 'lucide-react';
import { AuthSession } from '../lib/auth';
interface User {
id: string;
email: string;
created_at: string;
status: 'Master' | 'Validated' | 'Pending';
}
interface UserManagementViewProps {
session: AuthSession;
}
export function UserManagementView({ session }: UserManagementViewProps) {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [processingId, setProcessingId] = useState<string | null>(null);
const [deleteConfirmUser, setDeleteConfirmUser] = useState<User | null>(null);
const fetchUsers = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/users-admin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`
},
body: JSON.stringify({ action: 'list' })
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to fetch users.');
}
const data = await res.json();
setUsers(data.users || []);
} catch (err: any) {
console.error('[UserManagement] fetch error:', err);
setError(err.message || 'Error fetching user list.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, [session]);
const handleToggleValidation = async (user: User, approve: boolean) => {
setProcessingId(user.id);
setError(null);
try {
const res = await fetch('/api/users-admin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`
},
body: JSON.stringify({
action: 'validate',
targetUserId: user.id,
email: user.email,
validated: approve
})
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update user approval status.');
}
// Update local state
setUsers(prev => prev.map(u => {
if (u.id === user.id) {
return { ...u, status: approve ? 'Validated' : 'Pending' };
}
return u;
}));
} catch (err: any) {
setError(err.message || 'Error updating approval status.');
} finally {
setProcessingId(null);
}
};
const handleDeleteUser = async (userId: string) => {
setProcessingId(userId);
setError(null);
setDeleteConfirmUser(null);
try {
const res = await fetch('/api/users-admin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`
},
body: JSON.stringify({
action: 'delete',
targetUserId: userId
})
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to delete user.');
}
// Remove from local list
setUsers(prev => prev.filter(u => u.id !== userId));
} catch (err: any) {
setError(err.message || 'Error deleting user.');
} finally {
setProcessingId(null);
}
};
const filteredUsers = users.filter(user =>
user.email.toLowerCase().includes(search.toLowerCase())
);
const formatDate = (dateStr: string) => {
try {
return new Date(dateStr).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateStr;
}
};
return (
<div className="flex-1 flex flex-col h-full overflow-hidden bg-[#041021]">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500/10 rounded-lg">
<Users className="w-6 h-6 text-blue-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Validación de Usuarios</h2>
<p className="text-sm text-slate-400">
Administra los accesos y los registros de usuarios en la plataforma.
</p>
</div>
</div>
<div className="relative">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Buscar por email..."
value={search}
onChange={e => setSearch(e.target.value)}
className="bg-slate-900 border border-slate-800 rounded-md pl-9 pr-4 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 w-64 transition-all"
/>
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 overflow-auto p-6">
{error && (
<div className="mb-6 p-4 bg-red-950/30 border border-red-500/30 rounded-lg flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
<div className="flex-1 text-sm text-red-300">
<span className="font-semibold">Error:</span> {error}
</div>
<button
onClick={() => setError(null)}
className="text-red-400 hover:text-red-300 text-xs font-semibold px-2 py-1 rounded"
>
Descartar
</button>
</div>
)}
{loading ? (
<div className="h-64 flex flex-col items-center justify-center text-slate-400">
<Loader2 className="w-8 h-8 animate-spin text-blue-500 mb-3" />
<p className="text-sm">Cargando lista de usuarios...</p>
</div>
) : filteredUsers.length === 0 ? (
<div className="h-64 flex flex-col items-center justify-center border border-dashed border-slate-800 rounded-xl text-slate-500">
<Users className="w-12 h-12 mb-3 opacity-20" />
{search ? (
<p className="text-sm">No se encontraron usuarios que coincidan con la búsqueda.</p>
) : (
<p className="text-sm">No hay registros de usuarios registrados.</p>
)}
</div>
) : (
<div className="bg-[#08152c] border border-slate-800/60 rounded-xl overflow-hidden shadow-xl">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/30 text-slate-400 text-xs font-semibold uppercase tracking-wider">
<th className="py-4 px-6">Email</th>
<th className="py-4 px-6">Fecha de Registro</th>
<th className="py-4 px-6">Estado</th>
<th className="py-4 px-6 text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50 text-slate-300 text-sm">
{filteredUsers.map(user => {
const isProcessing = processingId === user.id;
return (
<tr key={user.id} className="hover:bg-slate-900/10 transition-colors">
<td className="py-4 px-6 font-medium text-white max-w-xs truncate">
{user.email}
</td>
<td className="py-4 px-6 text-slate-400">
{formatDate(user.created_at)}
</td>
<td className="py-4 px-6">
{user.status === 'Master' ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">
<Shield className="w-3.5 h-3.5" />
Administrador Principal
</span>
) : user.status === 'Validated' ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<CheckCircle className="w-3.5 h-3.5" />
Validado
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
<Clock className="w-3.5 h-3.5" />
Pendiente Validación
</span>
)}
</td>
<td className="py-4 px-6 text-right">
{user.status === 'Master' ? (
<span className="text-slate-500 text-xs italic">Protegido</span>
) : (
<div className="flex items-center justify-end gap-2">
{user.status === 'Pending' ? (
<button
onClick={() => handleToggleValidation(user, true)}
disabled={isProcessing}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white disabled:opacity-50 text-xs font-semibold rounded-md transition-colors shadow-sm"
>
{isProcessing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<CheckCircle className="w-3.5 h-3.5" />
)}
Validar
</button>
) : (
<button
onClick={() => handleToggleValidation(user, false)}
disabled={isProcessing}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 disabled:opacity-50 text-xs font-semibold rounded-md border border-slate-700 transition-colors"
>
{isProcessing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<XCircle className="w-3.5 h-3.5" />
)}
Revocar
</button>
)}
<button
onClick={() => setDeleteConfirmUser(user)}
disabled={isProcessing}
className="inline-flex items-center justify-center p-1.5 bg-red-950/30 hover:bg-red-900/50 text-red-400 hover:text-red-300 border border-red-900/20 disabled:opacity-50 rounded-md transition-colors"
title="Eliminar usuario"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Confirmation Modal */}
{deleteConfirmUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-[#08152c] border border-slate-800 rounded-xl max-w-md w-full overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
<div className="p-6">
<div className="flex items-center gap-3 text-red-400 mb-4">
<Trash2 className="w-6 h-6 shrink-0" />
<h3 className="text-lg font-bold text-white">¿Eliminar usuario definitivamente?</h3>
</div>
<p className="text-sm text-slate-300 mb-2">
Estás a punto de eliminar la cuenta del usuario:
</p>
<p className="text-sm font-mono bg-slate-900/60 border border-slate-800/80 p-2.5 rounded text-blue-400 break-all mb-4">
{deleteConfirmUser.email}
</p>
<p className="text-xs text-red-400/90 leading-relaxed">
Esta acción no se puede deshacer. Se eliminarán sus accesos y toda su información asociada al servicio de autenticación.
</p>
</div>
<div className="bg-slate-900/50 border-t border-slate-800/60 px-6 py-4 flex items-center justify-end gap-3">
<button
onClick={() => setDeleteConfirmUser(null)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm font-semibold rounded-md transition-colors"
>
Cancelar
</button>
<button
onClick={() => handleDeleteUser(deleteConfirmUser.id)}
className="px-4 py-2 bg-red-600 hover:bg-red-500 text-white text-sm font-semibold rounded-md transition-colors shadow-sm"
>
Eliminar Cuenta
</button>
</div>
</div>
</div>
)}
</div>
);
}
+25
View File
@@ -40,6 +40,31 @@ export async function signIn(email: string, password: string): Promise<AuthSessi
}
const data = await response.json();
// Validate status before signing in
try {
const statusRes = await fetch('/api/users-admin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${data.access_token}`
},
body: JSON.stringify({ action: 'check-status' })
});
if (!statusRes.ok) {
const err = await statusRes.json().catch(() => ({}));
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,
+974
View File
@@ -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<ControlTabId, 'control_dashboard'>;
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<string, HistorySyncRecord>;
export interface DashboardContext {
data: ExcelRow[];
pendingRows: Record<string, PendingRowInfo>;
rowStatuses: Record<string, string>;
historyEntries: HistoryEntry[];
historySyncMap?: HistorySyncMap;
}
export interface SnapshotStore {
[dateKey: string]: DashboardSnapshotTabs;
}
export const CONTROL_TABS: Array<{
id: Exclude<ControlTabId, 'control_dashboard'>;
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<number>();
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<number>();
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<string> {
const articles = new Set<string>();
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<string> {
const indices = getEditableIndices(tabId, headers);
const articles = new Set<string>();
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<string> {
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<string>();
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<string> {
const columns = resolveColumnIndices(headers);
const groups = new Map<string, ExcelRow[]>();
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<string>();
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<string> {
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<string>();
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<string> {
const columns = resolveColumnIndices(headers);
const launchIdx = findHeaderIndexFromHeaders(headers, [['launch', 'date']]);
const readyIdx = findHeaderIndexFromHeaders(headers, [['ready', 'to', 'order', 'date']]);
const articles = new Set<string>();
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<string>([...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<Record<string, DashboardSnapshotTabs>> {
const store = await getDashboardSnapshotStore();
return store;
}
export async function ensureDailyDashboardSnapshot(date: Date, snapshot: DashboardSnapshotTabs): Promise<void> {
const key = localDateKey(date);
await ensureDashboardSnapshot(key, {
...snapshot,
});
}
+199 -7
View File
@@ -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<Response> {
export async function safeFetch(url: string, options: RequestInit = {}): Promise<Response> {
const session = getStoredSession();
const token = session?.access_token || SUPABASE_ANON_KEY;
@@ -37,6 +37,7 @@ export interface ExcelRow extends Array<any> {}
export interface SyncedRow {
data: ExcelRow;
status?: 'pending' | 'edited' | 'synced' | 'excel';
updated_at?: string;
}
export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
@@ -49,7 +50,7 @@ export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
// 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<Record<string, SyncedRow>> {
}
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<Record<string, SyncedRow>> {
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<HistoryEntry[]> {
}];
}
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<Record<string, ExcelRow>
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<Record<string, ExcelRow>
}
}
export async function getDashboardSnapshotStore(): Promise<Record<string, DashboardSnapshotTabs>> {
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<string, DashboardSnapshotTabs> = {};
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<boolean> {
try {
const response = await safeFetch(
+3
View File
@@ -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 }> {
+2 -1
View File
@@ -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": [
{