Supra_App/src/routes/(authed)/tools/brew/+page.svelte

944 lines
26 KiB
Svelte

<script lang="ts">
import Button from '$lib/components/ui/button/button.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 * as adb from '$lib/core/adb/adb';
import { addNotification } from '$lib/core/stores/noti';
import { columns, type RecipeOverview } from '../../recipe/overview/columns';
import {
materialFromMachineQuery,
recipeFromMachine,
recipeFromMachineLoading,
recipeFromMachineQuery,
toppingListFromMachineQuery,
referenceFromPage,
toppingGroupFromMachineQuery
} from '$lib/core/stores/recipeStore';
import DataTable from '../../recipe/overview/data-table.svelte';
import { handleIncomingMessages } from '$lib/core/handlers/messageHandler';
import { auth as authStore } from '$lib/core/stores/auth';
import { machineInfoStore } from '$lib/core/stores/machineInfoStore';
import { get } from 'svelte/store';
import {
AdbDaemonWebUsbDevice,
AdbDaemonWebUsbDeviceManager
} from '@yume-chan/adb-daemon-webusb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
import { afterNavigate, goto } from '$app/navigation';
import { env } from '$env/dynamic/public';
import { adbWriter, isAdbWriterAvailable, sendToAndroid } from '$lib/core/stores/adbWriter';
import { AdbInstance } from '../../../state.svelte';
import {
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`;
// fetched recipe
let devRecipe: any | undefined = $state();
// data to display
let data: { recipes: RecipeOverview[] } = $state({
recipes: []
});
let brew_status: string = $state('');
// refresh command
let refresh_counter: number = $state(0);
let stagedMenus: any[] = $state([]);
let brewConfirmOpen = $state(false);
let pendingBrewMenu: any | null = $state(null);
let recipeLoading = $state(false);
let recipeAutoLoadAttempted = $state(false);
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
let isAndroidSocketConnected = $derived(Boolean($adbWriter));
let isRecipeLoaded = $derived(Boolean(devRecipe));
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 startFetchRecipeFromMachine() {
if (recipeLoading) return;
let instance = adb.getAdbInstance();
// recipeFromMachineLoading.set(true);
referenceFromPage.set('brew');
console.log('check instance', instance);
if (instance) {
recipeLoading = true;
try {
console.log('instance passed!');
const recipePaths = [
`${sourceDir}/cfg/recipe_branch_dev.json`,
`${sourceDir}/coffeethai02.json`
];
for (const recipePath of recipePaths) {
const dev_recipe = await pullTextWithRetry(recipePath);
console.log('dev recipe pull result', {
recipePath,
loaded: dev_recipe != undefined,
size: dev_recipe?.length ?? 0
});
if (!dev_recipe || dev_recipe.trim().length == 0) continue;
try {
devRecipe = JSON.parse(dev_recipe);
buildOverviewForBrewing();
return;
} catch (error) {
console.error('failed to parse recipe json', recipePath, error);
addNotification(`ERROR:Invalid recipe JSON from ${recipePath}`);
}
}
addNotification('ERROR:Cannot fetch recipe from machine');
} finally {
recipeLoading = false;
}
} else {
addNotification('ERROR:Cannot connect to machine');
// recipeFromMachineLoading.set(false);
}
}
async function loadEssentialFiles() {
let instance = adb.getAdbInstance();
if (instance) {
// check country
let country = await adb.pull('/sdcard/coffeevending/country/short');
// check dev
let devMode = await adb.pull('/sdcard/coffeevending/CURR_TEST');
// check .bid
let boxid = await adb.pull('/sdcard/coffeevending/.bid');
machineInfoStore.set({
boxId: boxid,
versions: {
firmware: '',
brew: '',
xmlengine: '',
netcore: '',
devbox: ''
},
devMode: devMode?.includes('1') ?? false,
country: country ?? '',
status: '',
errors: []
});
handleIncomingMessages(
JSON.stringify({
type: 'chat',
payload: `${new Date().toLocaleTimeString()}: ${get(authStore)?.displayName} has connected to ${boxid}`
})
);
} else {
addNotification('ERROR:Failed to get machine info');
}
}
async function connectAdb() {
try {
if (adb.getAdbInstance()) {
if (!isAdbWriterAvailable()) {
await adb.reconnectAndroidServer();
}
await loadBrewDataFromConnectedAdb();
return;
}
if (!('usb' in navigator)) {
throw new Error('WebUSB not supported, try using fallback method or different browser');
}
await adb.connnectViaWebUSB();
let instance = adb.getAdbInstance();
if (instance) {
await loadBrewDataFromConnectedAdb();
}
} catch (e: any) {
addNotification(`ERROR:${e}`);
}
}
async function loadBrewDataFromConnectedAdb() {
await startFetchRecipeFromMachine();
await loadEssentialFiles();
await loadStagedMenusFromAndroid();
}
async function tryAutoConnect() {
try {
if (adb.getAdbInstance()) {
if (!isAdbWriterAvailable()) {
await adb.reconnectAndroidServer();
}
return true;
}
if (!('usb' in navigator) || !AdbDaemonWebUsbDeviceManager.BROWSER) {
throw new Error('WebUSB not supported, try using fallback method or different browser');
}
const devices = await AdbDaemonWebUsbDeviceManager.BROWSER.getDevices();
if (!devices || devices.length == 0) {
throw new Error('No device found');
}
if (devices.length > 1) {
throw new Error('Too many connected devices');
}
const device = devices[0];
const credStore = new AdbWebCredentialStore();
try {
await adb.connectDeviceByCred(device, credStore);
return true;
} catch (e: any) {
if (e.message === 'CREDENTIAL_EXPIRED') {
try {
await deviceCredentialManager.clearAllCredentials();
} catch (ignored) {}
}
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
addNotification(
'ERR:Device is already in use by another program, please close the program and try again'
);
}
return false;
}
} catch (e) {
console.error('error on auto connect brew page', e);
addNotification('ERROR:Failed to auto connect, please try again');
}
}
async function ensureAndroidSocket() {
if (isAdbWriterAvailable()) return true;
if (!adb.getAdbInstance()) {
addNotification('ERR:ADB is not connected');
return false;
}
await adb.reconnectAndroidServer();
if (!isAdbWriterAvailable()) {
addNotification('ERR:Android socket is not connected');
return false;
}
return true;
}
afterNavigate(async () => {
console.log('after navigate brew');
await startFetchRecipeFromMachine();
});
function getMenuStatus(ms: number): RecipeOverview['status'] {
switch (ms) {
case 0:
return 'ready';
case 2:
return 'obsolete';
case 11:
return 'pending/online';
case 12:
return 'pending/offline';
default:
return 'drafted';
}
}
function getMenuCategory(pd: string): string {
// [country_code]-[category_code]-[drink_Type]-[id]
let pd_spl = pd.split('-');
let category = pd_spl[1] ?? '';
if (category) {
if (category.endsWith('1')) {
let result = 'coffee';
if (category === '01') {
result += ',v1';
} else {
result += ',v2+';
}
return result;
} else if (category.endsWith('2')) {
return 'tea';
} else if (category.endsWith('3')) {
return 'milk';
} else if (category.endsWith('4')) {
return 'whey';
} else if (category.endsWith('5')) {
return 'soda';
} else if (category == '99') {
return 'special';
}
}
return 'unknown';
}
function getDrinkType(pd: string): string {
// [country_code]-[category_code]-[drink_Type]-[id]
let pd_spl = pd.split('-');
let drink_type = pd_spl[2] ?? '';
if (drink_type) {
if (drink_type.endsWith('1')) {
return 'hot';
} else if (drink_type.endsWith('2')) {
return 'cold / iced';
} else if (drink_type.endsWith('3')) {
return 'smoothie / frappe';
}
}
return '';
}
// set material used in recipe to tags for using in filter
function getMainMaterialOfRecipe(rp: any): string {
let recipeList = rp['recipes'] ?? [];
let mat_lists = '';
for (let rpl of recipeList) {
let mat_id = rpl['materialPathId'];
let mat_in_use = rpl['isUse'];
if (mat_in_use && !mat_lists.includes(mat_id)) {
mat_lists += mat_id + ',';
}
}
mat_lists = mat_lists.substring(0, mat_lists.length - 1);
return mat_lists;
}
function buildTags(rp: any): string {
let result = '';
result += getMenuCategory(rp['productCode']);
let dt = getDrinkType(rp['productCode']);
let mats = getMainMaterialOfRecipe(rp);
if (dt !== '') result += ',' + dt;
if (mats !== '') result += ',' + mats;
return result;
}
function buildOverviewForBrewing() {
if (devRecipe) {
let recipe01_query: any = {};
recipeFromMachine.set(devRecipe);
data.recipes = [];
for (let rp of devRecipe['Recipe01']) {
data.recipes.push({
productCode: rp['productCode'] ?? '<not set>',
name: rp['name'] ? rp['name'] : (rp['otherName'] ?? '<not set>'),
description: rp['desciption']
? rp['desciption']
: (rp['otherDescription'] ?? '<not set>'),
tags: buildTags(rp),
status: getMenuStatus(rp['MenuStatus'])
});
recipe01_query[rp['productCode']] = rp;
if (rp['SubMenu'] && rp['SubMenu'].length > 0) {
for (let rps of rp['SubMenu']) {
data.recipes.push({
productCode: rps['productCode'] ?? '<not set>',
name: rps['name'] ? rps['name'] : (rps['otherName'] ?? '<not set>'),
description: rps['desciption']
? rps['desciption']
: (rps['otherDescription'] ?? '<not set>'),
tags: buildTags(rps),
status: getMenuStatus(rps['MenuStatus'])
});
recipe01_query[rps['productCode']] = rps;
}
}
}
for (const stagedMenu of stagedMenus) {
if (!recipe01_query[stagedMenu.productCode]) {
data.recipes.push({
productCode: stagedMenu.productCode ?? '<not set>',
name: stagedMenu.name ? stagedMenu.name : (stagedMenu.otherName ?? '<not set>'),
description: stagedMenu.Description
? stagedMenu.Description
: (stagedMenu.otherDescription ?? '<not set>'),
tags: buildTags(stagedMenu),
status: 'drafted'
});
}
}
let materialFromMachine = devRecipe['MaterialSetting'];
let currentQuery = get(recipeFromMachineQuery);
currentQuery = {
...currentQuery,
recipe: recipe01_query
};
let currentMaterialsQuery = materialFromMachine;
currentQuery = {
...currentQuery,
material: currentMaterialsQuery
};
recipeFromMachineQuery.set(currentQuery);
materialFromMachineQuery.set(currentMaterialsQuery);
toppingListFromMachineQuery.set(devRecipe['Topping']['ToppingList']);
toppingGroupFromMachineQuery.set(devRecipe['Topping']['ToppingGroup']);
}
}
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 isToppingSlotMaterial(materialId: number) {
return materialId > 8110 && materialId < 8131;
}
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 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();
}
buildOverviewForBrewing();
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 materialDisplayName(material: any) {
const thaiName = material?.materialName ?? '';
const englishName = material?.materialOtherName ?? '';
if (thaiName && englishName) return `${material.id} - ${thaiName} (${englishName})`;
return `${material?.id ?? '-'} - ${thaiName || englishName || 'Unnamed material'}`;
}
function getMaterial(materialPathId: number | null) {
if (materialPathId == null) return undefined;
return (devRecipe?.MaterialSetting ?? []).find(
(material: any) => Number(material?.id) === Number(materialPathId)
);
}
function toppingGroupDisplayName(group: any) {
const groupName = group?.otherName || group?.name || 'Unnamed group';
return `${group?.groupID ?? '-'} - ${groupName}`;
}
function toppingListDisplayName(topping: any) {
const toppingName = topping?.otherName || topping?.name || 'Unnamed topping';
return `${topping?.id ?? '-'} - ${toppingName}`;
}
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)
);
}
async function brewMenuOnAndroid(menu: any) {
if (!(await ensureAndroidSocket())) return;
await sendToAndroid({
type: 'brew_prep',
payload: {
start: new Date().toLocaleTimeString()
}
});
await sendToAndroid({
type: 'brew',
payload: {
start: new Date().toLocaleTimeString(),
target: '-',
data: menu
}
});
addNotification(`INFO:Brew request sent: ${menu.productCode}`);
}
function getRecipeStepValueSummary(step: any) {
const fields = [
['powderGram', 'Powder gram'],
['powderTime', 'Powder time'],
['syrupGram', 'Syrup gram'],
['syrupTime', 'Syrup time'],
['waterCold', 'Water cold'],
['waterYield', 'Water yield'],
['stirTime', 'Stir time']
];
return fields
.map(([key, label]) => ({ label, value: Number(step?.[key] ?? 0) }))
.filter((field) => Number.isFinite(field.value) && field.value !== 0);
}
function getPendingBrewMaterials() {
return (pendingBrewMenu?.recipes ?? [])
.filter((step: any) => {
const materialPathId = Number(step?.materialPathId);
return (
step?.isUse !== false &&
Number.isFinite(materialPathId) &&
!isToppingSlotMaterial(materialPathId)
);
})
.map((step: any, index: number) => {
const materialPathId = Number(step.materialPathId);
const material = getMaterial(materialPathId);
return {
index: index + 1,
materialPathId,
name: material ? materialDisplayName(material) : `${materialPathId} - Unknown material`,
values: getRecipeStepValueSummary(step)
};
});
}
function getPendingBrewToppings() {
return (pendingBrewMenu?.ToppingSet ?? [])
.map((toppingSet: any, index: number) => {
const groupID = Number(toppingSet?.groupID);
const defaultIDSelect = Number(toppingSet?.defaultIDSelect);
if (
toppingSet?.isUse === false ||
!Number.isFinite(groupID) ||
!Number.isFinite(defaultIDSelect) ||
defaultIDSelect <= 0
) {
return null;
}
const group = getAnyToppingGroup(groupID);
const topping = getAnyToppingList(defaultIDSelect);
const slotMaterial = getMaterial(8111 + index);
return {
slot: index + 1,
slotName:
slotMaterial?.materialOtherName ||
slotMaterial?.materialName ||
`Topping slot ${index + 1}`,
groupName: group ? toppingGroupDisplayName(group) : `${groupID} - Unknown group`,
toppingName: topping
? toppingListDisplayName(topping)
: `${defaultIDSelect} - Unknown topping`
};
})
.filter(Boolean);
}
function openBrewConfirm(menu: any) {
pendingBrewMenu = menu;
brewConfirmOpen = true;
}
function promptBrewStagedMenu(menu: any) {
openBrewConfirm(menu);
}
async function confirmBrewNow() {
if (!pendingBrewMenu) return;
await brewMenuOnAndroid(pendingBrewMenu);
brewConfirmOpen = false;
pendingBrewMenu = null;
}
// Track menus pending save verification
let pendingSaveVerification = $state<Set<string>>(new Set());
async function verifyMenuSaved(productCode: string, attempt: number = 1): Promise<boolean> {
const maxAttempts = 3;
const delayMs = 2000; // 2 seconds between attempts
if (!adb.getAdbInstance() || recipeLoading) {
return false;
}
await startFetchRecipeFromMachine();
if (findRecipeByProductCode(productCode)) {
return true;
}
// Retry if not found and attempts remaining
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}`);
buildOverviewForBrewing();
}
onMount(() => {
// Set up callback for when menu is saved to Android
setOnMenuSavedCallback(handleMenuSaved);
try {
const stored = localStorage.getItem(stagedMenuStorageKey);
stagedMenus = stored ? JSON.parse(stored) : [];
} catch (error) {
stagedMenus = [];
}
buildOverviewForBrewing();
if (adb.getAdbInstance()) {
void loadStagedMenusFromAndroid();
}
});
onDestroy(() => {
clearOnMenuSavedCallback();
});
$effect(() => {
if (!isAdbConnected) {
recipeAutoLoadAttempted = false;
return;
}
if (isRecipeLoaded || recipeLoading || recipeAutoLoadAttempted) return;
recipeAutoLoadAttempted = true;
void loadBrewDataFromConnectedAdb();
});
$effect(() => {
const brewAppStatusInterval = setInterval(async () => {
// schedule status from .brew_web_status.log
let inst = adb.getAdbInstance();
if (inst && devRecipe && !recipeLoading) {
await adb.executeCmd(
'tail -n 1 /sdcard/coffeevending/.brew_web_status.log > /sdcard/coffeevending/.brew_web_status.latest.log'
);
const latestStatusPath = env.PUBLIC_BREW_WEB_LATEST_STATUS;
if (!latestStatusPath) return;
let brew_status_log = await adb.pull(latestStatusPath);
if (brew_status_log) {
let latest_log = brew_status_log;
if (brew_status !== latest_log) {
brew_status = latest_log;
// noti from machine
if (latest_log.includes('!!!')) {
let log = latest_log.split('!!!');
let noti_cfg = log[1];
if (noti_cfg.includes('=')) {
let noti_level = noti_cfg.split('=')[1];
let spl = log[0].split(':');
let pure_msg = spl[spl.length - 1];
// case special message
if (pure_msg.includes('starting retry process')) {
// is waiting/idle process
pure_msg = 'Wait for brewing';
}
addNotification(`${noti_level}:${pure_msg}`);
}
}
}
}
} else if (inst && !devRecipe) {
// try again
// await startFetchRecipeFromMachine();
}
}, 1000);
return () => {
clearInterval(brewAppStatusInterval);
};
});
</script>
<div class="mx-8 flex">
<div class="w-full">
<div class="mb-4 flex items-center justify-between">
<div>
<h1 class="m-8 text-4xl font-bold">Brew</h1>
<!-- <p class="mx-8 my-0 text-muted-foreground">Brewing directly from web to machine</p>
<p class="mx-8 my-0 text-muted-foreground">
Note: refreshing page may cut connection with machine
</p> -->
</div>
<div class="mx-8 my-4 flex gap-2">
{#if !isAdbConnected}
<Button variant="default" onclick={() => connectAdb()}>Connect</Button>
{:else if !isRecipeLoaded}
<Button
variant="default"
onclick={() => loadBrewDataFromConnectedAdb()}
disabled={recipeLoading}
>
{recipeLoading ? 'Loading...' : 'Load Recipes'}
</Button>
{#if !isAndroidSocketConnected}
<Button variant="outline" onclick={() => adb.reconnectAndroidServer()}
>Reconnect Socket</Button
>
{/if}
{:else}
<Button variant="default" onclick={() => goto('/tools/create-menu?open=true')}
>+ Create Menu</Button
>
{/if}
</div>
<!-- <DashboardQuickAdb enableComponent={true} /> -->
</div>
<!-- search bar -->
{#key refresh_counter}
<div class="w-full">
{#if $recipeFromMachineLoading}
<div class="flex items-center justify-center">
<p class="mx-4">Please wait</p>
<Spinner />
</div>
{:else}
<DataTable data={data.recipes} refPage="brew" {columns} />
{/if}
</div>
{/key}
</div>
</div>
<Dialog.Root bind:open={brewConfirmOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Confirm Brew</Dialog.Title>
<Dialog.Description>
Check the material and topping list before sending this menu to Android.
</Dialog.Description>
</Dialog.Header>
{#if pendingBrewMenu}
<div class="grid gap-4 py-2">
<div class="rounded-md border bg-muted/20 p-4">
<div class="text-sm text-muted-foreground">Menu</div>
<div class="mt-1 text-lg font-semibold">
{pendingBrewMenu.name || pendingBrewMenu.otherName || pendingBrewMenu.productCode}
</div>
<div class="mt-1 font-mono text-sm text-muted-foreground">
{pendingBrewMenu.productCode}
</div>
</div>
<div class="rounded-md border p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-base font-semibold">Materials</h3>
<span class="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">
{getPendingBrewMaterials().length} items
</span>
</div>
<div class="grid gap-2">
{#each getPendingBrewMaterials() as material}
<div class="rounded-md border bg-background/70 p-3">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-xs text-muted-foreground">Step {material.index}</div>
<div class="font-medium">{material.name}</div>
</div>
<div class="font-mono text-xs text-muted-foreground">
{material.materialPathId}
</div>
</div>
{#if material.values.length > 0}
<div class="mt-3 flex flex-wrap gap-2">
{#each material.values as field}
<span
class="rounded-full bg-emerald-500 px-2.5 py-1 font-mono text-xs font-semibold text-white"
>
{field.label}: {field.value}
</span>
{/each}
</div>
{:else}
<div class="mt-3 text-sm text-muted-foreground">No amount values set</div>
{/if}
</div>
{/each}
</div>
</div>
<div class="rounded-md border p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-base font-semibold">Toppings</h3>
<span class="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">
{getPendingBrewToppings().length} items
</span>
</div>
{#if getPendingBrewToppings().length === 0}
<div class="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
No toppings selected.
</div>
{:else}
<div class="grid gap-2">
{#each getPendingBrewToppings() as topping}
<div class="rounded-md border bg-background/70 p-3">
<div class="text-xs text-muted-foreground">
Slot {topping.slot}: {topping.slotName}
</div>
<div class="mt-1 font-medium">{topping.toppingName}</div>
<div class="mt-1 text-sm text-muted-foreground">{topping.groupName}</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<Dialog.Footer>
<Button
variant="outline"
onclick={() => {
brewConfirmOpen = false;
pendingBrewMenu = null;
}}
>
Cancel
</Button>
<Button onclick={confirmBrewNow} disabled={!pendingBrewMenu}>Confirm Brew</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>