From 6a2f4e5945eec23cd7fe1dc2c1d23254c9a4ad84 Mon Sep 17 00:00:00 2001 From: thanawat saiyota Date: Tue, 16 Jun 2026 11:30:23 +0700 Subject: [PATCH] update get data priceslot --- src/lib/core/adb/adb.ts | 10 + src/lib/core/handlers/messageHandler.ts | 67 +- src/lib/core/services/sheetService.ts | 76 +- src/lib/core/stores/sheetStore.ts | 373 +++++++-- .../sheet/priceslot/[country]/+page.svelte | 723 +++++++++++------- 5 files changed, 912 insertions(+), 337 deletions(-) diff --git a/src/lib/core/adb/adb.ts b/src/lib/core/adb/adb.ts index 2cab7d5..716911e 100644 --- a/src/lib/core/adb/adb.ts +++ b/src/lib/core/adb/adb.ts @@ -362,6 +362,16 @@ export async function executeCmd(command: string) { } } + +export async function goToMachineHome() { + if (!getAdbInstance()) return; + try { + await executeCmd('input keyevent KEYCODE_HOME'); + } catch (e) { + console.error('[goToMachineHome] error', e); + } +} + export async function disconnect() { let instance = getAdbInstance(); if (instance) { diff --git a/src/lib/core/handlers/messageHandler.ts b/src/lib/core/handlers/messageHandler.ts index 61cd6a3..d0f6cd5 100644 --- a/src/lib/core/handlers/messageHandler.ts +++ b/src/lib/core/handlers/messageHandler.ts @@ -21,6 +21,8 @@ import { handleSheetStreamEnd, handleSheetStreamError, handleCatalogsResponse, + handlePriceSlotsResponse, + isPriceSlotsPayload, handleListMenuResponse, sheetCatalogsLoading, handleRawStreamHeader, @@ -283,22 +285,55 @@ const handlers: Record void> = { if (from === 'sheet-service' && level === 'content') { const currentUid = auth.currentUser?.uid; + const content = p.content ?? p.value ?? p.payload; - if (target && currentUid && target === currentUid) { - if (!msg && p.content?.catalogs) { - handleCatalogsResponse(p.content); - addNotification(`INFO:Loaded ${p.content.catalogs?.length || 0} catalogs`); + console.log('[Sheet] Notify content received:', { + msg, + target, + currentUid, + contentKeys: content && typeof content === 'object' ? Object.keys(content) : [], + content + }); + + if (!target || (currentUid && target === currentUid)) { + if (!msg && content?.catalogs) { + handleCatalogsResponse(content); + addNotification(`INFO:Loaded ${content.catalogs?.length || 0} catalogs`); + return; + } + + if ( + !msg && + (content?.priceSlots || + content?.priceslots || + content?.price_slots || + content?.slots || + content?.param === 'priceslot' || + content?.option === 'PriceSlot' || + isPriceSlotsPayload(content)) + ) { + handlePriceSlotsResponse(content); + addNotification('INFO:Loaded PriceSlot data'); return; } // Handle streaming messages (with msg field) switch (msg) { + case 'priceslot': + case 'price_slot': + handlePriceSlotsResponse(content); + addNotification('INFO:Loaded PriceSlot data'); + break; case 'start': handleSheetStreamStart(p); addNotification('INFO:Sheet data streaming started'); break; case 'chunk': - handleSheetStreamChunk(p); + if (isPriceSlotsPayload(content)) { + handlePriceSlotsResponse(content); + } else { + handleSheetStreamChunk(p); + } break; case 'end': handleSheetStreamEnd(p); @@ -310,8 +345,15 @@ const handlers: Record void> = { break; default: // Handle other content notifications from sheet-service - console.log('[Sheet] Received content:', p.content); + console.log('[Sheet] Received content:', content); } + } else { + console.warn('[Sheet] Ignored content because target does not match current user:', { + target, + currentUid, + msg, + content + }); } return; } @@ -466,19 +508,30 @@ const handlers: Record void> = { // Header for price stream handleRawStreamHeader('price', p); }, + raw_stream_priceslot: (p) => { + handleRawStreamHeader('priceslot', p); + }, raw_stream_chunk_price: (p) => { // Chunk for price stream handleRawStreamChunk('price', p); }, + raw_stream_chunk_priceslot: (p) => { + handleRawStreamChunk('priceslot', p); + }, raw_stream_end_price: (p) => { // End for price stream handleRawStreamEnd('price', p); + }, + raw_stream_end_priceslot: (p) => { + handleRawStreamEnd('priceslot', p); } }; export function handleIncomingMessages(raw: string) { const msg: WSMessage = JSON.parse(raw); - // console.log(`[WS MSG] type=${msg.type}`, msg.payload); + if (msg.type !== 'heartbeat') { + console.log(`[WS MSG] type=${msg.type}`, msg.payload); + } if (msg == null) { // error response addNotification('ERR:No response from server'); diff --git a/src/lib/core/services/sheetService.ts b/src/lib/core/services/sheetService.ts index 8fb6c39..07aad5a 100644 --- a/src/lib/core/services/sheetService.ts +++ b/src/lib/core/services/sheetService.ts @@ -7,8 +7,12 @@ import { markSheetPriceAsSent, sheetPriceLoading, streamingRawData, - setPendingProductCodesCountry + setPendingProductCodesCountry, + setPendingPriceSlotsCountry, + priceSlotsLoading, + resetPriceSlotsCountry } from '../stores/sheetStore'; +import type { PriceSlot } from '../stores/sheetStore'; import { setGenLayoutGenerating } from '../stores/genLayoutStore'; export function requestCatalogs(country: string): boolean { @@ -19,21 +23,38 @@ export function requestCatalogs(country: string): boolean { } export function requestPriceSlots(country: string): boolean { - return sendCommandRequest('sheet', { + setPendingPriceSlotsCountry(country); + resetPriceSlotsCountry(country); + const request_id = crypto.randomUUID(); + + streamingRawData.update((data) => ({ + ...data, + priceslot: { + request_id, + country, + chunks: [], + rawParts: [] + } + })); + priceSlotsLoading.set(true); + + const values = { country: country, - param: 'priceslot' - }); + param: 'price', + option: 'PriceSlot', + stream: true, + request_id + }; + console.log('[sheetService] Sending PriceSlot request:', values); + const sent = sendCommandRequest('sheet', values); + console.log('[sheetService] PriceSlot request sent:', sent); + if (!sent) { + priceSlotsLoading.set(false); + } + return sent; } -export function updatePriceSlot( - country: string, - content: { - slot: number; - name: string; - description: string; - products: { product_code: string; price: number | null; row_index?: number }[]; - } -): boolean { +export function updatePriceSlot(country: string, content: PriceSlot): boolean { return sendCommandRequest('sheet', { country: country, content: content, @@ -210,7 +231,14 @@ export function requestSheetPrice(country: string, productCodes: string[]): bool // Convert to array of objects (backend expects objects, not strings) const content = productCodes.map((code) => ({ product_code: code })); - console.log('[sheetService] Sending sheet price request for country:', country, 'codes:', productCodes.length, 'request_id:', request_id); + console.log( + '[sheetService] Sending sheet price request for country:', + country, + 'codes:', + productCodes.length, + 'request_id:', + request_id + ); const sent = sendCommandRequest('sheet', { country: country, @@ -242,7 +270,12 @@ export function updateSheetPrice( return false; } - console.log('[sheetService] Updating sheet price for country:', country, 'items:', content.length); + console.log( + '[sheetService] Updating sheet price for country:', + country, + 'items:', + content.length + ); return sendCommandRequest('sheet', { country: country, @@ -255,16 +288,19 @@ export function updateSheetPrice( * Add new price rows to sheet (for product codes that don't exist in price sheet) * content: [{ cells: [product_code, name_en, name_th, ..., price, ...] }] */ -export function addSheetPrice( - country: string, - content: { cells: string[] }[] -): boolean { +export function addSheetPrice(country: string, content: { cells: string[] }[]): boolean { if (!content || content.length === 0) { console.warn('[sheetService] No content to add'); return false; } - console.log('[sheetService] Adding price rows for country:', country, 'items:', content.length, content); + console.log( + '[sheetService] Adding price rows for country:', + country, + 'items:', + content.length, + content + ); return sendCommandRequest('sheet', { country: country, diff --git a/src/lib/core/stores/sheetStore.ts b/src/lib/core/stores/sheetStore.ts index eb2d626..a17421b 100644 --- a/src/lib/core/stores/sheetStore.ts +++ b/src/lib/core/stores/sheetStore.ts @@ -24,16 +24,212 @@ export interface PriceSlotProduct { row_index?: number; } +export interface PriceSlotServiceRow { + row_index?: number; + cells: Record; +} + export interface PriceSlot { slot: number; name: string; description: string; + kind?: 'price' | 'service'; + header?: string[]; products: PriceSlotProduct[]; + serviceRows?: PriceSlotServiceRow[]; } export const priceSlots = writable>({}); export const priceSlotsLoading = writable(false); export const priceSlotsError = writable(null); +let pendingPriceSlotsCountry = ''; + +export function setPendingPriceSlotsCountry(country: string) { + pendingPriceSlotsCountry = country.toLowerCase(); +} + +export function resetPriceSlotsCountry(country: string) { + const key = country.toLowerCase(); + priceSlots.update((data) => ({ + ...data, + [key]: [] + })); + priceSlotsError.set(null); +} + +function normalizePriceSlotProduct(product: any): PriceSlotProduct | null { + const cells = Array.isArray(product?.cells) ? product.cells : []; + const cellValue = (col: number) => cells.find((cell: any) => cell?.coord?.col === col)?.value; + const productCode = + product?.product_code ?? product?.ProductCode ?? product?.code ?? cellValue(1); + + if (!productCode) return null; + + const priceValue = + product?.price ?? + product?.Price ?? + product?.value ?? + product?.cash_price ?? + product?.CashPrice ?? + cellValue(5); + const price = + priceValue === '' || priceValue === undefined || priceValue === null + ? null + : Number(priceValue); + + return { + product_code: String(productCode), + name: String( + product?.name ?? product?.ProductName ?? product?.product_name ?? cellValue(2) ?? '' + ), + price: Number.isNaN(price) ? null : price, + row_index: product?.row_index ?? product?.row + }; +} + +function getPriceSlotHeader(slot: any): string[] { + const header = Array.isArray(slot?.header) ? slot.header : []; + return header.map((value: any) => String(value ?? '').trim()); +} + +function isServicePriceSlotHeader(header: string[]): boolean { + return header.some((value) => value.toLowerCase() === 'servicetype'); +} + +function normalizePriceSlotServiceRow(row: any, header: string[]): PriceSlotServiceRow | null { + const cells = Array.isArray(row?.cells) ? row.cells : []; + const mappedCells = header.reduce>((result, columnName, index) => { + if (!columnName) return result; + const value = + row?.[columnName] ?? + row?.[columnName.replace(/\s+/g, '')] ?? + cells.find((cell: any) => cell?.coord?.col === index + 1)?.value ?? + ''; + result[columnName] = String(value ?? ''); + return result; + }, {}); + + if (Object.values(mappedCells).every((value) => value === '')) return null; + + return { + row_index: row?.row_index ?? row?.row, + cells: mappedCells + }; +} + +function normalizePriceSlot(slot: any, index: number): PriceSlot { + const sheetName = slot?.sheet ?? slot?.Sheet; + const displayName = slot?.name ?? slot?.title ?? sheetName; + const slotNumber = Number( + slot?.slot ?? slot?.price_slot ?? slot?.id ?? displayName?.match?.(/\d+/)?.[0] ?? index + 1 + ); + const productsSource = slot?.products ?? slot?.items ?? slot?.rows ?? slot?.payload ?? []; + const header = getPriceSlotHeader(slot); + const isServiceSlot = isServicePriceSlotHeader(header); + const headerName = isServiceSlot ? header[12] : header[10]; + const headerDescription = isServiceSlot ? header[13] : header[11]; + const products = (Array.isArray(productsSource) ? productsSource : []) + .map(normalizePriceSlotProduct) + .filter((product): product is PriceSlotProduct => product !== null); + const serviceRows = isServiceSlot + ? (Array.isArray(productsSource) ? productsSource : []) + .map((row) => normalizePriceSlotServiceRow(row, header)) + .filter((row): row is PriceSlotServiceRow => row !== null) + : []; + + return { + slot: Number.isNaN(slotNumber) ? index + 1 : slotNumber, + name: String( + headerName ?? displayName ?? `PriceSlot${Number.isNaN(slotNumber) ? index + 1 : slotNumber}` + ), + description: String(headerDescription ?? ''), + kind: isServiceSlot ? 'service' : 'price', + header, + products: isServiceSlot ? [] : products, + serviceRows + }; +} + +export function handlePriceSlotsResponse(content: any) { + console.log('[PriceSlot] Raw backend response:', content); + const country = String( + content?.country ?? content?.Country ?? pendingPriceSlotsCountry + ).toLowerCase(); + const source = + content?.priceSlots ?? + content?.priceslots ?? + content?.price_slots ?? + content?.slots ?? + content?.data ?? + content?.value ?? + content?.content ?? + content; + const slotList = Array.isArray(source) + ? source + : typeof source === 'object' && source + ? Object.entries(source).map(([key, value]) => ({ + ...(typeof value === 'object' && value ? value : {}), + name: (value as any)?.name ?? key + })) + : []; + + if (!country || slotList.length === 0) { + console.warn('[PriceSlot] No slot list found:', { country, source, content }); + priceSlotsError.set('No PriceSlot data found in backend response'); + priceSlotsLoading.set(false); + return; + } + + const normalizedSlots = slotList + .map(normalizePriceSlot) + .filter((slot) => + slot.kind === 'service' ? (slot.serviceRows?.length ?? 0) > 0 : slot.products.length > 0 + ); + + if (normalizedSlots.length === 0) { + console.warn('[PriceSlot] Response did not include usable rows:', { country, slotList }); + return; + } + + console.log('[PriceSlot] Normalized slots:', { + country, + slots: normalizedSlots.length, + firstSlot: normalizedSlots[0] + }); + + priceSlots.update((data) => { + const merged = new Map(); + for (const slot of data[country] ?? []) { + merged.set(`${slot.slot}:${slot.name}`, slot); + } + for (const slot of normalizedSlots) { + merged.set(`${slot.slot}:${slot.name}`, slot); + } + + return { + ...data, + [country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot) + }; + }); + priceSlotsError.set(null); + priceSlotsLoading.set(false); +} + +export function isPriceSlotsPayload(content: any): boolean { + const source = + content?.priceSlots ?? + content?.priceslots ?? + content?.price_slots ?? + content?.slots ?? + content?.data ?? + content?.value ?? + content?.content ?? + content; + + if (content?.param === 'priceslot' || content?.option === 'PriceSlot') return true; + if (!Array.isArray(source)) return false; + return source.some((item) => String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot')); +} export const countryPrimaryLanguageMap: Record = { THAI: 'Thai', @@ -78,11 +274,14 @@ export function getCountryPrimaryLanguage(countryCode: string): string { // Sheet column configuration by country for new_layout_v2 // Maps language keys to column indices and product code columns -export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record; - productCode: { hot: number; cold: number; blend: number }; - primaryLanguage: string; -}> = { +export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record< + string, + { + language: Record; + productCode: { hot: number; cold: number; blend: number }; + primaryLanguage: string; + } +> = { tha: { language: { en: 3, th: 4, zh: 5, my: 8 }, productCode: { hot: 9, cold: 10, blend: 11 }, @@ -151,8 +350,10 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record = { +export const PRICE_HEADER_NAMES_BY_COUNTRY: Record< + string, + { + cash_price: string[]; // Possible header names for cash price + non_cash_price: string[]; // Possible header names for non-cash price + } +> = { tha: { cash_price: ['Price'], non_cash_price: ['MainPrice'] @@ -366,7 +570,7 @@ export const PRICE_HEADER_NAMES_BY_COUNTRY: Record h.toLowerCase() === name.toLowerCase()); + const idx = headerArray.findIndex((h) => h.toLowerCase() === name.toLowerCase()); if (idx !== -1) { // Return col index (header index + 1 because cells start from col 1) return idx + 1; @@ -382,7 +586,9 @@ export const lastRequestSheetPrice = writable>({}); // Store: sheetPriceAllRows[country][product_code] = array of {row, cells} (ALL rows for duplicates) -export const sheetPriceAllRows = writable>>({}); +export const sheetPriceAllRows = writable< + Record> +>({}); // Helper function to get price value from cells using dynamic header lookup export function getPriceFromCells( @@ -397,15 +603,20 @@ export function getPriceFromCells( } // Get possible header names for this country - const headerNames = PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default; - const possibleNames = priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price; + const headerNames = + PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default; + const possibleNames = + priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price; // Find the column index for this price type const colIdx = findHeaderIndex(headers, possibleNames); //console.log(`[getPriceFromCells] ${country} ${priceType}: colIdx=${colIdx}, headers=`, headers, 'possibleNames=', possibleNames); if (colIdx < 0) { - console.warn(`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, possibleNames); + console.warn( + `[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, + possibleNames + ); return null; } @@ -444,15 +655,20 @@ export const streamingRawData = writable< // Handler: raw_stream header (e.g., raw_stream_price) export function handleRawStreamHeader(subtype: string, payload: any) { - console.log(`[RawStream] Header for ${subtype}:`, payload); + let targetSubtype = subtype; + const currentData = get(streamingRawData); + if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) { + targetSubtype = 'priceslot'; + } + + console.log(`[RawStream] Header for ${targetSubtype}:`, payload); // Get existing stream data to preserve country from request - const currentData = get(streamingRawData); - const existingData = currentData[subtype]; + const existingData = currentData[targetSubtype]; streamingRawData.update((data) => ({ ...data, - [subtype]: { + [targetSubtype]: { request_id: payload.request_id, header: payload.header || payload.headers, country: payload.country || existingData?.country || '', @@ -461,7 +677,7 @@ export function handleRawStreamHeader(subtype: string, payload: any) { } })); - if (subtype === 'price') { + if (targetSubtype === 'price') { sheetPriceStreamMeta.set({ request_id: payload.request_id, country: payload.country || existingData?.country || '', @@ -473,13 +689,21 @@ export function handleRawStreamHeader(subtype: string, payload: any) { // Handler: raw_stream chunk (e.g., raw_stream_chunk_price) export function handleRawStreamChunk(subtype: string, payload: any) { - console.log(`[RawStream] Chunk ${payload.idx} for ${subtype}, raw length:`, payload.raw?.length); - const currentData = get(streamingRawData); - const streamData = currentData[subtype]; + let targetSubtype = subtype; + if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) { + targetSubtype = 'priceslot'; + } + + console.log( + `[RawStream] Chunk ${payload.idx} for ${targetSubtype}, raw length:`, + payload.raw?.length + ); + + const streamData = currentData[targetSubtype]; if (!streamData || streamData.request_id !== payload.request_id) { - console.warn(`[RawStream] Chunk received for unknown stream: ${subtype}`); + console.warn(`[RawStream] Chunk received for unknown stream: ${targetSubtype}`); return; } @@ -488,13 +712,13 @@ export function handleRawStreamChunk(subtype: string, payload: any) { // Accumulate raw parts - will be joined and parsed in handleRawStreamEnd streamingRawData.update((data) => ({ ...data, - [subtype]: { + [targetSubtype]: { ...streamData, country: payload.country || streamData.country, rawParts: [...(streamData.rawParts || []), payload.raw] } })); - console.log(`[RawStream] Accumulated chunk ${payload.idx} for ${subtype}`); + console.log(`[RawStream] Accumulated chunk ${payload.idx} for ${targetSubtype}`); return; } @@ -504,25 +728,30 @@ export function handleRawStreamChunk(subtype: string, payload: any) { streamingRawData.update((data) => ({ ...data, - [subtype]: { + [targetSubtype]: { ...streamData, country: payload.country || streamData.country, chunks: [...streamData.chunks, ...contentArray] } })); - console.log(`[RawStream] Chunk for ${subtype}: +${contentArray.length} items`); + console.log(`[RawStream] Chunk for ${targetSubtype}: +${contentArray.length} items`); } // Handler: raw_stream end (e.g., raw_stream_end_price) export function handleRawStreamEnd(subtype: string, payload: any) { - console.log(`[RawStream] End payload for ${subtype}:`, payload); - const currentData = get(streamingRawData); - const streamData = currentData[subtype]; + let targetSubtype = subtype; + if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) { + targetSubtype = 'priceslot'; + } + + console.log(`[RawStream] End payload for ${targetSubtype}:`, payload); + + const streamData = currentData[targetSubtype]; if (!streamData || streamData.request_id !== payload.request_id) { - console.warn(`[RawStream] End received for unknown stream: ${subtype}`); + console.warn(`[RawStream] End received for unknown stream: ${targetSubtype}`); return; } @@ -554,18 +783,36 @@ export function handleRawStreamEnd(subtype: string, payload: any) { } } - console.log(`[RawStream] End for ${subtype}: total ${chunks.length} items, country: ${country}`); + console.log( + `[RawStream] End for ${targetSubtype}: total ${chunks.length} items, country: ${country}` + ); - if (subtype === 'price') { - processSheetPriceData(country, streamData.header || [], chunks); - sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null)); - sheetPriceLoading.set(false); + if (targetSubtype === 'priceslot' && isPriceSlotsPayload({ slots: chunks })) { + handlePriceSlotsResponse({ country, slots: chunks }); + } + + if (targetSubtype === 'price') { + const looksLikePriceSlot = chunks.some((item) => { + return ( + String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') || + item?.option === 'PriceSlot' || + item?.param === 'priceslot' + ); + }); + + if (looksLikePriceSlot) { + handlePriceSlotsResponse({ country, slots: chunks }); + } else { + processSheetPriceData(country, streamData.header || [], chunks); + sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null)); + sheetPriceLoading.set(false); + } } // Clear the streaming data streamingRawData.update((data) => { const newData = { ...data }; - delete newData[subtype]; + delete newData[targetSubtype]; return newData; }); } @@ -600,8 +847,18 @@ function processSheetPriceData(country: string, header: string[], chunks: any[]) // Find column indices dynamically from header // product_code header is typically "ProductCode" or similar - const productCodeIdx = findHeaderIndex(effectiveHeader, ['ProductCode', 'Product_Code', 'product_code', 'Code']); - console.log(`[SheetPrice] productCodeIdx from header:`, productCodeIdx, 'header:', effectiveHeader); + const productCodeIdx = findHeaderIndex(effectiveHeader, [ + 'ProductCode', + 'Product_Code', + 'product_code', + 'Code' + ]); + console.log( + `[SheetPrice] productCodeIdx from header:`, + productCodeIdx, + 'header:', + effectiveHeader + ); const priceByProductCode: Record = {}; // Track ALL rows per product code (for duplicates) @@ -702,7 +959,10 @@ function processSheetPriceData(country: string, header: string[], chunks: any[]) // Log duplicates info const duplicates = Object.entries(allRowsByProductCode).filter(([_, rows]) => rows.length > 1); if (duplicates.length > 0) { - console.log(`[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`, duplicates.slice(0, 3)); + console.log( + `[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`, + duplicates.slice(0, 3) + ); } if (chunks.length > 0 && Object.keys(priceByProductCode).length > 0) { const sampleKey = Object.keys(priceByProductCode)[0]; @@ -769,14 +1029,24 @@ export function loadProductCodesFromCache(country?: string): boolean { // Only load if country matches (or no country filter specified) if (data.codes && Array.isArray(data.codes)) { if (country && data.country && data.country !== country) { - console.log('[sheetStore] Cache is for different country:', data.country, '!= requested:', country); + console.log( + '[sheetStore] Cache is for different country:', + data.country, + '!= requested:', + country + ); // Clear the store for different country existingProductCodes.set(new Set()); return false; } existingProductCodes.set(new Set(data.codes)); currentProductCodesCountry = data.country || ''; - console.log('[sheetStore] Loaded', data.codes.length, 'product codes from cache for', data.country || 'unknown'); + console.log( + '[sheetStore] Loaded', + data.codes.length, + 'product codes from cache for', + data.country || 'unknown' + ); return true; } } @@ -798,7 +1068,13 @@ export function clearProductCodes() { export function handleListMenuResponse(payload: { codes: string[]; country?: string }) { // Use pending country if not in payload const country = payload.country || pendingProductCodesCountry; - console.log('[sheetStore] Received list_menu_response for', country, ':', payload.codes?.length, 'codes'); + console.log( + '[sheetStore] Received list_menu_response for', + country, + ':', + payload.codes?.length, + 'codes' + ); if (payload && payload.codes) { existingProductCodes.set(new Set(payload.codes)); @@ -814,7 +1090,12 @@ export function handleListMenuResponse(payload: { codes: string[]; country?: str timestamp: Date.now() }) ); - console.log('[sheetStore] Saved', payload.codes.length, 'product codes to cache for', country); + console.log( + '[sheetStore] Saved', + payload.codes.length, + 'product codes to cache for', + country + ); } catch (e) { console.warn('[sheetStore] Failed to save to cache:', e); } diff --git a/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte b/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte index 86a2542..fa60048 100644 --- a/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte +++ b/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte @@ -8,15 +8,17 @@ import { permission as currentPerms } from '$lib/core/stores/permissions.js'; import { referenceFromPage } from '$lib/core/stores/recipeStore.js'; import { - clearSheetPriceSentTypes, getCountryPrimaryLanguage, getPriceFromCells, lastRequestSheetPrice, - sheetPriceLoading, + priceSlots, + priceSlotsError, + priceSlotsLoading, type PriceSlot, - type PriceSlotProduct + type PriceSlotProduct, + type PriceSlotServiceRow } from '$lib/core/stores/sheetStore.js'; - import { requestSheetPrice } from '$lib/core/services/sheetService.js'; + import { requestPriceSlots, updatePriceSlot } from '$lib/core/services/sheetService.js'; import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js'; import Button from '$lib/components/ui/button/button.svelte'; @@ -30,77 +32,31 @@ type AdjustmentMode = | 'increase_percent' | 'increase_amount' - | 'decrease_amount' - | 'decrease_percent'; + | 'decrease_percent' + | 'decrease_amount'; const adjustmentModeLabels: Record = { increase_percent: 'Increase by Percentage (%)', increase_amount: 'Increase by Fixed Amount', - decrease_amount: 'Decrease by Fixed Amount', - decrease_percent: 'Decrease by Percentage (%)' + decrease_percent: 'Decrease by Percentage (%)', + decrease_amount: 'Decrease by Fixed Amount' }; - const mockProducts: PriceSlotProduct[] = [ - { - product_code: '12-01-01-0001', - name: 'HOT ESPRESSO | เอสเพรสโซ่ร้อน', - price: 30, - row_index: 2 - }, - { product_code: '12-01-01-0003', name: 'HOT AMERICANO | กาแฟดำร้อน', price: 35, row_index: 3 }, - { product_code: '12-01-01-0004', name: 'HOT LATTE | ลาเต้ร้อน', price: 40, row_index: 5 }, - { product_code: '12-01-01-0006', name: 'HOT MOCHA | มอคค่าร้อน', price: 55, row_index: 7 }, - { - product_code: '12-01-02-0001', - name: 'Iced AMERICANO | กาแฟดำเย็น', - price: 40, - row_index: 16 - }, - { product_code: '12-01-02-0002', name: 'ICED LATTE | ลาเต้เย็น', price: 50, row_index: 17 }, - { product_code: '12-01-02-0003', name: 'ICED MOCHA | มอคค่าเย็น', price: 60, row_index: 18 }, - { - product_code: '12-02-01-0002', - name: 'Hot THAI MILK TEA | ชาไทยร้อน', - price: 40, - row_index: 27 - }, - { - product_code: '12-02-01-0004', - name: 'Hot MATCHA LATTE | มัทฉะลาเต้ร้อน', - price: 50, - row_index: 29 - } - ]; - - function buildMockSlots(): PriceSlot[] { - return Array.from({ length: 10 }, (_, index) => { - const slot = index + 1; - const increase = slot === 1 ? 15 : slot === 2 ? 25 : slot * 5; - - return { - slot, - name: slot <= 2 ? `ProfileIncrease${increase}` : `PriceSlot${slot}`, - description: slot <= 2 ? `increase price ${increase}%` : '', - products: mockProducts.map((product) => ({ - ...product, - price: - product.price === null - ? null - : slot <= 2 - ? Math.ceil((product.price * (1 + increase / 100)) / 5) * 5 - : product.price - })) - }; - }); - } + const emptySlot: PriceSlot = { + slot: 0, + name: '', + description: '', + kind: 'price', + header: [], + products: [] + }; let selectedCountry = $state($page.params.country || get(departmentStore) || ''); let enabledCountries = $state([]); - let selectedSlot = $state(1); - const initialSlots = buildMockSlots(); - let slots = $state(initialSlots); - let savedSnapshot = $state(structuredClone(initialSlots)); - let loading = $state(false); + let selectedSlot = $state(0); + let localSlots = $state([]); + let workingSlot = $state(null); + let savedSlot = $state(null); let productCodeSearch = $state(''); let createDialogOpen = $state(false); let adjustmentMode = $state('increase_percent'); @@ -108,17 +64,22 @@ let createName = $state('ProfileIncrease15'); let createDescription = $state('increase price 15%'); - let currentSlot = $derived(slots.find((slot) => slot.slot === selectedSlot) ?? slots[0]); + let selectedCountryKey = $derived(selectedCountry.toLowerCase()); let selectedCountryLanguage = $derived(getCountryPrimaryLanguage(selectedCountry)); + let backendSlots = $derived($priceSlots[selectedCountryKey] ?? []); + let displaySlots = $derived([...backendSlots, ...localSlots].sort((a, b) => a.slot - b.slot)); + let selectedSourceSlot = $derived( + displaySlots.find((slot) => slot.slot === selectedSlot) ?? displaySlots[0] ?? null + ); + let currentSlot = $derived(workingSlot ?? emptySlot); + let isServiceSlot = $derived(currentSlot.kind === 'service'); + let serviceHeaders = $derived(currentSlot.header?.filter(Boolean) ?? []); + let loading = $derived($priceSlotsLoading); let basePriceCells = $derived( $lastRequestSheetPrice[selectedCountry.toLowerCase()] || $lastRequestSheetPrice[selectedCountry] || {} ); - let basePricesLoadedCount = $derived( - mockProducts.filter((product) => getBasePrice(product) !== null).length - ); - let basePriceLoading = $derived($sheetPriceLoading); let filteredProducts = $derived( currentSlot.products.filter((product) => { const keyword = productCodeSearch.trim().toLowerCase(); @@ -127,12 +88,34 @@ return product.product_code.toLowerCase().includes(keyword); }) ); - let changedCount = $derived(countChangedProducts(currentSlot, savedSnapshot[selectedSlot - 1])); - let hasHeaderChanges = $derived( - currentSlot.name !== savedSnapshot[selectedSlot - 1]?.name || - currentSlot.description !== savedSnapshot[selectedSlot - 1]?.description + let filteredServiceRows = $derived( + (currentSlot.serviceRows ?? []).filter((row) => { + const keyword = productCodeSearch.trim().toLowerCase(); + if (!keyword) return true; + + return Object.values(row.cells).some((value) => value.toLowerCase().includes(keyword)); + }) + ); + let changedCount = $derived(countChangedProducts(currentSlot, savedSlot ?? undefined)); + let changedServiceCount = $derived(countChangedServiceRows(currentSlot, savedSlot ?? undefined)); + let totalChangedCount = $derived(changedCount + changedServiceCount); + let visibleRowCount = $derived( + isServiceSlot ? filteredServiceRows.length : filteredProducts.length + ); + let totalRowCount = $derived( + isServiceSlot ? (currentSlot.serviceRows?.length ?? 0) : currentSlot.products.length + ); + let hasHeaderChanges = $derived( + currentSlot.name !== savedSlot?.name || currentSlot.description !== savedSlot?.description + ); + let hasChanges = $derived(totalChangedCount > 0 || hasHeaderChanges); + let resetButtonTitle = $derived( + !currentSlot.slot + ? 'Select a PriceSlot first' + : !hasChanges + ? 'Reset is available after changing this PriceSlot' + : 'Discard unsaved changes and restore the last loaded values' ); - let hasChanges = $derived(changedCount > 0 || hasHeaderChanges); onMount(() => { referenceFromPage.set('priceslot'); @@ -143,8 +126,43 @@ const userPerms = get(currentPerms).filter((x) => x.startsWith('document.write')); enabledCountries = userPerms.map((x) => x.split('.')[2]); + + if (selectedCountry) { + void loadPriceSlots(); + } }); + let lastLoadedSlotSignature = $state(''); + + $effect(() => { + if (displaySlots.length === 0) { + workingSlot = null; + savedSlot = null; + lastLoadedSlotSignature = ''; + return; + } + + if (!displaySlots.some((slot) => slot.slot === selectedSlot)) { + selectedSlot = displaySlots[0]?.slot ?? 0; + return; + } + + if (!selectedSourceSlot) return; + + const signature = JSON.stringify(selectedSourceSlot); + if (signature === lastLoadedSlotSignature) return; + if (hasChanges && workingSlot?.slot === selectedSourceSlot.slot) return; + + lastLoadedSlotSignature = signature; + workingSlot = clonePriceSlot(selectedSourceSlot); + savedSlot = clonePriceSlot(selectedSourceSlot); + productCodeSearch = ''; + }); + + function getBaseProducts(): PriceSlotProduct[] { + return currentSlot.products; + } + function getBasePrice(product: PriceSlotProduct): number | null { const cells = basePriceCells[product.product_code]; if (cells?.length > 0) { @@ -165,6 +183,10 @@ }; } + function clonePriceSlot(slot: PriceSlot): PriceSlot { + return JSON.parse(JSON.stringify(slot)); + } + function calculateAdjustedPrice(basePrice: number | null): number | null { if (basePrice === null) return null; @@ -183,23 +205,6 @@ return Math.max(0, Math.round(nextPrice)); } - async function loadBasePrices() { - const productCodes = mockProducts.map((product) => product.product_code); - if (productCodes.length === 0) return; - - const socket = await waitForOpenSocket(); - if (!socket) { - addNotification('WARN:WebSocket not connected. Using local base price sample.'); - return; - } - - clearSheetPriceSentTypes(); - const sent = requestSheetPrice(selectedCountry, productCodes); - if (!sent) { - addNotification('ERR:Failed to request base prices'); - } - } - function applyCreateTemplate() { const value = Number(adjustmentValue); const formattedValue = Number.isNaN(value) ? 0 : value; @@ -221,9 +226,15 @@ } function createPriceSlotFromBase() { - const nextSlotNumber = Math.max(0, ...slots.map((slot) => slot.slot)) + 1; + const baseProducts = getBaseProducts(); + if (baseProducts.length === 0) { + addNotification('WARN:No backend PriceSlot data loaded'); + return; + } - const products = mockProducts.map((product) => ({ + const nextSlotNumber = Math.max(0, ...displaySlots.map((slot) => slot.slot)) + 1; + + const products = baseProducts.map((product) => ({ ...product, price: calculateAdjustedPrice(getBasePrice(product)) })); @@ -232,12 +243,16 @@ slot: nextSlotNumber, name: createName.trim() || `PriceSlot${nextSlotNumber}`, description: createDescription.trim(), + kind: 'price', + header: currentSlot.header, products }; - slots = [...slots, nextSlot]; - savedSnapshot = [...savedSnapshot, structuredClone(nextSlot)]; + localSlots = [...localSlots, nextSlot]; selectedSlot = nextSlotNumber; + workingSlot = clonePriceSlot(nextSlot); + savedSlot = clonePriceSlot(nextSlot); + lastLoadedSlotSignature = JSON.stringify(nextSlot); createDialogOpen = false; addNotification(`INFO:Created PriceSlot${nextSlotNumber} from base prices`); } @@ -245,56 +260,160 @@ function countChangedProducts(current: PriceSlot, saved: PriceSlot | undefined): number { if (!saved) return 0; - return current.products.filter((product) => { - const savedProduct = saved.products.find( - (item) => item.product_code === product.product_code - ); + return current.products.filter((product, index) => { + const savedProduct = + saved.products.find( + (item) => product.row_index !== undefined && item.row_index === product.row_index + ) ?? + saved.products[index] ?? + saved.products.find((item) => item.product_code === product.product_code); return savedProduct?.price !== product.price; }).length; } + function countChangedServiceRows(current: PriceSlot, saved: PriceSlot | undefined): number { + if (!saved || current.kind !== 'service') return 0; + + return (current.serviceRows ?? []).filter((row, index) => { + const savedRow = + saved.serviceRows?.find((item) => item.row_index === row.row_index) ?? + saved.serviceRows?.[index]; + return JSON.stringify(row.cells) !== JSON.stringify(savedRow?.cells ?? {}); + }).length; + } + function updateSlotField(field: 'name' | 'description', value: string) { - slots = slots.map((slot) => (slot.slot === selectedSlot ? { ...slot, [field]: value } : slot)); + if (!workingSlot) return; + workingSlot = { ...workingSlot, [field]: value }; } function updateProductPrice(productCode: string, value: string) { const price = value === '' ? null : Number(value); - slots = slots.map((slot) => { - if (slot.slot !== selectedSlot) return slot; + if (!workingSlot) return; - return { - ...slot, - products: slot.products.map((product) => - product.product_code === productCode - ? { ...product, price: Number.isNaN(price) ? product.price : price } - : product - ) - }; - }); + workingSlot = { + ...workingSlot, + products: workingSlot.products.map((product) => + product.product_code === productCode + ? { ...product, price: Number.isNaN(price) ? product.price : price } + : product + ) + }; + } + + function updateServiceCell( + row: PriceSlotServiceRow, + fallbackIndex: number, + columnName: string, + value: string + ) { + if (!workingSlot) return; + + workingSlot = { + ...workingSlot, + serviceRows: (workingSlot.serviceRows ?? []).map((serviceRow, index) => { + const sameRow = + row.row_index !== undefined + ? serviceRow.row_index === row.row_index + : serviceRow === row || index === fallbackIndex; + return sameRow + ? { + ...serviceRow, + cells: { + ...serviceRow.cells, + [columnName]: value + } + } + : serviceRow; + }) + }; + } + + function getServiceColumnClass(columnName: string) { + const normalized = columnName.toLowerCase(); + if (normalized === 'value') return 'min-w-[320px]'; + if (normalized === 'desc') return 'min-w-[300px]'; + if (normalized === 'l') return 'min-w-[360px]'; + if (normalized.includes('schedule')) return 'min-w-[300px]'; + if (normalized.includes('discount')) return 'min-w-[180px]'; + if (normalized === 'type/key') return 'min-w-[150px]'; + if (normalized === 'servicetype') return 'min-w-[130px]'; + if (normalized === 'daytype') return 'min-w-[130px]'; + if (normalized === 'command') return 'min-w-[130px]'; + if (normalized === 'extendvalue') return 'min-w-[130px]'; + if (normalized === 'time(24 hr)' || normalized === 'time( 24 hr)') return 'min-w-[130px]'; + if (['year', 'month', 'day'].includes(normalized)) return 'min-w-[110px]'; + return 'min-w-[120px]'; + } + + function getServiceInputClass(columnName: string) { + const normalized = columnName.toLowerCase(); + const textClass = normalized === 'l' || normalized.includes('schedule') ? '' : 'font-mono'; + return ['h-10 w-full min-w-0 text-sm', textClass].filter(Boolean).join(' '); + } + + function getServiceTableMinWidth() { + return serviceHeaders.reduce((total, header) => { + const normalized = header.toLowerCase(); + if (normalized === 'value') return total + 320; + if (normalized === 'desc') return total + 300; + if (normalized === 'l') return total + 360; + if (normalized.includes('schedule')) return total + 300; + if (normalized.includes('discount')) return total + 180; + if (['type/key', 'servicetype', 'daytype', 'command', 'extendvalue'].includes(normalized)) { + return total + 140; + } + return total + 120; + }, 0); } function resetSlot() { - const savedSlot = savedSnapshot[selectedSlot - 1]; if (!savedSlot) return; - slots = slots.map((slot) => (slot.slot === selectedSlot ? structuredClone(savedSlot) : slot)); + workingSlot = clonePriceSlot(savedSlot); addNotification(`INFO:Reset PriceSlot${selectedSlot}`); } function saveSlot() { - savedSnapshot = savedSnapshot.map((slot) => - slot.slot === selectedSlot ? structuredClone(currentSlot) : slot + if (!currentSlot.slot) { + addNotification('WARN:No PriceSlot selected'); + return; + } + + const sent = updatePriceSlot(selectedCountry, currentSlot); + if (!sent) { + addNotification('ERR:Failed to send PriceSlot update'); + return; + } + + savedSlot = clonePriceSlot(currentSlot); + localSlots = localSlots.map((slot) => + slot.slot === selectedSlot ? clonePriceSlot(currentSlot) : slot ); - addNotification('WARN:PriceSlot backend is not ready. Changes are kept in this UI only.'); + addNotification(`INFO:PriceSlot${selectedSlot} update sent`); } - function loadPriceSlots() { - loading = true; - setTimeout(() => { - loading = false; - addNotification('WARN:PriceSlot backend is not ready. Showing UI mock data.'); - }, 250); + async function loadPriceSlots() { + localSlots = []; + workingSlot = null; + savedSlot = null; + selectedSlot = 0; + priceSlotsLoading.set(true); + priceSlotsError.set(null); + + const socket = await waitForOpenSocket(); + if (!socket) { + priceSlotsLoading.set(false); + addNotification('ERR:WebSocket not connected'); + return; + } + + const sent = requestPriceSlots(selectedCountry); + if (!sent) { + priceSlotsLoading.set(false); + addNotification('ERR:Failed to request PriceSlot data'); + } } @@ -348,144 +467,233 @@ -
- {#each slots as slot} - + {/each} + {:else} +
+ {loading ? 'Loading PriceSlot data...' : 'No PriceSlot data loaded'} +
+ {/if} +
+ +
+
- PriceSlot{slot.slot} - - {/each} -
- -
-
-
-
-

PriceSlot{selectedSlot}

- - {hasChanges ? `${changedCount} changes` : 'No changes'} - +
+
+

+ {currentSlot.slot ? `PriceSlot${selectedSlot}` : 'No PriceSlot'} +

+ + {hasChanges ? `${totalChangedCount} changes` : 'No changes'} + +
+
- -
-
- - updateSlotField('name', event.currentTarget.value)} - /> -
- -
- - updateSlotField('description', event.currentTarget.value)} - /> -
- -
- - - -
-
-
- -
-
-
- -
- +
+ updateSlotField('name', event.currentTarget.value)} />
+ +
+ + updateSlotField('description', event.currentTarget.value)} + /> +
+ +
+
+
+ + + +
+

+ Reset discards unsaved changes for the selected PriceSlot. +

+
+
-

- Showing {filteredProducts.length} of {currentSlot.products.length} products -

-
- - - - ProductCode - ProductName [{selectedCountryLanguage}] - ProductNameEng - Price - - - - {#each filteredProducts as product (product.product_code)} - {@const productNames = getProductNames(product)} - - - {product.product_code} - - {productNames.local} - {productNames.english} - - - updateProductPrice(product.product_code, event.currentTarget.value)} - /> - - - {/each} - {#if filteredProducts.length === 0} - - - No product code found. - - - {/if} - - +
+
+
+ +
+ + +
+
+

+ Showing {visibleRowCount} of {totalRowCount} + {isServiceSlot ? 'service rows' : 'products'} +

+
+ +
+ {#if loading} +
+ Loading PriceSlot data... +
+ {:else if $priceSlotsError} +
+ {$priceSlotsError} +
+ {:else if displaySlots.length === 0} +
+ No PriceSlot data from backend. +
+ {:else if isServiceSlot} +
+ + + + {#each serviceHeaders as header} + {header} + {/each} + + + + {#each filteredServiceRows as row, index (`${row.row_index ?? index}-${index}`)} + + {#each serviceHeaders as header} + + + updateServiceCell(row, index, header, event.currentTarget.value)} + /> + + {/each} + + {/each} + {#if filteredServiceRows.length === 0} + + + No service rows found. + + + {/if} + + +
+ {:else} +
+ + + + ProductCode + ProductName [{selectedCountryLanguage}] + ProductNameEng + Price + + + + {#each filteredProducts as product, index (`${product.product_code}-${product.row_index ?? index}`)} + {@const productNames = getProductNames(product)} + + + {product.product_code} + + {productNames.local} + {productNames.english} + + + updateProductPrice(product.product_code, event.currentTarget.value)} + /> + + + {/each} + {#if filteredProducts.length === 0} + + + No product code found. + + + {/if} + + +
+ {/if} +
@@ -499,19 +707,6 @@
- -