Compare commits

...
Sign in to create a new pull request.

1 commit
master ... dev

Author SHA1 Message Date
bd239cf71b create topping and material page 2026-06-11 16:25:27 +07:00
9 changed files with 2606 additions and 57 deletions

View file

@ -12,6 +12,7 @@
CupSodaIcon,
Shield,
FileSpreadsheet,
DollarSign,
MonitorSmartphone,
PlusCircle,
ImageUp,
@ -135,6 +136,12 @@
url: '/departments',
icon: FileSpreadsheet,
requirePerm: 'document.write.*'
},
{
title: 'PriceSlot',
url: '/departments',
icon: DollarSign,
requirePerm: 'document.write.*'
}
]
}
@ -228,7 +235,7 @@
onclick={(e) => {
if (nav.title === 'Sheet') {
e.preventDefault();
referenceFromPage.set('sheet');
referenceFromPage.set(sub.title === 'PriceSlot' ? 'priceslot' : 'sheet');
goto(sub.url);
}
}}

View file

@ -395,59 +395,59 @@ const handlers: Record<string, (payload: any) => void> = {
lastRequestSheetPrice.set(lastRequestPriceInstance);
},
raw_stream: (p) => {
let streamRawInstance = get(streamingRawData);
let sub_type = p.sub_type;
let request_id = p.request_id;
let size_per_chunk = p.size_per_chunk;
let total_chunks = p.total_chunks;
let idx = p.idx;
// raw_stream: (p) => {
// let streamRawInstance = get(streamingRawData);
// let sub_type = p.sub_type;
// let request_id = p.request_id;
// let size_per_chunk = p.size_per_chunk;
// let total_chunks = p.total_chunks;
// let idx = p.idx;
switch (sub_type) {
case 'price':
streamingRawMeta.set({
id: request_id,
total_size: total_chunks,
chunk_size: size_per_chunk,
progress: 0
});
break;
case 'chunk_price':
streamingRawMeta.set({
id: request_id,
total_size: total_chunks,
chunk_size: size_per_chunk,
progress: idx
});
// switch (sub_type) {
// case 'price':
// streamingRawMeta.set({
// id: request_id,
// total_size: total_chunks,
// chunk_size: size_per_chunk,
// progress: 0
// });
// break;
// case 'chunk_price':
// streamingRawMeta.set({
// id: request_id,
// total_size: total_chunks,
// chunk_size: size_per_chunk,
// progress: idx
// });
let raw_payload = p.raw ?? '';
streamRawInstance[request_id] += raw_payload;
streamingRawData.set(streamRawInstance);
// let raw_payload = p.raw ?? '';
// streamRawInstance[request_id] += raw_payload;
// streamingRawData.set(streamRawInstance);
break;
case 'end_price':
let lastRequestPriceInstance = get(lastRequestSheetPrice);
let country = lastRequestPriceInstance[request_id];
// break;
// case 'end_price':
// let lastRequestPriceInstance = get(lastRequestSheetPrice);
// let country = lastRequestPriceInstance[request_id];
try {
let raw_payload = JSON.parse(streamRawInstance[request_id]);
let ref_from_raw = raw_payload.payload.ref ?? '';
let from_service_raw = raw_payload.payload.from ?? '';
let parsed_payload = raw_payload.payload ?? '';
// try {
// let raw_payload = JSON.parse(streamRawInstance[request_id]);
// let ref_from_raw = raw_payload.payload.ref ?? '';
// let from_service_raw = raw_payload.payload.from ?? '';
// let parsed_payload = raw_payload.payload ?? '';
if (from_service_raw == 'sheet-service') {
handleSheetResponseFromNoti(parsed_payload, ref_from_raw, country);
delete streamRawInstance[request_id];
streamingRawData.set(streamRawInstance);
}
} catch (e) {
console.log(`end price process error: ${e}`);
}
// if (from_service_raw == 'sheet-service') {
// handleSheetResponseFromNoti(parsed_payload, ref_from_raw, country);
// delete streamRawInstance[request_id];
// streamingRawData.set(streamRawInstance);
// }
// } catch (e) {
// console.log(`end price process error: ${e}`);
// }
break;
default:
}
},
// break;
// default:
// }
// },
heartbeat: (p) => {
socketConnectionOfflineCount.set(0);
socketAlreadySendHeartbeat.set(0);
@ -486,12 +486,12 @@ export function handleIncomingMessages(raw: string) {
}
// raw streaming type
if (msg.type.startsWith('raw_stream')) {
// convert
let sub_type = msg.type.replace('raw_stream_', '');
msg.payload.sub_type = sub_type;
msg.type = 'raw_stream';
}
// if (msg.type.startsWith('raw_stream')) {
// // convert
// let sub_type = msg.type.replace('raw_stream_', '');
// msg.payload.sub_type = sub_type;
// msg.type = 'raw_stream';
// }
handlers[msg.type]?.(msg.payload);
}

View file

