mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 15:35:23 +02:00
feat: implement manual user validation and user deletion flow
This commit is contained in:
+81
-39
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,26 +168,43 @@ 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)) {
|
||||
const refreshedPreview = await previewBusinessCentralSync(headers, bcPreviewRow);
|
||||
if (refreshedPreview.success) {
|
||||
setBcValidationResult(refreshedPreview);
|
||||
const retry = await applyBusinessCentralSync(headers, bcPreviewRow, refreshedPreview.previewToken);
|
||||
if (retry.success) {
|
||||
setBcValidationResult(prev => retry.preview ? { ...retry.preview, hasChanges: false } : prev);
|
||||
if (refreshedPreview.success) {
|
||||
setBcValidationResult(refreshedPreview);
|
||||
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');
|
||||
return;
|
||||
}
|
||||
setBcValidationError(retry.error || 'Apply failed');
|
||||
return;
|
||||
}
|
||||
setBcValidationError(result.error || 'Apply failed');
|
||||
if (result.warning) {
|
||||
setBcValidationWarning(result.warning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setBcValidationError(result.error || 'Apply failed');
|
||||
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">
|
||||
@@ -398,7 +424,7 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
|
||||
return (
|
||||
<div key={sectionKey} className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-white">
|
||||
API `{sectionKey}`
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
] : [])
|
||||
];
|
||||
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
Reference in New Issue
Block a user