2026-04-23 12:37:04 +02:00
|
|
|
import { refreshSession, getStoredSession } from './auth';
|
2026-05-20 15:00:26 +02:00
|
|
|
import { COLUMNS } from '../types';
|
2026-04-23 12:37:04 +02:00
|
|
|
|
2026-03-27 12:23:35 +01:00
|
|
|
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
2026-04-23 09:59:46 +02:00
|
|
|
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
2026-05-20 15:12:36 +02:00
|
|
|
const LEGACY_CPNP_INDEX = 77;
|
2026-04-23 09:59:46 +02:00
|
|
|
|
2026-05-20 13:33:37 +02:00
|
|
|
export async function safeFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
2026-04-23 12:37:04 +02:00
|
|
|
const session = getStoredSession();
|
|
|
|
|
const token = session?.access_token || SUPABASE_ANON_KEY;
|
|
|
|
|
|
|
|
|
|
const headers = {
|
|
|
|
|
...options.headers,
|
2026-04-23 09:59:46 +02:00
|
|
|
'apikey': SUPABASE_ANON_KEY,
|
2026-04-23 12:37:04 +02:00
|
|
|
'Authorization': `Bearer ${token}`,
|
2026-04-23 09:59:46 +02:00
|
|
|
};
|
2026-04-23 12:37:04 +02:00
|
|
|
|
|
|
|
|
let response = await fetch(url, { ...options, headers });
|
|
|
|
|
|
|
|
|
|
if (response.status === 401 && session?.refresh_token) {
|
|
|
|
|
try {
|
|
|
|
|
const newSession = await refreshSession(session.refresh_token);
|
|
|
|
|
const newHeaders = {
|
|
|
|
|
...options.headers,
|
|
|
|
|
'apikey': SUPABASE_ANON_KEY,
|
|
|
|
|
'Authorization': `Bearer ${newSession.access_token}`,
|
|
|
|
|
};
|
|
|
|
|
response = await fetch(url, { ...options, headers: newHeaders });
|
|
|
|
|
} catch (refreshError) {
|
2026-04-23 19:28:25 +02:00
|
|
|
console.error('Session refresh failed:', refreshError);
|
2026-04-23 12:37:04 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response;
|
2026-04-23 09:59:46 +02:00
|
|
|
}
|
2026-03-27 12:23:35 +01:00
|
|
|
|
2026-04-10 09:47:35 +02:00
|
|
|
export interface ExcelRow extends Array<any> {}
|
|
|
|
|
|
|
|
|
|
export interface SyncedRow {
|
|
|
|
|
data: ExcelRow;
|
2026-04-23 16:22:35 +02:00
|
|
|
status?: 'pending' | 'edited' | 'synced' | 'excel';
|
2026-05-20 13:33:37 +02:00
|
|
|
updated_at?: string;
|
2026-04-10 09:47:35 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-23 12:37:04 +02:00
|
|
|
export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
|
2026-04-23 16:56:54 +02:00
|
|
|
const PAGE_SIZE = 1000;
|
|
|
|
|
const result: Record<string, SyncedRow> = {};
|
2026-04-10 09:47:35 +02:00
|
|
|
try {
|
2026-04-23 16:56:54 +02:00
|
|
|
let offset = 0;
|
|
|
|
|
// Paginate until Supabase returns fewer rows than PAGE_SIZE (no more pages).
|
|
|
|
|
// Supabase REST caps a single response at 1000 rows, so we must page.
|
|
|
|
|
// Safety cap at 20 pages (20k products) to avoid infinite loops.
|
|
|
|
|
for (let page = 0; page < 20; page++) {
|
|
|
|
|
const response = await safeFetch(
|
2026-05-20 13:33:37 +02:00
|
|
|
`${SUPABASE_URL}/rest/v1/products?select=product_id,data,status,updated_at&order=product_id.asc&limit=${PAGE_SIZE}&offset=${offset}`,
|
2026-04-23 16:56:54 +02:00
|
|
|
{ cache: 'no-store' }
|
|
|
|
|
);
|
2026-04-10 09:47:35 +02:00
|
|
|
|
2026-04-23 16:56:54 +02:00
|
|
|
if (!response.ok) {
|
|
|
|
|
const errText = await response.text();
|
|
|
|
|
console.error('getAllSyncedRows failed:', response.status, errText);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
const rows = await response.json();
|
|
|
|
|
for (const row of rows) {
|
2026-05-20 13:33:37 +02:00
|
|
|
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 };
|
|
|
|
|
}
|
2026-04-23 16:56:54 +02:00
|
|
|
}
|
|
|
|
|
if (rows.length < PAGE_SIZE) break;
|
|
|
|
|
offset += PAGE_SIZE;
|
2026-04-09 08:22:58 +02:00
|
|
|
}
|
2026-04-10 09:47:35 +02:00
|
|
|
return result;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error fetching synced rows from Supabase:', error);
|
2026-04-23 16:56:54 +02:00
|
|
|
return result;
|
2026-04-09 08:22:58 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 16:03:12 +02:00
|
|
|
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, status: 'pending' | 'edited' | 'synced' = 'pending'): Promise<{ success: boolean; error?: string }> {
|
2026-03-27 12:23:35 +01:00
|
|
|
try {
|
2026-04-23 12:37:04 +02:00
|
|
|
const response = await safeFetch(
|
2026-05-20 13:33:37 +02:00
|
|
|
`${SUPABASE_URL}/rest/v1/products?on_conflict=product_id`,
|
2026-04-10 09:47:35 +02:00
|
|
|
{
|
2026-04-10 10:48:05 +02:00
|
|
|
method: 'POST',
|
2026-04-10 09:47:35 +02:00
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
2026-05-20 13:33:37 +02:00
|
|
|
'Prefer': 'resolution=merge-duplicates,return=minimal',
|
2026-04-10 09:47:35 +02:00
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
product_id: articleNo,
|
|
|
|
|
data: rowData,
|
2026-05-12 16:03:12 +02:00
|
|
|
status,
|
2026-04-10 09:47:35 +02:00
|
|
|
updated_at: new Date().toISOString()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
);
|
2026-03-27 12:23:35 +01:00
|
|
|
|
2026-04-10 12:06:16 +02:00
|
|
|
if (!response.ok) {
|
|
|
|
|
const err = await response.json().catch(() => ({}));
|
2026-04-23 09:59:46 +02:00
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
error: `${response.status} ${response.statusText}: ${err.message || err.error_description || 'Unknown error'}`
|
2026-04-10 12:06:16 +02:00
|
|
|
};
|
|
|
|
|
}
|
2026-04-23 09:59:46 +02:00
|
|
|
|
2026-04-10 12:06:16 +02:00
|
|
|
return { success: true };
|
|
|
|
|
} catch (error: any) {
|
2026-03-27 12:23:35 +01:00
|
|
|
console.error('Error saving to Supabase:', error);
|
2026-04-10 12:06:16 +02:00
|
|
|
return { success: false, error: error.message || 'Network error' };
|
2026-03-27 12:23:35 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 09:24:17 +02:00
|
|
|
export interface HistoryEntry {
|
2026-04-23 18:58:52 +02:00
|
|
|
id?: string;
|
2026-04-09 09:24:17 +02:00
|
|
|
product_id: string;
|
|
|
|
|
article_name: string;
|
|
|
|
|
old_data: ExcelRow;
|
|
|
|
|
new_data: ExcelRow;
|
|
|
|
|
changed_by: string;
|
2026-04-10 09:47:35 +02:00
|
|
|
changed_at: string;
|
2026-04-09 09:24:17 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 13:33:37 +02:00
|
|
|
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__';
|
|
|
|
|
|
2026-05-15 10:51:01 +02:00
|
|
|
function normalizeHistoryValue(value: any): any {
|
|
|
|
|
if (value === undefined || value === null || value === '') return null;
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function valuesEqual(a: any, b: any): boolean {
|
|
|
|
|
return normalizeHistoryValue(a) === normalizeHistoryValue(b);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getChangedIndices(oldData: ExcelRow = [], newData: ExcelRow = []): number[] {
|
|
|
|
|
const maxLen = Math.max(oldData.length, newData.length);
|
|
|
|
|
const changed: number[] = [];
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < maxLen; i++) {
|
|
|
|
|
if (!valuesEqual(oldData[i], newData[i])) {
|
|
|
|
|
changed.push(i);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return changed;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 09:24:17 +02:00
|
|
|
export async function saveHistoryEntry(
|
2026-04-10 09:47:35 +02:00
|
|
|
productId: string,
|
2026-04-09 09:24:17 +02:00
|
|
|
articleName: string,
|
|
|
|
|
oldData: ExcelRow,
|
|
|
|
|
newData: ExcelRow,
|
2026-04-23 12:37:04 +02:00
|
|
|
changedBy: string
|
2026-04-10 12:06:16 +02:00
|
|
|
): Promise<{ success: boolean; error?: string }> {
|
2026-04-09 09:24:17 +02:00
|
|
|
try {
|
2026-04-23 12:37:04 +02:00
|
|
|
const response = await safeFetch(
|
2026-04-10 09:47:35 +02:00
|
|
|
`${SUPABASE_URL}/rest/v1/products_history`,
|
|
|
|
|
{
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
2026-04-23 09:59:46 +02:00
|
|
|
'Prefer': 'return=minimal',
|
2026-04-10 09:47:35 +02:00
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
product_id: productId,
|
|
|
|
|
article_name: articleName,
|
|
|
|
|
old_data: oldData,
|
|
|
|
|
new_data: newData,
|
|
|
|
|
changed_by: changedBy,
|
|
|
|
|
changed_at: new Date().toISOString()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
);
|
2026-04-09 09:24:17 +02:00
|
|
|
|
2026-04-10 12:06:16 +02:00
|
|
|
if (!response.ok) {
|
|
|
|
|
const err = await response.json().catch(() => ({}));
|
2026-04-23 09:59:46 +02:00
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
error: `History ${response.status}: ${err.message || 'Unknown error'}`
|
2026-04-10 12:06:16 +02:00
|
|
|
};
|
|
|
|
|
}
|
2026-04-23 09:59:46 +02:00
|
|
|
|
2026-04-10 12:06:16 +02:00
|
|
|
return { success: true };
|
|
|
|
|
} catch (error: any) {
|
2026-04-09 09:24:17 +02:00
|
|
|
console.error('Error saving history to Supabase:', error);
|
2026-04-10 12:06:16 +02:00
|
|
|
return { success: false, error: error.message || 'Network error' };
|
2026-04-09 09:24:17 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 12:37:04 +02:00
|
|
|
export async function getHistory(): Promise<HistoryEntry[]> {
|
2026-04-09 09:24:17 +02:00
|
|
|
try {
|
2026-05-15 10:51:01 +02:00
|
|
|
const PAGE_SIZE = 1000;
|
|
|
|
|
const allRows: HistoryEntry[] = [];
|
|
|
|
|
for (let page = 0; page < 20; page++) {
|
|
|
|
|
const offset = page * PAGE_SIZE;
|
|
|
|
|
const response = await safeFetch(
|
|
|
|
|
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.asc&limit=${PAGE_SIZE}&offset=${offset}`,
|
|
|
|
|
{ cache: 'no-store' }
|
|
|
|
|
);
|
2026-04-09 09:24:17 +02:00
|
|
|
|
2026-05-15 10:51:01 +02:00
|
|
|
if (!response.ok) {
|
|
|
|
|
const errText = await response.text();
|
|
|
|
|
console.error('[getHistory] Error:', response.status, errText.substring(0, 200));
|
|
|
|
|
return [{
|
|
|
|
|
id: 'ERROR',
|
|
|
|
|
product_id: 'ERROR',
|
|
|
|
|
article_name: `Failed: ${response.status} ${errText.substring(0, 200)}`,
|
|
|
|
|
old_data: [],
|
|
|
|
|
new_data: [],
|
|
|
|
|
changed_at: new Date().toISOString(),
|
|
|
|
|
changed_by: 'system'
|
|
|
|
|
}];
|
|
|
|
|
}
|
|
|
|
|
const batch: HistoryEntry[] = await response.json();
|
2026-05-20 13:33:37 +02:00
|
|
|
allRows.push(...batch.filter(entry => entry.product_id !== CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID));
|
2026-05-15 10:51:01 +02:00
|
|
|
if (batch.length < PAGE_SIZE) break;
|
2026-04-10 19:48:33 +02:00
|
|
|
}
|
2026-05-15 10:51:01 +02:00
|
|
|
// Return oldest-first so index+1 = natural chronological number
|
|
|
|
|
return allRows;
|
2026-04-10 19:48:33 +02:00
|
|
|
} catch (error: any) {
|
2026-04-23 18:51:07 +02:00
|
|
|
console.error('[getHistory] Exception:', error.message);
|
2026-04-10 19:48:33 +02:00
|
|
|
return [{
|
2026-04-23 18:58:52 +02:00
|
|
|
id: 'EXCEPTION',
|
2026-04-23 09:59:46 +02:00
|
|
|
product_id: 'EXCEPTION',
|
|
|
|
|
article_name: `Message: ${error.message}`,
|
|
|
|
|
old_data: [],
|
|
|
|
|
new_data: [],
|
|
|
|
|
changed_at: new Date().toISOString(),
|
|
|
|
|
changed_by: 'system'
|
2026-04-10 19:48:33 +02:00
|
|
|
}];
|
2026-04-09 09:24:17 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-10 09:47:35 +02:00
|
|
|
|
2026-04-23 19:07:38 +02:00
|
|
|
export async function getHistoryDataForMerge(): Promise<Record<string, ExcelRow>> {
|
|
|
|
|
try {
|
2026-05-20 14:56:55 +02:00
|
|
|
const PAGE_SIZE = 1000;
|
|
|
|
|
const entries: HistoryEntry[] = [];
|
2026-04-23 19:07:38 +02:00
|
|
|
|
2026-05-20 14:56:55 +02:00
|
|
|
for (let page = 0; page < 20; page++) {
|
|
|
|
|
const offset = page * PAGE_SIZE;
|
|
|
|
|
const response = await safeFetch(
|
|
|
|
|
`${SUPABASE_URL}/rest/v1/products_history?select=product_id,old_data,new_data,changed_at,id&order=changed_at.asc&limit=${PAGE_SIZE}&offset=${offset}`,
|
|
|
|
|
{ cache: 'no-store' }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
console.error('[getHistoryDataForMerge] Error:', response.status);
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const batch: HistoryEntry[] = await response.json();
|
|
|
|
|
entries.push(
|
|
|
|
|
...batch.filter((entry: HistoryEntry) => entry.product_id !== CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (batch.length < PAGE_SIZE) break;
|
2026-04-23 19:07:38 +02:00
|
|
|
}
|
2026-05-20 14:56:55 +02:00
|
|
|
|
2026-05-15 10:51:01 +02:00
|
|
|
entries.sort((a, b) => {
|
|
|
|
|
const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime();
|
|
|
|
|
if (timeDelta !== 0) return timeDelta;
|
|
|
|
|
return String(a.id || '').localeCompare(String(b.id || ''));
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-23 19:07:38 +02:00
|
|
|
const result: Record<string, ExcelRow> = {};
|
2026-05-15 10:51:01 +02:00
|
|
|
|
2026-04-23 19:07:38 +02:00
|
|
|
for (const entry of entries) {
|
2026-05-15 10:51:01 +02:00
|
|
|
if (!entry.product_id) continue;
|
|
|
|
|
|
|
|
|
|
if (!result[entry.product_id]) {
|
|
|
|
|
result[entry.product_id] = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const target = result[entry.product_id];
|
|
|
|
|
const changedIndices = getChangedIndices(entry.old_data || [], entry.new_data || []);
|
|
|
|
|
|
|
|
|
|
for (const idx of changedIndices) {
|
|
|
|
|
target[idx] = entry.new_data?.[idx];
|
2026-04-23 19:07:38 +02:00
|
|
|
}
|
2026-05-20 15:00:26 +02:00
|
|
|
|
|
|
|
|
// CPNP values were saved through multiple code paths over time.
|
|
|
|
|
// Preserve the latest non-empty value even when a given history row
|
|
|
|
|
// does not surface it as a changed index.
|
|
|
|
|
const latestCpnp =
|
|
|
|
|
entry.new_data?.[COLUMNS.CPNP_NO] ??
|
2026-05-20 15:12:36 +02:00
|
|
|
entry.new_data?.[LEGACY_CPNP_INDEX] ??
|
2026-05-20 15:00:26 +02:00
|
|
|
entry.old_data?.[COLUMNS.CPNP_NO];
|
2026-05-20 15:12:36 +02:00
|
|
|
const fallbackCpnp =
|
|
|
|
|
latestCpnp !== undefined && latestCpnp !== null && latestCpnp !== ''
|
|
|
|
|
? latestCpnp
|
|
|
|
|
: entry.old_data?.[LEGACY_CPNP_INDEX];
|
2026-05-20 15:00:26 +02:00
|
|
|
|
2026-05-20 15:12:36 +02:00
|
|
|
if (fallbackCpnp !== undefined && fallbackCpnp !== null && fallbackCpnp !== '') {
|
|
|
|
|
target[COLUMNS.CPNP_NO] = fallbackCpnp;
|
2026-05-20 15:00:26 +02:00
|
|
|
}
|
2026-04-23 19:07:38 +02:00
|
|
|
}
|
2026-05-15 10:51:01 +02:00
|
|
|
|
2026-04-23 19:07:38 +02:00
|
|
|
return result;
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error('[getHistoryDataForMerge] Exception:', error.message);
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 13:33:37 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 12:37:04 +02:00
|
|
|
export async function deleteHistoryEntry(id: string): Promise<boolean> {
|
2026-04-10 09:47:35 +02:00
|
|
|
try {
|
2026-04-23 12:37:04 +02:00
|
|
|
const response = await safeFetch(
|
2026-04-23 18:58:52 +02:00
|
|
|
`${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(String(id))}`,
|
2026-04-23 12:37:04 +02:00
|
|
|
{ method: 'DELETE' }
|
2026-04-10 09:47:35 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return response.ok;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error deleting history from Supabase:', error);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 12:37:04 +02:00
|
|
|
export async function resetAllPendingRows(): Promise<boolean> {
|
2026-04-10 09:47:35 +02:00
|
|
|
try {
|
2026-04-23 12:37:04 +02:00
|
|
|
const response = await safeFetch(
|
2026-04-10 09:47:35 +02:00
|
|
|
`${SUPABASE_URL}/rest/v1/products?status=eq.pending`,
|
|
|
|
|
{
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
2026-04-23 09:59:46 +02:00
|
|
|
'Prefer': 'return=minimal',
|
2026-04-10 09:47:35 +02:00
|
|
|
},
|
2026-04-23 16:22:35 +02:00
|
|
|
body: JSON.stringify({ status: 'edited' })
|
2026-04-10 09:47:35 +02:00
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return response.ok;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error resetting pending rows in Supabase:', error);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-04-23 09:59:46 +02:00
|
|
|
}
|