diff --git a/bun.lock b/bun.lock
index b36186e..979b130 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,6 +11,7 @@
"@dnd-kit/abstract": "^0.2.4",
"@dnd-kit/helpers": "^0.2.4",
"@tanstack/match-sorter-utils": "^8.19.4",
+ "@types/semver": "^7.7.1",
"@xterm/xterm": "^5.5.0",
"@yume-chan/adb": "^2.6.0",
"@yume-chan/adb-credential-web": "^2.1.0",
@@ -22,6 +23,7 @@
"firebase": "^12.14.0",
"idb": "^8.0.3",
"mode-watcher": "^1.1.0",
+ "semver": "^7.8.4",
"usb": "^2.17.0",
"uuid": "^13.0.0",
"xterm-addon-fit": "^0.8.0",
@@ -54,7 +56,7 @@
"prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"storybook": "^10.3.5",
- "svelte": "^5.55.2",
+ "svelte": "^5.56.3",
"svelte-adapter-bun": "^1.0.1",
"svelte-check": "^4.4.6",
"svelte-sonner": "^1.1.0",
@@ -504,6 +506,8 @@
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
+ "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
+
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/w3c-web-usb": ["@types/w3c-web-usb@1.0.14", "", {}, "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA=="],
@@ -834,7 +838,7 @@
"scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
- "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
@@ -862,7 +866,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
- "svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="],
+ "svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="],
"svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="],
@@ -1040,9 +1044,15 @@
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+ "storybook/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "svelte/esrap": ["esrap@2.2.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow=="],
+ "svelte/@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="],
+
+ "svelte/devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
+
+ "svelte/esrap": ["esrap@2.2.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="],
"svelte-ast-print/esrap": ["esrap@1.2.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1" } }, "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw=="],
diff --git a/src/lib/components/AnnouncementDialog.svelte b/src/lib/components/AnnouncementDialog.svelte
index ed18c7d..d668515 100644
--- a/src/lib/components/AnnouncementDialog.svelte
+++ b/src/lib/components/AnnouncementDialog.svelte
@@ -3,7 +3,7 @@
import { GlobalEventBus } from '$lib/core/utils/eventBus';
import { XIcon } from '@lucide/svelte/icons';
- export interface AnnouncementPayload {
+ interface AnnouncementPayload {
title?: string;
subtitle?: string;
message: string;
@@ -84,10 +84,11 @@
{#if visible}
{
// Close on backdrop click
if (e.target === e.currentTarget) dismiss();
@@ -109,7 +110,7 @@
class:scale-95={!animating}
>
-
+
diff --git a/src/lib/components/RemoteShellConfirmDialog.svelte b/src/lib/components/RemoteShellConfirmDialog.svelte
new file mode 100644
index 0000000..7cd8297
--- /dev/null
+++ b/src/lib/components/RemoteShellConfirmDialog.svelte
@@ -0,0 +1,333 @@
+
+
+{#if current}
+
+
{
+ if (e.target === e.currentTarget && current?.status === 'pending') {
+ rejectRemoteShell();
+ }
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ {#if current.status !== 'pending' && current.status !== 'executing'}
+
remoteShellRequest.set(null)}
+ class="absolute top-4 right-4 rounded-full p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
+ aria-label="Close"
+ >
+
+
+ {/if}
+
+
+
+ {#if current.status === 'pending'}
+
+
+
+ Remote Shell Command
+
+
+ A remote user wants to run a command on your device
+
+
+ {:else if current.status === 'executing'}
+
+
+
+
+
+ Executing Command...
+
+
+ Running on your connected Android device
+ {#if current.timeoutSeconds}
+ · auto-cancels after {current.timeoutSeconds}s
+ {/if}
+
+
+ {:else if current.status === 'completed' && !current.timedOut}
+
+
+
+
+
+ Command Completed
+
+
+ Output sent back to server
+
+
+ {:else if current.timedOut}
+
+
+
+
+
+ Command Timed Out
+
+
+ Aborted after {current.timeoutSeconds ?? '?'}s — partial output sent back
+
+
+ {:else}
+
+
+
+
+
+ {current.status === 'rejected' ? 'Command Rejected' : 'Command Failed'}
+
+
+ {current.status === 'rejected'
+ ? 'You chose not to run this command'
+ : 'An error occurred'}
+
+
+ {/if}
+
+
+
+ {#if current.dangerous}
+
+
+
+ Security Warning
+
+
+ Command requested from server, this will not run unless you accepted.
+
+
+ {/if}
+
+
+ {#if current.purposeDeclaration}
+
+
+ Purpose
+
+
+ {current.purposeDeclaration}
+
+
+ {/if}
+
+
+
+
+
+ Command
+ {#if current.timeoutSeconds}
+
+
+ Timeout: {current.timeoutSeconds}s
+
+ {/if}
+
+
{current.commandInput}
+
+
+
+ {#if current.userInfo}
+
+ Requested by:
+ {current.userInfo.displayName ??
+ current.userInfo.email ??
+ current.userInfo.uid ??
+ 'Unknown'}
+ {#if current.userInfo.email}
+ ·
+ {current.userInfo.email}
+ {/if}
+
+ {/if}
+
+
+ {#if (current.status === 'completed' || current.status === 'failed') && current.output}
+
+
+ Output
+
+
{current.output}
+
+ {/if}
+
+ {#if current.status === 'failed' && current.error}
+
+
+ Error
+
+
{current.error}
+
+ {/if}
+
+
+
+ {formatTimestamp(current.timestamp)}
+ {#if current.exitCode !== undefined}
+
+ Exit code: {current.exitCode}
+
+ {/if}
+
+
+
+ {#if current.status === 'pending'}
+
+
+ Reject
+
+
+ Confirm & Run
+
+
+ {/if}
+
+ {#if current.status === 'executing'}
+
+
+ Running...
+
+ {/if}
+
+
+
+{/if}
diff --git a/src/lib/components/app-account-select.svelte b/src/lib/components/app-account-select.svelte
index 83d8428..a51e346 100644
--- a/src/lib/components/app-account-select.svelte
+++ b/src/lib/components/app-account-select.svelte
@@ -1,4 +1,5 @@
-
diff --git a/src/lib/components/dashboard.svelte b/src/lib/components/dashboard.svelte
index 4cd31e5..28ff663 100644
--- a/src/lib/components/dashboard.svelte
+++ b/src/lib/components/dashboard.svelte
@@ -1,4 +1,5 @@
-
diff --git a/src/lib/components/machine-info.svelte b/src/lib/components/machine-info.svelte
index d4b16ea..df03378 100644
--- a/src/lib/components/machine-info.svelte
+++ b/src/lib/components/machine-info.svelte
@@ -1,4 +1,5 @@
-
diff --git a/src/lib/components/recipe-details/recipelist-table.svelte b/src/lib/components/recipe-details/recipelist-table.svelte
index caf08f3..c559385 100644
--- a/src/lib/components/recipe-details/recipelist-table.svelte
+++ b/src/lib/components/recipe-details/recipelist-table.svelte
@@ -1,4 +1,5 @@
-
@@ -76,14 +77,14 @@
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
onStateChange: async (updater) => {
- console.log('table state change', data);
+ logger.info('table state change', data);
await onStateChange(
productCode,
table.getRowModel().rows.map((x) => x.original)
);
},
onSortingChange: async (updater) => {
- console.log('triggering sorting');
+ logger.info('triggering sorting');
if (typeof updater === 'function') {
sorting = updater(sorting);
} else {
@@ -100,7 +101,7 @@
rowSelection = updater(rowSelection);
let rows = table.getRowModel().rows;
- // console.log('state size', data, rows);
+ // logger.info('state size', data, rows);
} else {
rowSelection = updater;
}
@@ -126,10 +127,13 @@
// @ts-expect-error @dnd-kit/adbstract types are botched atm
RestrictToVerticalAxis
]}
- onDragEnd={(e) => {
+ onDragEnd={async (e) => {
// snap
data = move(data as any, e as any);
- //
+ // Notify parent of reorder so changes are saved
+ if (onStateChange) {
+ await onStateChange(productCode, data);
+ }
}}
>
diff --git a/src/lib/components/recipe-details/recipelist-value-editor.svelte b/src/lib/components/recipe-details/recipelist-value-editor.svelte
index 5defeff..b92b5e8 100644
--- a/src/lib/components/recipe-details/recipelist-value-editor.svelte
+++ b/src/lib/components/recipe-details/recipelist-value-editor.svelte
@@ -1,4 +1,5 @@
-
diff --git a/src/lib/components/recipe-details/recipelist-value.svelte b/src/lib/components/recipe-details/recipelist-value.svelte
index 2cd772b..faa0dfe 100644
--- a/src/lib/components/recipe-details/recipelist-value.svelte
+++ b/src/lib/components/recipe-details/recipelist-value.svelte
@@ -1,4 +1,5 @@
-
@@ -395,21 +484,27 @@
-
-
-
- {getCurrentSelectedToppingGroup()}
- , {getCurrentSelectedToppingList()}
-
- {#if oldToppingNamesOnly != null}
-
-
-
-
-
-
- {/if}
-
+ {#if toppingDataLoading}
+
+ {:else}
+
+
+
+ {getCurrentSelectedToppingGroup()}
+ , {getCurrentSelectedToppingList()}
+
+ {#if oldToppingNamesOnly != null}
+
+
+
+
+
+
+ {/if}
+
+ {/if}
{:else}
@@ -440,7 +535,7 @@
{/if}
- {#if water.yield > 0}
+ {#if displayWater.yield > 0}
Hot
- {water.cold} ml
+ {displayWater.cold} ml
{/if}
- {#if currentMaterialType !== 'cup' && currentMaterialType !== 'topping' && stir_time > 0}
+ {#if currentMaterialType !== 'cup' && currentMaterialType !== 'topping' && displayStirTime > 0}
{getStirTimeName()}
- {stir_time / 10} sec(s)
+ {displayStirTime / 10} sec(s)
{/if}
@@ -527,10 +622,10 @@
{/if}
- {#if feed.parameter > 0 || feed.pattern > 0}
+ {#if displayFeed.parameter > 0 || displayFeed.pattern > 0}
-
Style {feed.pattern}
-
Level {feed.parameter}
+
Style {displayFeed.pattern}
+
Level {displayFeed.parameter}
{/if}
diff --git a/src/lib/components/recipe-editor-dialog.svelte b/src/lib/components/recipe-editor-dialog.svelte
index cd8b52f..2ec46dc 100644
--- a/src/lib/components/recipe-editor-dialog.svelte
+++ b/src/lib/components/recipe-editor-dialog.svelte
@@ -1,7 +1,10 @@
@@ -419,7 +520,7 @@
save_change = true;
callback_revert_value_if_not_save(save_change);
- await saveRecipeMenuFileToAndroid();
+ // await saveRecipeMenuFileToAndroid();
addNotification('INFO:Save recipe');
}}
diff --git a/src/lib/components/terminal-drawer.svelte b/src/lib/components/terminal-drawer.svelte
index 195a065..337c100 100644
--- a/src/lib/components/terminal-drawer.svelte
+++ b/src/lib/components/terminal-drawer.svelte
@@ -11,6 +11,7 @@
closeTerminalDrawer
} from '$lib/core/stores/terminalDrawer';
import { getAdbInstance } from '$lib/core/adb/adb';
+ import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
import {
initTerminalSession,
reinitTerminalSession,
@@ -270,17 +271,17 @@
}
}
- // Check connection status periodically
- let _connInterval: ReturnType
| undefined;
+ // Subscribe to the shared connection store instead of polling every 2s.
+ // The store is updated by adb.ts whenever a device connects or disconnects.
+ let _unsubConnStatus: (() => void) | undefined;
onMount(() => {
- _connInterval = setInterval(() => {
- const instance = getAdbInstance();
- connStatus = instance ? 'connected' : 'disconnected';
- }, 2000);
+ _unsubConnStatus = adbConnectionStatus.subscribe((status) => {
+ connStatus = status === 'connected' ? 'connected' : 'disconnected';
+ });
});
onDestroy(() => {
- if (_connInterval) clearInterval(_connInterval);
+ _unsubConnStatus?.();
});
diff --git a/src/lib/components/ui/terminal/terminal.svelte b/src/lib/components/ui/terminal/terminal.svelte
index 3569ba5..c2ebb61 100644
--- a/src/lib/components/ui/terminal/terminal.svelte
+++ b/src/lib/components/ui/terminal/terminal.svelte
@@ -1,4 +1,5 @@
-
diff --git a/src/routes/(authed)/admin/+layout.svelte b/src/routes/(authed)/admin/+layout.svelte
index ba25fe8..29fafe5 100644
--- a/src/routes/(authed)/admin/+layout.svelte
+++ b/src/routes/(authed)/admin/+layout.svelte
@@ -1,4 +1,5 @@
-
@@ -459,21 +941,93 @@
{/if}
-
-
- {#if loading}
-
- Loading
- {:else if devRecipe}
- Reload From Android
- {:else}
- Connect & Load
- {/if}
-
+
+
+ Recipe Source
+
+
+
+
+ {adb.getAdbInstance() ? 'Machine Connected' : 'Connect Machine'}
+
+
+ {#if loading}
+
+ Loading
+ {:else}
+ Machine
+ {/if}
+
+
+ Server{selectedServerCountry ? `: ${selectedServerCountry}` : ''}
+
+
+
+
+
+ Load Recipe From Server
+ Select a country to load material data from server.
+
+
+
+ {#each serverCountries as country}
+ selectServerCountry(country)}
+ disabled={loading || saving}
+ >
+ {country}
+
+ {/each}
+
+
+
+ (serverCountryDialogOpen = false)}>Cancel
+
+
+
+
+
+
+
+ Machine Not Connected
+
+ Connect to the machine with ADB/WebUSB before loading recipe data from Machine.
+
+
+
+
+ (machineNotConnectedDialogOpen = false)}>OK
+
+
+
+
@@ -499,31 +1053,60 @@
- {existingMaterial ? 'Edit Material' : 'Add Material'}
+ {isEditingMaterial ? 'Edit Material' : 'Add Material'}
- Create or update one MaterialSetting entry. The JSON preview shows the payload
- before saving to Android.
+ Create or update one MaterialSetting entry. Server-loaded data is read-only until
+ ADB is connected.
- {existingMaterial ? 'Edit Material' : 'Add Material'}
+ {isEditingMaterial ? 'Edit Material' : 'Add Material'}
- {#if existingMaterial}
+ {#if duplicateMaterialOnCreate}
+
+ Material ID {form.id} already exists. Choose another ID before creating.
+
+ {:else if isEditingMaterial && Number(form.id) !== Number(editingMaterialId) && existingMaterial}
+
+ Material ID {form.id} already exists. Choose another ID before saving.
+
+ {:else if isEditingMaterial}
- Material ID {form.id} already exists. Saving will update this MaterialSetting.
+ Editing Material ID {editingMaterialId}.
{/if}
+
+
Material Type
+
(materialTypeDialogOpen = true)}
+ >
+
+ {getSelectedMaterialTypeLabel()}
+
+
+
+
Alternate ID
@@ -546,11 +1129,11 @@
-
-
- Channel
- {
- form.channel = event.currentTarget.value as MaterialChannel;
- applyChannelPreset();
- }}
- >
- {#each channelOptions as option}
- {option.label}
- {/each}
-
-
+
Canister Type
@@ -618,21 +1185,6 @@
-
-
Alarm ID
@@ -686,12 +1238,18 @@
- (showMaterialForm = false)} disabled={saving}
- >Cancel Cancel
Reset
-
- {saving ? 'Saving...' : existingMaterial ? 'Update Material' : 'Create Material'}
+
+ {saving ? 'Saving...' : isEditingMaterial ? 'Update Material' : 'Create Material'}
@@ -713,14 +1271,46 @@
+
+
+
+ Select Material Type
+
+ Choose the type for the new material. Material ID will be generated from existing IDs in
+ that type + 1.
+
+
+
+
+ {#each selectableMaterialTypeOptions as option}
+
selectMaterialType(option.value)}
+ >
+
+
TYPE {option.typeId} - {option.label}
+
+
+ {/each}
+
+
+
+ (materialTypeDialogOpen = false)}>Cancel
+
+
+
+
Existing Materials
-
- Use Edit to update a material, or Delete to remove it after confirmation.
-
+
Add Material
- Loading materials from Android...
+ Loading materials...
{:else if !devRecipe}
- Connect and load recipe first.
+ Load recipe first.
{:else if filteredMaterials.length === 0}
No materials found.
{:else}
ID
-
Thai Name
+
Name ({primaryLanguageLabel})
English Name
Path
Use
@@ -762,7 +1352,7 @@
{#each filteredMaterials as material}
{material.id}
{material.materialName || '-'}
@@ -788,7 +1378,7 @@
variant="destructive"
size="sm"
onclick={() => openDeleteConfirm(material)}
- disabled={saving}
+ disabled={saving || !canWriteToAndroid}
>
Delete
@@ -833,7 +1423,11 @@
>
Cancel
-
+
{saving ? 'Deleting...' : 'Delete Material'}
diff --git a/src/routes/(authed)/recipe/overview/+page.server.ts b/src/routes/(authed)/recipe/overview/+page.server.ts
index 9fb0976..df6b933 100644
--- a/src/routes/(authed)/recipe/overview/+page.server.ts
+++ b/src/routes/(authed)/recipe/overview/+page.server.ts
@@ -1,10 +1,11 @@
+import { logger } from '$lib/core/utils/logger';
import { getRecipes } from '$lib/core/client/server';
import { recipeData } from '$lib/core/stores/recipeStore';
import { get } from 'svelte/store';
export async function load({ cookies, params }) {
let dep = cookies.get('department');
- console.log('load recipe ', dep, params);
+ logger.info('load recipe ', dep, params);
let recipes = await getRecipes();
recipes = get(recipeData);
diff --git a/src/routes/(authed)/recipe/overview/+page.svelte b/src/routes/(authed)/recipe/overview/+page.svelte
index edd1459..1e220c9 100644
--- a/src/routes/(authed)/recipe/overview/+page.svelte
+++ b/src/routes/(authed)/recipe/overview/+page.svelte
@@ -1,4 +1,5 @@
-
@@ -551,19 +775,93 @@
{/if}
-
- {#if loading}
-
- Loading
- {:else if devRecipe}
- Reload From Android
- {:else}
- Connect & Load
- {/if}
-
+
+
+ Recipe Source
+
+
+
+
+ {adb.getAdbInstance() ? 'Machine Connected' : 'Connect Machine'}
+
+
+ {#if loading}
+
+ Loading
+ {:else}
+ Machine
+ {/if}
+
+
+ Server{selectedServerCountry ? `: ${selectedServerCountry}` : ''}
+
+
+
+
+
+
+ Load Recipe From Server
+ Select a country to load topping data from server.
+
+
+
+ {#each serverCountries as country}
+ selectServerCountry(country)}
+ disabled={loading || saving}
+ >
+ {country}
+
+ {/each}
+
+
+
+ (serverCountryDialogOpen = false)}>Cancel
+
+
+
+
+
+
+
+ Machine Not Connected
+
+ Connect to the machine with ADB/WebUSB before loading recipe data from Machine.
+
+
+
+
+ (machineNotConnectedDialogOpen = false)}>OK
+
+
+
+
@@ -591,23 +889,23 @@
{activeTab === 'list' ? 'Topping List' : 'Topping Group'}
-
- Switch between list items and groups. Edit/Delete actions are explicit per row.
-
+
- (activeTab = 'list')}
- >
- ToppingList
-
(activeTab = 'group')}
>
ToppingGroup
+ (activeTab = 'list')}
+ >
+ ToppingList
+
@@ -615,12 +913,14 @@
{#if activeTab === 'list'}
- Add Topping Add Topping
{:else}
- Add Group Add Group
{/if}
@@ -629,19 +929,19 @@
{#if loading}
- Loading toppings from Android...
+ Loading toppings...
{:else if !devRecipe}
-
Connect and load recipe first.
+
Load recipe first.
{:else if activeTab === 'list'}
{#if filteredToppingList.length === 0}
No topping list items found.
{:else}
ID
-
Thai Name
+
Name ({primaryLanguageLabel})
English Name
Use
Actions
@@ -649,7 +949,7 @@
{#each filteredToppingList as item}
{item.id}
{item.name || '-'}
@@ -662,14 +962,17 @@
{(item.isUse as boolean) !== false ? 'Use' : 'Not use'}
-
loadListIntoForm(item)}
- >Edit loadListIntoForm(item)}
+ disabled={!canWriteToAndroid}>Edit
openDeleteConfirm('list', item)}
- disabled={saving}
+ disabled={saving || !canWriteToAndroid}
>
Delete
@@ -682,10 +985,10 @@
No topping groups found.
{:else}
ID
- Thai Name
+ Name ({primaryLanguageLabel})
English Name
IDs in group
Use
@@ -693,33 +996,52 @@
{#each filteredToppingGroups as group}
-
-
{group.groupID}
-
{group.name || '-'}
-
{group.otherName || '-'}
-
{group.idInGroup || '-'}
+
openGroupToppingDialog(group)}
+ onkeydown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') openGroupToppingDialog(group);
+ }}
>
-
- {(group.inUse as boolean) !== false ? 'Use' : 'Not use'}
-
-
-
loadGroupIntoForm(group)}
- >Edit {group.groupID}
+
{group.name || '-'}
+
{group.otherName || '-'}
+
{group.idInGroup || '-'}
-
openDeleteConfirm('group', group)}
- disabled={saving}
+
- Delete
-
+ {(group.inUse as boolean) !== false ? 'Use' : 'Not use'}
+
+
+ {
+ event.stopPropagation();
+ loadGroupIntoForm(group);
+ }}
+ disabled={!canWriteToAndroid}>Edit
+ {
+ event.stopPropagation();
+ openDeleteConfirm('group', group);
+ }}
+ disabled={saving || !canWriteToAndroid}
+ >
+ Delete
+
+
{/each}
@@ -729,11 +1051,70 @@
+
+
+
+
+ ToppingList in Group {selectedGroupForToppings?.groupID ?? ''}
+
+
+ {selectedGroupForToppings?.otherName ||
+ selectedGroupForToppings?.name ||
+ 'Selected group'}
+ {selectedGroupForToppings?.idInGroup ? ` (${selectedGroupForToppings.idInGroup})` : ''}
+
+
+
+ {#if selectedGroupForToppings}
+ {@const groupToppings = getToppingListsForGroup(selectedGroupForToppings)}
+
+ {#if groupToppings.length === 0}
+
+ No matching topping list items found for {selectedGroupForToppings.idInGroup ||
+ 'this group'}.
+
+ {:else}
+
+ {#each groupToppings as toppingItem}
+
+
+
+
{toppingItem.id}
+
{toppingItem.name || '-'}
+
{toppingItem.otherName || '-'}
+
+
+ {(toppingItem.isUse as boolean) !== false ? 'Use' : 'Not use'}
+
+
+
+ {/each}
+
+ {/if}
+
+ {/if}
+
+
+ (groupToppingDialogOpen = false)}>Close
+
+
+
+
{existingListItem ? 'Edit Topping' : 'Add Topping'}
- Manage one item inside ToppingList.
+
+ Manage one item inside ToppingList. Server-loaded data is read-only until ADB
+ is connected.
+
@@ -756,7 +1137,7 @@
- Thai Name
+ Name ({primaryLanguageLabel})
@@ -927,7 +1308,7 @@
(listDialogOpen = false)} disabled={saving}
>Cancel
- {saving ? 'Saving...' : 'Save Topping'}
@@ -955,7 +1336,10 @@
{existingGroup ? 'Edit Topping Group' : 'Add Topping Group'}
- Manage one group inside ToppingGroup.
+
+ Manage one group inside ToppingGroup. Server-loaded data is read-only until
+ ADB is connected.
+
@@ -978,7 +1362,7 @@
- Thai Name
+ Name ({primaryLanguageLabel})
@@ -1002,7 +1386,7 @@
(groupDialogOpen = false)} disabled={saving}
>Cancel
- {saving ? 'Saving...' : 'Save Group'}
@@ -1030,9 +1414,9 @@
Delete Topping?
- This will remove the selected entry from Android recipe JSON.
+
+ This will remove the selected entry from Android recipe JSON. ADB is required.
+
{#if pendingDelete}
@@ -1062,7 +1446,11 @@
>
Cancel
-
+
{saving ? 'Deleting...' : 'Delete'}
diff --git a/src/routes/(authed)/sheet/add/[country]/[catalog]/+page.svelte b/src/routes/(authed)/sheet/add/[country]/[catalog]/+page.svelte
index 00a1b59..4cf1cfc 100644
--- a/src/routes/(authed)/sheet/add/[country]/[catalog]/+page.svelte
+++ b/src/routes/(authed)/sheet/add/[country]/[catalog]/+page.svelte
@@ -1,4 +1,5 @@
-
-
- {#if loading}
-
- {:else}
-
- {/if}
- Refresh
-
+
+
+
+ Banners
+
+
(createOpen = true)}>
+
+ New Promo
+
+
+ {#if loading}
+
+ {:else}
+
+ {/if}
+ Refresh
+
+
@@ -159,10 +622,7 @@
type="single"
value={selectedCountry}
onValueChange={(v) => {
- if (v) {
- selectedCountry = v;
- goto(`/sheet/overview/${v}`);
- }
+ if (v) void handleCountryChange(v);
}}
>
{:else if error}
-
+
{error}
@@ -219,7 +681,9 @@
variant="secondary"
class="inline-flex shrink-0 items-center gap-1 rounded-full border-yellow-500/30 bg-yellow-500/15 px-2.5 py-1 text-xs text-yellow-700 dark:text-yellow-200"
>
-
+
Main
{/if}
@@ -277,3 +741,547 @@
+
+
{
+ if (!open) resetCreateForm();
+ }}
+>
+
+
+
+
+
+
+
+
+ New Promo Catalog
+
+ Create a promo page, banner & event for {selectedCountry.toUpperCase()}
+
+
+
+
+
+
+
+
+ {#if AdbInstance.instance}
+
+
+ Machine connected
+
+ {:else}
+
+
+ Connect required
+
+ {/if}
+
+ {#if !AdbInstance.instance}
+
+ {#if adbConnecting}
+
+ Connecting…
+ {:else}
+
+ Connect
+ {/if}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
Banner image
+
+ {#if bannerSizeWarning}
+
+ {bannerSizeWarning}
+
+ {/if}
+
+ Recommended 484 × 616 px
+
+
+
+
{
+ e.preventDefault();
+ bannerDragging = true;
+ }}
+ ondragleave={() => (bannerDragging = false)}
+ ondrop={onBannerDrop}
+ >
+ {#if bannerPreview}
+
+ {
+ e.preventDefault();
+ setBanner(null);
+ }}
+ aria-label="Remove banner"
+ >
+
+
+ {:else}
+
+
+
+
+
+
Drop a banner here
+
or click to browse image files
+
+
+ {/if}
+
+
+ {#if bannerFile}
+
Selected: {bannerFile.name}
+ {/if}
+
+
+
+
Generated files
+ {#if slugPreview}
+
+
+ File
+ {filePreview}
+
+
+ State
+ {tagPreview}State
+
+
+ {:else}
+
+ Enter a promo name to preview generated output.
+
+ {/if}
+
+
+
+
+
+
+ Promo name
+ onNameInput((e.target as HTMLInputElement).value)}
+ />
+
+
+
+
Slug
+
+
+ Lowercase letters, numbers and underscore only.
+
+
+
+
+
+
+
+
Schedule
+
Choose when this promo banner appears.
+
+
+ Today
+ Tomorrow
+
+
+
+
+
+
Start
+
+
+
+ openCalendarFor(createForm.start)}
+ >
+
+ {formatDisplayDate(createForm.start)}
+
+
+
+
+
moveCalendarMonth(-1)}
+ >
+
+
+
+ {monthFormatter.format(calendarMonth)}
+
+
moveCalendarMonth(1)}
+ >
+
+
+
+
+ {#each weekdayLabels as day}
+
{day}
+ {/each}
+
+
+ {#each getCalendarDays() as day, index (`start-${index}-${day?.toISOString() ?? 'blank'}`)}
+ {#if day}
+
selectCalendarDate('start', day)}
+ >
+ {day.getDate()}
+
+ {:else}
+
+ {/if}
+ {/each}
+
+
+
+
+ setDateTimeField('start', 'time', (e.target as HTMLInputElement).value)}
+ />
+
+
+
+
+
+ End optional
+ (createForm.end = '')}>No end
+
+
+
+
+ openCalendarFor(createForm.end)}
+ >
+
+ {formatDisplayDate(createForm.end)}
+
+
+
+
+
moveCalendarMonth(-1)}
+ >
+
+
+
+ {monthFormatter.format(calendarMonth)}
+
+
moveCalendarMonth(1)}
+ >
+
+
+
+
+ {#each weekdayLabels as day}
+
{day}
+ {/each}
+
+
+ {#each getCalendarDays() as day, index (`end-${index}-${day?.toISOString() ?? 'blank'}`)}
+ {#if day}
+
selectCalendarDate('end', day)}
+ >
+ {day.getDate()}
+
+ {:else}
+
+ {/if}
+ {/each}
+
+
+
+
+ setDateTimeField('end', 'time', (e.target as HTMLInputElement).value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
(createOpen = false)} disabled={creating}>
+ Cancel
+
+
+ {#if creating}
+
+ Creating…
+ {:else}
+
+ Create & Push
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Promo Banners
+
+ Banner images for {selectedCountry.toUpperCase()} — replace
+ & push to the machine
+
+
+
+
+
+
+
+
+ {#if AdbInstance.instance}
+
+
+ Machine connected
+
+ {:else}
+
+
+ Machine not connected
+
+ {/if}
+
+ {#if !AdbInstance.instance}
+
+ {#if adbConnecting}
+
+ Connecting…
+ {:else}
+
+ Connect
+ {/if}
+
+ {/if}
+
+ {#if !AdbInstance.instance}
+
+ Not connected — replacing a banner will only update the server. Connect to also push it to
+ the machine.
+
+ {/if}
+
+
+
+
+ {#if bannerListLoading}
+
+
+
+ {:else if bannerList.length === 0}
+
+ No web-created promos found for {selectedCountry.toUpperCase()}
+
+ {:else}
+ {#each bannerList as promo (promo.slug)}
+
+
+ {#if promo.banner}
+
+ {:else}
+
+ {/if}
+
+
+
+
{promo.slug}
+ {#if promo.start}
+
+ {promo.start?.slice(0, 10)} → {promo.end && promo.end !== 'NONE'
+ ? promo.end.slice(0, 10)
+ : 'no end'}
+
+ {/if}
+
+
+ {#if promo.banner}
+ pushBannerToMachine(promo)}
+ >
+ {#if pushingSlug === promo.slug}
+
+ Pushing…
+ {:else}
+
+ Push
+ {/if}
+
+ {/if}
+
+ {#if uploadingSlug === promo.slug}
+
+ Uploading…
+ {:else}
+
+ Replace
+ {/if}
+ onReplaceBanner(promo, e)}
+ />
+
+
+
+
+ {/each}
+ {/if}
+
+
+
+
+
+
+ Refresh
+
+ (bannersOpen = false)}>Done
+
+
+
diff --git a/src/routes/(authed)/sheet/price/[country]/+page.svelte b/src/routes/(authed)/sheet/price/[country]/+page.svelte
new file mode 100644
index 0000000..3667b69
--- /dev/null
+++ b/src/routes/(authed)/sheet/price/[country]/+page.svelte
@@ -0,0 +1,280 @@
+
+
+
+
+
+
+
+ Price [ {selectedCountry.toUpperCase()} ]
+
+
View main Price sheet data for this country.
+
+
+
+ {#if saving}
+
+ Saving
+ {:else}
+
+ Save Changes ({getChangedUpdates().length})
+ {/if}
+
+
+ {#if loading}
+
+ Loading
+ {:else}
+
+ Refresh
+ {/if}
+
+
+
+
+
+
+
+ {#if loading && rows.length === 0}
+
+
+ Loading Price data...
+
+ {:else if rows.length === 0}
+
+ No Price data loaded. Click Refresh to load data.
+
+ {:else if filteredRows.length === 0}
+
+ No rows match your search.
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte b/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte
index 86a2542..13f26b2 100644
--- a/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte
+++ b/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte
@@ -8,16 +8,34 @@
import { permission as currentPerms } from '$lib/core/stores/permissions.js';
import { referenceFromPage } from '$lib/core/stores/recipeStore.js';
import {
- clearSheetPriceSentTypes,
+ existingProductCodes,
+ getPriceColumnIndex,
getCountryPrimaryLanguage,
getPriceFromCells,
lastRequestSheetPrice,
+ priceSlotNamespaces,
+ priceSlots,
+ priceSlotsError,
+ priceSlotsLoading,
+ sheetPriceAllRows,
+ sheetPriceHeader,
sheetPriceLoading,
+ type GristCell,
type PriceSlot,
- type PriceSlotProduct
+ type PriceSlotProduct,
+ type PriceSlotServiceRow
} from '$lib/core/stores/sheetStore.js';
- import { requestSheetPrice } from '$lib/core/services/sheetService.js';
- import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js';
+ import {
+ addPriceSlot,
+ addPriceSlotRows,
+ deletePriceSlotRows,
+ requestListMenu,
+ requestPriceSlot,
+ requestPriceSlots,
+ requestSheetPrice,
+ updatePriceSlot
+ } from '$lib/core/services/sheetService.js';
+ import { waitForAuthenticatedSocket } from '$lib/core/stores/websocketStore.js';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
@@ -25,100 +43,106 @@
import * as Select from '$lib/components/ui/select/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import Badge from '$lib/components/ui/badge/badge.svelte';
- import { Calculator, RefreshCw, Save, RotateCcw, Search } from '@lucide/svelte/icons';
+ import {
+ Calculator,
+ Plus,
+ RefreshCw,
+ Save,
+ RotateCcw,
+ Search,
+ Trash2
+ } from '@lucide/svelte/icons';
type AdjustmentMode =
| 'increase_percent'
| 'increase_amount'
- | 'decrease_amount'
- | 'decrease_percent';
+ | 'decrease_percent'
+ | 'decrease_amount';
const adjustmentModeLabels: Record
= {
increase_percent: 'Increase by Percentage (%)',
increase_amount: 'Increase by Fixed Amount',
- decrease_amount: 'Decrease by Fixed Amount',
- decrease_percent: 'Decrease by Percentage (%)'
+ decrease_percent: 'Decrease by Percentage (%)',
+ decrease_amount: 'Decrease by Fixed Amount'
};
- const mockProducts: PriceSlotProduct[] = [
- {
- product_code: '12-01-01-0001',
- name: 'HOT ESPRESSO | เอสเพรสโซ่ร้อน',
- price: 30,
- row_index: 2
- },
- { product_code: '12-01-01-0003', name: 'HOT AMERICANO | กาแฟดำร้อน', price: 35, row_index: 3 },
- { product_code: '12-01-01-0004', name: 'HOT LATTE | ลาเต้ร้อน', price: 40, row_index: 5 },
- { product_code: '12-01-01-0006', name: 'HOT MOCHA | มอคค่าร้อน', price: 55, row_index: 7 },
- {
- product_code: '12-01-02-0001',
- name: 'Iced AMERICANO | กาแฟดำเย็น',
- price: 40,
- row_index: 16
- },
- { product_code: '12-01-02-0002', name: 'ICED LATTE | ลาเต้เย็น', price: 50, row_index: 17 },
- { product_code: '12-01-02-0003', name: 'ICED MOCHA | มอคค่าเย็น', price: 60, row_index: 18 },
- {
- product_code: '12-02-01-0002',
- name: 'Hot THAI MILK TEA | ชาไทยร้อน',
- price: 40,
- row_index: 27
- },
- {
- product_code: '12-02-01-0004',
- name: 'Hot MATCHA LATTE | มัทฉะลาเต้ร้อน',
- price: 50,
- row_index: 29
- }
- ];
-
- function buildMockSlots(): PriceSlot[] {
- return Array.from({ length: 10 }, (_, index) => {
- const slot = index + 1;
- const increase = slot === 1 ? 15 : slot === 2 ? 25 : slot * 5;
-
- return {
- slot,
- name: slot <= 2 ? `ProfileIncrease${increase}` : `PriceSlot${slot}`,
- description: slot <= 2 ? `increase price ${increase}%` : '',
- products: mockProducts.map((product) => ({
- ...product,
- price:
- product.price === null
- ? null
- : slot <= 2
- ? Math.ceil((product.price * (1 + increase / 100)) / 5) * 5
- : product.price
- }))
- };
- });
- }
+ const emptySlot: PriceSlot = {
+ slot: 0,
+ name: '',
+ description: '',
+ kind: 'price',
+ header: [],
+ products: []
+ };
+ const rowsPerPage = 200;
+ const supportedCountries = new Set([
+ 'aus',
+ 'gbr',
+ 'hkg',
+ 'ltu',
+ 'mys',
+ 'rou',
+ 'sgp',
+ 'tha',
+ 'uae_dubai'
+ ]);
let selectedCountry = $state($page.params.country || get(departmentStore) || '');
let enabledCountries = $state([]);
- let selectedSlot = $state(1);
- const initialSlots = buildMockSlots();
- let slots = $state(initialSlots);
- let savedSnapshot = $state(structuredClone(initialSlots));
- let loading = $state(false);
+ let selectedSlot = $state(0);
+ let localSlots = $state([]);
+ let workingSlot = $state(null);
+ let savedSlot = $state(null);
+ let saveLoading = $state(false);
let productCodeSearch = $state('');
let createDialogOpen = $state(false);
let adjustmentMode = $state('increase_percent');
let adjustmentValue = $state(15);
+ let createSlotNumber = $state(1);
let createName = $state('ProfileIncrease15');
let createDescription = $state('increase price 15%');
+ let productPage = $state(1);
+ let servicePage = $state(1);
+ let requestedSlotKeys = new Set();
+ let requestedProductCodeCountries = new Set();
+ let requestedPriceCountries = new Set();
+ let pendingCreateDialog = $state(false);
+ let deletedProductRowIds = $state([]);
+ let createMode = $state<'from_price' | 'blank'>('from_price');
+ let addDialogOpen = $state(false);
+ let addProductCode = $state('');
+ let addProductName = $state('');
+ let addProductPrice = $state('');
- let currentSlot = $derived(slots.find((slot) => slot.slot === selectedSlot) ?? slots[0]);
+ let selectedCountryKey = $derived(selectedCountry.toLowerCase());
let selectedCountryLanguage = $derived(getCountryPrimaryLanguage(selectedCountry));
+ let backendSlots = $derived($priceSlots[selectedCountryKey] ?? []);
+ let namespaceSlots = $derived($priceSlotNamespaces[selectedCountryKey] ?? []);
+ let displaySlots = $derived(
+ mergeDisplaySlots(namespaceSlots, backendSlots, localSlots).sort((a, b) => a.slot - b.slot)
+ );
+ let selectedSourceSlot = $derived(
+ displaySlots.find((slot) => slot.slot === selectedSlot) ?? displaySlots[0] ?? null
+ );
+ let currentSlot = $derived(workingSlot ?? emptySlot);
+ let isServiceSlot = $derived(currentSlot.kind === 'service');
+ let serviceHeaders = $derived(currentSlot.header?.filter(Boolean) ?? []);
+ let loading = $derived($priceSlotsLoading);
let basePriceCells = $derived(
$lastRequestSheetPrice[selectedCountry.toLowerCase()] ||
$lastRequestSheetPrice[selectedCountry] ||
{}
);
- let basePricesLoadedCount = $derived(
- mockProducts.filter((product) => getBasePrice(product) !== null).length
+ let basePriceRows = $derived(
+ $sheetPriceAllRows[selectedCountry.toLowerCase()] || $sheetPriceAllRows[selectedCountry] || {}
);
- let basePriceLoading = $derived($sheetPriceLoading);
+ let basePriceRowCount = $derived(
+ Object.values(basePriceRows).reduce((total, rows) => total + rows.length, 0)
+ );
+ let basePriceHeader = $derived(
+ $sheetPriceHeader[selectedCountry.toLowerCase()] || $sheetPriceHeader[selectedCountry] || []
+ );
+ let createPriceBaseLoading = $derived($sheetPriceLoading);
let filteredProducts = $derived(
currentSlot.products.filter((product) => {
const keyword = productCodeSearch.trim().toLowerCase();
@@ -127,12 +151,56 @@
return product.product_code.toLowerCase().includes(keyword);
})
);
- let changedCount = $derived(countChangedProducts(currentSlot, savedSnapshot[selectedSlot - 1]));
- let hasHeaderChanges = $derived(
- currentSlot.name !== savedSnapshot[selectedSlot - 1]?.name ||
- currentSlot.description !== savedSnapshot[selectedSlot - 1]?.description
+ let filteredServiceRows = $derived(
+ (currentSlot.serviceRows ?? []).filter((row) => {
+ const keyword = productCodeSearch.trim().toLowerCase();
+ if (!keyword) return true;
+
+ return Object.values(row.cells).some((value) => value.toLowerCase().includes(keyword));
+ })
);
- let hasChanges = $derived(changedCount > 0 || hasHeaderChanges);
+ let productPageCount = $derived(Math.max(1, Math.ceil(filteredProducts.length / rowsPerPage)));
+ let servicePageCount = $derived(Math.max(1, Math.ceil(filteredServiceRows.length / rowsPerPage)));
+ let pagedProducts = $derived(
+ filteredProducts.slice((productPage - 1) * rowsPerPage, productPage * rowsPerPage)
+ );
+ let pagedServiceRows = $derived(
+ filteredServiceRows.slice((servicePage - 1) * rowsPerPage, servicePage * rowsPerPage)
+ );
+ let changedCount = $derived(countChangedProducts(currentSlot, savedSlot ?? undefined));
+ let changedServiceCount = $derived(countChangedServiceRows(currentSlot, savedSlot ?? undefined));
+ let addedProductCount = $derived(countAddedProducts(currentSlot, savedSlot ?? undefined));
+ let deletedProductCount = $derived(deletedProductRowIds.length);
+ let totalChangedCount = $derived(
+ changedCount + changedServiceCount + addedProductCount + deletedProductCount
+ );
+ let isLocalSlot = $derived(localSlots.some((slot) => slot.slot === currentSlot.slot));
+ let visibleRowCount = $derived(
+ isServiceSlot ? filteredServiceRows.length : filteredProducts.length
+ );
+ let totalRowCount = $derived(
+ isServiceSlot ? (currentSlot.serviceRows?.length ?? 0) : currentSlot.products.length
+ );
+ let hasHeaderChanges = $derived(
+ currentSlot.name !== savedSlot?.name || currentSlot.description !== savedSlot?.description
+ );
+ let hasChanges = $derived(totalChangedCount > 0 || hasHeaderChanges || isLocalSlot);
+ let resetButtonTitle = $derived(
+ !currentSlot.slot
+ ? 'Select a PriceSlot first'
+ : !savedSlot
+ ? 'This PriceSlot has not been saved yet'
+ : !hasChanges
+ ? 'Reset is available after changing this PriceSlot'
+ : 'Discard unsaved changes and restore the last loaded values'
+ );
+
+ function getEnabledSheetCountries() {
+ return get(currentPerms)
+ .filter((x) => x.startsWith('document.write'))
+ .map((x) => x.split('.')[2])
+ .filter((country) => supportedCountries.has(country));
+ }
onMount(() => {
referenceFromPage.set('priceslot');
@@ -141,11 +209,149 @@
departmentStore.set(selectedCountry);
}
- const userPerms = get(currentPerms).filter((x) => x.startsWith('document.write'));
- enabledCountries = userPerms.map((x) => x.split('.')[2]);
+ enabledCountries = getEnabledSheetCountries();
+
+ if (selectedCountry) {
+ void loadPriceSlots();
+ }
});
+ let lastLoadedSlotSignature = $state('');
+
+ $effect(() => {
+ if (displaySlots.length === 0) {
+ workingSlot = null;
+ savedSlot = null;
+ lastLoadedSlotSignature = '';
+ return;
+ }
+
+ if (!displaySlots.some((slot) => slot.slot === selectedSlot)) {
+ selectedSlot = displaySlots[0]?.slot ?? 0;
+ return;
+ }
+
+ if (!selectedSourceSlot) return;
+
+ const signature = getSlotSignature(selectedSourceSlot);
+ if (signature === lastLoadedSlotSignature) return;
+ if (hasChanges && workingSlot?.slot === selectedSourceSlot.slot) return;
+
+ lastLoadedSlotSignature = signature;
+ workingSlot = clonePriceSlot(selectedSourceSlot);
+ savedSlot = clonePriceSlot(selectedSourceSlot);
+ deletedProductRowIds = [];
+ productCodeSearch = '';
+ productPage = 1;
+ servicePage = 1;
+ void ensureSelectedSlotLoaded(selectedSourceSlot);
+ });
+
+ $effect(() => {
+ if (!pendingCreateDialog) return;
+
+ if (basePriceRowCount > 0) {
+ pendingCreateDialog = false;
+ applyCreateTemplate();
+ createDialogOpen = true;
+ return;
+ }
+
+ if (!$sheetPriceLoading) {
+ const productCodes = getProductCodesForPriceBase();
+ if (productCodes.length > 0) void requestPriceBaseFromProductCodes(productCodes);
+ }
+ });
+
+ $effect(() => {
+ if (currentSlot.products.length === 0) return;
+ if (requestedPriceCountries.has(selectedCountryKey)) return;
+ if (isLocalSlot) return;
+ if (Object.keys(basePriceCells).length > 0) return;
+
+ const productCodes = currentSlot.products
+ .filter((p) => p.product_code)
+ .map((p) => p.product_code);
+ if (productCodes.length === 0) return;
+
+ void requestPriceBaseFromProductCodes(productCodes);
+ });
+
+ function getBaseProducts(): (PriceSlotProduct & { sourceCells: GristCell[] })[] {
+ return Object.entries(basePriceRows).flatMap(([productCode, rows]) =>
+ rows.map((row) => {
+ const productName = getCellValue(row.cells, 2);
+ const price = getPriceFromCells(selectedCountryKey, row.cells, 'cash_price');
+ const parsedPrice = Number(price);
+
+ return {
+ product_code: productCode,
+ name: productName,
+ price: Number.isNaN(parsedPrice) ? null : parsedPrice,
+ row_index: row.row,
+ sourceCells: row.cells
+ };
+ })
+ );
+ }
+
+ function mergeDisplaySlots(
+ namespaces: PriceSlot[],
+ loadedSlots: PriceSlot[],
+ draftSlots: PriceSlot[]
+ ) {
+ const merged = new Map();
+ for (const slot of namespaces) merged.set(slot.slot, slot);
+ for (const slot of loadedSlots) merged.set(slot.slot, slot);
+ for (const slot of draftSlots) merged.set(slot.slot, slot);
+ return Array.from(merged.values());
+ }
+
+ function getSlotSignature(slot: PriceSlot) {
+ return [
+ slot.slot,
+ slot.name,
+ slot.description,
+ slot.kind ?? '',
+ slot.products.length,
+ slot.serviceRows?.length ?? 0
+ ].join('|');
+ }
+
+ function isSlotDataLoaded(slot: PriceSlot) {
+ return (
+ (slot.header?.length ?? 0) > 0 ||
+ slot.products.length > 0 ||
+ (slot.serviceRows?.length ?? 0) > 0
+ );
+ }
+
+ async function ensureSelectedSlotLoaded(slot: PriceSlot) {
+ if (!slot.slot || isLocalSlot || isSlotDataLoaded(slot)) return;
+
+ const requestKey = `${selectedCountryKey}:${slot.slot}`;
+ if (requestedSlotKeys.has(requestKey)) return;
+
+ requestedSlotKeys.add(requestKey);
+ const sent = await requestPriceSlot(selectedCountry, slot.slot);
+ if (!sent) {
+ requestedSlotKeys.delete(requestKey);
+ addNotification(`ERR:Failed to request PriceSlot${slot.slot}`);
+ }
+ }
+
function getBasePrice(product: PriceSlotProduct): number | null {
+ const productWithSource = product as PriceSlotProduct & { sourceCells?: GristCell[] };
+ if (productWithSource.sourceCells?.length) {
+ const price = getPriceFromCells(
+ selectedCountryKey,
+ productWithSource.sourceCells,
+ 'cash_price'
+ );
+ const parsed = Number(price);
+ if (!Number.isNaN(parsed)) return parsed;
+ }
+
const cells = basePriceCells[product.product_code];
if (cells?.length > 0) {
const price = getPriceFromCells(selectedCountry.toLowerCase(), cells, 'cash_price');
@@ -153,10 +359,27 @@
if (!Number.isNaN(parsed)) return parsed;
}
- return product.price;
+ return null;
+ }
+
+ function getCellValue(cells: GristCell[], col: number) {
+ return String(cells.find((cell) => cell.coord?.col === col)?.value ?? '');
}
function getProductNames(product: PriceSlotProduct) {
+ const priceCells = basePriceCells[product.product_code];
+ if (priceCells?.length) {
+ const englishName = getCellValue(priceCells, 2);
+ const localName = getCellValue(priceCells, 3);
+
+ if (englishName) {
+ return {
+ english: englishName,
+ local: localName || englishName
+ };
+ }
+ }
+
const [englishName = '', localName = ''] = product.name.split('|').map((name) => name.trim());
return {
@@ -165,6 +388,10 @@
};
}
+ function clonePriceSlot(slot: PriceSlot): PriceSlot {
+ return JSON.parse(JSON.stringify(slot));
+ }
+
function calculateAdjustedPrice(basePrice: number | null): number | null {
if (basePrice === null) return null;
@@ -183,23 +410,6 @@
return Math.max(0, Math.round(nextPrice));
}
- async function loadBasePrices() {
- const productCodes = mockProducts.map((product) => product.product_code);
- if (productCodes.length === 0) return;
-
- const socket = await waitForOpenSocket();
- if (!socket) {
- addNotification('WARN:WebSocket not connected. Using local base price sample.');
- return;
- }
-
- clearSheetPriceSentTypes();
- const sent = requestSheetPrice(selectedCountry, productCodes);
- if (!sent) {
- addNotification('ERR:Failed to request base prices');
- }
- }
-
function applyCreateTemplate() {
const value = Number(adjustmentValue);
const formattedValue = Number.isNaN(value) ? 0 : value;
@@ -215,15 +425,135 @@
createDescription = `${isIncrease ? 'increase' : 'decrease'} price ${valueLabel}`;
}
- function openCreateDialog() {
- applyCreateTemplate();
+ function getNextPriceSlotNumber() {
+ return Math.max(0, ...displaySlots.map((slot) => slot.slot)) + 1;
+ }
+
+ function getCreateSlotNumber() {
+ const slotNumber = Number(createSlotNumber);
+ if (!Number.isInteger(slotNumber) || slotNumber <= 0) {
+ addNotification('WARN:PriceSlot number must be a positive integer');
+ return null;
+ }
+
+ if (displaySlots.some((slot) => slot.slot === slotNumber)) {
+ addNotification(`WARN:PriceSlot${slotNumber} already exists`);
+ return null;
+ }
+
+ return slotNumber;
+ }
+
+ async function openCreateDialog() {
+ createSlotNumber = getNextPriceSlotNumber();
+ createName = '';
+ createDescription = '';
+
+ if (basePriceRowCount > 0) {
+ createMode = 'from_price';
+ applyCreateTemplate();
+ createDialogOpen = true;
+ return;
+ }
+
+ createMode = 'blank';
createDialogOpen = true;
}
- function createPriceSlotFromBase() {
- const nextSlotNumber = Math.max(0, ...slots.map((slot) => slot.slot)) + 1;
+ async function ensurePriceBaseRequested() {
+ const productCodes = getProductCodesForPriceBase();
+ if (productCodes.length > 0) {
+ await requestPriceBaseFromProductCodes(productCodes);
+ return;
+ }
- const products = mockProducts.map((product) => ({
+ if (requestedProductCodeCountries.has(selectedCountryKey)) {
+ addNotification('INFO:Waiting for product codes before loading Price data');
+ return;
+ }
+
+ requestedProductCodeCountries.add(selectedCountryKey);
+ const sent = await requestListMenu(selectedCountry);
+ if (sent) {
+ addNotification('INFO:Requesting product codes before loading Price data');
+ } else {
+ requestedProductCodeCountries.delete(selectedCountryKey);
+ pendingCreateDialog = false;
+ addNotification('ERR:Failed to request product codes');
+ }
+ }
+
+ function getProductCodesForPriceBase() {
+ const productCodes = new Set();
+
+ if (requestedProductCodeCountries.has(selectedCountryKey)) {
+ for (const code of $existingProductCodes) productCodes.add(code);
+ }
+
+ for (const product of currentSlot.products) {
+ if (product.product_code) productCodes.add(product.product_code);
+ }
+
+ if (productCodes.size === 0) {
+ for (const slot of backendSlots) {
+ for (const product of slot.products) {
+ if (product.product_code) productCodes.add(product.product_code);
+ }
+ }
+ }
+
+ return Array.from(productCodes);
+ }
+
+ async function requestPriceBaseFromProductCodes(productCodes = getProductCodesForPriceBase()) {
+ if (requestedPriceCountries.has(selectedCountryKey)) return;
+
+ if (productCodes.length === 0) return;
+
+ requestedPriceCountries.add(selectedCountryKey);
+ const sent = await requestSheetPrice(selectedCountry, productCodes, true);
+ if (sent) {
+ addNotification(`INFO:Requesting Price data for ${productCodes.length} product codes`);
+ } else {
+ requestedPriceCountries.delete(selectedCountryKey);
+ pendingCreateDialog = false;
+ addNotification('ERR:Failed to request Price data');
+ }
+ }
+
+ function createPriceSlotFromBase() {
+ const nextSlotNumber = getCreateSlotNumber();
+ if (!nextSlotNumber) return;
+
+ if (createMode === 'blank') {
+ const nextSlot: PriceSlot = {
+ slot: nextSlotNumber,
+ name: createName.trim() || `PriceSlot${nextSlotNumber}`,
+ description: createDescription.trim(),
+ kind: 'price',
+ header: [],
+ products: []
+ };
+
+ localSlots = [...localSlots, nextSlot];
+ selectedSlot = nextSlotNumber;
+ workingSlot = clonePriceSlot(nextSlot);
+ savedSlot = null;
+ lastLoadedSlotSignature = getSlotSignature(nextSlot);
+ productPage = 1;
+ servicePage = 1;
+ createDialogOpen = false;
+ addNotification(`INFO:Created blank PriceSlot${nextSlotNumber}`);
+ return;
+ }
+
+ const baseProducts = getBaseProducts();
+ if (baseProducts.length === 0) {
+ addNotification('WARN:Main Price data is not ready');
+ return;
+ }
+
+ const products = baseProducts.map((product) => ({
...product,
price: calculateAdjustedPrice(getBasePrice(product))
}));
@@ -232,12 +562,18 @@
slot: nextSlotNumber,
name: createName.trim() || `PriceSlot${nextSlotNumber}`,
description: createDescription.trim(),
+ kind: 'price',
+ header: basePriceHeader,
products
};
- slots = [...slots, nextSlot];
- savedSnapshot = [...savedSnapshot, structuredClone(nextSlot)];
+ localSlots = [...localSlots, nextSlot];
selectedSlot = nextSlotNumber;
+ workingSlot = clonePriceSlot(nextSlot);
+ savedSlot = null;
+ lastLoadedSlotSignature = getSlotSignature(nextSlot);
+ productPage = 1;
+ servicePage = 1;
createDialogOpen = false;
addNotification(`INFO:Created PriceSlot${nextSlotNumber} from base prices`);
}
@@ -245,56 +581,432 @@
function countChangedProducts(current: PriceSlot, saved: PriceSlot | undefined): number {
if (!saved) return 0;
- return current.products.filter((product) => {
- const savedProduct = saved.products.find(
- (item) => item.product_code === product.product_code
+ return current.products.filter((product, index) => {
+ if (!product.row_index) return false;
+ const savedProduct =
+ saved.products.find(
+ (item) => product.row_index !== undefined && item.row_index === product.row_index
+ ) ??
+ saved.products[index] ??
+ saved.products.find((item) => item.product_code === product.product_code);
+ return (
+ savedProduct?.product_code !== product.product_code || savedProduct?.price !== product.price
);
- return savedProduct?.price !== product.price;
}).length;
}
- function updateSlotField(field: 'name' | 'description', value: string) {
- slots = slots.map((slot) => (slot.slot === selectedSlot ? { ...slot, [field]: value } : slot));
+ function countAddedProducts(current: PriceSlot, saved: PriceSlot | undefined): number {
+ if (current.kind === 'service') return 0;
+ return current.products.filter((product, index) => {
+ if (product.row_index) return false;
+ const savedProduct = saved?.products[index];
+ return (
+ !savedProduct ||
+ savedProduct.product_code !== product.product_code ||
+ savedProduct.price !== product.price
+ );
+ }).length;
}
- function updateProductPrice(productCode: string, value: string) {
- const price = value === '' ? null : Number(value);
+ function countChangedServiceRows(current: PriceSlot, saved: PriceSlot | undefined): number {
+ if (!saved || current.kind !== 'service') return 0;
- slots = slots.map((slot) => {
- if (slot.slot !== selectedSlot) return slot;
+ return (current.serviceRows ?? []).filter((row, index) => {
+ const savedRow =
+ saved.serviceRows?.find((item) => item.row_index === row.row_index) ??
+ saved.serviceRows?.[index];
+ return JSON.stringify(row.cells) !== JSON.stringify(savedRow?.cells ?? {});
+ }).length;
+ }
+
+ function getChangedPriceRows(current: PriceSlot, saved: PriceSlot | undefined) {
+ return current.products
+ .map((product, index) => {
+ if (!product.row_index) return null;
+
+ const savedProduct =
+ saved?.products.find(
+ (item) => product.row_index !== undefined && item.row_index === product.row_index
+ ) ??
+ saved?.products[index] ??
+ saved?.products.find((item) => item.product_code === product.product_code);
+
+ const cells = [];
+ if (savedProduct?.product_code !== product.product_code) {
+ cells.push({
+ value: product.product_code,
+ coord: { row: product.row_index, col: 1 }
+ });
+ }
+ if (savedProduct?.price !== product.price) {
+ cells.push({
+ value: product.price === null ? '' : String(product.price),
+ coord: { row: product.row_index, col: 5 }
+ });
+ }
+
+ if (cells.length === 0) return null;
+
+ return {
+ row_index: product.row_index,
+ cells
+ };
+ })
+ .filter((row): row is NonNullable => row !== null);
+ }
+
+ function getAddedPriceSlotRows(slot: PriceSlot) {
+ const draftSlot = {
+ ...slot,
+ products: slot.products.filter((product) => !product.row_index)
+ };
+ return getCreatePriceSlotRows(draftSlot).map(({ cells }) => ({ cells }));
+ }
+
+ function getChangedServiceRows(current: PriceSlot, saved: PriceSlot | undefined) {
+ if (current.kind !== 'service') return [];
+
+ return (current.serviceRows ?? [])
+ .map((row, index) => {
+ const savedRow =
+ saved?.serviceRows?.find((item) => item.row_index === row.row_index) ??
+ saved?.serviceRows?.[index];
+ if (!row.row_index) return null;
+
+ const cells = serviceHeaders
+ .map((header, headerIndex) => {
+ const value = row.cells[header] ?? '';
+ if (value === (savedRow?.cells[header] ?? '')) return null;
+
+ return {
+ value,
+ coord: { row: row.row_index!, col: headerIndex + 1 }
+ };
+ })
+ .filter((cell): cell is NonNullable => cell !== null);
+
+ if (cells.length === 0) return null;
+
+ return { row_index: row.row_index, cells };
+ })
+ .filter((row): row is NonNullable => row !== null);
+ }
+
+ function getCreatePriceCellIndex() {
+ const priceColumnIndex = getPriceColumnIndex(selectedCountryKey, 'cash_price');
+ return priceColumnIndex > 0 ? priceColumnIndex - 1 : 4;
+ }
+
+ function getCreatePriceSlotHeader(slot: PriceSlot) {
+ const header = Array.from({ length: 26 }, () => '');
+ const priceCellIndex = getCreatePriceCellIndex();
+
+ header[0] = 'ProductCode';
+ header[1] = 'Name';
+ header[priceCellIndex] = basePriceHeader[priceCellIndex] || 'Price';
+ header[10] = slot.name;
+ header[11] = slot.description;
+
+ return header;
+ }
+
+ function getCreatePriceSlotRows(slot: PriceSlot) {
+ const header = getCreatePriceSlotHeader(slot);
+ const priceCellIndex = getCreatePriceCellIndex();
+
+ return slot.products.map((product, index) => {
+ const productWithSource = product as PriceSlotProduct & { sourceCells?: GristCell[] };
+ const sourceCells = productWithSource.sourceCells ?? [];
+ const cells = Array.from({ length: header.length }, () => '');
+ const englishName = getCellValue(sourceCells, 2) || product.name;
+ const localName = getCellValue(sourceCells, 3);
+
+ cells[0] = product.product_code;
+ cells[1] = englishName;
+ cells[2] = localName;
+ cells[priceCellIndex] = product.price === null ? '' : String(product.price);
return {
- ...slot,
- products: slot.products.map((product) =>
- product.product_code === productCode
- ? { ...product, price: Number.isNaN(price) ? product.price : price }
- : product
- )
+ header: index === 0 ? header : undefined,
+ cells
};
});
}
+ function updateSlotField(field: 'name' | 'description', value: string) {
+ if (!workingSlot) return;
+ workingSlot = { ...workingSlot, [field]: value };
+ }
+
+ function updateProductCode(
+ targetProduct: PriceSlotProduct,
+ fallbackIndex: number,
+ value: string
+ ) {
+ if (!workingSlot) return;
+
+ workingSlot = {
+ ...workingSlot,
+ products: workingSlot.products.map((product, index) => {
+ const sameProduct =
+ targetProduct.row_index !== undefined
+ ? product.row_index === targetProduct.row_index
+ : product === targetProduct || index === fallbackIndex;
+
+ return sameProduct ? { ...product, product_code: value } : product;
+ })
+ };
+ }
+
+ function updateProductPrice(
+ targetProduct: PriceSlotProduct,
+ fallbackIndex: number,
+ value: string
+ ) {
+ const price = value === '' ? null : Number(value);
+
+ if (!workingSlot) return;
+
+ workingSlot = {
+ ...workingSlot,
+ products: workingSlot.products.map((product, index) => {
+ const sameProduct =
+ targetProduct.row_index !== undefined
+ ? product.row_index === targetProduct.row_index
+ : product === targetProduct || index === fallbackIndex;
+
+ return sameProduct
+ ? { ...product, price: Number.isNaN(price) ? product.price : price }
+ : product;
+ })
+ };
+ }
+
+ function openAddDialog() {
+ if (!workingSlot || workingSlot.kind === 'service') return;
+ addProductCode = '';
+ addProductName = '';
+ addProductPrice = '';
+ addDialogOpen = true;
+ }
+
+ function confirmAddProductRow() {
+ if (!workingSlot || workingSlot.kind === 'service') return;
+
+ const code = addProductCode.trim();
+ if (!code) {
+ addNotification('WARN:Product code is required');
+ return;
+ }
+
+ const price = addProductPrice === '' ? null : Number(addProductPrice);
+
+ workingSlot = {
+ ...workingSlot,
+ products: [
+ ...workingSlot.products,
+ {
+ product_code: code,
+ name: addProductName.trim(),
+ price: Number.isNaN(price) ? null : price
+ }
+ ]
+ };
+
+ productPage = Math.max(1, Math.ceil(workingSlot.products.length / rowsPerPage));
+ addDialogOpen = false;
+ addNotification(`INFO:Added product code ${code}`);
+ }
+
+ function deleteProductRow(targetProduct: PriceSlotProduct, fallbackIndex: number) {
+ if (!workingSlot || workingSlot.kind === 'service') return;
+
+ if (targetProduct.row_index) {
+ deletedProductRowIds = Array.from(
+ new Set([...deletedProductRowIds, targetProduct.row_index])
+ );
+ }
+
+ workingSlot = {
+ ...workingSlot,
+ products: workingSlot.products.filter((product, index) => {
+ const sameProduct =
+ targetProduct.row_index !== undefined
+ ? product.row_index === targetProduct.row_index
+ : product === targetProduct || index === fallbackIndex;
+ return !sameProduct;
+ })
+ };
+ productPage = Math.min(
+ productPage,
+ Math.max(1, Math.ceil(workingSlot.products.length / rowsPerPage))
+ );
+ }
+
+ function updateServiceCell(
+ row: PriceSlotServiceRow,
+ fallbackIndex: number,
+ columnName: string,
+ value: string
+ ) {
+ if (!workingSlot) return;
+
+ workingSlot = {
+ ...workingSlot,
+ serviceRows: (workingSlot.serviceRows ?? []).map((serviceRow, index) => {
+ const sameRow =
+ row.row_index !== undefined
+ ? serviceRow.row_index === row.row_index
+ : serviceRow === row || index === fallbackIndex;
+ return sameRow
+ ? {
+ ...serviceRow,
+ cells: {
+ ...serviceRow.cells,
+ [columnName]: value
+ }
+ }
+ : serviceRow;
+ })
+ };
+ }
+
+ function getServiceColumnClass(columnName: string) {
+ const normalized = columnName.toLowerCase();
+ if (normalized === 'value') return 'min-w-[320px]';
+ if (normalized === 'desc') return 'min-w-[300px]';
+ if (normalized === 'l') return 'min-w-[360px]';
+ if (normalized.includes('schedule')) return 'min-w-[300px]';
+ if (normalized.includes('discount')) return 'min-w-[180px]';
+ if (normalized === 'type/key') return 'min-w-[150px]';
+ if (normalized === 'servicetype') return 'min-w-[130px]';
+ if (normalized === 'daytype') return 'min-w-[130px]';
+ if (normalized === 'command') return 'min-w-[130px]';
+ if (normalized === 'extendvalue') return 'min-w-[130px]';
+ if (normalized === 'time(24 hr)' || normalized === 'time( 24 hr)') return 'min-w-[130px]';
+ if (['year', 'month', 'day'].includes(normalized)) return 'min-w-[110px]';
+ return 'min-w-[120px]';
+ }
+
+ function getServiceInputClass(columnName: string) {
+ const normalized = columnName.toLowerCase();
+ const textClass = normalized === 'l' || normalized.includes('schedule') ? '' : 'font-mono';
+ return ['h-10 w-full min-w-0 text-sm', textClass].filter(Boolean).join(' ');
+ }
+
+ function getServiceTableMinWidth() {
+ return serviceHeaders.reduce((total, header) => {
+ const normalized = header.toLowerCase();
+ if (normalized === 'value') return total + 320;
+ if (normalized === 'desc') return total + 300;
+ if (normalized === 'l') return total + 360;
+ if (normalized.includes('schedule')) return total + 300;
+ if (normalized.includes('discount')) return total + 180;
+ if (['type/key', 'servicetype', 'daytype', 'command', 'extendvalue'].includes(normalized)) {
+ return total + 140;
+ }
+ return total + 120;
+ }, 0);
+ }
+
function resetSlot() {
- const savedSlot = savedSnapshot[selectedSlot - 1];
if (!savedSlot) return;
- slots = slots.map((slot) => (slot.slot === selectedSlot ? structuredClone(savedSlot) : slot));
+ workingSlot = clonePriceSlot(savedSlot);
+ deletedProductRowIds = [];
addNotification(`INFO:Reset PriceSlot${selectedSlot}`);
}
- function saveSlot() {
- savedSnapshot = savedSnapshot.map((slot) =>
- slot.slot === selectedSlot ? structuredClone(currentSlot) : slot
- );
- addNotification('WARN:PriceSlot backend is not ready. Changes are kept in this UI only.');
+ async function saveSlot() {
+ if (!currentSlot.slot) {
+ addNotification('WARN:No PriceSlot selected');
+ return;
+ }
+ if (saveLoading) return;
+
+ const content =
+ currentSlot.kind === 'service'
+ ? getChangedServiceRows(currentSlot, savedSlot ?? undefined)
+ : getChangedPriceRows(currentSlot, savedSlot ?? undefined);
+ const createContent =
+ isLocalSlot && currentSlot.kind !== 'service' ? getCreatePriceSlotRows(currentSlot) : [];
+ const addContent =
+ !isLocalSlot && currentSlot.kind !== 'service' ? getAddedPriceSlotRows(currentSlot) : [];
+ const deleteRowIds = currentSlot.kind !== 'service' ? deletedProductRowIds : [];
+ if (
+ !isLocalSlot &&
+ content.length === 0 &&
+ addContent.length === 0 &&
+ deleteRowIds.length === 0
+ ) {
+ addNotification('WARN:No row changes to save');
+ return;
+ }
+ if (isLocalSlot && createContent.length === 0) {
+ addNotification('WARN:No PriceSlot rows to create');
+ return;
+ }
+
+ saveLoading = true;
+ try {
+ const slotSnapshot = clonePriceSlot(currentSlot);
+ const updateSent =
+ isLocalSlot || content.length === 0
+ ? true
+ : await updatePriceSlot(selectedCountry, slotSnapshot, content);
+ const addSent = isLocalSlot
+ ? await addPriceSlot(selectedCountry, slotSnapshot, createContent)
+ : await addPriceSlotRows(selectedCountry, slotSnapshot, addContent);
+ const deleteSent = await deletePriceSlotRows(selectedCountry, slotSnapshot, deleteRowIds);
+
+ if (!updateSent || !addSent || !deleteSent) {
+ addNotification('ERR:Failed to send PriceSlot update');
+ return;
+ }
+
+ const savedSlotNumber = selectedSlot;
+ savedSlot = clonePriceSlot(currentSlot);
+ deletedProductRowIds = [];
+ localSlots = localSlots.filter((slot) => slot.slot !== savedSlotNumber);
+ requestedSlotKeys.delete(`${selectedCountryKey}:${savedSlotNumber}`);
+ selectedSlot = savedSlotNumber;
+ addNotification(`INFO:PriceSlot${savedSlotNumber} update sent`);
+
+ const refreshed = await requestPriceSlot(selectedCountry, savedSlotNumber);
+ if (!refreshed) {
+ addNotification(`WARN:PriceSlot${savedSlotNumber} saved but refresh failed`);
+ }
+ } catch (error) {
+ console.error('[PriceSlot] Failed to save PriceSlot', error);
+ addNotification('ERR:Failed to send PriceSlot update');
+ } finally {
+ saveLoading = false;
+ }
}
- function loadPriceSlots() {
- loading = true;
- setTimeout(() => {
- loading = false;
- addNotification('WARN:PriceSlot backend is not ready. Showing UI mock data.');
- }, 250);
+ async function loadPriceSlots() {
+ localSlots = [];
+ workingSlot = null;
+ savedSlot = null;
+ selectedSlot = 0;
+ requestedSlotKeys = new Set();
+ pendingCreateDialog = false;
+ deletedProductRowIds = [];
+ priceSlotsLoading.set(true);
+ priceSlotsError.set(null);
+
+ const socket = await waitForAuthenticatedSocket();
+ if (!socket) {
+ priceSlotsLoading.set(false);
+ addNotification('ERR:WebSocket auth not ready');
+ return;
+ }
+
+ const sent = await requestPriceSlots(selectedCountry);
+ if (!sent) {
+ priceSlotsLoading.set(false);
+ addNotification('ERR:Failed to request PriceSlot data');
+ }
}
@@ -348,144 +1060,305 @@
-
- {#each slots as slot}
-
(selectedSlot = slot.slot)}
+
+
+ {#if displaySlots.length > 0}
+ {#each displaySlots as slot}
+
{
+ selectedSlot = slot.slot;
+ productPage = 1;
+ servicePage = 1;
+ }}
+ >
+ PriceSlot{slot.slot}
+
+ {/each}
+ {:else}
+
+ {loading ? 'Loading PriceSlot data...' : 'No PriceSlot data loaded'}
+
+ {/if}
+
+
+
+
- PriceSlot{slot.slot}
-
- {/each}
-
-
-
-
-
-
-
PriceSlot{selectedSlot}
-
- {hasChanges ? `${changedCount} changes` : 'No changes'}
-
+
+
+
+ {currentSlot.slot ? `PriceSlot${selectedSlot}` : 'No PriceSlot'}
+
+
+ {hasChanges ? `${totalChangedCount} changes` : 'No changes'}
+
+
+
-
-
-
- Name
- updateSlotField('name', event.currentTarget.value)}
- />
-
-
-
-
- Description
-
- updateSlotField('description', event.currentTarget.value)}
- />
-
-
-
-
-
- Create PriceSlot
-
-
-
- Save Draft
-
-
-
- Reset
-
-
-
-
-
-
-
-
-
Search ProductCode
-
-
+
+ Name
updateSlotField('name', event.currentTarget.value)}
/>
+
+
+
+ Description
+
+ updateSlotField('description', event.currentTarget.value)}
+ />
+
+
+
+
+
+
+
+ {createPriceBaseLoading ? 'Loading Price...' : 'Create PriceSlot'}
+
+
+
+ {saveLoading ? 'Saving...' : 'Save'}
+
+
+
+ Reset
+
+
+
+ Reset discards unsaved changes for the selected PriceSlot.
+
+
+
-
- Showing {filteredProducts.length} of {currentSlot.products.length} products
-
-
-
-
-
- ProductCode
- ProductName [{selectedCountryLanguage}]
- ProductNameEng
- Price
-
-
-
- {#each filteredProducts as product (product.product_code)}
- {@const productNames = getProductNames(product)}
-
-
- {product.product_code}
-
- {productNames.local}
- {productNames.english}
-
-
- updateProductPrice(product.product_code, event.currentTarget.value)}
- />
-
-
- {/each}
- {#if filteredProducts.length === 0}
-
-
- No product code found.
-
-
+
+
+
+
+ {isServiceSlot ? 'Search ServiceType' : 'Search ProductCode'}
+
+
+
+ {
+ productCodeSearch = event.currentTarget.value;
+ productPage = 1;
+ servicePage = 1;
+ }}
+ />
+
+
+
+ {#if !isServiceSlot && currentSlot.slot}
+
+
+ Add ProductCode
+
{/if}
-
-
+
+ Showing {isServiceSlot ? pagedServiceRows.length : pagedProducts.length} of
+ {visibleRowCount} filtered / {totalRowCount}
+ {isServiceSlot ? 'service rows' : 'products'}
+
+
{
+ if (isServiceSlot) servicePage = Math.max(1, servicePage - 1);
+ else productPage = Math.max(1, productPage - 1);
+ }}
+ >
+ Prev
+
+
+ Page {isServiceSlot ? servicePage : productPage} / {isServiceSlot
+ ? servicePageCount
+ : productPageCount}
+
+
= servicePageCount
+ : productPage >= productPageCount}
+ onclick={() => {
+ if (isServiceSlot) servicePage = Math.min(servicePageCount, servicePage + 1);
+ else productPage = Math.min(productPageCount, productPage + 1);
+ }}
+ >
+ Next
+
+
+
+
+
+ {#if loading && displaySlots.length === 0}
+
+ Loading PriceSlot data...
+
+ {:else if $priceSlotsError}
+
+ {$priceSlotsError}
+
+ {:else if displaySlots.length === 0}
+
+ No PriceSlot data from backend.
+
+ {:else if currentSlot.slot && !isServiceSlot && currentSlot.products.length === 0}
+
+ PriceSlot{currentSlot.slot} data is not loaded yet.
+
+ {:else if currentSlot.slot && isServiceSlot && (currentSlot.serviceRows?.length ?? 0) === 0}
+
+ PriceSlot{currentSlot.slot} data is not loaded yet.
+
+ {:else if isServiceSlot}
+
+
+
+
+ {#each serviceHeaders as header}
+ {header}
+ {/each}
+
+
+
+ {#each pagedServiceRows as row, index (`${row.row_index ?? index}-${index}`)}
+
+ {#each serviceHeaders as header}
+
+
+ updateServiceCell(row, index, header, event.currentTarget.value)}
+ />
+
+ {/each}
+
+ {/each}
+ {#if filteredServiceRows.length === 0}
+
+
+ No service rows found.
+
+
+ {/if}
+
+
+
+ {:else}
+
+ {/if}
+
@@ -493,68 +1366,96 @@
Create PriceSlot
-
- Choose how to adjust base prices before creating a new PriceSlot.
-
+ Choose how to create a new PriceSlot.
-
+ {/if}
-
- Adjustment Mode
- {
- if (v) adjustmentMode = v as AdjustmentMode;
- applyCreateTemplate();
- }}
- >
-
- {adjustmentModeLabels[adjustmentMode]}
-
-
- Increase by Percentage (%)
- Increase by Fixed Amount
- Decrease by Percentage (%)
- Decrease by Fixed Amount
-
-
-
-
-
-
- Adjustment Value
- {adjustmentMode === 'increase_percent' || adjustmentMode === 'decrease_percent'
- ? '(%)'
- : ''}
-
- {
- adjustmentValue = Number(event.currentTarget.value);
- applyCreateTemplate();
- }}
- />
-
-
Name
+
+
+
+
+ Add ProductCode
+
+ Enter the product code, name, and price for the new row.
+
+
+
+
+
+
+ (addDialogOpen = false)}>Cancel
+
+
+ Add
+
+
+
+
diff --git a/src/routes/(authed)/tools/adv-upload/+page.svelte b/src/routes/(authed)/tools/adv-upload/+page.svelte
index 8c95d2b..613e46b 100644
--- a/src/routes/(authed)/tools/adv-upload/+page.svelte
+++ b/src/routes/(authed)/tools/adv-upload/+page.svelte
@@ -1,4 +1,5 @@
-
+
+
+
+
+
+
+
Advertisement Videos
+
Main-page & brewing-page videos, scheduled by date
+
+
+
+ {isAdbConnected ? 'Machine connected' : 'Machine offline'}
+
+ {#if !isAdbConnected}
+
+ {#if connecting} Connecting...{:else} Connect Machine{/if}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Add a new video
+
+
+ Upload the same clip twice — the main-page version and the brewing-page
+ _long version. Auto-named
+ brewing_adv<N> (next free 1–40, never overwrites a video in use).
+
+
+
+
+
+
Country
+
+ {countryLabel}
+
+ {#each COUNTRIES as c (c.value)}
+ {c.label}
+ {/each}
+
+
+
+ Writes to inter/{country}/video{country === 'tha' ? ' + flat video/' : ''}.
+
+
+
+
Name
+
+
Used for the comment & the …VideoEnable variable.
+
+
+ Start date
+
+
+
+
End date (optional)
+
+ {#if !endDate}
Blank = open-ended
{/if}
+
+
+
+
+
+
+
+
+ Main-page video
+ brewing_adv<N>.mp4
+
+ {#if mainFile}
+
+
+
+
+
+
{mainFile.name} · {fmtMB(mainFile.size)}
+ {:else}
+
+
+
+ Click to select .mp4
+
+ {/if}
+
+
+
+
+
+ Brewing-page video
+ brewing_adv<N>_long.mp4
+
+ {#if brewingFile}
+
+
+
+
+
+
{brewingFile.name} · {fmtMB(brewingFile.size)}
+
+
+ Plays {brewingPlaySeconds}s
+ (length {Math.round(brewingRawSeconds)}s − {DURATION_TRIM})
+
+ {:else}
+
+
+
+ Click to select _long .mp4
+
+ {/if}
+
+
+
+
+ Text overlays (.png, required)
+
+
+ {#each [{ label: 'Thai', name: 'brewing_txt_adv
.png', file: brewingTxtFile, preview: brewingTxtPreview, pick: pickBrewingTxt, clear: clearBrewingTxt }, { label: 'English', name: 'brewing_txt_adv_en.png', file: brewingTxtEnFile, preview: brewingTxtEnPreview, pick: pickBrewingTxtEn, clear: clearBrewingTxtEn }] as t (t.label)}
+
+
{t.label}
+ {#if t.file}
+
+
+
+
+
{t.file.name}
+ {:else}
+
+
+ {t.label} .png
+
+ {/if}
+
+ {/each}
+
+
+
+
+
+ {#if pushProgress.active && submitting}
+
+
+
Pushing {pushProgress.name} ({pushProgress.percent}%)
+
+ {/if}
+
+
+
+ {#if submitting} Saving...{:else} Create & Push{/if}
+
+
+
+
+
+
+
+
Existing videos
+
+ {#if loadingList} {:else} {/if}Refresh
+
+
+
+
+ {#if managed.length === 0}
+
+ No web-managed videos yet. Ones you add above appear here and can be edited.
+
+ {:else}
+
+ {#each managed as v (v.slug)}
+
+
+
+ {#if v.main}
+
+
+ {:else}
+
no main
+ {/if}
+
+
+ {#if v.brewing}
+
+
+ {:else}
+
no brewing
+ {/if}
+
+
+
+
+
{v.name}
+
+ brewing_adv{v.n}
+ {v.range_label}
+ {#if v.brewing?.duration} {v.brewing.duration}s {/if}
+ main
+ brewing
+
+
+
openEdit(v)}>
+ Edit
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
(showReadonly = !showReadonly)}
+ >
+
+
+ Hand-maintained videos ({readonlyList.length})
+ read-only
+ {showReadonly ? 'Click to hide' : 'Click to show'}
+
+ {#if showReadonly}
+
+ {#each readonlyList as v (v.filename)}
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Edit video
+
+ {#if editTarget}brewing_adv{editTarget.n} · change dates, rename, or replace either clip{/if}
+
+
+
+
+
+ Name
+
+
+
+
+
+
+
Main-page
+ {#if editTarget?.main}
+
+
+ {/if}
+
+
+ {editMainFile ? editMainFile.name : 'Replace (optional)'}
+
+
+
+
Brewing-page
+ {#if editTarget?.brewing}
+
+
+ {/if}
+
+
+ {editBrewingFile ? editBrewingFile.name : 'Replace (optional)'}
+
+ {#if editBrewingFile}
+
Plays {editBrewingPlaySeconds}s
+ {/if}
+
+
+ pickEditTxt(e, false)} />
+ {editBrewingTxtFile ? 'TH ✓' : 'Text TH (.png)'}
+
+
+ pickEditTxt(e, true)} />
+ {editBrewingTxtEnFile ? 'EN ✓' : 'Text EN (.png)'}
+
+
+
+
+
+ {#if pushProgress.active}
+
+
+
Pushing {pushProgress.name} ({pushProgress.percent}%)
+
+ {/if}
+
+
+
+ (editOpen = false)} disabled={editSaving}>Cancel
+
+ {#if editSaving} Saving...{:else} Save & Push{/if}
+
+
+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index caf81ce..714bbe7 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,6 +1,7 @@
-