1892 lines
57 KiB
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>
|