update get data priceslot
This commit is contained in:
parent
cd88d5aed9
commit
6a2f4e5945
5 changed files with 912 additions and 337 deletions
|
|
@ -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() {
|
export async function disconnect() {
|
||||||
let instance = getAdbInstance();
|
let instance = getAdbInstance();
|
||||||
if (instance) {
|
if (instance) {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import {
|
||||||
handleSheetStreamEnd,
|
handleSheetStreamEnd,
|
||||||
handleSheetStreamError,
|
handleSheetStreamError,
|
||||||
handleCatalogsResponse,
|
handleCatalogsResponse,
|
||||||
|
handlePriceSlotsResponse,
|
||||||
|
isPriceSlotsPayload,
|
||||||
handleListMenuResponse,
|
handleListMenuResponse,
|
||||||
sheetCatalogsLoading,
|
sheetCatalogsLoading,
|
||||||
handleRawStreamHeader,
|
handleRawStreamHeader,
|
||||||
|
|
@ -283,22 +285,55 @@ const handlers: Record<string, (payload: any) => void> = {
|
||||||
|
|
||||||
if (from === 'sheet-service' && level === 'content') {
|
if (from === 'sheet-service' && level === 'content') {
|
||||||
const currentUid = auth.currentUser?.uid;
|
const currentUid = auth.currentUser?.uid;
|
||||||
|
const content = p.content ?? p.value ?? p.payload;
|
||||||
|
|
||||||
if (target && currentUid && target === currentUid) {
|
console.log('[Sheet] Notify content received:', {
|
||||||
if (!msg && p.content?.catalogs) {
|
msg,
|
||||||
handleCatalogsResponse(p.content);
|
target,
|
||||||
addNotification(`INFO:Loaded ${p.content.catalogs?.length || 0} catalogs`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle streaming messages (with msg field)
|
// Handle streaming messages (with msg field)
|
||||||
switch (msg) {
|
switch (msg) {
|
||||||
|
case 'priceslot':
|
||||||
|
case 'price_slot':
|
||||||
|
handlePriceSlotsResponse(content);
|
||||||
|
addNotification('INFO:Loaded PriceSlot data');
|
||||||
|
break;
|
||||||
case 'start':
|
case 'start':
|
||||||
handleSheetStreamStart(p);
|
handleSheetStreamStart(p);
|
||||||
addNotification('INFO:Sheet data streaming started');
|
addNotification('INFO:Sheet data streaming started');
|
||||||
break;
|
break;
|
||||||
case 'chunk':
|
case 'chunk':
|
||||||
handleSheetStreamChunk(p);
|
if (isPriceSlotsPayload(content)) {
|
||||||
|
handlePriceSlotsResponse(content);
|
||||||
|
} else {
|
||||||
|
handleSheetStreamChunk(p);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'end':
|
case 'end':
|
||||||
handleSheetStreamEnd(p);
|
handleSheetStreamEnd(p);
|
||||||
|
|
@ -310,8 +345,15 @@ const handlers: Record<string, (payload: any) => void> = {
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Handle other content notifications from sheet-service
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -466,19 +508,30 @@ const handlers: Record<string, (payload: any) => void> = {
|
||||||
// Header for price stream
|
// Header for price stream
|
||||||
handleRawStreamHeader('price', p);
|
handleRawStreamHeader('price', p);
|
||||||
},
|
},
|
||||||
|
raw_stream_priceslot: (p) => {
|
||||||
|
handleRawStreamHeader('priceslot', p);
|
||||||
|
},
|
||||||
raw_stream_chunk_price: (p) => {
|
raw_stream_chunk_price: (p) => {
|
||||||
// Chunk for price stream
|
// Chunk for price stream
|
||||||
handleRawStreamChunk('price', p);
|
handleRawStreamChunk('price', p);
|
||||||
},
|
},
|
||||||
|
raw_stream_chunk_priceslot: (p) => {
|
||||||
|
handleRawStreamChunk('priceslot', p);
|
||||||
|
},
|
||||||
raw_stream_end_price: (p) => {
|
raw_stream_end_price: (p) => {
|
||||||
// End for price stream
|
// End for price stream
|
||||||
handleRawStreamEnd('price', p);
|
handleRawStreamEnd('price', p);
|
||||||
|
},
|
||||||
|
raw_stream_end_priceslot: (p) => {
|
||||||
|
handleRawStreamEnd('priceslot', p);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function handleIncomingMessages(raw: string) {
|
export function handleIncomingMessages(raw: string) {
|
||||||
const msg: WSMessage = JSON.parse(raw);
|
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) {
|
if (msg == null) {
|
||||||
// error response
|
// error response
|
||||||
addNotification('ERR:No response from server');
|
addNotification('ERR:No response from server');
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,12 @@ import {
|
||||||
markSheetPriceAsSent,
|
markSheetPriceAsSent,
|
||||||
sheetPriceLoading,
|
sheetPriceLoading,
|
||||||
streamingRawData,
|
streamingRawData,
|
||||||
setPendingProductCodesCountry
|
setPendingProductCodesCountry,
|
||||||
|
setPendingPriceSlotsCountry,
|
||||||
|
priceSlotsLoading,
|
||||||
|
resetPriceSlotsCountry
|
||||||
} from '../stores/sheetStore';
|
} from '../stores/sheetStore';
|
||||||
|
import type { PriceSlot } from '../stores/sheetStore';
|
||||||
import { setGenLayoutGenerating } from '../stores/genLayoutStore';
|
import { setGenLayoutGenerating } from '../stores/genLayoutStore';
|
||||||
|
|
||||||
export function requestCatalogs(country: string): boolean {
|
export function requestCatalogs(country: string): boolean {
|
||||||
|
|
@ -19,21 +23,38 @@ export function requestCatalogs(country: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requestPriceSlots(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,
|
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(
|
export function updatePriceSlot(country: string, content: PriceSlot): boolean {
|
||||||
country: string,
|
|
||||||
content: {
|
|
||||||
slot: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
products: { product_code: string; price: number | null; row_index?: number }[];
|
|
||||||
}
|
|
||||||
): boolean {
|
|
||||||
return sendCommandRequest('sheet', {
|
return sendCommandRequest('sheet', {
|
||||||
country: country,
|
country: country,
|
||||||
content: content,
|
content: content,
|
||||||
|
|
@ -210,7 +231,14 @@ export function requestSheetPrice(country: string, productCodes: string[]): bool
|
||||||
// Convert to array of objects (backend expects objects, not strings)
|
// Convert to array of objects (backend expects objects, not strings)
|
||||||
const content = productCodes.map((code) => ({ product_code: code }));
|
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', {
|
const sent = sendCommandRequest('sheet', {
|
||||||
country: country,
|
country: country,
|
||||||
|
|
@ -242,7 +270,12 @@ export function updateSheetPrice(
|
||||||
return false;
|
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', {
|
return sendCommandRequest('sheet', {
|
||||||
country: country,
|
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)
|
* 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, ...] }]
|
* content: [{ cells: [product_code, name_en, name_th, ..., price, ...] }]
|
||||||
*/
|
*/
|
||||||
export function addSheetPrice(
|
export function addSheetPrice(country: string, content: { cells: string[] }[]): boolean {
|
||||||
country: string,
|
|
||||||
content: { cells: string[] }[]
|
|
||||||
): boolean {
|
|
||||||
if (!content || content.length === 0) {
|
if (!content || content.length === 0) {
|
||||||
console.warn('[sheetService] No content to add');
|
console.warn('[sheetService] No content to add');
|
||||||
return false;
|
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', {
|
return sendCommandRequest('sheet', {
|
||||||
country: country,
|
country: country,
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,212 @@ export interface PriceSlotProduct {
|
||||||
row_index?: number;
|
row_index?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PriceSlotServiceRow {
|
||||||
|
row_index?: number;
|
||||||
|
cells: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PriceSlot {
|
export interface PriceSlot {
|
||||||
slot: number;
|
slot: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
kind?: 'price' | 'service';
|
||||||
|
header?: string[];
|
||||||
products: PriceSlotProduct[];
|
products: PriceSlotProduct[];
|
||||||
|
serviceRows?: PriceSlotServiceRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const priceSlots = writable<Record<string, PriceSlot[]>>({});
|
export const priceSlots = writable<Record<string, PriceSlot[]>>({});
|
||||||
export const priceSlotsLoading = writable<boolean>(false);
|
export const priceSlotsLoading = writable<boolean>(false);
|
||||||
export const priceSlotsError = writable<string | null>(null);
|
export const priceSlotsError = writable<string | null>(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<Record<string, string>>((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<string, PriceSlot>();
|
||||||
|
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<string, string> = {
|
export const countryPrimaryLanguageMap: Record<string, string> = {
|
||||||
THAI: 'Thai',
|
THAI: 'Thai',
|
||||||
|
|
@ -78,11 +274,14 @@ export function getCountryPrimaryLanguage(countryCode: string): string {
|
||||||
|
|
||||||
// Sheet column configuration by country for new_layout_v2
|
// Sheet column configuration by country for new_layout_v2
|
||||||
// Maps language keys to column indices and product code columns
|
// Maps language keys to column indices and product code columns
|
||||||
export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
|
export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<
|
||||||
language: Record<string, number>;
|
string,
|
||||||
productCode: { hot: number; cold: number; blend: number };
|
{
|
||||||
primaryLanguage: string;
|
language: Record<string, number>;
|
||||||
}> = {
|
productCode: { hot: number; cold: number; blend: number };
|
||||||
|
primaryLanguage: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
tha: {
|
tha: {
|
||||||
language: { en: 3, th: 4, zh: 5, my: 8 },
|
language: { en: 3, th: 4, zh: 5, my: 8 },
|
||||||
productCode: { hot: 9, cold: 10, blend: 11 },
|
productCode: { hot: 9, cold: 10, blend: 11 },
|
||||||
|
|
@ -151,8 +350,10 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSheetColumnConfig(countryCode: string) {
|
export function getSheetColumnConfig(countryCode: string) {
|
||||||
return SHEET_COLUMN_CONFIG_BY_COUNTRY[countryCode.toLowerCase()]
|
return (
|
||||||
|| SHEET_COLUMN_CONFIG_BY_COUNTRY.default;
|
SHEET_COLUMN_CONFIG_BY_COUNTRY[countryCode.toLowerCase()] ||
|
||||||
|
SHEET_COLUMN_CONFIG_BY_COUNTRY.default
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleCatalogsResponse(content: CatalogsResponse) {
|
export function handleCatalogsResponse(content: CatalogsResponse) {
|
||||||
|
|
@ -304,10 +505,13 @@ export interface SheetPriceItem {
|
||||||
|
|
||||||
// Price sheet header name mappings by country
|
// Price sheet header name mappings by country
|
||||||
// Maps our field names to the actual header names in the sheet
|
// Maps our field names to the actual header names in the sheet
|
||||||
export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<string, {
|
export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<
|
||||||
cash_price: string[]; // Possible header names for cash price
|
string,
|
||||||
non_cash_price: string[]; // Possible header names for non-cash price
|
{
|
||||||
}> = {
|
cash_price: string[]; // Possible header names for cash price
|
||||||
|
non_cash_price: string[]; // Possible header names for non-cash price
|
||||||
|
}
|
||||||
|
> = {
|
||||||
tha: {
|
tha: {
|
||||||
cash_price: ['Price'],
|
cash_price: ['Price'],
|
||||||
non_cash_price: ['MainPrice']
|
non_cash_price: ['MainPrice']
|
||||||
|
|
@ -366,7 +570,7 @@ export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<string, {
|
||||||
// Find column index from header array by matching header names
|
// Find column index from header array by matching header names
|
||||||
export function findHeaderIndex(headerArray: string[], possibleNames: string[]): number {
|
export function findHeaderIndex(headerArray: string[], possibleNames: string[]): number {
|
||||||
for (const name of possibleNames) {
|
for (const name of possibleNames) {
|
||||||
const idx = headerArray.findIndex(h => h.toLowerCase() === name.toLowerCase());
|
const idx = headerArray.findIndex((h) => h.toLowerCase() === name.toLowerCase());
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
// Return col index (header index + 1 because cells start from col 1)
|
// Return col index (header index + 1 because cells start from col 1)
|
||||||
return idx + 1;
|
return idx + 1;
|
||||||
|
|
@ -382,7 +586,9 @@ export const lastRequestSheetPrice = writable<Record<string, Record<string, Gris
|
||||||
export const sheetPriceHeader = writable<Record<string, string[]>>({});
|
export const sheetPriceHeader = writable<Record<string, string[]>>({});
|
||||||
|
|
||||||
// Store: sheetPriceAllRows[country][product_code] = array of {row, cells} (ALL rows for duplicates)
|
// Store: sheetPriceAllRows[country][product_code] = array of {row, cells} (ALL rows for duplicates)
|
||||||
export const sheetPriceAllRows = writable<Record<string, Record<string, { row: number; cells: GristCell[] }[]>>>({});
|
export const sheetPriceAllRows = writable<
|
||||||
|
Record<string, Record<string, { row: number; cells: GristCell[] }[]>>
|
||||||
|
>({});
|
||||||
|
|
||||||
// Helper function to get price value from cells using dynamic header lookup
|
// Helper function to get price value from cells using dynamic header lookup
|
||||||
export function getPriceFromCells(
|
export function getPriceFromCells(
|
||||||
|
|
@ -397,15 +603,20 @@ export function getPriceFromCells(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get possible header names for this country
|
// Get possible header names for this country
|
||||||
const headerNames = PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
|
const headerNames =
|
||||||
const possibleNames = priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price;
|
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
|
// Find the column index for this price type
|
||||||
const colIdx = findHeaderIndex(headers, possibleNames);
|
const colIdx = findHeaderIndex(headers, possibleNames);
|
||||||
//console.log(`[getPriceFromCells] ${country} ${priceType}: colIdx=${colIdx}, headers=`, headers, 'possibleNames=', possibleNames);
|
//console.log(`[getPriceFromCells] ${country} ${priceType}: colIdx=${colIdx}, headers=`, headers, 'possibleNames=', possibleNames);
|
||||||
|
|
||||||
if (colIdx < 0) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,15 +655,20 @@ export const streamingRawData = writable<
|
||||||
|
|
||||||
// Handler: raw_stream header (e.g., raw_stream_price)
|
// Handler: raw_stream header (e.g., raw_stream_price)
|
||||||
export function handleRawStreamHeader(subtype: string, payload: any) {
|
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
|
// Get existing stream data to preserve country from request
|
||||||
const currentData = get(streamingRawData);
|
const existingData = currentData[targetSubtype];
|
||||||
const existingData = currentData[subtype];
|
|
||||||
|
|
||||||
streamingRawData.update((data) => ({
|
streamingRawData.update((data) => ({
|
||||||
...data,
|
...data,
|
||||||
[subtype]: {
|
[targetSubtype]: {
|
||||||
request_id: payload.request_id,
|
request_id: payload.request_id,
|
||||||
header: payload.header || payload.headers,
|
header: payload.header || payload.headers,
|
||||||
country: payload.country || existingData?.country || '',
|
country: payload.country || existingData?.country || '',
|
||||||
|
|
@ -461,7 +677,7 @@ export function handleRawStreamHeader(subtype: string, payload: any) {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (subtype === 'price') {
|
if (targetSubtype === 'price') {
|
||||||
sheetPriceStreamMeta.set({
|
sheetPriceStreamMeta.set({
|
||||||
request_id: payload.request_id,
|
request_id: payload.request_id,
|
||||||
country: payload.country || existingData?.country || '',
|
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)
|
// Handler: raw_stream chunk (e.g., raw_stream_chunk_price)
|
||||||
export function handleRawStreamChunk(subtype: string, payload: any) {
|
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 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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -488,13 +712,13 @@ export function handleRawStreamChunk(subtype: string, payload: any) {
|
||||||
// Accumulate raw parts - will be joined and parsed in handleRawStreamEnd
|
// Accumulate raw parts - will be joined and parsed in handleRawStreamEnd
|
||||||
streamingRawData.update((data) => ({
|
streamingRawData.update((data) => ({
|
||||||
...data,
|
...data,
|
||||||
[subtype]: {
|
[targetSubtype]: {
|
||||||
...streamData,
|
...streamData,
|
||||||
country: payload.country || streamData.country,
|
country: payload.country || streamData.country,
|
||||||
rawParts: [...(streamData.rawParts || []), payload.raw]
|
rawParts: [...(streamData.rawParts || []), payload.raw]
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
console.log(`[RawStream] Accumulated chunk ${payload.idx} for ${subtype}`);
|
console.log(`[RawStream] Accumulated chunk ${payload.idx} for ${targetSubtype}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -504,25 +728,30 @@ export function handleRawStreamChunk(subtype: string, payload: any) {
|
||||||
|
|
||||||
streamingRawData.update((data) => ({
|
streamingRawData.update((data) => ({
|
||||||
...data,
|
...data,
|
||||||
[subtype]: {
|
[targetSubtype]: {
|
||||||
...streamData,
|
...streamData,
|
||||||
country: payload.country || streamData.country,
|
country: payload.country || streamData.country,
|
||||||
chunks: [...streamData.chunks, ...contentArray]
|
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)
|
// Handler: raw_stream end (e.g., raw_stream_end_price)
|
||||||
export function handleRawStreamEnd(subtype: string, payload: any) {
|
export function handleRawStreamEnd(subtype: string, payload: any) {
|
||||||
console.log(`[RawStream] End payload for ${subtype}:`, payload);
|
|
||||||
|
|
||||||
const currentData = get(streamingRawData);
|
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) {
|
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;
|
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') {
|
if (targetSubtype === 'priceslot' && isPriceSlotsPayload({ slots: chunks })) {
|
||||||
processSheetPriceData(country, streamData.header || [], chunks);
|
handlePriceSlotsResponse({ country, slots: chunks });
|
||||||
sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null));
|
}
|
||||||
sheetPriceLoading.set(false);
|
|
||||||
|
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
|
// Clear the streaming data
|
||||||
streamingRawData.update((data) => {
|
streamingRawData.update((data) => {
|
||||||
const newData = { ...data };
|
const newData = { ...data };
|
||||||
delete newData[subtype];
|
delete newData[targetSubtype];
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -600,8 +847,18 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
|
||||||
|
|
||||||
// Find column indices dynamically from header
|
// Find column indices dynamically from header
|
||||||
// product_code header is typically "ProductCode" or similar
|
// product_code header is typically "ProductCode" or similar
|
||||||
const productCodeIdx = findHeaderIndex(effectiveHeader, ['ProductCode', 'Product_Code', 'product_code', 'Code']);
|
const productCodeIdx = findHeaderIndex(effectiveHeader, [
|
||||||
console.log(`[SheetPrice] productCodeIdx from header:`, productCodeIdx, 'header:', effectiveHeader);
|
'ProductCode',
|
||||||
|
'Product_Code',
|
||||||
|
'product_code',
|
||||||
|
'Code'
|
||||||
|
]);
|
||||||
|
console.log(
|
||||||
|
`[SheetPrice] productCodeIdx from header:`,
|
||||||
|
productCodeIdx,
|
||||||
|
'header:',
|
||||||
|
effectiveHeader
|
||||||
|
);
|
||||||
|
|
||||||
const priceByProductCode: Record<string, GristCell[]> = {};
|
const priceByProductCode: Record<string, GristCell[]> = {};
|
||||||
// Track ALL rows per product code (for duplicates)
|
// Track ALL rows per product code (for duplicates)
|
||||||
|
|
@ -702,7 +959,10 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
|
||||||
// Log duplicates info
|
// Log duplicates info
|
||||||
const duplicates = Object.entries(allRowsByProductCode).filter(([_, rows]) => rows.length > 1);
|
const duplicates = Object.entries(allRowsByProductCode).filter(([_, rows]) => rows.length > 1);
|
||||||
if (duplicates.length > 0) {
|
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) {
|
if (chunks.length > 0 && Object.keys(priceByProductCode).length > 0) {
|
||||||
const sampleKey = Object.keys(priceByProductCode)[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)
|
// Only load if country matches (or no country filter specified)
|
||||||
if (data.codes && Array.isArray(data.codes)) {
|
if (data.codes && Array.isArray(data.codes)) {
|
||||||
if (country && data.country && data.country !== country) {
|
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
|
// Clear the store for different country
|
||||||
existingProductCodes.set(new Set());
|
existingProductCodes.set(new Set());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
existingProductCodes.set(new Set(data.codes));
|
existingProductCodes.set(new Set(data.codes));
|
||||||
currentProductCodesCountry = data.country || '';
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -798,7 +1068,13 @@ export function clearProductCodes() {
|
||||||
export function handleListMenuResponse(payload: { codes: string[]; country?: string }) {
|
export function handleListMenuResponse(payload: { codes: string[]; country?: string }) {
|
||||||
// Use pending country if not in payload
|
// Use pending country if not in payload
|
||||||
const country = payload.country || pendingProductCodesCountry;
|
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) {
|
if (payload && payload.codes) {
|
||||||
existingProductCodes.set(new Set(payload.codes));
|
existingProductCodes.set(new Set(payload.codes));
|
||||||
|
|
@ -814,7 +1090,12 @@ export function handleListMenuResponse(payload: { codes: string[]; country?: str
|
||||||
timestamp: Date.now()
|
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) {
|
} catch (e) {
|
||||||
console.warn('[sheetStore] Failed to save to cache:', e);
|
console.warn('[sheetStore] Failed to save to cache:', e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,17 @@
|
||||||
import { permission as currentPerms } from '$lib/core/stores/permissions.js';
|
import { permission as currentPerms } from '$lib/core/stores/permissions.js';
|
||||||
import { referenceFromPage } from '$lib/core/stores/recipeStore.js';
|
import { referenceFromPage } from '$lib/core/stores/recipeStore.js';
|
||||||
import {
|
import {
|
||||||
clearSheetPriceSentTypes,
|
|
||||||
getCountryPrimaryLanguage,
|
getCountryPrimaryLanguage,
|
||||||
getPriceFromCells,
|
getPriceFromCells,
|
||||||
lastRequestSheetPrice,
|
lastRequestSheetPrice,
|
||||||
sheetPriceLoading,
|
priceSlots,
|
||||||
|
priceSlotsError,
|
||||||
|
priceSlotsLoading,
|
||||||
type PriceSlot,
|
type PriceSlot,
|
||||||
type PriceSlotProduct
|
type PriceSlotProduct,
|
||||||
|
type PriceSlotServiceRow
|
||||||
} from '$lib/core/stores/sheetStore.js';
|
} 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 { waitForOpenSocket } from '$lib/core/stores/websocketStore.js';
|
||||||
|
|
||||||
import Button from '$lib/components/ui/button/button.svelte';
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
|
|
@ -30,77 +32,31 @@
|
||||||
type AdjustmentMode =
|
type AdjustmentMode =
|
||||||
| 'increase_percent'
|
| 'increase_percent'
|
||||||
| 'increase_amount'
|
| 'increase_amount'
|
||||||
| 'decrease_amount'
|
| 'decrease_percent'
|
||||||
| 'decrease_percent';
|
| 'decrease_amount';
|
||||||
|
|
||||||
const adjustmentModeLabels: Record<AdjustmentMode, string> = {
|
const adjustmentModeLabels: Record<AdjustmentMode, string> = {
|
||||||
increase_percent: 'Increase by Percentage (%)',
|
increase_percent: 'Increase by Percentage (%)',
|
||||||
increase_amount: 'Increase by Fixed Amount',
|
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[] = [
|
const emptySlot: PriceSlot = {
|
||||||
{
|
slot: 0,
|
||||||
product_code: '12-01-01-0001',
|
name: '',
|
||||||
name: 'HOT ESPRESSO | เอสเพรสโซ่ร้อน',
|
description: '',
|
||||||
price: 30,
|
kind: 'price',
|
||||||
row_index: 2
|
header: [],
|
||||||
},
|
products: []
|
||||||
{ 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
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectedCountry = $state<string>($page.params.country || get(departmentStore) || '');
|
let selectedCountry = $state<string>($page.params.country || get(departmentStore) || '');
|
||||||
let enabledCountries = $state<string[]>([]);
|
let enabledCountries = $state<string[]>([]);
|
||||||
let selectedSlot = $state(1);
|
let selectedSlot = $state(0);
|
||||||
const initialSlots = buildMockSlots();
|
let localSlots = $state<PriceSlot[]>([]);
|
||||||
let slots = $state<PriceSlot[]>(initialSlots);
|
let workingSlot = $state<PriceSlot | null>(null);
|
||||||
let savedSnapshot = $state<PriceSlot[]>(structuredClone(initialSlots));
|
let savedSlot = $state<PriceSlot | null>(null);
|
||||||
let loading = $state(false);
|
|
||||||
let productCodeSearch = $state('');
|
let productCodeSearch = $state('');
|
||||||
let createDialogOpen = $state(false);
|
let createDialogOpen = $state(false);
|
||||||
let adjustmentMode = $state<AdjustmentMode>('increase_percent');
|
let adjustmentMode = $state<AdjustmentMode>('increase_percent');
|
||||||
|
|
@ -108,17 +64,22 @@
|
||||||
let createName = $state('ProfileIncrease15');
|
let createName = $state('ProfileIncrease15');
|
||||||
let createDescription = $state('increase price 15%');
|
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 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(
|
let basePriceCells = $derived(
|
||||||
$lastRequestSheetPrice[selectedCountry.toLowerCase()] ||
|
$lastRequestSheetPrice[selectedCountry.toLowerCase()] ||
|
||||||
$lastRequestSheetPrice[selectedCountry] ||
|
$lastRequestSheetPrice[selectedCountry] ||
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
let basePricesLoadedCount = $derived(
|
|
||||||
mockProducts.filter((product) => getBasePrice(product) !== null).length
|
|
||||||
);
|
|
||||||
let basePriceLoading = $derived($sheetPriceLoading);
|
|
||||||
let filteredProducts = $derived(
|
let filteredProducts = $derived(
|
||||||
currentSlot.products.filter((product) => {
|
currentSlot.products.filter((product) => {
|
||||||
const keyword = productCodeSearch.trim().toLowerCase();
|
const keyword = productCodeSearch.trim().toLowerCase();
|
||||||
|
|
@ -127,12 +88,34 @@
|
||||||
return product.product_code.toLowerCase().includes(keyword);
|
return product.product_code.toLowerCase().includes(keyword);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
let changedCount = $derived(countChangedProducts(currentSlot, savedSnapshot[selectedSlot - 1]));
|
let filteredServiceRows = $derived(
|
||||||
let hasHeaderChanges = $derived(
|
(currentSlot.serviceRows ?? []).filter((row) => {
|
||||||
currentSlot.name !== savedSnapshot[selectedSlot - 1]?.name ||
|
const keyword = productCodeSearch.trim().toLowerCase();
|
||||||
currentSlot.description !== savedSnapshot[selectedSlot - 1]?.description
|
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(() => {
|
onMount(() => {
|
||||||
referenceFromPage.set('priceslot');
|
referenceFromPage.set('priceslot');
|
||||||
|
|
@ -143,8 +126,43 @@
|
||||||
|
|
||||||
const userPerms = get(currentPerms).filter((x) => x.startsWith('document.write'));
|
const userPerms = get(currentPerms).filter((x) => x.startsWith('document.write'));
|
||||||
enabledCountries = userPerms.map((x) => x.split('.')[2]);
|
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 {
|
function getBasePrice(product: PriceSlotProduct): number | null {
|
||||||
const cells = basePriceCells[product.product_code];
|
const cells = basePriceCells[product.product_code];
|
||||||
if (cells?.length > 0) {
|
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 {
|
function calculateAdjustedPrice(basePrice: number | null): number | null {
|
||||||
if (basePrice === null) return null;
|
if (basePrice === null) return null;
|
||||||
|
|
||||||
|
|
@ -183,23 +205,6 @@
|
||||||
return Math.max(0, Math.round(nextPrice));
|
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() {
|
function applyCreateTemplate() {
|
||||||
const value = Number(adjustmentValue);
|
const value = Number(adjustmentValue);
|
||||||
const formattedValue = Number.isNaN(value) ? 0 : value;
|
const formattedValue = Number.isNaN(value) ? 0 : value;
|
||||||
|
|
@ -221,9 +226,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPriceSlotFromBase() {
|
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,
|
...product,
|
||||||
price: calculateAdjustedPrice(getBasePrice(product))
|
price: calculateAdjustedPrice(getBasePrice(product))
|
||||||
}));
|
}));
|
||||||
|
|
@ -232,12 +243,16 @@
|
||||||
slot: nextSlotNumber,
|
slot: nextSlotNumber,
|
||||||
name: createName.trim() || `PriceSlot${nextSlotNumber}`,
|
name: createName.trim() || `PriceSlot${nextSlotNumber}`,
|
||||||
description: createDescription.trim(),
|
description: createDescription.trim(),
|
||||||
|
kind: 'price',
|
||||||
|
header: currentSlot.header,
|
||||||
products
|
products
|
||||||
};
|
};
|
||||||
|
|
||||||
slots = [...slots, nextSlot];
|
localSlots = [...localSlots, nextSlot];
|
||||||
savedSnapshot = [...savedSnapshot, structuredClone(nextSlot)];
|
|
||||||
selectedSlot = nextSlotNumber;
|
selectedSlot = nextSlotNumber;
|
||||||
|
workingSlot = clonePriceSlot(nextSlot);
|
||||||
|
savedSlot = clonePriceSlot(nextSlot);
|
||||||
|
lastLoadedSlotSignature = JSON.stringify(nextSlot);
|
||||||
createDialogOpen = false;
|
createDialogOpen = false;
|
||||||
addNotification(`INFO:Created PriceSlot${nextSlotNumber} from base prices`);
|
addNotification(`INFO:Created PriceSlot${nextSlotNumber} from base prices`);
|
||||||
}
|
}
|
||||||
|
|
@ -245,56 +260,160 @@
|
||||||
function countChangedProducts(current: PriceSlot, saved: PriceSlot | undefined): number {
|
function countChangedProducts(current: PriceSlot, saved: PriceSlot | undefined): number {
|
||||||
if (!saved) return 0;
|
if (!saved) return 0;
|
||||||
|
|
||||||
return current.products.filter((product) => {
|
return current.products.filter((product, index) => {
|
||||||
const savedProduct = saved.products.find(
|
const savedProduct =
|
||||||
(item) => item.product_code === product.product_code
|
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;
|
return savedProduct?.price !== product.price;
|
||||||
}).length;
|
}).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) {
|
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) {
|
function updateProductPrice(productCode: string, value: string) {
|
||||||
const price = value === '' ? null : Number(value);
|
const price = value === '' ? null : Number(value);
|
||||||
|
|
||||||
slots = slots.map((slot) => {
|
if (!workingSlot) return;
|
||||||
if (slot.slot !== selectedSlot) return slot;
|
|
||||||
|
|
||||||
return {
|
workingSlot = {
|
||||||
...slot,
|
...workingSlot,
|
||||||
products: slot.products.map((product) =>
|
products: workingSlot.products.map((product) =>
|
||||||
product.product_code === productCode
|
product.product_code === productCode
|
||||||
? { ...product, price: Number.isNaN(price) ? product.price : price }
|
? { ...product, price: Number.isNaN(price) ? product.price : price }
|
||||||
: product
|
: 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() {
|
function resetSlot() {
|
||||||
const savedSlot = savedSnapshot[selectedSlot - 1];
|
|
||||||
if (!savedSlot) return;
|
if (!savedSlot) return;
|
||||||
|
|
||||||
slots = slots.map((slot) => (slot.slot === selectedSlot ? structuredClone(savedSlot) : slot));
|
workingSlot = clonePriceSlot(savedSlot);
|
||||||
addNotification(`INFO:Reset PriceSlot${selectedSlot}`);
|
addNotification(`INFO:Reset PriceSlot${selectedSlot}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSlot() {
|
function saveSlot() {
|
||||||
savedSnapshot = savedSnapshot.map((slot) =>
|
if (!currentSlot.slot) {
|
||||||
slot.slot === selectedSlot ? structuredClone(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() {
|
async function loadPriceSlots() {
|
||||||
loading = true;
|
localSlots = [];
|
||||||
setTimeout(() => {
|
workingSlot = null;
|
||||||
loading = false;
|
savedSlot = null;
|
||||||
addNotification('WARN:PriceSlot backend is not ready. Showing UI mock data.');
|
selectedSlot = 0;
|
||||||
}, 250);
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -348,144 +467,233 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-5 flex gap-2 overflow-x-auto border-b">
|
<div class="sticky top-0 z-30 mb-5 bg-background/95 pt-2 pb-1 backdrop-blur">
|
||||||
{#each slots as slot}
|
<div class="mb-4 flex gap-2 overflow-x-auto border-b">
|
||||||
<button
|
{#if displaySlots.length > 0}
|
||||||
class={[
|
{#each displaySlots as slot}
|
||||||
'min-w-28 border-b-2 px-4 py-3 text-sm font-semibold transition-colors',
|
<button
|
||||||
selectedSlot === slot.slot
|
class={[
|
||||||
? 'border-emerald-500 text-foreground'
|
'min-w-28 border-b-2 px-4 py-3 text-sm font-semibold transition-colors',
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
selectedSlot === slot.slot
|
||||||
]}
|
? 'border-emerald-500 text-foreground'
|
||||||
onclick={() => (selectedSlot = slot.slot)}
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
|
]}
|
||||||
|
onclick={() => (selectedSlot = slot.slot)}
|
||||||
|
>
|
||||||
|
PriceSlot{slot.slot}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="py-3 text-sm text-muted-foreground">
|
||||||
|
{loading ? 'Loading PriceSlot data...' : 'No PriceSlot data loaded'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/80 bg-card p-4 shadow-sm">
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 items-end gap-4 xl:grid-cols-[180px_minmax(220px,1fr)_minmax(280px,1.25fr)_auto]"
|
||||||
>
|
>
|
||||||
PriceSlot{slot.slot}
|
<div class="flex min-w-0 flex-col justify-end gap-2 pb-1">
|
||||||
</button>
|
<div class="flex items-center gap-3">
|
||||||
{/each}
|
<h2 class="text-xl font-bold tracking-normal">
|
||||||
</div>
|
{currentSlot.slot ? `PriceSlot${selectedSlot}` : 'No PriceSlot'}
|
||||||
|
</h2>
|
||||||
<div class="mb-5 rounded-lg border border-border/80 bg-card p-4 shadow-sm">
|
<Badge variant={hasChanges ? 'default' : 'secondary'}>
|
||||||
<div
|
{hasChanges ? `${totalChangedCount} changes` : 'No changes'}
|
||||||
class="grid grid-cols-1 items-end gap-4 xl:grid-cols-[180px_minmax(220px,1fr)_minmax(280px,1.25fr)_auto]"
|
</Badge>
|
||||||
>
|
</div>
|
||||||
<div class="flex min-w-0 flex-col justify-end gap-2 pb-1">
|
<!-- <p class="text-sm text-muted-foreground">Column K/L</p> -->
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<h2 class="text-xl font-bold tracking-normal">PriceSlot{selectedSlot}</h2>
|
|
||||||
<Badge variant={hasChanges ? 'default' : 'secondary'}>
|
|
||||||
{hasChanges ? `${changedCount} changes` : 'No changes'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- <p class="text-sm text-muted-foreground">Column K/L</p> -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label class="text-xs font-medium text-muted-foreground" for="priceslot-name">Name</label>
|
<label class="text-xs font-medium text-muted-foreground" for="priceslot-name"
|
||||||
<Input
|
>Name</label
|
||||||
id="priceslot-name"
|
>
|
||||||
class="h-10"
|
|
||||||
value={currentSlot.name}
|
|
||||||
oninput={(event) => updateSlotField('name', event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<label class="text-xs font-medium text-muted-foreground" for="priceslot-description">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="priceslot-description"
|
|
||||||
class="h-10"
|
|
||||||
value={currentSlot.description}
|
|
||||||
oninput={(event) => updateSlotField('description', event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-end justify-start gap-2 xl:justify-end">
|
|
||||||
<Button class="h-11 rounded-lg" onclick={openCreateDialog}>
|
|
||||||
<Calculator class="mr-2 h-4 w-4" />
|
|
||||||
Create PriceSlot
|
|
||||||
</Button>
|
|
||||||
<Button class="h-11 rounded-lg px-4" onclick={saveSlot} disabled={!hasChanges}>
|
|
||||||
<Save class="mr-2 h-4 w-4" />
|
|
||||||
Save Draft
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
class="h-11 rounded-lg px-4"
|
|
||||||
onclick={resetSlot}
|
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
|
||||||
<RotateCcw class="mr-2 h-4 w-4" />
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mx-auto w-full max-w-6xl">
|
|
||||||
<div class="mb-3 flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div class="w-full max-w-sm space-y-2">
|
|
||||||
<label class="text-sm font-medium" for="product-code-search">Search ProductCode</label>
|
|
||||||
<div class="relative">
|
|
||||||
<Search
|
|
||||||
class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<Input
|
<Input
|
||||||
id="product-code-search"
|
id="priceslot-name"
|
||||||
class="pl-9 font-mono"
|
class="h-10"
|
||||||
placeholder="12-01-01-0001"
|
value={currentSlot.name}
|
||||||
bind:value={productCodeSearch}
|
disabled={!currentSlot.slot}
|
||||||
|
oninput={(event) => updateSlotField('name', event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-xs font-medium text-muted-foreground" for="priceslot-description">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="priceslot-description"
|
||||||
|
class="h-10"
|
||||||
|
value={currentSlot.description}
|
||||||
|
disabled={!currentSlot.slot}
|
||||||
|
oninput={(event) => updateSlotField('description', event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-end justify-start gap-2 xl:justify-end">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex flex-wrap gap-2 xl:justify-end">
|
||||||
|
<Button
|
||||||
|
class="h-11 rounded-lg"
|
||||||
|
onclick={openCreateDialog}
|
||||||
|
disabled={isServiceSlot || currentSlot.products.length === 0}
|
||||||
|
>
|
||||||
|
<Calculator class="mr-2 h-4 w-4" />
|
||||||
|
Create PriceSlot
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="h-11 rounded-lg px-4"
|
||||||
|
onclick={saveSlot}
|
||||||
|
disabled={!hasChanges || !currentSlot.slot}
|
||||||
|
>
|
||||||
|
<Save class="mr-2 h-4 w-4" />
|
||||||
|
Save Draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="h-11 rounded-lg px-4"
|
||||||
|
onclick={resetSlot}
|
||||||
|
disabled={!hasChanges || !currentSlot.slot}
|
||||||
|
title={resetButtonTitle}
|
||||||
|
>
|
||||||
|
<RotateCcw class="mr-2 h-4 w-4" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-right text-xs text-muted-foreground">
|
||||||
|
Reset discards unsaved changes for the selected PriceSlot.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
Showing {filteredProducts.length} of {currentSlot.products.length} products
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full overflow-hidden rounded-lg border">
|
<div class={['mx-auto mt-4 w-full', isServiceSlot ? 'max-w-none' : 'max-w-6xl']}>
|
||||||
<Table.Root>
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
<Table.Header>
|
<div class="w-full max-w-sm space-y-2">
|
||||||
<Table.Row>
|
<label class="text-sm font-medium" for="product-code-search">
|
||||||
<Table.Head class="w-[190px]">ProductCode</Table.Head>
|
{isServiceSlot ? 'Search ServiceType' : 'Search ProductCode'}
|
||||||
<Table.Head>ProductName [{selectedCountryLanguage}]</Table.Head>
|
</label>
|
||||||
<Table.Head>ProductNameEng</Table.Head>
|
<div class="relative">
|
||||||
<Table.Head class="w-[150px] text-right">Price</Table.Head>
|
<Search
|
||||||
</Table.Row>
|
class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||||
</Table.Header>
|
/>
|
||||||
<Table.Body>
|
<Input
|
||||||
{#each filteredProducts as product (product.product_code)}
|
id="product-code-search"
|
||||||
{@const productNames = getProductNames(product)}
|
class="pl-9 font-mono"
|
||||||
<Table.Row>
|
placeholder={isServiceSlot ? 'Trigger1, Discount, ProductCode' : '12-01-01-0001'}
|
||||||
<Table.Cell class="font-mono text-sm font-semibold">
|
bind:value={productCodeSearch}
|
||||||
{product.product_code}
|
/>
|
||||||
</Table.Cell>
|
</div>
|
||||||
<Table.Cell class="min-w-64 font-medium">{productNames.local}</Table.Cell>
|
</div>
|
||||||
<Table.Cell class="min-w-64 text-muted-foreground">{productNames.english}</Table.Cell>
|
<p class="text-sm text-muted-foreground">
|
||||||
<Table.Cell>
|
Showing {visibleRowCount} of {totalRowCount}
|
||||||
<Input
|
{isServiceSlot ? 'service rows' : 'products'}
|
||||||
type="number"
|
</p>
|
||||||
min="0"
|
</div>
|
||||||
step="1"
|
|
||||||
class="ml-auto w-32 text-right font-semibold"
|
|
||||||
value={product.price ?? ''}
|
|
||||||
oninput={(event) =>
|
|
||||||
updateProductPrice(product.product_code, event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
{#if filteredProducts.length === 0}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell colspan={4} class="h-28 text-center text-muted-foreground">
|
|
||||||
No product code found.
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/if}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class={['mx-auto w-full', isServiceSlot ? 'max-w-none' : 'max-w-6xl']}>
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex h-48 items-center justify-center rounded-lg border text-muted-foreground">
|
||||||
|
Loading PriceSlot data...
|
||||||
|
</div>
|
||||||
|
{:else if $priceSlotsError}
|
||||||
|
<div class="rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
{$priceSlotsError}
|
||||||
|
</div>
|
||||||
|
{:else if displaySlots.length === 0}
|
||||||
|
<div class="flex h-48 items-center justify-center rounded-lg border text-muted-foreground">
|
||||||
|
No PriceSlot data from backend.
|
||||||
|
</div>
|
||||||
|
{:else if isServiceSlot}
|
||||||
|
<div class="w-full overflow-x-auto rounded-lg border">
|
||||||
|
<Table.Root style={`min-width: ${Math.max(1400, getServiceTableMinWidth())}px`}>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
{#each serviceHeaders as header}
|
||||||
|
<Table.Head class={getServiceColumnClass(header)}>{header}</Table.Head>
|
||||||
|
{/each}
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each filteredServiceRows as row, index (`${row.row_index ?? index}-${index}`)}
|
||||||
|
<Table.Row>
|
||||||
|
{#each serviceHeaders as header}
|
||||||
|
<Table.Cell class={getServiceColumnClass(header)}>
|
||||||
|
<Input
|
||||||
|
class={getServiceInputClass(header)}
|
||||||
|
value={row.cells[header] ?? ''}
|
||||||
|
oninput={(event) =>
|
||||||
|
updateServiceCell(row, index, header, event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
{/each}
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
{#if filteredServiceRows.length === 0}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell
|
||||||
|
colspan={Math.max(serviceHeaders.length, 1)}
|
||||||
|
class="h-28 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No service rows found.
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/if}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="w-full overflow-hidden rounded-lg border">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head class="w-[190px]">ProductCode</Table.Head>
|
||||||
|
<Table.Head>ProductName [{selectedCountryLanguage}]</Table.Head>
|
||||||
|
<Table.Head>ProductNameEng</Table.Head>
|
||||||
|
<Table.Head class="w-[150px] text-right">Price</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each filteredProducts as product, index (`${product.product_code}-${product.row_index ?? index}`)}
|
||||||
|
{@const productNames = getProductNames(product)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-mono text-sm font-semibold">
|
||||||
|
{product.product_code}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="min-w-64 font-medium">{productNames.local}</Table.Cell>
|
||||||
|
<Table.Cell class="min-w-64 text-muted-foreground"
|
||||||
|
>{productNames.english}</Table.Cell
|
||||||
|
>
|
||||||
|
<Table.Cell>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
class="ml-auto w-32 text-right font-semibold"
|
||||||
|
value={product.price ?? ''}
|
||||||
|
oninput={(event) =>
|
||||||
|
updateProductPrice(product.product_code, event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
{#if filteredProducts.length === 0}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell colspan={4} class="h-28 text-center text-muted-foreground">
|
||||||
|
No product code found.
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/if}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -499,19 +707,6 @@
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<!-- <div class="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-semibold">Base prices</p>
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
{basePriceLoading ? 'Loading prices from backend' : `${basePricesLoadedCount} products ready`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onclick={loadBasePrices} disabled={basePriceLoading}>
|
|
||||||
<RefreshCw class="mr-2 h-4 w-4" />
|
|
||||||
Load Base
|
|
||||||
</Button>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-sm font-medium" for="adjustment-mode">Adjustment Mode</label>
|
<label class="text-sm font-medium" for="adjustment-mode">Adjustment Mode</label>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue