Supra_App/src/routes/(authed)/tools/create-menu/+page.svelte

1892 lines
57 KiB
Svelte

<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as adb from '$lib/core/adb/adb';
import { addNotification } from '$lib/core/stores/noti';
import { referenceFromPage } from '$lib/core/stores/recipeStore';
import { env } from '$env/dynamic/public';
import { adbWriter, isAdbWriterAvailable } from '$lib/core/stores/adbWriter';
import { AdbInstance } from '../../../state.svelte';
import {
categoryOptions,
tempCodes,
countryCodeMap,
type TempType
} from '$lib/core/utils/productCode';
import {
existingProductCodes,
loadProductCodesFromCache,
addGeneratedProductCode
} from '$lib/core/stores/sheetStore';
import { requestListMenu } from '$lib/core/services/sheetService';
import {
menuSaveStates,
setMenuSaving,
setMenuSaveError,
setOnMenuSavedCallback,
clearOnMenuSavedCallback,
clearMenuSaveState
} from '$lib/core/stores/menuSaveStore';
const sourceDir = '/sdcard/coffeevending';
const stagedMenuStorageKey = 'brew.create-menu.drafts.v1';
const deletedStagedMenuStorageKey = `${stagedMenuStorageKey}.deleted`;
const stagedMenuAndroidPath = `${sourceDir}/cfg/supra_draft_menus.json`;
const saveResponseTimeoutMs = 60000;
// Recipe data from Android
let devRecipe: any | undefined = $state();
let recipeLoading = $state(false);
let recipeAutoLoadAttempted = $state(false);
// Country detection from Android
let detectedCountry = $state<string>('');
let countryLoading = $state(false);
const detectedCountryCode = $derived(countryCodeMap[detectedCountry] || countryCodeMap[detectedCountry.toUpperCase()] || '');
// ADB connection state
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
let isAndroidSocketConnected = $derived(Boolean($adbWriter));
let isRecipeLoaded = $derived(Boolean(devRecipe));
// Setup popup state
let setupPopupOpen = $state(false);
let selectedTemps = $state<{ hot: boolean; cold: boolean; blend: boolean }>({
hot: false,
cold: false,
blend: false
});
let selectedCategory = $state<string>('');
let generatedCodes = $state<{ hot: string; cold: string; blend: string }>({
hot: '',
cold: '',
blend: ''
});
// Derive selected temp count
const selectedTempList = $derived(
(['hot', 'cold', 'blend'] as const).filter((t) => selectedTemps[t])
);
const selectedTempCount = $derived(selectedTempList.length);
// Form state
let createMenuOpen = $state(false);
let createMenuSaving = $state(false);
let editingDraftProductCode: string | null = $state(null);
let activeTabIndex = $state(0);
// Per-temp form data
type TempFormData = {
temp: TempType;
productCode: string;
name: string;
otherName: string;
description: string;
otherDescription: string;
cashPrice: string;
nonCashPrice: string;
image: string;
isUse: boolean;
recipeSteps: any[];
toppingOptions: any[];
};
let tempForms = $state<TempFormData[]>([]);
const activeForm = $derived(tempForms[activeTabIndex]);
// Staged menus (drafts)
let stagedMenus: any[] = $state([]);
// Brew state
let brewConfirmOpen = $state(false);
let pendingBrewMenu: any | null = $state(null);
// Track menus pending save verification
let pendingSaveVerification = $state<Set<string>>(new Set());
// Derived data from devRecipe
let activeMaterials = $derived(
(devRecipe?.MaterialSetting ?? [])
.filter(
(material: any) =>
material?.isUse !== false &&
material?.MaterialStatus !== 2 &&
!isToppingSlotMaterial(Number(material?.id))
)
.sort((left: any, right: any) => Number(left?.id ?? 0) - Number(right?.id ?? 0))
);
let activeToppingSlotMaterials = $derived(
(devRecipe?.MaterialSetting ?? [])
.filter(
(material: any) =>
material?.isUse !== false &&
material?.MaterialStatus !== 2 &&
isToppingSlotMaterial(Number(material?.id))
)
.sort((left: any, right: any) => Number(left?.id ?? 0) - Number(right?.id ?? 0))
);
let activeToppingGroups = $derived(
(devRecipe?.Topping?.ToppingGroup ?? [])
.filter((group: any) => group?.inUse !== false)
.sort((left: any, right: any) => Number(left?.groupID ?? 0) - Number(right?.groupID ?? 0))
);
let activeToppingLists = $derived(
(devRecipe?.Topping?.ToppingList ?? [])
.filter((topping: any) => topping?.isUse !== false)
.sort((left: any, right: any) => Number(left?.id ?? 0) - Number(right?.id ?? 0))
);
// Helper functions
function createEmptyRecipeStep(materialPathId: number | null = null) {
return {
MixOrder: 0,
FeedParameter: 0,
FeedPattern: 0,
isUse: true,
materialPathId,
powderGram: 0,
powderTime: 0,
stirTime: 0,
syrupGram: 0,
syrupTime: 0,
waterCold: 0,
waterYield: 0
};
}
function createEmptyToppingOption(slot: number | null = null) {
return {
slot,
groupID: null as number | null,
defaultIDSelect: null as number | null
};
}
function createToppingRecipeStep(slot: number) {
return {
MixOrder: 0,
FeedParameter: 0,
FeedPattern: 0,
isUse: true,
materialPathId: 8110 + slot,
powderGram: 0,
powderTime: 0,
stirTime: 0,
syrupGram: 0,
syrupTime: 0,
waterCold: 0,
waterYield: 0
};
}
function createEmptyToppingSet() {
return {
ListGroupID: [0, 0, 0, 0],
defaultIDSelect: 0,
groupID: '0',
isUse: false
};
}
function formatAndroidRecipeDate(date = new Date()) {
const day = String(date.getDate()).padStart(2, '0');
const month = date.toLocaleString('en-US', { month: 'short' });
const year = date.getFullYear();
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
const second = String(date.getSeconds()).padStart(2, '0');
return `${day}-${month}-${year} ${hour}:${minute}:${second}`;
}
function isToppingSlotMaterial(materialId: number) {
return materialId > 8110 && materialId < 8131;
}
function materialDisplayName(material: any) {
return `${material.id} - ${material.materialName || material.materialOtherName || 'Unknown'}`;
}
function toppingSlotDisplayName(material: any) {
const slot = Number(material?.id) - 8110;
const slotName = material?.materialOtherName || material?.materialName;
return slotName ? `${slot} - ${slotName}` : `Slot ${slot}`;
}
function toppingGroupDisplayName(group: any) {
const groupName = group?.otherName || group?.name || group?.groupName || 'Unnamed group';
return `${group?.groupID ?? '-'} - ${groupName}`;
}
function toppingListDisplayName(topping: any) {
const toppingName =
topping?.otherName || topping?.name || topping?.toppingOtherName || topping?.toppingName;
return `${topping?.id ?? '-'} - ${toppingName || 'Unnamed topping'}`;
}
function normalizeToppingListIDs(value: any) {
if (Array.isArray(value)) {
return value.map(Number).filter((id: number) => Number.isFinite(id) && id > 0);
}
if (typeof value === 'string') {
return value
.split(',')
.map((part) => Number(part.trim()))
.filter((id: number) => Number.isFinite(id) && id > 0);
}
if (value && typeof value === 'object') {
return Object.values(value)
.map(Number)
.filter((id: number) => Number.isFinite(id) && id > 0);
}
const numericValue = Number(value);
return Number.isFinite(numericValue) && numericValue > 0 ? [numericValue] : [];
}
function getToppingGroupListIDs(group: any) {
if (!group) return [];
const rawIDs =
group.idInGroup ??
group.ListGroupID ??
group.listGroupID ??
group.listGroupId ??
group.listGroupIds ??
group.ToppingListID ??
group.ToppingListIDs ??
group.toppingListID ??
group.toppingListIds;
return normalizeToppingListIDs(rawIDs);
}
function getToppingListsForGroup(groupID: number | null) {
if (groupID == null) return [];
const group = activeToppingGroups.find((g: any) => Number(g.groupID) === groupID);
const listIDs = new Set(getToppingGroupListIDs(group));
if (listIDs.size === 0) return [];
const matchedLists = activeToppingLists.filter((t: any) => listIDs.has(Number(t.id)));
return matchedLists;
}
// ADB functions
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 (!isAdbWriterAvailable()) {
await adb.reconnectAndroidRecipeMenuServer();
}
await loadRecipeFromMachine();
return;
}
if (!('usb' in navigator)) {
throw new Error('WebUSB not supported');
}
await adb.connectRecipeMenuViaWebUSB();
if (adb.getAdbInstance()) {
await loadRecipeFromMachine();
}
} catch (e: any) {
addNotification(`ERROR:${e}`);
}
}
async function loadRecipeFromMachine() {
if (recipeLoading) return;
let instance = adb.getAdbInstance();
referenceFromPage.set('create-menu');
if (instance) {
recipeLoading = true;
try {
const recipePaths = [
`${sourceDir}/cfg/recipe_branch_dev.json`,
`${sourceDir}/coffeethai02.json`
];
for (const recipePath of recipePaths) {
const dev_recipe = await pullTextWithRetry(recipePath);
if (!dev_recipe || dev_recipe.trim().length == 0) continue;
try {
devRecipe = JSON.parse(dev_recipe);
addNotification('INFO:Recipe loaded');
return;
} catch (error) {
console.error('failed to parse recipe json', recipePath, error);
}
}
addNotification('ERROR:Cannot fetch recipe from machine');
} finally {
recipeLoading = false;
}
} else {
addNotification('ERROR:Cannot connect to machine');
}
}
async function loadCountryFromMachine() {
if (countryLoading || !adb.getAdbInstance()) return;
countryLoading = true;
try {
const countryPath = `${sourceDir}/country/short`;
const country = await pullTextWithRetry(countryPath, 5000, 2);
if (country && country.trim()) {
detectedCountry = country.trim();
} else {
// No country file means Thailand
detectedCountry = 'THAI';
}
console.log('[CreateMenu] Detected country:', detectedCountry, '-> prefix:', detectedCountryCode);
} catch (error) {
// Error reading file means Thailand (default)
detectedCountry = 'THAI';
console.log('[CreateMenu] Country file not found, defaulting to THAI');
} finally {
countryLoading = false;
}
}
async function ensureAndroidSocket(): Promise<boolean> {
if (isAdbWriterAvailable()) return true;
try {
await adb.reconnectAndroidRecipeMenuServer();
if (isAdbWriterAvailable()) return true;
} catch {}
addNotification('WARN:Android socket not connected');
return false;
}
// Product code functions
function findRecipeByProductCode(productCode: string) {
if (!devRecipe?.Recipe01) return null;
for (const recipe of devRecipe.Recipe01) {
if (recipe?.productCode === productCode) return recipe;
for (const subMenu of recipe?.SubMenu ?? []) {
if (subMenu?.productCode === productCode) return subMenu;
}
}
return null;
}
function isCodeAvailable(code: string): boolean {
if ($existingProductCodes.has(code)) return false;
if (stagedMenus.some((menu) => menu.productCode === code)) return false;
if (findRecipeByProductCode(code)) return false;
return true;
}
function getKnownProductCodes() {
const codes = new Set<string>($existingProductCodes);
for (const menu of stagedMenus) {
if (menu?.productCode) codes.add(menu.productCode);
}
for (const recipe of devRecipe?.Recipe01 ?? []) {
if (recipe?.productCode) codes.add(recipe.productCode);
for (const subMenu of recipe?.SubMenu ?? []) {
if (subMenu?.productCode) codes.add(subMenu.productCode);
}
}
return codes;
}
function generateNextKnownSuffix(category: string) {
let maxSuffix = 0;
for (const code of getKnownProductCodes()) {
const parts = code.split('-');
if (parts[1] !== category) continue;
const suffix = Number(parts[3]);
if (Number.isFinite(suffix) && suffix > maxSuffix) {
maxSuffix = suffix;
}
}
const nextSuffix = maxSuffix + 1;
if (nextSuffix > 9999) {
throw new Error('Product code suffix exceeded 9999');
}
return String(nextSuffix).padStart(4, '0');
}
function generateProductCodes(
temps: { hot: boolean; cold: boolean; blend: boolean } = selectedTemps,
category: string = selectedCategory
) {
if (!category) return;
// Use detected country code, fallback to '12' (Thailand) if not detected
const countryCode = detectedCountryCode || '12';
let suffix: string;
const existingCode = generatedCodes.hot || generatedCodes.cold || generatedCodes.blend;
const existingCategory = existingCode?.split('-')[1];
let existingSuffix = existingCategory === category ? existingCode?.split('-')[3] : null;
if (existingSuffix) {
const testCodes = [];
if (temps.hot)
testCodes.push(`${countryCode}-${category}-${tempCodes.hot}-${existingSuffix}`);
if (temps.cold)
testCodes.push(`${countryCode}-${category}-${tempCodes.cold}-${existingSuffix}`);
if (temps.blend)
testCodes.push(`${countryCode}-${category}-${tempCodes.blend}-${existingSuffix}`);
if (!testCodes.every(isCodeAvailable)) {
existingSuffix = null;
}
}
try {
suffix = existingSuffix || generateNextKnownSuffix(category);
let attempts = 0;
const maxAttempts = 100;
while (attempts < maxAttempts) {
const testCodes = [];
if (temps.hot) testCodes.push(`${countryCode}-${category}-${tempCodes.hot}-${suffix}`);
if (temps.cold) testCodes.push(`${countryCode}-${category}-${tempCodes.cold}-${suffix}`);
if (temps.blend) testCodes.push(`${countryCode}-${category}-${tempCodes.blend}-${suffix}`);
if (testCodes.every(isCodeAvailable)) {
break;
}
const nextNum = parseInt(suffix, 10) + 1;
if (nextNum > 9999) {
throw new Error('No available product code suffix');
}
suffix = String(nextNum).padStart(4, '0');
attempts++;
}
if (attempts >= maxAttempts) {
throw new Error('Cannot find available product code after many attempts');
}
} catch (e) {
addNotification('ERR:Cannot generate product code');
return;
}
generatedCodes = {
hot: temps.hot ? `${countryCode}-${category}-${tempCodes.hot}-${suffix}` : '',
cold: temps.cold ? `${countryCode}-${category}-${tempCodes.cold}-${suffix}` : '',
blend: temps.blend ? `${countryCode}-${category}-${tempCodes.blend}-${suffix}` : ''
};
}
// Setup popup functions
function openSetupPopup() {
selectedTemps = { hot: false, cold: false, blend: false };
selectedCategory = '';
generatedCodes = { hot: '', cold: '', blend: '' };
setupPopupOpen = true;
}
function toggleTemp(temp: TempType) {
const newTemps = { ...selectedTemps, [temp]: !selectedTemps[temp] };
selectedTemps = newTemps;
if (selectedCategory) {
generateProductCodes(newTemps, selectedCategory);
}
}
function selectCategory(categoryValue: string) {
selectedCategory = categoryValue;
generateProductCodes(selectedTemps, categoryValue);
}
function continueToForm() {
if (selectedTempCount === 0) {
addNotification('ERR:Please select at least one temperature');
return;
}
if (!selectedCategory) {
addNotification('ERR:Please select a category');
return;
}
generateProductCodes();
const codesToCheck = [generatedCodes.hot, generatedCodes.cold, generatedCodes.blend].filter(
Boolean
);
if (codesToCheck.length === 0) {
addNotification('ERR:Failed to generate product codes');
return;
}
setupPopupOpen = false;
editingDraftProductCode = null;
tempForms = selectedTempList.map((temp) => ({
temp,
productCode: generatedCodes[temp],
name: '',
otherName: '',
description: '',
otherDescription: '',
cashPrice: '0',
nonCashPrice: '0',
image: '',
isUse: false,
recipeSteps: [createEmptyRecipeStep()],
toppingOptions: []
}));
activeTabIndex = 0;
createMenuOpen = true;
}
// Form functions
function addRecipeStep() {
tempForms = tempForms.map((form, index) =>
index === activeTabIndex
? { ...form, recipeSteps: [...form.recipeSteps, createEmptyRecipeStep()] }
: form
);
}
function removeRecipeStep(stepIndex: number) {
tempForms = tempForms.map((form, formIndex) =>
formIndex === activeTabIndex
? { ...form, recipeSteps: form.recipeSteps.filter((_, i) => i !== stepIndex) }
: form
);
}
function updateActiveFormField(field: keyof TempFormData, value: string | boolean) {
tempForms = tempForms.map((form, formIndex) =>
formIndex === activeTabIndex ? { ...form, [field]: value } : form
);
}
function updateRecipeStepNumber(stepIndex: number, field: string, value: string) {
const numValue = Number(value);
tempForms = tempForms.map((form, formIndex) =>
formIndex === activeTabIndex
? {
...form,
recipeSteps: form.recipeSteps.map((step, i) =>
i === stepIndex
? { ...step, [field]: Number.isFinite(numValue) ? numValue : 0 }
: step
)
}
: form
);
}
function getNextToppingSlot(formIndex: number = activeTabIndex) {
const form = tempForms[formIndex];
if (!form) return null;
const usedSlots = new Set(
form.toppingOptions
.map((option: any) => Number(option.slot))
.filter((slot: number) => Number.isFinite(slot))
);
const firstAvailable = activeToppingSlotMaterials.find((material: any) => {
const slot = Number(material.id) - 8110;
return !usedSlots.has(slot);
});
return firstAvailable ? Number(firstAvailable.id) - 8110 : null;
}
function addToppingOption() {
const nextSlot = getNextToppingSlot();
tempForms = tempForms.map((form, index) =>
index === activeTabIndex
? { ...form, toppingOptions: [...form.toppingOptions, createEmptyToppingOption(nextSlot)] }
: form
);
}
function removeToppingOption(index: number) {
tempForms = tempForms.map((form, formIndex) =>
formIndex === activeTabIndex
? {
...form,
toppingOptions: form.toppingOptions.filter((_, optionIndex) => optionIndex !== index)
}
: form
);
}
function updateToppingSlot(index: number, value: string) {
const slot = Number(value);
tempForms = tempForms.map((form, formIndex) =>
formIndex === activeTabIndex
? {
...form,
toppingOptions: form.toppingOptions.map((option, optionIndex) =>
optionIndex === index
? { ...option, slot: Number.isFinite(slot) ? slot : null }
: option
)
}
: form
);
}
function updateToppingGroup(index: number, value: string) {
const groupID = Number(value);
tempForms = tempForms.map((form, formIndex) =>
formIndex === activeTabIndex
? {
...form,
toppingOptions: form.toppingOptions.map((option, optionIndex) =>
optionIndex === index
? {
...option,
groupID: Number.isFinite(groupID) ? groupID : null,
defaultIDSelect: null
}
: option
)
}
: form
);
}
function updateToppingList(index: number, value: string) {
const defaultIDSelect = Number(value);
tempForms = tempForms.map((form, formIndex) =>
formIndex === activeTabIndex
? {
...form,
toppingOptions: form.toppingOptions.map((option, optionIndex) =>
optionIndex === index
? {
...option,
defaultIDSelect: Number.isFinite(defaultIDSelect) ? defaultIDSelect : null
}
: option
)
}
: form
);
}
// Draft management
function getDeletedStagedMenuCodes() {
try {
const stored = localStorage.getItem(deletedStagedMenuStorageKey);
const parsed = stored ? JSON.parse(stored) : [];
return new Set(Array.isArray(parsed) ? parsed.map(String) : []);
} catch (error) {
return new Set<string>();
}
}
function persistDeletedStagedMenuCodes(codes: Set<string>) {
localStorage.setItem(deletedStagedMenuStorageKey, JSON.stringify([...codes]));
}
function markDeletedStagedMenu(productCode: string) {
const deletedCodes = getDeletedStagedMenuCodes();
deletedCodes.add(productCode);
persistDeletedStagedMenuCodes(deletedCodes);
}
function clearDeletedStagedMenus(productCodes: string[]) {
const deletedCodes = getDeletedStagedMenuCodes();
for (const productCode of productCodes) {
deletedCodes.delete(productCode);
}
persistDeletedStagedMenuCodes(deletedCodes);
}
function hasValidMaterialPathId(step: any) {
const materialPathId = Number(step?.materialPathId);
return Number.isFinite(materialPathId) && materialPathId > 0;
}
function getActiveRecipeStepCount(menu: any) {
return (menu?.recipes ?? []).filter(
(step: any) => step?.isUse !== false && hasValidMaterialPathId(step)
).length;
}
function getActiveToppingSetCount(menu: any) {
return (menu?.ToppingSet ?? []).filter(
(toppingSet: any) => toppingSet?.isUse === true && Number(toppingSet?.groupID) > 0
).length;
}
function persistStagedMenus() {
localStorage.setItem(stagedMenuStorageKey, JSON.stringify(stagedMenus));
void persistStagedMenusToAndroid();
}
async function persistStagedMenusToAndroid() {
if (!adb.getAdbInstance()) return;
try {
await adb.push(
stagedMenuAndroidPath,
JSON.stringify(
{
version: 1,
updatedAt: new Date().toISOString(),
menus: stagedMenus
},
null,
2
)
);
} catch (error) {
console.error('failed to persist staged menus to Android', error);
addNotification('WARN:Failed to save draft menus to Android');
}
}
async function loadStagedMenusFromAndroid() {
if (!adb.getAdbInstance()) return false;
try {
const content = await adb.pull(stagedMenuAndroidPath, 10000);
if (!content || content.trim().length === 0) {
if (stagedMenus.length > 0) {
await persistStagedMenusToAndroid();
}
return false;
}
const parsed = JSON.parse(content);
const menus = Array.isArray(parsed) ? parsed : parsed?.menus;
if (!Array.isArray(menus)) {
addNotification('WARN:Android draft menu file has invalid format');
return false;
}
const deletedCodes = getDeletedStagedMenuCodes();
const filteredMenus = menus.filter((menu) => !deletedCodes.has(String(menu?.productCode)));
stagedMenus = filteredMenus;
localStorage.setItem(stagedMenuStorageKey, JSON.stringify(stagedMenus));
if (filteredMenus.length !== menus.length) {
await persistStagedMenusToAndroid();
}
return true;
} catch (error) {
console.error('failed to load staged menus from Android', error);
addNotification('WARN:Failed to load draft menus from Android');
return false;
}
}
function removeStagedMenu(productCode: string) {
markDeletedStagedMenu(productCode);
stagedMenus = stagedMenus.filter((menu) => menu.productCode !== productCode);
persistStagedMenus();
}
function buildMenuFromForm(form: TempFormData) {
const toppingSet = form.toppingOptions
.filter((opt) => opt.slot != null && opt.groupID != null)
.map((opt) => {
const group = activeToppingGroups.find((g: any) => Number(g.groupID) === opt.groupID);
const listGroupIDs = getToppingGroupListIDs(group);
return {
ListGroupID: listGroupIDs.length > 0 ? listGroupIDs : [0, 0, 0, 0],
defaultIDSelect: opt.defaultIDSelect ?? 0,
groupID: String(opt.groupID),
isUse: true
};
});
while (toppingSet.length < 4) {
toppingSet.push(createEmptyToppingSet());
}
const recipeSteps = form.recipeSteps.filter(hasValidMaterialPathId).map((step) => ({
...step,
materialPathId: Number(step.materialPathId)
}));
for (const opt of form.toppingOptions) {
if (opt.slot != null) {
const existingIdx = recipeSteps.findIndex((s) => s.materialPathId === 8110 + opt.slot);
if (existingIdx === -1) {
recipeSteps.push(createToppingRecipeStep(opt.slot));
}
}
}
return {
Description: form.description,
ExtendID: 0,
OnTOP: false,
LastChange: formatAndroidRecipeDate(),
MenuStatus: 0,
StringParam: ',filter-enable=no,',
TextForWarningBeforePay: Array(8).fill('stg_warning=Invisible,img_warning=none'),
cashPrice: Number(form.cashPrice) || 0,
changerecipe: '',
EncoderCount: 0,
id: 0,
isUse: form.isUse,
productCode: form.productCode,
name: form.name,
otherName: form.otherName,
nonCashPrice: Number(form.nonCashPrice) || 0,
otherDescription: form.otherDescription,
recipes: recipeSteps,
ToppingSet: toppingSet,
SubMenu: [],
total_time: -1,
total_weight: -1,
uriData: form.image ? `img=${form.image}` : '',
useGram: true,
weight_float: -1.0
};
}
async function createMenuDraft() {
if (tempForms.length === 0) {
addNotification('ERR:No forms to save');
return;
}
createMenuSaving = true;
try {
const newRecipes = tempForms.map(buildMenuFromForm);
if (editingDraftProductCode) {
stagedMenus = stagedMenus.map((menu) =>
menu.productCode === editingDraftProductCode ? newRecipes[0] : menu
);
addNotification(`INFO:Draft menu updated: ${newRecipes[0].productCode}`);
} else {
stagedMenus = [...stagedMenus, ...newRecipes];
for (const recipe of newRecipes) {
addGeneratedProductCode(recipe.productCode);
}
const codes = newRecipes.map((r) => r.productCode).join(', ');
addNotification(`INFO:Draft menus created: ${codes}`);
}
clearDeletedStagedMenus(newRecipes.map((recipe) => recipe.productCode));
persistStagedMenus();
createMenuOpen = false;
editingDraftProductCode = null;
} finally {
createMenuSaving = false;
}
}
// Save to Android
function startSaveResponseTimeout(productCode: string) {
setTimeout(() => {
const saveState = $menuSaveStates.get(productCode);
if (saveState?.status !== 'saving') return;
setMenuSaveError(productCode, 'Android did not confirm save');
addNotification(`WARN:Save request sent but Android did not confirm: ${productCode}`);
}, saveResponseTimeoutMs);
}
async function saveStagedMenuToAndroid(menu: any) {
if (!(await ensureAndroidSocket())) return;
if (getActiveRecipeStepCount(menu) === 0) {
setMenuSaveError(menu.productCode, 'Select at least one material before saving');
addNotification(`ERR:Select at least one material before saving: ${menu.productCode}`);
return;
}
setMenuSaving(menu.productCode);
const sent = await adb.sendRecipeMenuMessageToAndroid({
type: 'save_recipe_menu_file',
payload: {
time: new Date().toLocaleTimeString(),
data: menu
}
});
if (!sent) {
setMenuSaveError(menu.productCode, 'Unable to send save request');
return;
}
startSaveResponseTimeout(menu.productCode);
addNotification(`INFO:Save request sent: ${menu.productCode}`);
}
function getReadyStagedMenus() {
return stagedMenus.filter((menu) => {
const status = $menuSaveStates.get(menu.productCode)?.status;
return status !== 'saving' && !pendingSaveVerification.has(menu.productCode);
});
}
function hasSavingDraftMenu() {
return stagedMenus.some((menu) => {
const status = $menuSaveStates.get(menu.productCode)?.status;
return status === 'saving' || pendingSaveVerification.has(menu.productCode);
});
}
async function saveAllStagedMenusToAndroid() {
if (!(await ensureAndroidSocket())) return;
const menus = getReadyStagedMenus();
if (menus.length === 0) {
addNotification('WARN:No draft menus ready to save');
return;
}
const invalidMenus = menus.filter((menu) => getActiveRecipeStepCount(menu) === 0);
if (invalidMenus.length > 0) {
for (const menu of invalidMenus) {
setMenuSaveError(menu.productCode, 'Select at least one material before saving');
}
addNotification(
`ERR:Select at least one material before saving: ${invalidMenus
.map((menu) => menu.productCode)
.join(', ')}`
);
return;
}
console.log(
'[Create Menu] save all draft menus',
menus.map((menu) => menu.productCode)
);
for (const menu of menus) {
setMenuSaving(menu.productCode);
}
const sent = await adb.sendRecipeMenuMessageToAndroid({
type: 'save_recipe_menu_file_batch',
payload: {
time: new Date().toLocaleTimeString(),
data: menus
}
});
if (!sent) {
for (const menu of menus) {
setMenuSaveError(menu.productCode, 'Unable to send batch save request');
}
return;
}
for (const menu of menus) {
startSaveResponseTimeout(menu.productCode);
}
addNotification(`INFO:Batch save request sent: ${menus.length} menu(s)`);
}
// Brew functions
function openBrewConfirm(menu: any) {
pendingBrewMenu = menu;
brewConfirmOpen = true;
}
async function brewMenuOnAndroid(menu: any) {
if (!(await ensureAndroidSocket())) return;
await adb.sendRecipeMenuMessageToAndroid({
type: 'brew_prep',
payload: {
start: new Date().toLocaleTimeString()
}
});
await adb.sendRecipeMenuMessageToAndroid({
type: 'brew',
payload: {
start: new Date().toLocaleTimeString(),
target: '-',
data: menu
}
});
addNotification(`INFO:Brew request sent: ${menu.productCode}`);
}
function confirmBrewNow() {
if (pendingBrewMenu) {
void brewMenuOnAndroid(pendingBrewMenu);
brewConfirmOpen = false;
pendingBrewMenu = null;
}
}
// Helper functions for brew confirm dialog
function getMaterial(materialPathId: number | null) {
if (materialPathId == null) return undefined;
return (devRecipe?.MaterialSetting ?? []).find(
(material: any) => Number(material?.id) === Number(materialPathId)
);
}
function getPendingBrewMaterials() {
if (!pendingBrewMenu?.recipes) return [];
return pendingBrewMenu.recipes
.filter(
(step: any) => step?.isUse !== false && !isToppingSlotMaterial(Number(step?.materialPathId))
)
.map((step: any) => {
const material = getMaterial(step.materialPathId);
return {
name: material ? materialDisplayName(material) : `Material ${step.materialPathId}`,
powderGram: step.powderGram,
waterYield: step.waterYield
};
});
}
function getAnyToppingGroup(groupID: number | null) {
if (groupID == null) return undefined;
return (devRecipe?.Topping?.ToppingGroup ?? []).find(
(group: any) => Number(group?.groupID) === Number(groupID)
);
}
function getAnyToppingList(toppingID: number | null) {
if (toppingID == null) return undefined;
return (devRecipe?.Topping?.ToppingList ?? []).find(
(topping: any) => Number(topping?.id) === Number(toppingID)
);
}
function getPendingBrewToppings() {
if (!pendingBrewMenu?.ToppingSet) return [];
return pendingBrewMenu.ToppingSet.filter(
(ts: any) => ts?.isUse !== false && Number(ts?.defaultIDSelect) > 0
).map((ts: any) => {
const group = getAnyToppingGroup(Number(ts.groupID));
const topping = getAnyToppingList(Number(ts.defaultIDSelect));
return {
group: group ? toppingGroupDisplayName(group) : `Group ${ts.groupID}`,
topping: topping ? toppingListDisplayName(topping) : `Topping ${ts.defaultIDSelect}`
};
});
}
async function verifyMenuSaved(productCode: string, attempt: number = 1): Promise<boolean> {
const maxAttempts = 3;
const delayMs = 2000;
if (!adb.getAdbInstance() || recipeLoading) {
return false;
}
await loadRecipeFromMachine();
if (findRecipeByProductCode(productCode)) {
return true;
}
if (attempt < maxAttempts) {
addNotification(`INFO:Retry ${attempt}/${maxAttempts} for ${productCode}...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
return verifyMenuSaved(productCode, attempt + 1);
}
return false;
}
function handleMenuSaved(productCode: string) {
markDeletedStagedMenu(productCode);
stagedMenus = stagedMenus.filter((menu) => menu.productCode !== productCode);
persistStagedMenus();
clearMenuSaveState(productCode);
addNotification(`INFO:Menu saved: ${productCode}`);
}
// Edit draft
function openEditDraftMenu(menu: any) {
editingDraftProductCode = menu.productCode;
const productCode = menu.productCode || '';
const tempCode = productCode.split('-')[2];
const temp: TempType = tempCode === '01' ? 'hot' : tempCode === '03' ? 'blend' : 'cold';
const recipeSteps = (menu.recipes ?? [])
.filter((step: any) => !isToppingSlotMaterial(Number(step.materialPathId)))
.map((step: any) => ({ ...step }));
const toppingOptionsFromMenu = (m: any) => {
return (m.ToppingSet ?? [])
.filter((ts: any) => ts.isUse && Number(ts.groupID) > 0)
.map((ts: any, index: number) => ({
slot: index + 1,
groupID: Number(ts.groupID),
defaultIDSelect: Number(ts.defaultIDSelect) || null
}));
};
tempForms = [
{
temp,
productCode: menu.productCode ?? '',
name: menu.name ?? '',
otherName: menu.otherName ?? '',
description: menu.Description ?? '',
otherDescription: menu.otherDescription ?? '',
cashPrice: String(menu.cashPrice ?? 0),
nonCashPrice: String(menu.nonCashPrice ?? 0),
image: menu.uriData?.replace(/^img=/, '') ?? '',
isUse: menu.isUse !== false,
recipeSteps: recipeSteps.length > 0 ? recipeSteps : [createEmptyRecipeStep()],
toppingOptions: toppingOptionsFromMenu(menu)
}
];
activeTabIndex = 0;
createMenuOpen = true;
}
onMount(async () => {
loadProductCodesFromCache();
// Get boxid from connected machine if available
let boxid: string | undefined;
if (adb.getAdbInstance()) {
try {
boxid = (await adb.pull('/sdcard/coffeevending/.bid')) || undefined;
} catch (e) {
console.warn('Failed to get boxid from machine:', e);
}
}
requestListMenu('tha', boxid);
setOnMenuSavedCallback(handleMenuSaved);
try {
const stored = localStorage.getItem(stagedMenuStorageKey);
stagedMenus = stored ? JSON.parse(stored) : [];
} catch (error) {
stagedMenus = [];
}
if (adb.getAdbInstance()) {
void loadStagedMenusFromAndroid();
}
});
onDestroy(() => {
clearOnMenuSavedCallback();
});
// Auto-load when ADB is connected
$effect(() => {
if (!isAdbConnected) {
recipeAutoLoadAttempted = false;
detectedCountry = '';
return;
}
if (isRecipeLoaded || recipeLoading || recipeAutoLoadAttempted) return;
recipeAutoLoadAttempted = true;
// Auto-load recipe data and country
(async () => {
if (!isAdbWriterAvailable()) {
await adb.reconnectAndroidRecipeMenuServer();
}
await loadCountryFromMachine();
await loadStagedMenusFromAndroid();
await loadRecipeFromMachine();
})();
});
// Auto-open setup popup when coming from Brew page with ?open=true
let hasOpenedFromQuery = $state(false);
$effect(() => {
const shouldOpen = $page.url.searchParams.get('open') === 'true';
if (shouldOpen && isRecipeLoaded && !hasOpenedFromQuery && !setupPopupOpen) {
hasOpenedFromQuery = true;
openSetupPopup();
// Clear the query param
goto('/tools/create-menu', { replaceState: true });
}
});
</script>
<div class="flex min-h-screen flex-col">
<div class="mx-8 my-4">
<h1 class="text-2xl font-bold">Create Menu</h1>
<p class="text-muted-foreground">Create and manage menu drafts for Android</p>
</div>
<!-- Connection Status & Actions -->
<div class="mx-8 my-4 flex flex-wrap items-center gap-2">
{#if !isAdbConnected}
<Button variant="default" onclick={() => connectAdb()}>Connect to Android</Button>
{:else if !isRecipeLoaded}
<Button variant="default" onclick={() => loadRecipeFromMachine()} disabled={recipeLoading}>
{recipeLoading ? 'Loading...' : 'Load Recipe Data'}
</Button>
{#if !isAndroidSocketConnected}
<Button variant="outline" onclick={() => adb.reconnectAndroidRecipeMenuServer()}
>Reconnect Socket</Button
>
{/if}
{:else}
<Button variant="default" onclick={openSetupPopup}>+ Create New Menu</Button>
<Button variant="outline" onclick={() => loadRecipeFromMachine()} disabled={recipeLoading}>
{recipeLoading ? 'Refreshing...' : 'Refresh Data'}
</Button>
{/if}
<!-- Country indicator -->
{#if isAdbConnected}
<div class="ml-auto flex items-center gap-2">
{#if countryLoading}
<span class="text-sm text-muted-foreground">Loading country...</span>
{:else if detectedCountry}
<span class="rounded-md border bg-muted/50 px-2 py-1 text-sm font-medium">
Country: {detectedCountry} ({detectedCountryCode})
</span>
{/if}
</div>
{/if}
</div>
<!-- Draft Menus -->
{#if stagedMenus.length > 0}
<div class="mx-8 mb-6 rounded-md border bg-card p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold">Draft Menus</h2>
<p class="text-sm text-muted-foreground">
Menus created on web. Save to Android when ready.
</p>
</div>
<Button
onclick={saveAllStagedMenusToAndroid}
disabled={!isAndroidSocketConnected || hasSavingDraftMenu()}
>
Save All to Android
</Button>
</div>
<div class="grid gap-3">
{#each stagedMenus as menu (menu.productCode)}
{@const saveState = $menuSaveStates.get(menu.productCode)}
{@const isSaving = saveState?.status === 'saving'}
{@const isSaved = saveState?.status === 'saved'}
{@const isError = saveState?.status === 'error'}
{@const isVerifying = pendingSaveVerification.has(menu.productCode)}
{@const isProcessing = isSaving || isVerifying}
<div
class="flex items-center justify-between gap-3 rounded-md border p-3 {isProcessing
? 'opacity-70'
: ''}"
>
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="font-mono text-sm text-muted-foreground">{menu.productCode}</div>
{#if isSaving}
<div class="flex items-center gap-1 text-xs text-amber-600">
<Spinner class="h-3 w-3" />
<span>Saving...</span>
</div>
{:else if isVerifying}
<div class="flex items-center gap-1 text-xs text-blue-600">
<Spinner class="h-3 w-3" />
<span>Verifying...</span>
</div>
{:else if isSaved}
<div class="text-xs text-green-600">Saved</div>
{:else if isError}
<div class="text-xs text-destructive">
{saveState?.error ?? 'Save failed'}
</div>
{/if}
</div>
<div class="truncate text-base font-semibold">{menu.name || menu.otherName}</div>
<div class="truncate text-sm text-muted-foreground">
{getActiveRecipeStepCount(menu)} recipe steps - {getActiveToppingSetCount(menu)}
topping groups
</div>
</div>
<div class="flex shrink-0 gap-2">
<Button
variant="outline"
onclick={() => openBrewConfirm(menu)}
disabled={isProcessing || !isAndroidSocketConnected}>Brew</Button
>
<Button
variant="outline"
onclick={() => openEditDraftMenu(menu)}
disabled={isProcessing}>Edit</Button
>
<Button
variant="outline"
onclick={() => removeStagedMenu(menu.productCode)}
disabled={isProcessing}>Remove</Button
>
<Button
onclick={() => saveStagedMenuToAndroid(menu)}
disabled={isProcessing || !isAndroidSocketConnected}
>
{isSaving
? 'Saving...'
: isVerifying
? 'Verifying...'
: isError
? 'Retry Save'
: 'Save to Android'}
</Button>
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="mx-8 mb-6 rounded-md border border-dashed p-8 text-center text-muted-foreground">
No draft menus yet. Click "Create New Menu" to get started.
</div>
{/if}
</div>
<!-- Setup Popup -->
<Dialog.Root bind:open={setupPopupOpen}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Create New Menu</Dialog.Title>
<Dialog.Description>Select temperature types and category</Dialog.Description>
</Dialog.Header>
<div class="space-y-6 py-4">
<!-- Country Info -->
{#if detectedCountry}
<div class="rounded-md border bg-muted/30 p-3">
<div class="text-sm text-muted-foreground">Machine Country</div>
<div class="font-semibold">{detectedCountry} <span class="text-muted-foreground">(prefix: {detectedCountryCode})</span></div>
</div>
{/if}
<!-- Temperature Selection -->
<div>
<Label class="mb-3 block text-sm font-medium">Temperature Types</Label>
<div class="flex gap-2">
{#each ['hot', 'cold', 'blend'] as temp}
<button
type="button"
onclick={() => toggleTemp(temp as TempType)}
class="flex-1 rounded-lg border p-3 text-center transition-colors {selectedTemps[
temp as TempType
]
? 'border-primary bg-primary/10 font-medium'
: 'hover:border-primary/50'}"
>
{temp.charAt(0).toUpperCase() + temp.slice(1)}
</button>
{/each}
</div>
</div>
<!-- Category Selection -->
<div>
<Label class="mb-3 block text-sm font-medium">Drink Category</Label>
<div class="grid grid-cols-2 gap-2">
{#each categoryOptions as cat}
<button
type="button"
onclick={() => selectCategory(cat.value)}
disabled={selectedTempCount === 0}
class="rounded-lg border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50 {selectedCategory ===
cat.value
? 'border-primary bg-primary/10 font-medium'
: 'hover:border-primary/50'}"
>
{cat.label}
</button>
{/each}
</div>
</div>
<!-- Generated Codes Preview -->
{#if selectedTempCount > 0 && selectedCategory}
<div class="rounded-md bg-muted/50 p-3">
<Label class="mb-2 block text-sm font-medium">Generated Product Codes</Label>
<div class="space-y-1 font-mono text-sm">
{#if generatedCodes.hot}<div>Hot: {generatedCodes.hot}</div>{/if}
{#if generatedCodes.cold}<div>Cold: {generatedCodes.cold}</div>{/if}
{#if generatedCodes.blend}<div>Blend: {generatedCodes.blend}</div>{/if}
</div>
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (setupPopupOpen = false)}>Cancel</Button>
<Button onclick={continueToForm} disabled={selectedTempCount === 0 || !selectedCategory}>
Continue
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Create/Edit Menu Dialog -->
<Dialog.Root bind:open={createMenuOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<Dialog.Header>
<Dialog.Title>{editingDraftProductCode ? 'Edit Draft Menu' : 'Create Menu'}</Dialog.Title>
<Dialog.Description>
{editingDraftProductCode
? 'Update the draft menu.'
: `Creating ${tempForms.length} menu(s). Menu info is separate for each temperature.`}
</Dialog.Description>
</Dialog.Header>
<div class="space-y-6 py-4">
<!-- Tab Bar for Temps -->
{#if tempForms.length > 1}
<div class="flex gap-1 rounded-lg border bg-muted/30 p-1">
{#each tempForms as form, index}
<button
type="button"
onclick={() => (activeTabIndex = index)}
class="flex-1 rounded-md px-4 py-2 text-sm font-semibold transition-all {activeTabIndex ===
index
? 'bg-primary text-primary-foreground shadow-md'
: 'text-muted-foreground hover:bg-muted'}"
>
{form.temp.charAt(0).toUpperCase() + form.temp.slice(1)}
</button>
{/each}
</div>
{/if}
<!-- Per-Temp Content -->
{#if activeForm}
<div class="space-y-4">
<div class="space-y-4 rounded-md border p-4">
<h3 class="font-semibold">
Menu Info ({activeForm.temp.charAt(0).toUpperCase() + activeForm.temp.slice(1)})
</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-2">
<Label for={`name-${activeForm.temp}`}>Name (Thai)</Label>
<Input
id={`name-${activeForm.temp}`}
value={activeForm.name}
placeholder="ชื่อเมนู"
oninput={(event) => updateActiveFormField('name', event.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label for={`otherName-${activeForm.temp}`}>Name (English)</Label>
<Input
id={`otherName-${activeForm.temp}`}
value={activeForm.otherName}
placeholder="Menu name"
oninput={(event) => updateActiveFormField('otherName', event.currentTarget.value)}
/>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-2">
<Label for={`description-${activeForm.temp}`}>Description (Thai)</Label>
<Input
id={`description-${activeForm.temp}`}
value={activeForm.description}
oninput={(event) =>
updateActiveFormField('description', event.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label for={`otherDescription-${activeForm.temp}`}>Description (English)</Label>
<Input
id={`otherDescription-${activeForm.temp}`}
value={activeForm.otherDescription}
oninput={(event) =>
updateActiveFormField('otherDescription', event.currentTarget.value)}
/>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="grid gap-2">
<Label for={`cashPrice-${activeForm.temp}`}>Cash price</Label>
<Input
id={`cashPrice-${activeForm.temp}`}
type="number"
min="0"
value={activeForm.cashPrice}
oninput={(event) => updateActiveFormField('cashPrice', event.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label for={`nonCashPrice-${activeForm.temp}`}>Non-cash price</Label>
<Input
id={`nonCashPrice-${activeForm.temp}`}
type="number"
min="0"
value={activeForm.nonCashPrice}
oninput={(event) =>
updateActiveFormField('nonCashPrice', event.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label for={`image-${activeForm.temp}`}>Image file</Label>
<Input
id={`image-${activeForm.temp}`}
value={activeForm.image}
placeholder="bn_menu.png"
oninput={(event) => updateActiveFormField('image', event.currentTarget.value)}
/>
</div>
</div>
<label class="flex items-start gap-3 rounded-md border p-3">
<input
type="checkbox"
class="mt-1 h-4 w-4"
checked={activeForm.isUse}
onchange={(event) => updateActiveFormField('isUse', event.currentTarget.checked)}
/>
<span class="grid gap-1">
<span class="text-sm font-medium">Enable menu</span>
</span>
</label>
</div>
<!-- Product Code -->
<div class="grid gap-2">
<Label>Product Code ({activeForm.temp})</Label>
<Input value={activeForm.productCode} disabled class="bg-muted/50 font-mono" />
</div>
<!-- Materials Section -->
<div class="rounded-md border p-4">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-base font-semibold">Materials</h3>
<p class="text-sm text-muted-foreground">Recipe steps for {activeForm.temp}</p>
</div>
<Button type="button" variant="outline" onclick={addRecipeStep}>Add material</Button>
</div>
{#if activeMaterials.length === 0}
<div
class="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive"
>
No active materials found. Load recipe data from Android first.
</div>
{:else}
<div class="grid gap-4">
{#each activeForm.recipeSteps as step, index}
<div class="rounded-md border bg-muted/20 p-3">
<div class="mb-3 flex items-center justify-between">
<div class="text-sm font-semibold">Step {index + 1}</div>
{#if activeForm.recipeSteps.length > 1}
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => removeRecipeStep(index)}
>
Remove
</Button>
{/if}
</div>
<div class="grid gap-3">
<div class="grid gap-2">
<Label>Material</Label>
<select
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={step.materialPathId == null ? '' : String(step.materialPathId)}
onchange={(event) =>
updateRecipeStepNumber(
index,
'materialPathId',
event.currentTarget.value
)}
>
<option value="" disabled>Select material</option>
{#each activeMaterials as material}
<option value={String(material.id)}
>{materialDisplayName(material)}</option
>
{/each}
</select>
</div>
<div class="grid gap-3 sm:grid-cols-4">
<div class="grid gap-2">
<Label>Powder gram</Label>
<Input
type="number"
value={step.powderGram ?? 0}
oninput={(e) =>
updateRecipeStepNumber(index, 'powderGram', e.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label>Powder time</Label>
<Input
type="number"
value={step.powderTime ?? 0}
oninput={(e) =>
updateRecipeStepNumber(index, 'powderTime', e.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label>Syrup gram</Label>
<Input
type="number"
value={step.syrupGram ?? 0}
oninput={(e) =>
updateRecipeStepNumber(index, 'syrupGram', e.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label>Syrup time</Label>
<Input
type="number"
value={step.syrupTime ?? 0}
oninput={(e) =>
updateRecipeStepNumber(index, 'syrupTime', e.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label>Water yield</Label>
<Input
type="number"
value={step.waterYield ?? 0}
oninput={(e) =>
updateRecipeStepNumber(index, 'waterYield', e.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label>Water cold</Label>
<Input
type="number"
value={step.waterCold ?? 0}
oninput={(e) =>
updateRecipeStepNumber(index, 'waterCold', e.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label>Stir time</Label>
<Input
type="number"
value={step.stirTime ?? 0}
oninput={(e) =>
updateRecipeStepNumber(index, 'stirTime', e.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label>Mix order</Label>
<Input
type="number"
value={step.MixOrder ?? 0}
oninput={(e) =>
updateRecipeStepNumber(index, 'MixOrder', e.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label>Feed parameter</Label>
<Input
type="number"
value={step.FeedParameter ?? 0}
oninput={(e) =>
updateRecipeStepNumber(index, 'FeedParameter', e.currentTarget.value)}
/>
</div>
<div class="grid gap-2">
<Label>Feed pattern</Label>
<Input
type="number"
value={step.FeedPattern ?? 0}
oninput={(e) =>
updateRecipeStepNumber(index, 'FeedPattern', e.currentTarget.value)}
/>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Toppings Section -->
<div class="rounded-md border p-4">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-base font-semibold">Toppings</h3>
<p class="text-sm text-muted-foreground">Toppings for {activeForm.temp}</p>
</div>
<Button type="button" variant="outline" onclick={addToppingOption}>Add topping</Button
>
</div>
{#if activeToppingGroups.length === 0}
<div class="rounded-md border border-muted p-3 text-sm text-muted-foreground">
No topping data found.
</div>
{:else if activeForm.toppingOptions.length === 0}
<div class="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
No toppings added yet.
</div>
{:else}
<div class="grid gap-4">
{#each activeForm.toppingOptions as topping, index}
<div class="rounded-md border bg-muted/20 p-3">
<div class="mb-3 flex items-center justify-between">
<div class="text-sm font-semibold">Topping {index + 1}</div>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => removeToppingOption(index)}
>
Remove
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<div class="grid gap-2">
<Label>Slot</Label>
<select
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={topping.slot == null ? '' : String(topping.slot)}
onchange={(e) => updateToppingSlot(index, e.currentTarget.value)}
>
<option value="" disabled>Select slot</option>
{#each activeToppingSlotMaterials as material}
<option value={String(Number(material.id) - 8110)}>
{toppingSlotDisplayName(material)}
</option>
{/each}
</select>
</div>
<div class="grid gap-2">
<Label>Topping group</Label>
<select
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={topping.groupID == null ? '' : String(topping.groupID)}
onchange={(e) => updateToppingGroup(index, e.currentTarget.value)}
>
<option value="" disabled>Select group</option>
{#each activeToppingGroups as group}
<option value={String(group.groupID)}
>{toppingGroupDisplayName(group)}</option
>
{/each}
</select>
</div>
<div class="grid gap-2">
<Label>Default topping</Label>
<select
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={topping.defaultIDSelect == null
? ''
: String(topping.defaultIDSelect)}
disabled={topping.groupID == null}
onchange={(e) => updateToppingList(index, e.currentTarget.value)}
>
<option value="" disabled>Select topping</option>
{#each getToppingListsForGroup(topping.groupID) as toppingItem}
<option value={String(toppingItem.id)}
>{toppingListDisplayName(toppingItem)}</option
>
{/each}
</select>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (createMenuOpen = false)}>Cancel</Button>
<Button onclick={createMenuDraft} disabled={createMenuSaving}>
{createMenuSaving ? 'Saving...' : editingDraftProductCode ? 'Update Draft' : 'Create Draft'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Brew Confirm Dialog -->
<Dialog.Root bind:open={brewConfirmOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>Confirm Brew</Dialog.Title>
<Dialog.Description>Review the recipe before brewing</Dialog.Description>
</Dialog.Header>
{#if pendingBrewMenu}
<div class="space-y-4 py-4">
<div>
<div class="text-sm font-medium text-muted-foreground">Product Code</div>
<div class="font-mono text-lg">{pendingBrewMenu.productCode}</div>
</div>
<div>
<div class="text-sm font-medium text-muted-foreground">Name</div>
<div class="text-base font-semibold">
{pendingBrewMenu.name || pendingBrewMenu.otherName}
</div>
</div>
{#if getPendingBrewMaterials().length > 0}
<div>
<div class="mb-2 text-sm font-medium text-muted-foreground">Materials</div>
<div class="space-y-2">
{#each getPendingBrewMaterials() as mat}
<div class="flex items-center justify-between rounded-md border p-2 text-sm">
<span class="truncate">{mat.name}</span>
<span class="shrink-0 text-muted-foreground">
{mat.powderGram}g / {mat.waterYield}ml
</span>
</div>
{/each}
</div>
</div>
{/if}
{#if getPendingBrewToppings().length > 0}
<div>
<div class="mb-2 text-sm font-medium text-muted-foreground">Toppings</div>
<div class="space-y-2">
{#each getPendingBrewToppings() as topping}
<div class="rounded-md border p-2 text-sm">
<div class="text-muted-foreground">{topping.group}</div>
<div>{topping.topping}</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={() => (brewConfirmOpen = false)}>Cancel</Button>
<Button onclick={confirmBrewNow}>Brew Now</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>