From 6a2f4e5945eec23cd7fe1dc2c1d23254c9a4ad84 Mon Sep 17 00:00:00 2001 From: thanawat saiyota Date: Tue, 16 Jun 2026 11:30:23 +0700 Subject: [PATCH 1/2] 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 @@
- -
From 47ee23777d91d384473e1f6a3b86fa287a49b324 Mon Sep 17 00:00:00 2001 From: thanawat saiyota Date: Tue, 30 Jun 2026 17:38:59 +0700 Subject: [PATCH 2/2] feat: main & brewing video tool, catalog APIs, sheet/recipe updates - Add /tools/video-mainpage page (main + brewing-page advertisement videos, date-gated, per-country, push to machine over ADB) + api/video-mainpage create/list/update proxies; sidebar entry "Main & Brewing Video" - Add catalog API proxies (catalog-create, catalog-list, catalog-banner, catalog-banner-image) - Sheet: overview/edit/add/priceslot/price updates, stores & services - Misc: adb, websocket/message handlers, crypto, recipe & brew tweaks Co-Authored-By: Claude Opus 4.8 --- src/lib/components/app-sidebar.svelte | 21 +- src/lib/core/adb/adb.ts | 3 +- src/lib/core/handlers/messageHandler.ts | 41 +- src/lib/core/handlers/ws_messageSender.ts | 52 +- src/lib/core/services/sheetService.ts | 197 ++- src/lib/core/stores/sheetStore.ts | 206 +++- src/lib/core/stores/websocketStore.ts | 40 +- src/lib/core/utils/crypto.ts | 16 +- src/routes/(authed)/departments/+page.svelte | 6 +- .../(authed)/recipe/material/+page.svelte | 802 +++++++++++-- .../(authed)/recipe/topping/+page.svelte | 538 +++++++-- .../add/[country]/[catalog]/+page.svelte | 2 +- .../edit/[country]/[catalog]/+page.svelte | 204 +++- .../sheet/overview/[country]/+page.svelte | 1058 ++++++++++++++++- .../sheet/price/[country]/+page.svelte | 280 +++++ .../sheet/priceslot/[country]/+page.svelte | 968 +++++++++++++-- src/routes/(authed)/tools/brew/+page.svelte | 1 + .../(authed)/tools/create-menu/+page.svelte | 575 +++++++-- .../tools/video-mainpage/+page.svelte | 797 +++++++++++++ .../api/catalog-banner-image/+server.ts | 24 + src/routes/api/catalog-banner/+server.ts | 40 + src/routes/api/catalog-create/+server.ts | 62 + src/routes/api/catalog-list/+server.ts | 26 + src/routes/api/video-mainpage/+server.ts | 80 ++ src/routes/api/video-mainpage/list/+server.ts | 26 + .../api/video-mainpage/update/+server.ts | 56 + 26 files changed, 5582 insertions(+), 539 deletions(-) create mode 100644 src/routes/(authed)/sheet/price/[country]/+page.svelte create mode 100644 src/routes/(authed)/tools/video-mainpage/+page.svelte create mode 100644 src/routes/api/catalog-banner-image/+server.ts create mode 100644 src/routes/api/catalog-banner/+server.ts create mode 100644 src/routes/api/catalog-create/+server.ts create mode 100644 src/routes/api/catalog-list/+server.ts create mode 100644 src/routes/api/video-mainpage/+server.ts create mode 100644 src/routes/api/video-mainpage/list/+server.ts create mode 100644 src/routes/api/video-mainpage/update/+server.ts diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte index 34de1f0..57a6194 100644 --- a/src/lib/components/app-sidebar.svelte +++ b/src/lib/components/app-sidebar.svelte @@ -17,6 +17,7 @@ PlusCircle, ImageUp, Video, + MonitorPlay, Sun, Moon } from '@lucide/svelte/icons'; @@ -125,6 +126,12 @@ url: '/tools/adv-upload', icon: Video, requirePerm: '' + }, + { + title: 'Main & Brewing Video', + url: '/tools/video-mainpage', + icon: MonitorPlay, + requirePerm: '' } ] }, @@ -142,6 +149,12 @@ url: '/departments', icon: DollarSign, requirePerm: 'document.write.*' + }, + { + title: 'Price', + url: '/departments', + icon: FileSpreadsheet, + requirePerm: 'document.write.*' } ] } @@ -235,7 +248,13 @@ onclick={(e) => { if (nav.title === 'Sheet') { e.preventDefault(); - referenceFromPage.set(sub.title === 'PriceSlot' ? 'priceslot' : 'sheet'); + referenceFromPage.set( + sub.title === 'PriceSlot' + ? 'priceslot' + : sub.title === 'Price' + ? 'price' + : 'sheet' + ); goto(sub.url); } }} diff --git a/src/lib/core/adb/adb.ts b/src/lib/core/adb/adb.ts index 716911e..c5b0576 100644 --- a/src/lib/core/adb/adb.ts +++ b/src/lib/core/adb/adb.ts @@ -137,7 +137,7 @@ async function connectWithRetry( export async function connnectViaWebUSB(connectAndroidServer = true) { const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice(); - console.log('usb ok', globalThis.navigator.usb); + console.log('usb ok', (globalThis.navigator as Navigator & { usb?: unknown }).usb); if (device) { console.log('connect ', device.name); @@ -362,7 +362,6 @@ export async function executeCmd(command: string) { } } - export async function goToMachineHome() { if (!getAdbInstance()) return; try { diff --git a/src/lib/core/handlers/messageHandler.ts b/src/lib/core/handlers/messageHandler.ts index 6bf44b2..9e7b8f6 100644 --- a/src/lib/core/handlers/messageHandler.ts +++ b/src/lib/core/handlers/messageHandler.ts @@ -27,7 +27,8 @@ import { sheetCatalogsLoading, handleRawStreamHeader, handleRawStreamChunk, - handleRawStreamEnd + handleRawStreamEnd, + handleSheetPriceResponse } from '../stores/sheetStore'; import { handleGenLayoutBatchStart, @@ -293,13 +294,14 @@ const handlers: Record void> = { if (from === 'sheet-service' && level === 'content') { const currentUid = auth.currentUser?.uid; const content = p.content ?? p.value ?? p.payload; + const ref = p.ref ?? ''; console.log('[Sheet] Notify content received:', { msg, target, currentUid, contentKeys: content && typeof content === 'object' ? Object.keys(content) : [], - content + contentItems: Array.isArray(content) ? content.length : undefined }); if (!target || (currentUid && target === currentUid)) { @@ -324,6 +326,12 @@ const handlers: Record void> = { return; } + if (!msg && ref === 'price') { + handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content); + addNotification('INFO:Loaded sheet price data'); + return; + } + // Handle streaming messages (with msg field) switch (msg) { case 'priceslot': @@ -332,19 +340,29 @@ const handlers: Record void> = { addNotification('INFO:Loaded PriceSlot data'); break; case 'start': - handleSheetStreamStart(p); - addNotification('INFO:Sheet data streaming started'); + if (ref === 'price') { + addNotification('INFO:Sheet price streaming started'); + } else { + handleSheetStreamStart(p); + addNotification('INFO:Sheet data streaming started'); + } break; case 'chunk': if (isPriceSlotsPayload(content)) { handlePriceSlotsResponse(content); + } else if (ref === 'price') { + handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content); } else { handleSheetStreamChunk(p); } break; case 'end': - handleSheetStreamEnd(p); - addNotification('INFO:Sheet data streaming complete'); + if (ref === 'price') { + addNotification('INFO:Sheet price streaming complete'); + } else { + handleSheetStreamEnd(p); + addNotification('INFO:Sheet data streaming complete'); + } break; case 'error': handleSheetStreamError(p); @@ -352,14 +370,16 @@ const handlers: Record void> = { break; default: // Handle other content notifications from sheet-service - console.log('[Sheet] Received content:', content); + console.log('[Sheet] Received content:', { + contentItems: Array.isArray(content) ? content.length : undefined + }); } } else { console.warn('[Sheet] Ignored content because target does not match current user:', { target, currentUid, msg, - content + contentItems: Array.isArray(content) ? content.length : undefined }); } return; @@ -438,6 +458,7 @@ const handlers: Record void> = { country: current_meta?.country ?? '', content: saved_product_code_to_get_from_sheet, param: 'price', + option: 'price', stream: true, request_id }); @@ -564,7 +585,7 @@ export async function handleIncomingMessages(raw: string, clientPrivateKey?: Cry ); let actual_message: WSMessage = JSON.parse(decrypted_string); if (actual_message.type !== 'heartbeat') { - console.log(`[WS MSG] type=${actual_message.type}`, actual_message.payload); + // console.log(`[WS MSG] type=${actual_message.type}`, actual_message.payload); } handlers[actual_message.type]?.(actual_message.payload); @@ -572,7 +593,7 @@ export async function handleIncomingMessages(raw: string, clientPrivateKey?: Cry } else { const msg: WSMessage = parsedMessage; if (msg.type !== 'heartbeat') { - console.log(`[WS MSG] type=${msg.type}`, msg.payload); + // console.log(`[WS MSG] type=${msg.type}`, msg.payload); } if (msg == null) { // error response diff --git a/src/lib/core/handlers/ws_messageSender.ts b/src/lib/core/handlers/ws_messageSender.ts index 852dfcb..7c68cdd 100644 --- a/src/lib/core/handlers/ws_messageSender.ts +++ b/src/lib/core/handlers/ws_messageSender.ts @@ -1,14 +1,17 @@ import { get, writable } from 'svelte/store'; import type { OutMessage } from '../types/outMessage'; -import { sharedKey, socketStore } from '../stores/websocketStore'; +import { sharedKey, socketStore, wsAuthReady } from '../stores/websocketStore'; import { addNotification } from '../stores/noti'; import { auth } from '../stores/auth'; import { WebCryptoHelper } from '../utils/crypto'; -import * as semver from 'semver'; import { env } from '$env/dynamic/public'; export const queue = writable([]); +function isSecuredAppVersion(version: string | undefined) { + return version?.startsWith('0.0.2') ?? false; +} + type CommandRequest = 'sheet' | 'command'; function getServiceName(cmdReq: CommandRequest) { @@ -20,8 +23,40 @@ function getServiceName(cmdReq: CommandRequest) { } } +function waitForWsAuthReady(timeoutMs = 10000): Promise { + if (get(wsAuthReady)) return Promise.resolve(true); + + return new Promise((resolve) => { + let settled = false; + let unsubscribe = () => {}; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + unsubscribe(); + resolve(false); + }, timeoutMs); + + unsubscribe = wsAuthReady.subscribe((ready) => { + if (!ready || settled) return; + settled = true; + clearTimeout(timeout); + unsubscribe(); + resolve(true); + }); + }); +} + // Websocket message wrapper for commands like `sheet`, `command` export async function sendCommandRequest(target: CommandRequest, values: any): Promise { + const authReady = await waitForWsAuthReady(); + if (!authReady) { + console.warn('[WS Send] Skip command request because websocket auth is not ready', { + target, + param: values?.param + }); + return false; + } + let srv_name = getServiceName(target); let curr_user = get(auth); @@ -71,10 +106,10 @@ export async function sendMessage( return false; } - // console.log('send v2', APP_VERSION, semver.satisfies(APP_VERSION, '^0.0.2')); + // console.log('send v2', APP_VERSION, isSecuredAppVersion(APP_VERSION)); - if (semver.satisfies(APP_VERSION, '^0.0.2')) { - // console.log('sending secured'); + if (isSecuredAppVersion(APP_VERSION)) { + console.log('sending secured'); let sharedKeyRes = get(sharedKey); // do encrypt @@ -82,6 +117,13 @@ export async function sendMessage( data = JSON.stringify(await WebCryptoHelper.encryptMessage(sharedKeyRes, data)); } + // console.log('[WS Send]', { + // type: logMessage.type, + // service: logMessage.payload?.srv_name, + // param: logMessage.payload?.values?.param, + // bytes: data.length, + // secured: isSecuredAppVersion(APP_VERSION) + // }); socket.send(data); return true; } diff --git a/src/lib/core/services/sheetService.ts b/src/lib/core/services/sheetService.ts index ac9d4f5..9b89acd 100644 --- a/src/lib/core/services/sheetService.ts +++ b/src/lib/core/services/sheetService.ts @@ -15,6 +15,10 @@ import { import type { PriceSlot } from '../stores/sheetStore'; import { setGenLayoutGenerating } from '../stores/genLayoutStore'; +type SheetCellUpdate = { value: string; coord: { row: number; col: number } }; +type SheetRowUpdate = { row_index: number; cells: SheetCellUpdate[] }; +type SheetRowCreate = { header?: string[]; cells: string[] }; + export async function requestCatalogs(country: string): Promise { return await sendCommandRequest('sheet', { country: country, @@ -22,9 +26,36 @@ export async function requestCatalogs(country: string): Promise { }); } +/** + * Register a newly created catalog as a Grist table so it shows in the overview + * and menus can be added to it. `catalog` is the .skt filename produced by + * /api/catalog-create (e.g. "page_catalog_group_pro_summer_splash.skt"). + */ +export async function addCatalog( + country: string, + catalogName: string, + catalog: string +): Promise { + return await sendCommandRequest('sheet', { + country: country, + catalog: catalog, + catalog_name: catalogName, + param: 'add/catalog' + }); +} + export async function requestPriceSlots(country: string): Promise { setPendingPriceSlotsCountry(country); resetPriceSlotsCountry(country); + return requestPriceSlotOption(country, 'PriceSlot'); +} + +export async function requestPriceSlot(country: string, slotNumber: number): Promise { + setPendingPriceSlotsCountry(country); + return requestPriceSlotOption(country, `PriceSlot${slotNumber}`); +} + +async function requestPriceSlotOption(country: string, option: string): Promise { const request_id = crypto.randomUUID(); streamingRawData.update((data) => ({ @@ -41,7 +72,7 @@ export async function requestPriceSlots(country: string): Promise { const values = { country: country, param: 'price', - option: 'PriceSlot', + option, stream: true, request_id }; @@ -54,12 +85,120 @@ export async function requestPriceSlots(country: string): Promise { return sent; } -export async function updatePriceSlot(country: string, content: PriceSlot): Promise { - return await sendCommandRequest('sheet', { +export async function refreshPriceSlotList(country: string): Promise { + return requestPriceSlotOption(country, 'PriceSlot'); +} + +export async function updatePriceSlot( + country: string, + slot: PriceSlot, + content: SheetRowUpdate[] +): Promise { + // console.log('[sheetService] Sending PriceSlot update:', { + // country, + // slot: slot.slot, + // name: slot.name, + // description: slot.description, + // kind: slot.kind, + // rows: content.length, + // param: 'update/price', + // option: `PriceSlot${slot.slot}` + // }); + + const sent = await sendCommandRequest('sheet', { country: country, content: content, - param: 'update/priceslot' + param: 'update/price', + option: `PriceSlot${slot.slot}` }); + + console.log('[sheetService] PriceSlot update sent:', { + country, + slot: slot.slot, + sent + }); + + return sent; +} + +export async function addPriceSlot( + country: string, + slot: PriceSlot, + content: SheetRowCreate[] +): Promise { + console.log('[sheetService] Sending PriceSlot create:', { + country, + slot: slot.slot, + name: slot.name, + description: slot.description, + kind: slot.kind, + rows: content.length, + param: 'add/price', + option: `PriceSlot${slot.slot}` + }); + + const sent = await sendCommandRequest('sheet', { + country: country, + content: content, + param: 'add/price', + option: `PriceSlot${slot.slot}` + }); + + console.log('[sheetService] PriceSlot create sent:', { + country, + slot: slot.slot, + sent + }); + + return sent; +} + +export async function addPriceSlotRows( + country: string, + slot: PriceSlot, + content: SheetRowCreate[] +): Promise { + if (!content || content.length === 0) return true; + + const sent = await sendCommandRequest('sheet', { + country: country, + content: content, + param: 'add/price', + option: `PriceSlot${slot.slot}` + }); + + console.log('[sheetService] PriceSlot rows add sent:', { + country, + slot: slot.slot, + rows: content.length, + sent + }); + + return sent; +} + +export async function deletePriceSlotRows( + country: string, + slot: PriceSlot, + rowIds: number[] +): Promise { + if (!rowIds || rowIds.length === 0) return true; + + const sent = await sendCommandRequest('sheet', { + country: country, + content: rowIds.map((target_id) => ({ target_id })), + param: 'delete/price', + option: `PriceSlot${slot.slot}` + }); + + console.log('[sheetService] PriceSlot rows delete sent:', { + country, + slot: slot.slot, + rows: rowIds.length, + sent + }); + + return sent; } export async function enterRoom(country: string, catalog: string): Promise { @@ -208,9 +347,13 @@ export async function requestGenLayout(country: string): Promise { * Request price data from sheet for specific product codes * NOTE: Can only send once per type (price). Use hasSheetPriceBeenSent to check. */ -export async function requestSheetPrice(country: string, productCodes: string[]): Promise { +export async function requestSheetPrice( + country: string, + productCodes: string[], + force = false +): Promise { // Check if already sent - if (hasSheetPriceBeenSent('price')) { + if (!force && hasSheetPriceBeenSent('price')) { console.warn('[sheetService] Price request already sent, skipping'); return false; } @@ -252,6 +395,48 @@ export async function requestSheetPrice(country: string, productCodes: string[]) country: country, content: content, param: 'price', + option: 'price', + stream: true, + request_id + }); + console.log('[sheetService] Sheet price request sent:', { country, request_id, sent }); + + if (sent) { + markSheetPriceAsSent('price'); + } else { + sheetPriceLoading.set(false); + } + + return sent; +} + +export async function requestAllSheetPrice(country: string, force = false): Promise { + if (!force && hasSheetPriceBeenSent('price')) { + console.warn('[sheetService] Price request already sent, skipping'); + return false; + } + + const request_id = crypto.randomUUID(); + + streamingRawData.update((data) => ({ + ...data, + price: { + request_id, + country, + chunks: [], + rawParts: [] + } + })); + + sheetPriceLoading.set(true); + + console.log('[sheetService] Sending all sheet price request:', { country, request_id }); + + const sent = await sendCommandRequest('sheet', { + country, + content: [], + param: 'price', + option: 'price', stream: true, request_id }); diff --git a/src/lib/core/stores/sheetStore.ts b/src/lib/core/stores/sheetStore.ts index a17421b..cecd26f 100644 --- a/src/lib/core/stores/sheetStore.ts +++ b/src/lib/core/stores/sheetStore.ts @@ -40,6 +40,7 @@ export interface PriceSlot { } export const priceSlots = writable>({}); +export const priceSlotNamespaces = writable>({}); export const priceSlotsLoading = writable(false); export const priceSlotsError = writable(null); let pendingPriceSlotsCountry = ''; @@ -54,6 +55,10 @@ export function resetPriceSlotsCountry(country: string) { ...data, [key]: [] })); + priceSlotNamespaces.update((data) => ({ + ...data, + [key]: [] + })); priceSlotsError.set(null); } @@ -150,12 +155,22 @@ function normalizePriceSlot(slot: any, index: number): PriceSlot { }; } -export function handlePriceSlotsResponse(content: any) { - console.log('[PriceSlot] Raw backend response:', content); - const country = String( - content?.country ?? content?.Country ?? pendingPriceSlotsCountry - ).toLowerCase(); - const source = +function normalizePriceSlotNamespace(sheetName: string, index: number): PriceSlot { + const slotNumber = Number(sheetName.match(/\d+/)?.[0] ?? index + 1); + const slot = Number.isNaN(slotNumber) ? index + 1 : slotNumber; + + return { + slot, + name: sheetName || `PriceSlot${slot}`, + description: '', + kind: 'price', + header: [], + products: [] + }; +} + +function getPriceSlotSource(content: any) { + return ( content?.priceSlots ?? content?.priceslots ?? content?.price_slots ?? @@ -163,31 +178,70 @@ export function handlePriceSlotsResponse(content: any) { 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 - })) - : []; + content + ); +} + +function getPriceSlotItems(content: any): any[] { + const source = getPriceSlotSource(content); + + if (Array.isArray(source)) { + return source.flatMap((item) => { + if (Array.isArray(item?.sheet)) { + return item.sheet.map((sheetName: any, index: number) => + normalizePriceSlotNamespace(String(sheetName ?? ''), index) + ); + } + return [item]; + }); + } + if (Array.isArray(source?.sheet)) { + return source.sheet.map((sheetName: any, index: number) => + normalizePriceSlotNamespace(String(sheetName ?? ''), index) + ); + } + if (typeof source?.sheet === 'string' && source.sheet.startsWith('PriceSlot')) return [source]; + if (typeof source === 'object' && source) { + return Object.entries(source).map(([key, value]) => ({ + ...(typeof value === 'object' && value ? value : {}), + name: (value as any)?.name ?? key + })); + } + + return []; +} + +export function handlePriceSlotsResponse(content: any) { + console.log('[PriceSlot] Raw backend response:', { + items: Array.isArray(content) ? content.length : undefined, + keys: + content && typeof content === 'object' && !Array.isArray(content) ? Object.keys(content) : [] + }); + const country = String( + content?.country ?? content?.Country ?? pendingPriceSlotsCountry + ).toLowerCase(); + const source = getPriceSlotSource(content); + const slotList = getPriceSlotItems(content); if (!country || slotList.length === 0) { - console.warn('[PriceSlot] No slot list found:', { country, source, content }); + console.warn('[PriceSlot] No slot list found:', { + country, + sourceItems: Array.isArray(source) ? source.length : undefined + }); 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 - ); + const normalizedSlots = slotList.map((slot, index) => + isPriceSlotNamespace(slot) ? slot : normalizePriceSlot(slot, index) + ); if (normalizedSlots.length === 0) { - console.warn('[PriceSlot] Response did not include usable rows:', { country, slotList }); + console.warn('[PriceSlot] Response did not include usable rows:', { + country, + slotListItems: slotList.length + }); return; } @@ -195,15 +249,41 @@ export function handlePriceSlotsResponse(content: any) { country, slots: normalizedSlots.length, firstSlot: normalizedSlots[0] + ? { + slot: normalizedSlots[0].slot, + name: normalizedSlots[0].name, + kind: normalizedSlots[0].kind, + products: normalizedSlots[0].products.length, + serviceRows: normalizedSlots[0].serviceRows?.length ?? 0 + } + : undefined }); - priceSlots.update((data) => { - const merged = new Map(); + const loadedSlots = normalizedSlots.filter((slot) => !isPriceSlotNamespace(slot as any)); + + if (loadedSlots.length > 0) { + priceSlots.update((data) => { + const merged = new Map(); + for (const slot of data[country] ?? []) { + merged.set(slot.slot, slot); + } + for (const slot of loadedSlots) { + merged.set(slot.slot, slot); + } + + return { + ...data, + [country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot) + }; + }); + } + priceSlotNamespaces.update((data) => { + const merged = new Map(); for (const slot of data[country] ?? []) { - merged.set(`${slot.slot}:${slot.name}`, slot); + merged.set(slot.slot, slot); } for (const slot of normalizedSlots) { - merged.set(`${slot.slot}:${slot.name}`, slot); + merged.set(slot.slot, slot); } return { @@ -216,19 +296,31 @@ export function handlePriceSlotsResponse(content: any) { } export function isPriceSlotsPayload(content: any): boolean { - const source = - content?.priceSlots ?? - content?.priceslots ?? - content?.price_slots ?? - content?.slots ?? - content?.data ?? - content?.value ?? - content?.content ?? - content; + const source = getPriceSlotSource(content); if (content?.param === 'priceslot' || content?.option === 'PriceSlot') return true; + if (Array.isArray(source?.sheet)) { + return source.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot')); + } + if (typeof source?.sheet === 'string') return source.sheet.startsWith('PriceSlot'); if (!Array.isArray(source)) return false; - return source.some((item) => String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot')); + return source.some( + (item) => + String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') || + (Array.isArray(item?.sheet) && + item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'))) + ); +} + +function isPriceSlotNamespace(slot: any): slot is PriceSlot { + return ( + typeof slot?.slot === 'number' && + Array.isArray(slot?.products) && + slot.products.length === 0 && + Array.isArray(slot?.header) && + slot.header.length === 0 && + slot.name?.startsWith?.('PriceSlot') + ); } export const countryPrimaryLanguageMap: Record = { @@ -277,7 +369,12 @@ export function getCountryPrimaryLanguage(countryCode: string): string { export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record< string, { + // Column→language map for the new-layout-v2 sheet (menu name/desc rows). language: Record; + // Column→language map for the name-desc-v2 sheet (Translations). Different + // namespace/sheet so the columns can differ from new-layout-v2; falls back + // to `language` when not set (countries where the two are identical). + nameDescLanguage?: Record; productCode: { hot: number; cold: number; blend: number }; primaryLanguage: string; } @@ -289,6 +386,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record< }, aus: { language: { en: 3, th: 4 }, + nameDescLanguage: { en: 3, th: 4, ms: 7 }, productCode: { hot: 9, cold: 10, blend: 11 }, primaryLanguage: 'en' }, @@ -299,11 +397,13 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record< }, hkg: { language: { en: 3, zh_hans: 4, zh_hant: 5, th: 6 }, + nameDescLanguage: { en: 3, zh_hans: 4, zh_hant: 5 }, productCode: { hot: 9, cold: 10, blend: 11 }, primaryLanguage: 'zh_hant' }, ltu: { language: { en: 3, th: 4, lt: 5, ro: 6 }, + nameDescLanguage: { en: 3, lt: 5, ro: 6 }, productCode: { hot: 9, cold: 10, blend: 11 }, primaryLanguage: 'lt' }, @@ -329,6 +429,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record< }, sgp: { language: { en: 3, th: 4 }, + nameDescLanguage: { en: 3 }, productCode: { hot: 9, cold: 10, blend: 11 }, primaryLanguage: 'en' }, @@ -596,10 +697,22 @@ export function getPriceFromCells( cells: GristCell[], priceType: 'cash_price' | 'non_cash_price' = 'cash_price' ): string | null { + const colIdx = getPriceColumnIndex(country, priceType); + if (colIdx < 0) return null; + + // Find the cell with matching column index + const priceCell = cells.find((c) => c.coord?.col === colIdx); + return priceCell?.value ?? null; +} + +export function getPriceColumnIndex( + country: string, + priceType: 'cash_price' | 'non_cash_price' = 'cash_price' +): number { const headers = get(sheetPriceHeader)[country]; if (!headers || headers.length === 0) { console.warn(`[getPriceFromCells] No header found for country: ${country}`); - return null; + return -1; } // Get possible header names for this country @@ -617,13 +730,10 @@ export function getPriceFromCells( `[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, possibleNames ); - return null; + return -1; } - // Find the cell with matching column index - const priceCell = cells.find((c) => c.coord?.col === colIdx); - //console.log(`[getPriceFromCells] Found cell for col ${colIdx}:`, priceCell); - return priceCell?.value ?? null; + return colIdx; } // Store for tracking streaming state @@ -790,11 +900,16 @@ export function handleRawStreamEnd(subtype: string, payload: any) { if (targetSubtype === 'priceslot' && isPriceSlotsPayload({ slots: chunks })) { handlePriceSlotsResponse({ country, slots: chunks }); } + if (targetSubtype === 'priceslot') { + priceSlotsLoading.set(false); + } if (targetSubtype === 'price') { const looksLikePriceSlot = chunks.some((item) => { return ( String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') || + (Array.isArray(item?.sheet) && + item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'))) || item?.option === 'PriceSlot' || item?.param === 'priceslot' ); @@ -970,6 +1085,13 @@ function processSheetPriceData(country: string, header: string[], chunks: any[]) } } +export function handleSheetPriceResponse(country: string, content: any) { + const resolvedCountry = country || get(streamingRawData).price?.country || ''; + const chunks = Array.isArray(content) ? content : [content]; + processSheetPriceData(resolvedCountry.toLowerCase(), [], chunks); + sheetPriceLoading.set(false); +} + // Reset sheet price stores export function resetSheetPriceStore() { sheetPriceStreamMeta.set(null); diff --git a/src/lib/core/stores/websocketStore.ts b/src/lib/core/stores/websocketStore.ts index 4fa779c..ca540da 100644 --- a/src/lib/core/stores/websocketStore.ts +++ b/src/lib/core/stores/websocketStore.ts @@ -18,6 +18,7 @@ const ENABLE_WS_DEBUG: boolean = false; export const socketConnectionOfflineCount = writable(0); export const socketAlreadySendHeartbeat = writable(0); export const socketStore = writable(null); +export const wsAuthReady = writable(false); export const sharedKey = writable(null); @@ -53,6 +54,31 @@ export function waitForOpenSocket(timeoutMs = 8000): Promise { }); } +export async function waitForAuthenticatedSocket(timeoutMs = 10000): Promise { + const openSocket = await waitForOpenSocket(timeoutMs); + if (!openSocket) return null; + if (get(wsAuthReady)) return openSocket; + + return new Promise((resolve) => { + let settled = false; + let unsubscribe = () => {}; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + unsubscribe(); + resolve(null); + }, timeoutMs); + + unsubscribe = wsAuthReady.subscribe((ready) => { + if (!ready || settled) return; + settled = true; + clearTimeout(timeout); + unsubscribe(); + resolve(openSocket); + }); + }); +} + export async function connectToWebsocket(id_token?: string) { if (browser) { // console.log('connecting to ', env.PUBLIC_WSS); @@ -63,6 +89,7 @@ export async function connectToWebsocket(id_token?: string) { let ws_url = env.PUBLIC_WSS; socket = new WebSocket(ws_url); + wsAuthReady.set(false); sharedKey.set(null); const { privateKey, publicKeyBase64 } = await WebCryptoHelper.generateKeyPair(); @@ -87,13 +114,16 @@ export async function connectToWebsocket(id_token?: string) { sendAuthInfoInterval = setInterval(async () => { if (get(sharedKey)) { + auth_data = get(authStore); + perms = get(permission); // Debug: check if auth_data has uid console.log('[WS Auth] Sending auth info with:', { uid: auth_data?.uid, name: auth_data?.displayName, - email: auth_data?.email + email: auth_data?.email, + date: new Date() }); - await sendMessage({ + const sent = await sendMessage({ type: 'auth', payload: { user: { @@ -104,9 +134,10 @@ export async function connectToWebsocket(id_token?: string) { } } }); + wsAuthReady.set(sent); clearInterval(sendAuthInfoInterval); } - }, 3000); + }, 2000); } console.log(socket); @@ -159,10 +190,12 @@ export async function connectToWebsocket(id_token?: string) { socket.addEventListener('close', () => { socketStore.set(null); + wsAuthReady.set(false); sharedKey.set(null); socket = null; clearInterval(socketCheck); + clearInterval(sendAuthInfoInterval); if (auth.currentUser && !socket) { console.log('try reconnect websocket ...'); @@ -177,6 +210,7 @@ export async function connectToWebsocket(id_token?: string) { socket.addEventListener('error', (e) => { // console.log('WebSocket error: ', e); socketStore.set(null); + wsAuthReady.set(false); sharedKey.set(null); }); } catch (socket_error: any) { diff --git a/src/lib/core/utils/crypto.ts b/src/lib/core/utils/crypto.ts index 0803966..7cbe33f 100644 --- a/src/lib/core/utils/crypto.ts +++ b/src/lib/core/utils/crypto.ts @@ -1,4 +1,14 @@ export class WebCryptoHelper { + private static bytesToBase64(bytes: Uint8Array) { + const chunkSize = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + + return btoa(binary); + } + static async generateKeyPair() { const keyPair = await window.crypto.subtle.generateKey( { @@ -10,7 +20,7 @@ export class WebCryptoHelper { ); const exportedPublic = await window.crypto.subtle.exportKey('raw', keyPair.publicKey); - const publicKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(exportedPublic))); + const publicKeyBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(exportedPublic)); return { privateKey: keyPair.privateKey, publicKeyBase64 }; } @@ -60,8 +70,8 @@ export class WebCryptoHelper { encodedText ); - const ciphertextBase64 = btoa(String.fromCharCode(...new Uint8Array(ciphertextBuffer))); - const ivBase64 = btoa(String.fromCharCode(...iv)); + const ciphertextBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(ciphertextBuffer)); + const ivBase64 = WebCryptoHelper.bytesToBase64(iv); return { ciphertext: ciphertextBase64, iv: ivBase64 }; } diff --git a/src/routes/(authed)/departments/+page.svelte b/src/routes/(authed)/departments/+page.svelte index 77ce3d9..31a295c 100644 --- a/src/routes/(authed)/departments/+page.svelte +++ b/src/routes/(authed)/departments/+page.svelte @@ -27,6 +27,8 @@ if (refPage === 'priceslot') { await goto(`/sheet/priceslot/${cnt}`); + } else if (refPage === 'price') { + await goto(`/sheet/price/${cnt}`); } else if (refPage === 'sheet') { await goto(`/sheet/overview/${cnt}`); } else { @@ -37,7 +39,7 @@ // read or write permission let userCurrentPerms = get(currentPerms).filter((x) => { - if (refPage === 'sheet') { + if (refPage === 'sheet' || refPage === 'priceslot' || refPage === 'price') { return x.startsWith('document.write'); } return x.startsWith('document.read'); @@ -50,7 +52,7 @@ setTimeout(() => { // read or write permission let userCurrentPerms = get(currentPerms).filter((x) => { - if (refPage === 'sheet') { + if (refPage === 'sheet' || refPage === 'priceslot' || refPage === 'price') { return x.startsWith('document.write'); } return x.startsWith('document.read'); diff --git a/src/routes/(authed)/recipe/material/+page.svelte b/src/routes/(authed)/recipe/material/+page.svelte index e7beba8..a91f747 100644 --- a/src/routes/(authed)/recipe/material/+page.svelte +++ b/src/routes/(authed)/recipe/material/+page.svelte @@ -1,5 +1,5 @@ @@ -459,21 +939,93 @@ {/if}
-
- +
+
+ Recipe Source +
+
+ + + +
+ + + + Load Recipe From Server + Select a country to load material data from server. + + +
+ {#each serverCountries as country} + + {/each} +
+ +
+ +
+
+
+ + + + + Machine Not Connected + + Connect to the machine with ADB/WebUSB before loading recipe data from Machine. + + + +
+ +
+
+
+
@@ -499,31 +1051,60 @@ - {existingMaterial ? 'Edit Material' : 'Add Material'} + {isEditingMaterial ? 'Edit Material' : 'Add Material'} - Create or update one MaterialSetting entry. The JSON preview shows the payload - before saving to Android. + Create or update one MaterialSetting entry. Server-loaded data is read-only until + ADB is connected.
- {existingMaterial ? 'Edit Material' : 'Add Material'} + {isEditingMaterial ? 'Edit Material' : 'Add Material'} - {#if existingMaterial} + {#if duplicateMaterialOnCreate} +
+ Material ID {form.id} already exists. Choose another ID before creating. +
+ {:else if isEditingMaterial && Number(form.id) !== Number(editingMaterialId) && existingMaterial} +
+ Material ID {form.id} already exists. Choose another ID before saving. +
+ {:else if isEditingMaterial}
- Material ID {form.id} already exists. Saving will update this MaterialSetting. + Editing Material ID {editingMaterialId}.
{/if} +
+ + +
+
- + +

+ Generated from the highest existing ID in this type + 1. +

@@ -546,11 +1127,11 @@
- +
@@ -582,23 +1163,7 @@
-
-
- - -
+
@@ -618,21 +1183,6 @@

-
-
- - -
-
- - -
-
-
@@ -686,12 +1236,18 @@
- Cancel -
@@ -713,14 +1269,46 @@ + + + + Select Material Type + + Choose the type for the new material. Material ID will be generated from existing IDs in + that type + 1. + + + +
+ {#each selectableMaterialTypeOptions as option} + + {/each} +
+ + + + +
+
+
Existing Materials - - Use Edit to update a material, or Delete to remove it after confirmation. - +
- Loading materials from Android... + Loading materials...
{:else if !devRecipe} -
Connect and load recipe first.
+
Load recipe first.
{:else if filteredMaterials.length === 0}
No materials found.
{:else}
+ + + + Load Recipe From Server + Select a country to load topping data from server. + + +
+ {#each serverCountries as country} + + {/each} +
+ +
+ +
+
+
+ + + + + Machine Not Connected + + Connect to the machine with ADB/WebUSB before loading recipe data from Machine. + + + +
+ +
+
+
+
@@ -591,23 +887,23 @@
{activeTab === 'list' ? 'Topping List' : 'Topping Group'} - - Switch between list items and groups. Edit/Delete actions are explicit per row. - +
- +
@@ -615,12 +911,14 @@
{#if activeTab === 'list'} - Add Topping {:else} - Add Group {/if}
@@ -629,19 +927,19 @@ {#if loading}
- Loading toppings from Android... + Loading toppings...
{:else if !devRecipe} -
Connect and load recipe first.
+
Load recipe first.
{:else if activeTab === 'list'} {#if filteredToppingList.length === 0}
No topping list items found.
{:else}