feat: implement manual user validation and user deletion flow

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