mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 14:55:24 +02:00
feat: implement manual user validation and user deletion flow
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user