@ -18,6 +18,29 @@ export function requestCatalogs(country: string): boolean {
});
}
export function requestPriceSlots(country: string): boolean {
return sendCommandRequest('sheet', {
country: country,
param: 'priceslot'
});
}
export function updatePriceSlot(
country: string,
content: {
slot: number;
name: string;
description: string;
products: { product_code: string; price: number | null; row_index?: number }[];
}
): boolean {
return sendCommandRequest('sheet', {
country: country,
content: content,
param: 'update/priceslot'
});
}
export function enterRoom(country: string, catalog: string): boolean {
return sendCommandRequest('sheet', {
country: country,

View file

@ -17,6 +17,24 @@ export interface CatalogsResponse {
export const sheetCatalogs = writable<Catalog[]>([]);
export const sheetCatalogsLoading = writable<boolean>(false);
export interface PriceSlotProduct {
product_code: string;
name: string;
price: number | null;
row_index?: number;
}
export interface PriceSlot {
slot: number;
name: string;
description: string;
products: PriceSlotProduct[];
}
export const priceSlots = writable<Record<string, PriceSlot[]>>({});
export const priceSlotsLoading = writable<boolean>(false);
export const priceSlotsError = writable<string | null>(null);
export const countryPrimaryLanguageMap: Record<string, string> = {
THAI: 'Thai',
tha: 'Thai',

View file

@ -25,7 +25,9 @@
console.log(get(departmentStore));
departmentStore.set(cnt);
if (refPage === 'sheet') {
if (refPage === 'priceslot') {
await goto(`/sheet/priceslot/${cnt}`);
} else if (refPage === 'sheet') {
await goto(`/sheet/overview/${cnt}`);
} else {
await goto('/recipe/overview');

View file

@ -0,0 +1,842 @@
<script lang="ts">
import { onMount } from 'svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import * as adb from '$lib/core/adb/adb';
import { addNotification } from '$lib/core/stores/noti';
import { referenceFromPage } from '$lib/core/stores/recipeStore';
import type { Material } from '$lib/models/material.model';
const sourceDir = '/sdcard/coffeevending';
const recipePaths = [`${sourceDir}/cfg/recipe_branch_dev.json`, `${sourceDir}/coffeethai02.json`];
type MaterialChannel =
| 'BeanChannel'
| 'PowderChannel'
| 'SyrupChannel'
| 'FreshSyrupChannel'
| 'FrozenFruitChannel'
| 'LeavesChannel'
| 'SodaChannel'
| 'ItemChannel'
| 'IceScreamBingsuChannel';
type MaterialForm = {
id: number;
idAlternate: number;
isUse: boolean;
MaterialStatus: number;
materialName: string;
materialOtherName: string;
MaterialDescrption: string;
pathOtherName: string;
CanisterType: string;
channel: MaterialChannel;
LowToOffline: number;
AlarmIDWhenOffline: number;
DrainTimer: number;
ScheduleDrainType: number;
pay_rettry_max_count: number;
RawMaterialUnit: string;
RefillUnitGram: boolean;
RefillUnitMilliliters: boolean;
RefillUnitPCS: boolean;
IsEquipment: boolean;
MaterialParameter: string;
errorThai: string;
errorEnglish: string;
};
const channelOptions: {
value: MaterialChannel;
label: string;
canisterType: string;
unit: string;
}[] = [
{
value: 'BeanChannel',
label: 'Bean',
canisterType: 'BeanType',
unit: 'refill=$bag,sum=#gram,rec=$gram'
},
{
value: 'PowderChannel',
label: 'Powder',
canisterType: 'PowderType',
unit: 'refill=$bag,sum=$gram,rec=$gram'
},
{
value: 'SyrupChannel',
label: 'Syrup',
canisterType: 'Bag In Box',
unit: 'refill=$bag,sum=$gram,rec=$gram'
},
{
value: 'FreshSyrupChannel',
label: 'Fresh Syrup',
canisterType: 'Tank',
unit: 'refill=$bag,sum=$gram,rec=$gram'
},
{
value: 'FrozenFruitChannel',
label: 'Frozen Fruit',
canisterType: '',
unit: 'refill=$L,sum=$ml,rec=$ml'
},
{
value: 'LeavesChannel',
label: 'Leaves',
canisterType: '',
unit: 'refill=$bag,sum=#gram,rec=$gram'
},
{
value: 'SodaChannel',
label: 'Soda',
canisterType: '',
unit: 'refill=$L,sum=$ml,rec=$ml'
},
{
value: 'ItemChannel',
label: 'Item',
canisterType: '',
unit: 'refill=$cup,sum=$pcs,rec=$pcs'
},
{
value: 'IceScreamBingsuChannel',
label: 'Machine / Ice Cream',
canisterType: 'Machine',
unit: 'refill=$bag,sum=$gram,rec=$gram'
}
];
let devRecipe: any = $state(null);
let loadedRecipePath = $state('');
let loading = $state(false);
let saving = $state(false);
let search = $state('');
let showMaterialForm = $state(false);
let deleteConfirmOpen = $state(false);
let pendingDeleteMaterial: Material | null = $state(null);
let form: MaterialForm = $state(createInitialForm());
let materials = $derived<Material[]>(devRecipe?.MaterialSetting ?? []);
let filteredMaterials = $derived(
materials.filter((material) => {
const text =
`${material.id} ${material.materialName ?? ''} ${material.materialOtherName ?? ''} ${material.pathOtherName ?? ''}`.toLowerCase();
return text.includes(search.toLowerCase());
})
);
let materialPreview = $derived(buildMaterialSetting());
let previewJson = $derived(JSON.stringify(materialPreview, null, 2));
let existingMaterial = $derived(
materials.find((material) => Number(material.id) === Number(form.id)) ?? null
);
let activeMaterialCount = $derived(
materials.filter((material) => (material.isUse as boolean) !== false).length
);
let channelSummary = $derived(
channelOptions
.map((option) => ({
...option,
count: materials.filter((material) => Boolean(material[option.value])).length
}))
.filter((option) => option.count > 0)
);
function createInitialForm(): MaterialForm {
return {
id: 1001,
idAlternate: 0,
isUse: true,
MaterialStatus: 0,
materialName: '',
materialOtherName: '',
MaterialDescrption: '',
pathOtherName: 'Bean box',
CanisterType: 'BeanType',
channel: 'BeanChannel',
LowToOffline: 30,
AlarmIDWhenOffline: 0,
DrainTimer: 0,
ScheduleDrainType: 0,
pay_rettry_max_count: 0,
RawMaterialUnit: 'refill=$bag,sum=#gram,rec=$gram',
RefillUnitGram: false,
RefillUnitMilliliters: false,
RefillUnitPCS: false,
IsEquipment: false,
MaterialParameter: '',
errorThai: '',
errorEnglish: ''
};
}
async function pullTextWithRetry(path: string, timeoutMs = 15000, attempts = 2) {
for (let attempt = 1; attempt <= attempts; attempt++) {
const content = await adb.pull(path, timeoutMs);
if (content != undefined) return content;
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
}
}
async function connectAdb() {
try {
if (!adb.getAdbInstance()) {
if (!('usb' in navigator)) throw new Error('WebUSB not supported');
await adb.connectRecipeMenuViaWebUSB();
}
await loadRecipeFromMachine();
} catch (error: any) {
addNotification(`ERR:${error?.message ?? error}`);
}
}
async function loadRecipeFromMachine() {
if (loading) return;
if (!adb.getAdbInstance()) {
addNotification('ERR:ADB is not connected');
return;
}
loading = true;
referenceFromPage.set('material');
try {
for (const recipePath of recipePaths) {
const content = await pullTextWithRetry(recipePath);
if (!content || content.trim().length === 0) continue;
try {
devRecipe = JSON.parse(content);
loadedRecipePath = recipePath;
setNextAvailableId();
addNotification(`INFO:Recipe loaded from ${recipePath}`);
return;
} catch (error) {
console.error('failed to parse recipe json', recipePath, error);
addNotification(`ERR:Invalid recipe JSON from ${recipePath}`);
}
}
addNotification('ERR:Cannot fetch recipe from machine');
} finally {
loading = false;
}
}
function setNextAvailableId() {
const usedIds = new Set(materials.map((material) => Number(material.id)));
let nextId = Math.max(1001, ...[...usedIds].filter(Number.isFinite)) + 1;
while (usedIds.has(nextId)) nextId++;
form.id = nextId;
}
function applyChannelPreset() {
const preset = channelOptions.find((option) => option.value === form.channel);
if (!preset) return;
form.CanisterType = preset.canisterType;
form.RawMaterialUnit = preset.unit;
if (!form.pathOtherName) form.pathOtherName = preset.label;
}
function buildMaterialSetting(): Material {
const channelFlags = Object.fromEntries(channelOptions.map((option) => [option.value, false]));
channelFlags[form.channel] = true;
return {
AlarmIDWhenOffline: Number(form.AlarmIDWhenOffline) || 0,
BeanChannel: Boolean(channelFlags.BeanChannel),
CanisterType: form.CanisterType,
DrainTimer: Number(form.DrainTimer) || 0,
FreshSyrupChannel: Boolean(channelFlags.FreshSyrupChannel),
FrozenFruitChannel: Boolean(channelFlags.FrozenFruitChannel),
IceScreamBingsuChannel: Boolean(channelFlags.IceScreamBingsuChannel),
IsEquipment: form.IsEquipment,
ItemChannel: Boolean(channelFlags.ItemChannel),
LeavesChannel: Boolean(channelFlags.LeavesChannel),
LowToOffline: Number(form.LowToOffline) || 0,
MaterialDescrption: form.MaterialDescrption,
MaterialDescription: form.MaterialDescrption,
MaterialStatus: Number(form.MaterialStatus) || 0,
PowderChannel: Boolean(channelFlags.PowderChannel),
RefillUnitGram: form.RefillUnitGram,
RefillUnitMilliliters: form.RefillUnitMilliliters,
RefillUnitPCS: form.RefillUnitPCS,
ScheduleDrainType: Number(form.ScheduleDrainType) || 0,
SodaChannel: Boolean(channelFlags.SodaChannel),
StrTextShowError: [form.errorThai, form.errorEnglish, '', '', '', '', '', ''],
SyrupChannel: Boolean(channelFlags.SyrupChannel),
id: Number(form.id) || 0,
idAlternate: Number(form.idAlternate) || 0,
isUse: form.isUse,
materialOtherName: form.materialOtherName,
materialName: form.materialName,
pathOtherName: form.pathOtherName,
pay_rettry_max_count: Number(form.pay_rettry_max_count) || 0,
RawMaterialUnit: form.RawMaterialUnit,
MaterialParameter: form.MaterialParameter
} as Material;
}
function validateMaterial() {
if (!devRecipe) return 'Load recipe from Android first';
if (!Array.isArray(devRecipe.MaterialSetting)) return 'Recipe has no MaterialSetting array';
if (!Number.isFinite(Number(form.id)) || Number(form.id) <= 0) return 'Material ID is required';
if (!form.materialName.trim()) return 'Thai material name is required';
if (!form.materialOtherName.trim()) return 'English material name is required';
if (!form.RawMaterialUnit.trim()) return 'RawMaterialUnit is required';
return '';
}
async function persistRecipeToAndroid(nextRecipe: any) {
nextRecipe.Timestamp = new Date().toLocaleString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
const targetPath = loadedRecipePath || recipePaths[0];
const tempPath = `${targetPath}.tmp`;
await adb.push(tempPath, JSON.stringify(nextRecipe, null, 2));
const result = await adb.executeCmd(`mv ${tempPath} ${targetPath}`);
if (result?.error) throw new Error(String(result.error));
devRecipe = nextRecipe;
return targetPath;
}
async function saveMaterialToAndroid() {
const error = validateMaterial();
if (error) {
addNotification(`ERR:${error}`);
return;
}
saving = true;
try {
const material = buildMaterialSetting();
const nextRecipe = JSON.parse(JSON.stringify(devRecipe));
const materialIndex = nextRecipe.MaterialSetting.findIndex(
(item: Material) => Number(item.id) === Number(material.id)
);
if (materialIndex >= 0) {
nextRecipe.MaterialSetting[materialIndex] = {
...nextRecipe.MaterialSetting[materialIndex],
...material
};
} else {
nextRecipe.MaterialSetting.push(material);
}
const targetPath = await persistRecipeToAndroid(nextRecipe);
addNotification(
`INFO:Material ${material.id} ${materialIndex >= 0 ? 'updated' : 'created'} in ${targetPath}`
);
if (materialIndex < 0) setNextAvailableId();
showMaterialForm = false;
} catch (error: any) {
console.error('failed to save material', error);
addNotification(`ERR:Failed to save material: ${error?.message ?? error}`);
} finally {
saving = false;
}
}
function loadMaterialIntoForm(material: Material) {
const activeChannel = channelOptions.find((option) => Boolean(material[option.value]));
form = {
...createInitialForm(),
id: Number(material.id) || 0,
idAlternate: Number(material.idAlternate) || 0,
isUse: (material.isUse as boolean) !== false,
MaterialStatus: Number(material.MaterialStatus) || 0,
materialName: material.materialName ?? '',
materialOtherName: material.materialOtherName ?? '',
MaterialDescrption: material.MaterialDescrption ?? material.MaterialDescription ?? '',
pathOtherName: material.pathOtherName ?? '',
CanisterType: material.CanisterType ?? '',
channel: activeChannel?.value ?? 'BeanChannel',
LowToOffline: Number(material.LowToOffline) || 0,
AlarmIDWhenOffline: Number(material.AlarmIDWhenOffline) || 0,
DrainTimer: Number(material.DrainTimer) || 0,
ScheduleDrainType: Number(material.ScheduleDrainType) || 0,
pay_rettry_max_count: Number(material.pay_rettry_max_count) || 0,
RawMaterialUnit: material.RawMaterialUnit ?? '',
RefillUnitGram: Boolean(material.RefillUnitGram),
RefillUnitMilliliters: Boolean(material.RefillUnitMilliliters),
RefillUnitPCS: Boolean(material.RefillUnitPCS),
IsEquipment: Boolean(material.IsEquipment),
MaterialParameter: material.MaterialParameter ?? '',
errorThai: material.StrTextShowError?.[0] ?? '',
errorEnglish: material.StrTextShowError?.[1] ?? ''
};
showMaterialForm = true;
}
function resetForm() {
form = createInitialForm();
if (devRecipe) setNextAvailableId();
}
function openAddMaterialForm() {
resetForm();
showMaterialForm = true;
}
function openDeleteConfirm(material: Material) {
pendingDeleteMaterial = material;
deleteConfirmOpen = true;
}
async function confirmDeleteMaterial() {
if (!pendingDeleteMaterial) return;
if (!devRecipe || !Array.isArray(devRecipe.MaterialSetting)) {
addNotification('ERR:Load recipe from Android first');
return;
}
saving = true;
try {
const materialId = Number(pendingDeleteMaterial.id);
const nextRecipe = JSON.parse(JSON.stringify(devRecipe));
const beforeCount = nextRecipe.MaterialSetting.length;
nextRecipe.MaterialSetting = nextRecipe.MaterialSetting.filter(
(material: Material) => Number(material.id) !== materialId
);
if (nextRecipe.MaterialSetting.length === beforeCount) {
addNotification(`WARN:Material not found: ${materialId}`);
return;
}
await persistRecipeToAndroid(nextRecipe);
addNotification(`INFO:Material deleted: ${materialId}`);
deleteConfirmOpen = false;
pendingDeleteMaterial = null;
} catch (error: any) {
console.error('failed to delete material', error);
addNotification(`ERR:Failed to delete material: ${error?.message ?? error}`);
} finally {
saving = false;
}
}
onMount(() => {
referenceFromPage.set('material');
if (adb.getAdbInstance()) void loadRecipeFromMachine();
});
</script>
<div class="mx-auto flex w-full max-w-7xl flex-col gap-6 p-8">
<div class="overflow-hidden rounded-2xl border bg-card shadow-sm">
<div
class="flex flex-col gap-5 bg-gradient-to-br from-muted/70 via-card to-card p-6 md:flex-row md:items-end md:justify-between"
>
<div>
<p class="text-xs font-semibold tracking-[0.2em] text-muted-foreground uppercase">
Android Recipe
</p>
<h1 class="mt-2 text-4xl font-bold tracking-tight">Material Setting</h1>
<!-- <p class="mt-2 max-w-2xl text-sm text-muted-foreground">
Browse existing Android <code>MaterialSetting</code> entries first, then add or edit a material
in a dialog without leaving this list.
</p> -->
{#if loadedRecipePath}
<p class="mt-3 font-mono text-xs text-muted-foreground">Loaded: {loadedRecipePath}</p>
{/if}
</div>
<div class="flex gap-2">
<Button variant="outline" onclick={connectAdb} disabled={loading || saving}>
{#if loading}
<Spinner />
Loading
{:else if devRecipe}
Reload From Android
{:else}
Connect & Load
{/if}
</Button>
</div>
</div>
</div>
<div class="grid gap-2 md:grid-cols-3">
<div class="rounded-lg border border-sky-200 bg-card px-4 py-3 shadow-sm dark:border-sky-900">
<div class="mb-2 h-1 w-10 rounded-full bg-sky-500"></div>
<div class="text-xs text-muted-foreground">Total materials</div>
<div class="mt-1 text-xl font-semibold">{materials.length}</div>
</div>
<div
class="rounded-lg border border-emerald-200 bg-card px-4 py-3 shadow-sm dark:border-emerald-900"
>
<div class="mb-2 h-1 w-10 rounded-full bg-emerald-500"></div>
<div class="text-xs text-muted-foreground">Active materials</div>
<div class="mt-1 text-xl font-semibold">{activeMaterialCount}</div>
</div>
<div
class="rounded-lg border border-violet-200 bg-card px-4 py-3 shadow-sm dark:border-violet-900"
>
<div class="mb-2 h-1 w-10 rounded-full bg-violet-500"></div>
<div class="text-xs text-muted-foreground">Channels in use</div>
<div class="mt-1 text-xl font-semibold">{channelSummary.length}</div>
</div>
</div>
<Dialog.Root bind:open={showMaterialForm}>
<Dialog.Content class="max-h-[92vh] overflow-y-auto sm:max-w-6xl">
<Dialog.Header>
<Dialog.Title>{existingMaterial ? 'Edit Material' : 'Add Material'}</Dialog.Title>
<Dialog.Description>
Create or update one <code>MaterialSetting</code> entry. The JSON preview shows the payload
before saving to Android.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<Card.Root>
<Card.Header>
<Card.Title>{existingMaterial ? 'Edit Material' : 'Add Material'}</Card.Title>
</Card.Header>
<Card.Content class="grid gap-6">
{#if existingMaterial}
<div
class="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200"
>
Material ID {form.id} already exists. Saving will update this MaterialSetting.
</div>
{/if}
<div class="grid gap-4 md:grid-cols-3">
<div class="grid gap-2">
<Label for="material-id">Material ID</Label>
<Input id="material-id" type="number" bind:value={form.id} />
</div>
<div class="grid gap-2">
<Label for="material-id-alt">Alternate ID</Label>
<Input id="material-id-alt" type="number" bind:value={form.idAlternate} />
</div>
<div class="grid gap-2">
<Label for="material-status">Status</Label>
<select
id="material-status"
class="h-9 rounded-md border bg-background px-3 text-sm"
bind:value={form.MaterialStatus}
>
<option value={0}>0 - Ready</option>
<option value={2}>2 - Obsolete</option>
<option value={11}>11 - Pending online</option>
<option value={12}>12 - Pending offline</option>
</select>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="material-name">Thai Name</Label>
<Input
id="material-name"
bind:value={form.materialName}
placeholder="เช่น กาแฟคั่วเข้ม"
/>
</div>
<div class="grid gap-2">
<Label for="material-other-name">English Name</Label>
<Input
id="material-other-name"
bind:value={form.materialOtherName}
placeholder="e.g. dark-roasts"
/>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="material-description">Description</Label>
<Input
id="material-description"
bind:value={form.MaterialDescrption}
placeholder="e.g. Barista Blend"
/>
</div>
<div class="grid gap-2">
<Label for="path-other-name">Path / Canister Name</Label>
<Input
id="path-other-name"
bind:value={form.pathOtherName}
placeholder="e.g. Bean box"
/>
</div>
</div>
<div class="grid gap-4 md:grid-cols-3">
<div class="grid gap-2">
<Label for="channel">Channel</Label>
<select
id="channel"
class="h-9 rounded-md border bg-background px-3 text-sm"
value={form.channel}
onchange={(event) => {
form.channel = event.currentTarget.value as MaterialChannel;
applyChannelPreset();
}}
>
{#each channelOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="grid gap-2">
<Label for="canister-type">Canister Type</Label>
<Input id="canister-type" bind:value={form.CanisterType} />
</div>
<div class="grid gap-2">
<Label for="low-to-offline">Low To Offline</Label>
<Input id="low-to-offline" type="number" bind:value={form.LowToOffline} />
</div>
</div>
<div class="grid gap-2">
<Label for="raw-material-unit">RawMaterialUnit</Label>
<Input id="raw-material-unit" bind:value={form.RawMaterialUnit} />
<p class="text-xs text-muted-foreground">
Examples: <code>refill=$bag,sum=$gram,rec=$gram</code>,
<code>refill=$L,sum=$ml,rec=$ml</code>
</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="error-thai">Error Text TH</Label>
<Input id="error-thai" bind:value={form.errorThai} placeholder="เช่น กาแฟหมด" />
</div>
<div class="grid gap-2">
<Label for="error-english">Error Text EN</Label>
<Input
id="error-english"
bind:value={form.errorEnglish}
placeholder="e.g. Out of Coffee bean"
/>
</div>
</div>
<div class="grid gap-4 md:grid-cols-4">
<div class="grid gap-2">
<Label for="alarm-id">Alarm ID</Label>
<Input id="alarm-id" type="number" bind:value={form.AlarmIDWhenOffline} />
</div>
<div class="grid gap-2">
<Label for="drain-timer">Drain Timer</Label>
<Input id="drain-timer" type="number" bind:value={form.DrainTimer} />
</div>
<div class="grid gap-2">
<Label for="schedule-drain">Schedule Drain</Label>
<Input id="schedule-drain" type="number" bind:value={form.ScheduleDrainType} />
</div>
<div class="grid gap-2">
<Label for="pay-retry">Pay Retry Max</Label>
<Input id="pay-retry" type="number" bind:value={form.pay_rettry_max_count} />
</div>
</div>
<div class="grid gap-3 rounded-md border p-4 md:grid-cols-5">
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={form.isUse} />
Use material
</label>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={form.IsEquipment} />
Equipment
</label>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={form.RefillUnitGram} />
Refill gram
</label>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={form.RefillUnitMilliliters} />
Refill ml
</label>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={form.RefillUnitPCS} />
Refill pcs
</label>
</div>
<div class="grid gap-2">
<Label for="material-parameter">MaterialParameter</Label>
<textarea
id="material-parameter"
class="min-h-20 rounded-md border bg-background px-3 py-2 font-mono text-sm"
bind:value={form.MaterialParameter}
placeholder="Optional extra Android parameter"
></textarea>
</div>
<div class="flex justify-end gap-2">
<Button variant="outline" onclick={() => (showMaterialForm = false)} disabled={saving}
>Cancel</Button
>
<Button variant="outline" onclick={resetForm} disabled={saving}>Reset</Button>
<Button onclick={saveMaterialToAndroid} disabled={!devRecipe || loading || saving}>
{saving ? 'Saving...' : existingMaterial ? 'Update Material' : 'Create Material'}
</Button>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Preview JSON</Card.Title>
<Card.Description>
Payload that will be upserted into <code>MaterialSetting</code>.
</Card.Description>
</Card.Header>
<Card.Content>
<pre
class="max-h-[520px] overflow-auto rounded-md bg-muted p-4 text-xs">{previewJson}</pre>
</Card.Content>
</Card.Root>
</div>
</Dialog.Content>
</Dialog.Root>
<Card.Root>
<Card.Header>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<Card.Title>Existing Materials</Card.Title>
<Card.Description>
Use Edit to update a material, or Delete to remove it after confirmation.
</Card.Description>
</div>
<Button onclick={openAddMaterialForm} disabled={!devRecipe || loading || saving}
>Add Material</Button
>
</div>
</Card.Header>
<Card.Content class="grid gap-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<Input class="lg:max-w-md" bind:value={search} placeholder="Search by id, name, path" />
<div class="flex flex-wrap gap-2">
{#each channelSummary as channel}
<span class="rounded-full border bg-muted/40 px-3 py-1 text-xs text-muted-foreground">
{channel.label}: {channel.count}
</span>
{/each}
</div>
</div>
<div class="overflow-hidden rounded-md border">
{#if loading}
<div class="flex items-center gap-3 p-4 text-sm text-muted-foreground">
<Spinner />
Loading materials from Android...
</div>
{:else if !devRecipe}
<div class="p-4 text-sm text-muted-foreground">Connect and load recipe first.</div>
{:else if filteredMaterials.length === 0}
<div class="p-4 text-sm text-muted-foreground">No materials found.</div>
{:else}
<div
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid md:grid-cols-[120px_minmax(0,1fr)_minmax(0,1fr)_150px_90px_150px] md:items-center"
>
<span>ID</span>
<span>Thai Name</span>
<span>English Name</span>
<span>Path</span>
<span>Use</span>
<span class="text-right">Actions</span>
</div>
<div class="grid max-h-[70vh] overflow-auto">
{#each filteredMaterials as material}
<div
class="grid w-full gap-3 border-b p-4 text-sm transition-colors hover:bg-primary/5 md:grid-cols-[120px_minmax(0,1fr)_minmax(0,1fr)_150px_90px_150px] md:items-center"
>
<span class="font-mono font-medium text-primary">{material.id}</span>
<span class="font-medium">{material.materialName || '-'}</span>
<span class="text-muted-foreground">{material.materialOtherName || '-'}</span>
<span class="text-muted-foreground">{material.pathOtherName || '-'}</span>
<span
class="w-fit rounded-full px-2.5 py-1 text-xs {(material.isUse as boolean) !==
false
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'}"
>
{(material.isUse as boolean) !== false ? 'Use' : 'Not use'}
</span>
<div class="flex gap-2 md:justify-end">
<Button
variant="outline"
size="sm"
onclick={() => loadMaterialIntoForm(material)}
>
Edit
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => openDeleteConfirm(material)}
disabled={saving}
>
Delete
</Button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</Card.Content>
</Card.Root>
<Dialog.Root bind:open={deleteConfirmOpen}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Delete Material?</Dialog.Title>
<Dialog.Description>
This will remove the material from <code>MaterialSetting</code> in the Android recipe JSON.
</Dialog.Description>
</Dialog.Header>
{#if pendingDeleteMaterial}
<div class="rounded-md border bg-muted/30 p-4 text-sm">
<div class="text-xs text-muted-foreground">Material</div>
<div class="mt-1 font-mono font-medium">{pendingDeleteMaterial.id}</div>
<div class="mt-1 font-medium">
{pendingDeleteMaterial.materialName ||
pendingDeleteMaterial.materialOtherName ||
'Unnamed'}
</div>
</div>
{/if}
<div class="flex justify-end gap-2">
<Button
variant="outline"
onclick={() => {
deleteConfirmOpen = false;
pendingDeleteMaterial = null;
}}
disabled={saving}
>
Cancel
</Button>
<Button variant="destructive" onclick={confirmDeleteMaterial} disabled={saving}>
{saving ? 'Deleting...' : 'Delete Material'}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,586 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { get } from 'svelte/store';
import { addNotification } from '$lib/core/stores/noti.js';
import { departmentStore } from '$lib/core/stores/departments.js';
import { permission as currentPerms } from '$lib/core/stores/permissions.js';
import { referenceFromPage } from '$lib/core/stores/recipeStore.js';
import {
clearSheetPriceSentTypes,
getCountryPrimaryLanguage,
getPriceFromCells,
lastRequestSheetPrice,
sheetPriceLoading,
type PriceSlot,
type PriceSlotProduct
} from '$lib/core/stores/sheetStore.js';
import { requestSheetPrice } from '$lib/core/services/sheetService.js';
import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { Calculator, RefreshCw, Save, RotateCcw, Search } from '@lucide/svelte/icons';
type AdjustmentMode =
| 'increase_percent'
| 'increase_amount'
| 'decrease_amount'
| 'decrease_percent';
const adjustmentModeLabels: Record<AdjustmentMode, string> = {
increase_percent: 'Increase by Percentage (%)',
increase_amount: 'Increase by Fixed Amount',
decrease_amount: 'Decrease by Fixed Amount',
decrease_percent: 'Decrease by Percentage (%)'
};
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
}))
};
});
}
let selectedCountry = $state<string>($page.params.country || get(departmentStore) || '');
let enabledCountries = $state<string[]>([]);
let selectedSlot = $state(1);
const initialSlots = buildMockSlots();
let slots = $state<PriceSlot[]>(initialSlots);
let savedSnapshot = $state<PriceSlot[]>(structuredClone(initialSlots));
let loading = $state(false);
let productCodeSearch = $state('');
let createDialogOpen = $state(false);
let adjustmentMode = $state<AdjustmentMode>('increase_percent');
let adjustmentValue = $state(15);
let createName = $state('ProfileIncrease15');
let createDescription = $state('increase price 15%');
let currentSlot = $derived(slots.find((slot) => slot.slot === selectedSlot) ?? slots[0]);
let selectedCountryLanguage = $derived(getCountryPrimaryLanguage(selectedCountry));
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();
if (!keyword) return true;
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 hasChanges = $derived(changedCount > 0 || hasHeaderChanges);
onMount(() => {
referenceFromPage.set('priceslot');
if (selectedCountry) {
departmentStore.set(selectedCountry);
}
const userPerms = get(currentPerms).filter((x) => x.startsWith('document.write'));
enabledCountries = userPerms.map((x) => x.split('.')[2]);
});
function getBasePrice(product: PriceSlotProduct): number | null {
const cells = basePriceCells[product.product_code];
if (cells?.length > 0) {
const price = getPriceFromCells(selectedCountry.toLowerCase(), cells, 'cash_price');
const parsed = Number(price);
if (!Number.isNaN(parsed)) return parsed;
}
return product.price;
}
function getProductNames(product: PriceSlotProduct) {
const [englishName = '', localName = ''] = product.name.split('|').map((name) => name.trim());
return {
english: englishName || product.name,
local: localName || englishName || product.name
};
}
function calculateAdjustedPrice(basePrice: number | null): number | null {
if (basePrice === null) return null;
const value = Number(adjustmentValue);
if (Number.isNaN(value)) return basePrice;
const nextPrice =
adjustmentMode === 'increase_percent'
? basePrice * (1 + value / 100)
: adjustmentMode === 'increase_amount'
? basePrice + value
: adjustmentMode === 'decrease_percent'
? basePrice * (1 - value / 100)
: basePrice - value;
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;
const isPercent =
adjustmentMode === 'increase_percent' || adjustmentMode === 'decrease_percent';
const isIncrease =
adjustmentMode === 'increase_percent' || adjustmentMode === 'increase_amount';
const action = isIncrease ? 'Increase' : 'Decrease';
const valueLabel = isPercent ? `${formattedValue}%` : `${formattedValue}`;
const nameSuffix = isPercent ? `${formattedValue}` : `Fixed${formattedValue}`;
createName = `Profile${action}${nameSuffix}`;
createDescription = `${isIncrease ? 'increase' : 'decrease'} price ${valueLabel}`;
}
function openCreateDialog() {
applyCreateTemplate();
createDialogOpen = true;
}
function createPriceSlotFromBase() {
const nextSlotNumber = Math.max(0, ...slots.map((slot) => slot.slot)) + 1;
const products = mockProducts.map((product) => ({
...product,
price: calculateAdjustedPrice(getBasePrice(product))
}));
const nextSlot: PriceSlot = {
slot: nextSlotNumber,
name: createName.trim() || `PriceSlot${nextSlotNumber}`,
description: createDescription.trim(),
products
};
slots = [...slots, nextSlot];
savedSnapshot = [...savedSnapshot, structuredClone(nextSlot)];
selectedSlot = nextSlotNumber;
createDialogOpen = false;
addNotification(`INFO:Created PriceSlot${nextSlotNumber} from base prices`);
}
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 savedProduct?.price !== product.price;
}).length;
}
function updateSlotField(field: 'name' | 'description', value: string) {
slots = slots.map((slot) => (slot.slot === selectedSlot ? { ...slot, [field]: value } : slot));
}
function updateProductPrice(productCode: string, value: string) {
const price = value === '' ? null : Number(value);
slots = slots.map((slot) => {
if (slot.slot !== selectedSlot) return slot;
return {
...slot,
products: slot.products.map((product) =>
product.product_code === productCode
? { ...product, price: Number.isNaN(price) ? product.price : price }
: product
)
};
});
}
function resetSlot() {
const savedSlot = savedSnapshot[selectedSlot - 1];
if (!savedSlot) return;
slots = slots.map((slot) => (slot.slot === selectedSlot ? structuredClone(savedSlot) : slot));
addNotification(`INFO:Reset PriceSlot${selectedSlot}`);
}
function saveSlot() {
savedSnapshot = savedSnapshot.map((slot) =>
slot.slot === selectedSlot ? structuredClone(currentSlot) : slot
);
addNotification('WARN:PriceSlot backend is not ready. Changes are kept in this UI only.');
}
function loadPriceSlots() {
loading = true;
setTimeout(() => {
loading = false;
addNotification('WARN:PriceSlot backend is not ready. Showing UI mock data.');
}, 250);
}
</script>
<div class="min-h-screen bg-background">
<div class="w-full px-6 py-8 lg:px-8">
<div class="mb-7 flex flex-wrap items-start justify-between gap-5">
<div class="min-w-0">
<h1 class="text-4xl leading-tight font-bold tracking-normal">
PriceSlot [ {selectedCountry.toUpperCase()} ]
</h1>
<p class="mt-4 text-muted-foreground">
Edit sheet PriceSlot names, descriptions, and product prices.
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
{#if enabledCountries.length > 0}
<Select.Root
type="single"
value={selectedCountry}
onValueChange={(v) => {
if (v) {
selectedCountry = v;
goto(`/sheet/priceslot/${v}`);
}
}}
>
<Select.Trigger
class="h-11 w-40 rounded-lg border-border/80 bg-card/70 px-4 font-semibold"
>
{selectedCountry.toUpperCase()}
</Select.Trigger>
<Select.Content>
{#each enabledCountries as country}
<Select.Item value={country}>
{country.toUpperCase()}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
<Button
variant="outline"
class="h-11 rounded-lg"
onclick={loadPriceSlots}
disabled={loading}
>
<RefreshCw class="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
</div>
<div class="mb-5 flex gap-2 overflow-x-auto border-b">
{#each slots as slot}
<button
class={[
'min-w-28 border-b-2 px-4 py-3 text-sm font-semibold transition-colors',
selectedSlot === slot.slot
? 'border-emerald-500 text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
]}
onclick={() => (selectedSlot = slot.slot)}
>
PriceSlot{slot.slot}
</button>
{/each}
</div>
<div class="mb-5 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]"
>
<div class="flex min-w-0 flex-col justify-end gap-2 pb-1">
<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>
<!-- <p class="text-sm text-muted-foreground">Column K/L</p> -->
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-muted-foreground" for="priceslot-name">Name</label>
<Input
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
id="product-code-search"
class="pl-9 font-mono"
placeholder="12-01-01-0001"
bind:value={productCodeSearch}
/>
</div>
</div>
<p class="text-sm text-muted-foreground">
Showing {filteredProducts.length} of {currentSlot.products.length} products
</p>
</div>
<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 (product.product_code)}
{@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>
</div>
</div>
</div>
<Dialog.Root bind:open={createDialogOpen}>
<Dialog.Content class="sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Create PriceSlot</Dialog.Title>
<Dialog.Description>
Choose how to adjust base prices before creating a new PriceSlot.
</Dialog.Description>
</Dialog.Header>
<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="space-y-2">
<label class="text-sm font-medium" for="adjustment-mode">Adjustment Mode</label>
<Select.Root
type="single"
value={adjustmentMode}
onValueChange={(v) => {
if (v) adjustmentMode = v as AdjustmentMode;
applyCreateTemplate();
}}
>
<Select.Trigger id="adjustment-mode" class="h-10 rounded-lg">
{adjustmentModeLabels[adjustmentMode]}
</Select.Trigger>
<Select.Content>
<Select.Item value="increase_percent">Increase by Percentage (%)</Select.Item>
<Select.Item value="increase_amount">Increase by Fixed Amount</Select.Item>
<Select.Item value="decrease_percent">Decrease by Percentage (%)</Select.Item>
<Select.Item value="decrease_amount">Decrease by Fixed Amount</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" for="adjustment-value">
Adjustment Value
{adjustmentMode === 'increase_percent' || adjustmentMode === 'decrease_percent'
? '(%)'
: ''}
</label>
<Input
id="adjustment-value"
type="number"
min="0"
step="1"
value={adjustmentValue}
oninput={(event) => {
adjustmentValue = Number(event.currentTarget.value);
applyCreateTemplate();
}}
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" for="create-name">Name</label>
<Input
id="create-name"
value={createName}
oninput={(event) => (createName = event.currentTarget.value)}
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" for="create-description">Description</label>
<Input
id="create-description"
value={createDescription}
oninput={(event) => (createDescription = event.currentTarget.value)}
/>
</div>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (createDialogOpen = false)}>Cancel</Button>
<Button onclick={createPriceSlotFromBase}>
<Calculator class="mr-2 h-4 w-4" />
Confirm Create
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -28,8 +28,8 @@
// pushes the selected .mp4 (from the browser), then
// `ls -l > sync_1.file` on the machine, pulls it, uploads it.
// ⚠️ FULL REPLACE — requires ADB; select the COMPLETE adv set.
//const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'ftp_listdir';
const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'machine';
const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'ftp_listdir';
//const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'machine';
// ─────────────────────────────────────────────────────────────────────────
// adv folder on the machine. Domestic Thailand uses the flat folder; every