Compare commits
13 commits
ea68fa5cc4
...
bd239cf71b
| Author | SHA1 | Date | |
|---|---|---|---|
| bd239cf71b | |||
|
|
4ca8b3b270 | ||
|
|
b3024e92c7 | ||
|
|
9dc1e65f5e | ||
|
|
828089ed2f | ||
|
|
c7de46109c | ||
|
|
be785825fe | ||
|
|
10be9e1220 | ||
|
|
58b496d5c8 | ||
|
|
8b0479bf58 | ||
|
|
244a234833 | ||
|
|
79a76f5c3e | ||
|
|
07977ce896 |
21 changed files with 3054 additions and 57 deletions
11
ISSUES.txt
11
ISSUES.txt
|
|
@ -1,13 +1,14 @@
|
||||||
Idea, Issue, Work Tracking
|
Idea, Issue, Work Tracking
|
||||||
|
|
||||||
[TODO]
|
[TODO]
|
||||||
|
- [] #10: [MenuCreation] recipe fill in until maximum limit (30)
|
||||||
|
- [] #11: Bring android app to front (`/brew` -> do swap to brew app, `/sheet` -> do swap to xml engine app)
|
||||||
|
- [] #12: [MenuCreation] Adjust input per material type
|
||||||
|
|
||||||
[Pending]
|
[Pending]
|
||||||
|
|
||||||
- [] #3: Save value to recipe
|
- [-] #7: material & menu creation
|
||||||
- [] #6: display all recipes with materials from csv [material usages with product code]
|
> Menu creation ready!
|
||||||
- [] #7: material & menu creation
|
|
||||||
- [] #9: show & edit price
|
- [] #9: show & edit price
|
||||||
|
|
||||||
[Rejected]
|
[Rejected]
|
||||||
|
|
@ -19,5 +20,7 @@ Idea, Issue, Work Tracking
|
||||||
- [x] #2: Send change value from editing in recipe to machine
|
- [x] #2: Send change value from editing in recipe to machine
|
||||||
- [x] #5: revert value on close dialog recipe
|
- [x] #5: revert value on close dialog recipe
|
||||||
- [x] #8: change recipe version
|
- [x] #8: change recipe version
|
||||||
|
- [x] #3: Save value to recipe
|
||||||
|
- [x] #6: display all recipes with materials from csv [material usages with product code]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
BIN
bun.lockb
Executable file
BIN
bun.lockb
Executable file
Binary file not shown.
|
|
@ -12,6 +12,7 @@
|
||||||
CupSodaIcon,
|
CupSodaIcon,
|
||||||
Shield,
|
Shield,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
|
DollarSign,
|
||||||
MonitorSmartphone,
|
MonitorSmartphone,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
ImageUp,
|
ImageUp,
|
||||||
|
|
@ -135,6 +136,12 @@
|
||||||
url: '/departments',
|
url: '/departments',
|
||||||
icon: FileSpreadsheet,
|
icon: FileSpreadsheet,
|
||||||
requirePerm: 'document.write.*'
|
requirePerm: 'document.write.*'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'PriceSlot',
|
||||||
|
url: '/departments',
|
||||||
|
icon: DollarSign,
|
||||||
|
requirePerm: 'document.write.*'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -228,7 +235,7 @@
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
if (nav.title === 'Sheet') {
|
if (nav.title === 'Sheet') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
referenceFromPage.set('sheet');
|
referenceFromPage.set(sub.title === 'PriceSlot' ? 'priceslot' : 'sheet');
|
||||||
goto(sub.url);
|
goto(sub.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import Input from '$lib/components/ui/input/input.svelte';
|
import Input from '$lib/components/ui/input/input.svelte';
|
||||||
import Button from '$lib/components/ui/button/button.svelte';
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||||
|
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge/index';
|
import { Badge } from '$lib/components/ui/badge/index';
|
||||||
import * as Item from '$lib/components/ui/item/index';
|
import * as Item from '$lib/components/ui/item/index';
|
||||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||||
|
|
@ -15,6 +16,7 @@
|
||||||
import { get, readable, writable } from 'svelte/store';
|
import { get, readable, writable } from 'svelte/store';
|
||||||
import {
|
import {
|
||||||
currentEditingRecipeProductCode,
|
currentEditingRecipeProductCode,
|
||||||
|
lastRequestSheetPrice,
|
||||||
latestRecipeToppingData,
|
latestRecipeToppingData,
|
||||||
materialFromMachineQuery,
|
materialFromMachineQuery,
|
||||||
materialFromServerQuery,
|
materialFromServerQuery,
|
||||||
|
|
@ -27,6 +29,8 @@
|
||||||
import { addNotification } from '$lib/core/stores/noti';
|
import { addNotification } from '$lib/core/stores/noti';
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
import { sendCommand, sendReset } from '$lib/core/brew/command';
|
import { sendCommand, sendReset } from '$lib/core/brew/command';
|
||||||
|
import { sendCommandRequest } from '$lib/core/handlers/ws_messageSender';
|
||||||
|
import { needPermission } from '$lib/core/handlers/permissionHandler';
|
||||||
import { isAdbWriterAvailable } from '$lib/core/stores/adbWriter';
|
import { isAdbWriterAvailable } from '$lib/core/stores/adbWriter';
|
||||||
import { sendToAndroid } from '$lib/core/stores/adbWriter';
|
import { sendToAndroid } from '$lib/core/stores/adbWriter';
|
||||||
import { departmentStore } from '$lib/core/stores/departments';
|
import { departmentStore } from '$lib/core/stores/departments';
|
||||||
|
|
@ -42,6 +46,10 @@
|
||||||
let menuName: string = $state('');
|
let menuName: string = $state('');
|
||||||
let menuCurrentPrice: number = $state(0);
|
let menuCurrentPrice: number = $state(0);
|
||||||
let isMenuHideByPrice: boolean = $state(false);
|
let isMenuHideByPrice: boolean = $state(false);
|
||||||
|
let sheetPriceValue: number | null = $state(null);
|
||||||
|
let showSheetPrice: boolean = $state(false);
|
||||||
|
let canEditSheetPrice: boolean = $state(false);
|
||||||
|
let sheetPriceRawCell: any = $state(null);
|
||||||
|
|
||||||
let materialSnapshot: any = $state();
|
let materialSnapshot: any = $state();
|
||||||
let machineInfoSnapshot: any = $state();
|
let machineInfoSnapshot: any = $state();
|
||||||
|
|
@ -51,6 +59,8 @@
|
||||||
|
|
||||||
let toppingSlotState: any = $state([]);
|
let toppingSlotState: any = $state([]);
|
||||||
|
|
||||||
|
let unsubSheetPrice: (() => void) | null = null;
|
||||||
|
|
||||||
const recipeDetailDispatch = createEventDispatcher();
|
const recipeDetailDispatch = createEventDispatcher();
|
||||||
|
|
||||||
function remappingToColumn(data: any[]): RecipelistMaterial[] {
|
function remappingToColumn(data: any[]): RecipelistMaterial[] {
|
||||||
|
|
@ -173,6 +183,22 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveSheetPrice() {
|
||||||
|
if (!canEditSheetPrice || sheetPriceValue === null) return;
|
||||||
|
sendCommandRequest('sheet', {
|
||||||
|
country: get(departmentStore),
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
product_code: productCode,
|
||||||
|
new_price: sheetPriceValue,
|
||||||
|
cell_coord: sheetPriceRawCell?.coord
|
||||||
|
}
|
||||||
|
],
|
||||||
|
param: 'price',
|
||||||
|
action: 'update'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function checkChanges(productCode: string, original: any) {
|
async function checkChanges(productCode: string, original: any) {
|
||||||
// console.log('old', original, 'updated', recipeListMatState);
|
// console.log('old', original, 'updated', recipeListMatState);
|
||||||
if (recipeListOriginal.length == 0) {
|
if (recipeListOriginal.length == 0) {
|
||||||
|
|
@ -188,6 +214,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateSheetPrice(sheetData: any) {
|
||||||
|
if (!productCode) return;
|
||||||
|
const country = get(departmentStore);
|
||||||
|
if (!country) return;
|
||||||
|
|
||||||
|
const sheetEntry = sheetData[country]?.[productCode];
|
||||||
|
if (sheetEntry && typeof sheetEntry === 'object' && sheetEntry?.value) {
|
||||||
|
sheetPriceRawCell = sheetEntry;
|
||||||
|
const parsed = parseInt(sheetEntry.value);
|
||||||
|
if (!isNaN(parsed) && parsed !== menuCurrentPrice) {
|
||||||
|
sheetPriceValue = parsed;
|
||||||
|
showSheetPrice = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sheetPriceValue = null;
|
||||||
|
showSheetPrice = false;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
machineInfoSnapshot = get(machineInfoStore);
|
machineInfoSnapshot = get(machineInfoStore);
|
||||||
|
|
||||||
|
|
@ -237,8 +283,19 @@
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// save old value\
|
// save old value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canEditSheetPrice = needPermission('document.write.*');
|
||||||
|
|
||||||
|
updateSheetPrice(get(lastRequestSheetPrice));
|
||||||
|
unsubSheetPrice = lastRequestSheetPrice.subscribe((data) => {
|
||||||
|
updateSheetPrice(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (unsubSheetPrice) unsubSheetPrice();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -271,25 +328,108 @@
|
||||||
<Card.Description>Info about this menu</Card.Description>
|
<Card.Description>Info about this menu</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
<Card.Content class="grid gap-6">
|
<!-- Basic Info Card -->
|
||||||
<div class="grid grid-flow-row gap-3">
|
<Card.Root class="mb-4">
|
||||||
<Label for="tabs-menu-name">Name</Label>
|
<Card.Header>
|
||||||
<Input id="tabs-menu-name" value={recipeData['name'] ?? ''} />
|
<Card.Title>Basic Information</Card.Title>
|
||||||
<Label for="tabs-menu-other-name">Other Name</Label>
|
</Card.Header>
|
||||||
<Input id="tabs-menu-other-name" value={recipeData['otherName'] ?? ''} />
|
<Card.Content class="space-y-4">
|
||||||
</div>
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="tabs-menu-name">Name</Label>
|
||||||
|
<Input id="tabs-menu-name" value={recipeData['name'] ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="tabs-menu-other-name">Other Name</Label>
|
||||||
|
<Input id="tabs-menu-other-name" value={recipeData['otherName'] ?? ''} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
<div class="grid gap-3">
|
<!-- Price Information Card -->
|
||||||
<!-- price -->
|
|
||||||
<Label for="tabs-menu-price"
|
<!-- Additional Recipe Data Card -->
|
||||||
>Price
|
<Card.Root>
|
||||||
{#if isMenuHideByPrice}
|
<Card.Header>
|
||||||
<b> Disabled </b>
|
<Card.Title>Additional Recipe Data</Card.Title>
|
||||||
{/if}
|
</Card.Header>
|
||||||
</Label>
|
<Card.Content class="space-y-4">
|
||||||
<Input id="tabs-menu-price" value={menuCurrentPrice} />
|
{#if recipeData}
|
||||||
</div>
|
<div class="space-y-3">
|
||||||
</Card.Content>
|
<div class="space-y-2">
|
||||||
|
<Label for="tabs-last-change">Last Change</Label>
|
||||||
|
<span id="tabs-last-change" class="block text-sm text-muted-foreground">
|
||||||
|
{recipeData.LastChange ?? 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="tabs-total-time">Total Time (seconds)</Label>
|
||||||
|
<Input
|
||||||
|
id="tabs-total-time"
|
||||||
|
type="number"
|
||||||
|
value={String((recipeData.total_time ?? 1000) / 1000)}
|
||||||
|
oninput={(e) => {
|
||||||
|
const input = e.target as HTMLInputElement | null;
|
||||||
|
if (input && input.value !== '') {
|
||||||
|
const value = parseInt(input.value);
|
||||||
|
if (!isNaN(value)) {
|
||||||
|
recipeData.total_time = value;
|
||||||
|
// Notify parent of change
|
||||||
|
onPendingChange({
|
||||||
|
target: 'recipeData',
|
||||||
|
ref_pd: productCode,
|
||||||
|
value: { ...recipeData, total_time: value }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="flex flex-row items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="tabs-use-gram"
|
||||||
|
checked={recipeData.useGram ?? false}
|
||||||
|
onchange={(e) => {
|
||||||
|
const checkbox = e.target as HTMLInputElement | null;
|
||||||
|
if (checkbox) {
|
||||||
|
recipeData.useGram = checkbox.checked;
|
||||||
|
onPendingChange({
|
||||||
|
target: 'recipeData',
|
||||||
|
ref_pd: productCode,
|
||||||
|
value: { ...recipeData, useGram: checkbox.checked }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Use Gram for Measurement</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="tabs-uri-data">URI Data</Label>
|
||||||
|
<Input
|
||||||
|
id="tabs-uri-data"
|
||||||
|
value={recipeData.uriData ?? ''}
|
||||||
|
oninput={(e) => {
|
||||||
|
const input = e.target as HTMLInputElement | null;
|
||||||
|
if (input) {
|
||||||
|
recipeData.uriData = input.value;
|
||||||
|
onPendingChange({
|
||||||
|
target: 'recipeData',
|
||||||
|
ref_pd: productCode,
|
||||||
|
value: { ...recipeData, uriData: input.value }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground">No recipe data available</p>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
machineInfoStore,
|
machineInfoStore,
|
||||||
updateMachineStatus
|
updateMachineStatus
|
||||||
} from '$lib/core/stores/machineInfoStore';
|
} from '$lib/core/stores/machineInfoStore';
|
||||||
|
import { formatCustomDate } from '$lib/helpers/formatDate';
|
||||||
|
|
||||||
const isDesktop = new MediaQuery('(min-width: 768px)');
|
const isDesktop = new MediaQuery('(min-width: 768px)');
|
||||||
|
|
||||||
|
|
@ -125,7 +126,7 @@
|
||||||
|
|
||||||
ready_to_send_brew.push(new_change);
|
ready_to_send_brew.push(new_change);
|
||||||
|
|
||||||
callback_revert_value_if_not_save = (save: any) => {
|
callback_revert_value_if_not_save = async (save: any) => {
|
||||||
if (!save) {
|
if (!save) {
|
||||||
latestRecipeToppingData.set(topping_value_for_revert);
|
latestRecipeToppingData.set(topping_value_for_revert);
|
||||||
console.log('revert change', get(latestRecipeToppingData));
|
console.log('revert change', get(latestRecipeToppingData));
|
||||||
|
|
@ -141,6 +142,16 @@
|
||||||
// currentData['ToppingSet'] = latestRecipeToppingData;
|
// currentData['ToppingSet'] = latestRecipeToppingData;
|
||||||
|
|
||||||
// console.log('current data', currentData);
|
// console.log('current data', currentData);
|
||||||
|
let curr_user = get(auth);
|
||||||
|
|
||||||
|
let user_info: any;
|
||||||
|
if (curr_user) {
|
||||||
|
user_info = {
|
||||||
|
displayName: curr_user.displayName,
|
||||||
|
email: curr_user.email,
|
||||||
|
uid: curr_user.uid
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (get(referenceFromPage) == 'brew') {
|
if (get(referenceFromPage) == 'brew') {
|
||||||
// send change to machine
|
// send change to machine
|
||||||
|
|
@ -164,11 +175,29 @@
|
||||||
data: ready_to_send_brew[0]
|
data: ready_to_send_brew[0]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let country = await adb.pull('/sdcard/coffeevending/country/short');
|
||||||
|
let box_id = await adb.pull('/sdcard/coffeevending/.bid');
|
||||||
|
|
||||||
|
// update last change
|
||||||
|
// format 16-Feb-2026 10:31:18
|
||||||
|
let date = new Date();
|
||||||
|
let formatted = formatCustomDate(date);
|
||||||
|
ready_to_send_brew[0].LastChange = formatted;
|
||||||
|
|
||||||
|
sendMessage({
|
||||||
|
type: 'save_recipe',
|
||||||
|
payload: {
|
||||||
|
user_info,
|
||||||
|
country: `${country?.toLowerCase() ?? 'unknown'}_${box_id ?? ''}`,
|
||||||
|
values: ready_to_send_brew[0]
|
||||||
|
}
|
||||||
|
});
|
||||||
} else if (get(referenceFromPage) == 'overview') {
|
} else if (get(referenceFromPage) == 'overview') {
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: 'save_recipe',
|
type: 'save_recipe',
|
||||||
payload: {
|
payload: {
|
||||||
user: get(auth)?.displayName ?? 'unknown',
|
user_info,
|
||||||
country: get(departmentStore) ?? 'unknown',
|
country: get(departmentStore) ?? 'unknown',
|
||||||
values: currentData
|
values: currentData
|
||||||
}
|
}
|
||||||
|
|
@ -322,31 +351,33 @@
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// interval check 1s
|
// interval check 1s
|
||||||
// machine
|
// machine
|
||||||
interval_get_machine_status = setInterval(() => {
|
if (refPage === 'brew') {
|
||||||
if (
|
interval_get_machine_status = setInterval(() => {
|
||||||
getMachineStatus() == undefined ||
|
if (
|
||||||
getMachineStatus() == null ||
|
getMachineStatus() == undefined ||
|
||||||
$machineInfoStore?.status === undefined
|
getMachineStatus() == null ||
|
||||||
) {
|
$machineInfoStore?.status === undefined
|
||||||
// set default now
|
) {
|
||||||
updateMachineStatus('');
|
// set default now
|
||||||
}
|
updateMachineStatus('');
|
||||||
|
|
||||||
console.log(
|
|
||||||
'machine status pinging recipe editor dialog',
|
|
||||||
getMachineStatus(),
|
|
||||||
$machineInfoStore?.status
|
|
||||||
);
|
|
||||||
|
|
||||||
// update machine status
|
|
||||||
// check-connection
|
|
||||||
sendToAndroid({
|
|
||||||
type: 'check-connection',
|
|
||||||
payload: {
|
|
||||||
start: new Date().toLocaleTimeString()
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}, 1000);
|
console.log(
|
||||||
|
'machine status pinging recipe editor dialog',
|
||||||
|
getMachineStatus(),
|
||||||
|
$machineInfoStore?.status
|
||||||
|
);
|
||||||
|
|
||||||
|
// update machine status
|
||||||
|
// check-connection
|
||||||
|
sendToAndroid({
|
||||||
|
type: 'check-connection',
|
||||||
|
payload: {
|
||||||
|
start: new Date().toLocaleTimeString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { get } from 'svelte/store';
|
||||||
import { updateMachineStatus } from '../stores/machineInfoStore';
|
import { updateMachineStatus } from '../stores/machineInfoStore';
|
||||||
import { addNotification } from '../stores/noti';
|
import { addNotification } from '../stores/noti';
|
||||||
import {
|
import {
|
||||||
|
|
@ -6,6 +7,7 @@ import {
|
||||||
} from '../services/androidRecipeExportService';
|
} from '../services/androidRecipeExportService';
|
||||||
import { handleIncomingMessages } from './messageHandler';
|
import { handleIncomingMessages } from './messageHandler';
|
||||||
import { setMenuSaved, setMenuSaveError } from '../stores/menuSaveStore';
|
import { setMenuSaved, setMenuSaveError } from '../stores/menuSaveStore';
|
||||||
|
import { recipeFromMachineQuery } from '../stores/recipeStore';
|
||||||
|
|
||||||
type AdbPayload = { type: string; payload: any };
|
type AdbPayload = { type: string; payload: any };
|
||||||
|
|
||||||
|
|
@ -104,9 +106,21 @@ async function handleAdbPayload(raw_payload: string) {
|
||||||
let plist = payload.payload.split('/');
|
let plist = payload.payload.split('/');
|
||||||
let pd = plist[0] ?? '';
|
let pd = plist[0] ?? '';
|
||||||
let total_time = plist[1] ?? '';
|
let total_time = plist[1] ?? '';
|
||||||
|
let mode_ref = plist[2] ?? '';
|
||||||
|
|
||||||
// update recipe data store
|
// update recipe data store
|
||||||
console.log('brewing finish', pd, 'total time', total_time);
|
console.log('brewing finish', pd, 'total time', total_time);
|
||||||
|
|
||||||
|
// update recipe from brew now
|
||||||
|
let recipeDevSnapshot = get(recipeFromMachineQuery) ?? {};
|
||||||
|
let recipe01Snap = recipeDevSnapshot['recipe'];
|
||||||
|
if (recipe01Snap) {
|
||||||
|
recipe01Snap[pd].total_time =
|
||||||
|
mode_ref != 'sim' ? total_time : recipe01Snap[pd].total_time;
|
||||||
|
|
||||||
|
recipeDevSnapshot['recipe'] = recipe01Snap;
|
||||||
|
recipeFromMachineQuery.set(recipeDevSnapshot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { get, writable } from 'svelte/store';
|
||||||
import { addNotification, notiStore } from '../stores/noti';
|
import { addNotification, notiStore } from '../stores/noti';
|
||||||
import {
|
import {
|
||||||
currentRecipeVersionsSelector,
|
currentRecipeVersionsSelector,
|
||||||
|
lastRequestSheetPrice,
|
||||||
materialFromServerQuery,
|
materialFromServerQuery,
|
||||||
priceRecipeData,
|
priceRecipeData,
|
||||||
recipeData,
|
recipeData,
|
||||||
|
|
@ -9,6 +10,8 @@ import {
|
||||||
recipeLoading,
|
recipeLoading,
|
||||||
recipeOverviewData,
|
recipeOverviewData,
|
||||||
recipeStreamMeta,
|
recipeStreamMeta,
|
||||||
|
streamingRawData,
|
||||||
|
streamingRawMeta,
|
||||||
toppingGroupFromServerQuery,
|
toppingGroupFromServerQuery,
|
||||||
toppingListFromServerQuery
|
toppingListFromServerQuery
|
||||||
} from '../stores/recipeStore';
|
} from '../stores/recipeStore';
|
||||||
|
|
@ -36,13 +39,16 @@ import { type RecipeVersion } from '$lib/models/recipe_version.model';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { socketAlreadySendHeartbeat, socketConnectionOfflineCount } from '../stores/websocketStore';
|
import { socketAlreadySendHeartbeat, socketConnectionOfflineCount } from '../stores/websocketStore';
|
||||||
import type { RecipePrice } from '$lib/models/price.model';
|
import type { RecipePrice } from '$lib/models/price.model';
|
||||||
import { sendMessage } from './ws_messageSender';
|
import { sendCommandRequest, sendMessage } from './ws_messageSender';
|
||||||
import { auth as authStore } from '../stores/auth';
|
import { auth as authStore } from '../stores/auth';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { handleSheetResponseFromNoti } from './sheetNotiHandler';
|
||||||
|
|
||||||
export const messages = writable<string[]>([]);
|
export const messages = writable<string[]>([]);
|
||||||
|
|
||||||
type WSMessage = { type: string; payload: any };
|
type WSMessage = { type: string; payload: any };
|
||||||
|
|
||||||
|
// MAXIMUM LIMIT = 1814355
|
||||||
const handlers: Record<string, (payload: any) => void> = {
|
const handlers: Record<string, (payload: any) => void> = {
|
||||||
chat: (p) => messages.update((m) => [...m, p]),
|
chat: (p) => messages.update((m) => [...m, p]),
|
||||||
ping: (p) => console.log('ping from server'),
|
ping: (p) => console.log('ping from server'),
|
||||||
|
|
@ -311,6 +317,9 @@ const handlers: Record<string, (payload: any) => void> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default notification handling
|
// Default notification handling
|
||||||
|
let from_service = p.from ?? '';
|
||||||
|
let ref_service = p.ref ?? '';
|
||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
let currentUsername = auth.currentUser?.displayName;
|
let currentUsername = auth.currentUser?.displayName;
|
||||||
if (currentUsername && currentUsername === target) {
|
if (currentUsername && currentUsername === target) {
|
||||||
|
|
@ -353,12 +362,92 @@ const handlers: Record<string, (payload: any) => void> = {
|
||||||
console.log('get price length: ', content.length);
|
console.log('get price length: ', content.length);
|
||||||
|
|
||||||
let current_price = get(priceRecipeData);
|
let current_price = get(priceRecipeData);
|
||||||
|
let lastRequestPriceInstance = get(lastRequestSheetPrice);
|
||||||
|
let saved_product_code_to_get_from_sheet = [];
|
||||||
|
let current_meta = get(recipeStreamMeta);
|
||||||
|
lastRequestPriceInstance[current_meta?.country ?? 'unknown'] = {};
|
||||||
for (const c of content) {
|
for (const c of content) {
|
||||||
current_price[c.ProductCode] = c.NewPrice + (c.StringParam ? `,${c.StringParam}` : '');
|
current_price[c.ProductCode] = c.NewPrice + (c.StringParam ? `,${c.StringParam}` : '');
|
||||||
|
lastRequestPriceInstance[current_meta?.country ?? 'unknown'][c.ProductCode] = '';
|
||||||
|
saved_product_code_to_get_from_sheet.push({
|
||||||
|
product_code: c.ProductCode
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
priceRecipeData.set(current_price);
|
priceRecipeData.set(current_price);
|
||||||
|
|
||||||
|
console.log('check length', saved_product_code_to_get_from_sheet.length);
|
||||||
|
// set command request to stream mode so
|
||||||
|
let request_id = uuidv4();
|
||||||
|
|
||||||
|
lastRequestPriceInstance[request_id] = current_meta?.country ?? '';
|
||||||
|
let current_streaming_instance = get(streamingRawData);
|
||||||
|
current_streaming_instance[request_id] = '';
|
||||||
|
streamingRawData.set(current_streaming_instance);
|
||||||
|
|
||||||
|
sendCommandRequest('sheet', {
|
||||||
|
country: current_meta?.country ?? '',
|
||||||
|
content: saved_product_code_to_get_from_sheet,
|
||||||
|
param: 'price',
|
||||||
|
stream: true,
|
||||||
|
request_id
|
||||||
|
});
|
||||||
|
|
||||||
|
lastRequestSheetPrice.set(lastRequestPriceInstance);
|
||||||
},
|
},
|
||||||
|
// raw_stream: (p) => {
|
||||||
|
// let streamRawInstance = get(streamingRawData);
|
||||||
|
// let sub_type = p.sub_type;
|
||||||
|
// let request_id = p.request_id;
|
||||||
|
// let size_per_chunk = p.size_per_chunk;
|
||||||
|
// let total_chunks = p.total_chunks;
|
||||||
|
// let idx = p.idx;
|
||||||
|
|
||||||
|
// switch (sub_type) {
|
||||||
|
// case 'price':
|
||||||
|
// streamingRawMeta.set({
|
||||||
|
// id: request_id,
|
||||||
|
// total_size: total_chunks,
|
||||||
|
// chunk_size: size_per_chunk,
|
||||||
|
// progress: 0
|
||||||
|
// });
|
||||||
|
// break;
|
||||||
|
// case 'chunk_price':
|
||||||
|
// streamingRawMeta.set({
|
||||||
|
// id: request_id,
|
||||||
|
// total_size: total_chunks,
|
||||||
|
// chunk_size: size_per_chunk,
|
||||||
|
// progress: idx
|
||||||
|
// });
|
||||||
|
|
||||||
|
// let raw_payload = p.raw ?? '';
|
||||||
|
// streamRawInstance[request_id] += raw_payload;
|
||||||
|
// streamingRawData.set(streamRawInstance);
|
||||||
|
|
||||||
|
// break;
|
||||||
|
// case 'end_price':
|
||||||
|
// let lastRequestPriceInstance = get(lastRequestSheetPrice);
|
||||||
|
// let country = lastRequestPriceInstance[request_id];
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// let raw_payload = JSON.parse(streamRawInstance[request_id]);
|
||||||
|
// let ref_from_raw = raw_payload.payload.ref ?? '';
|
||||||
|
// let from_service_raw = raw_payload.payload.from ?? '';
|
||||||
|
// let parsed_payload = raw_payload.payload ?? '';
|
||||||
|
|
||||||
|
// if (from_service_raw == 'sheet-service') {
|
||||||
|
// handleSheetResponseFromNoti(parsed_payload, ref_from_raw, country);
|
||||||
|
// delete streamRawInstance[request_id];
|
||||||
|
// streamingRawData.set(streamRawInstance);
|
||||||
|
// }
|
||||||
|
// } catch (e) {
|
||||||
|
// console.log(`end price process error: ${e}`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// break;
|
||||||
|
// default:
|
||||||
|
// }
|
||||||
|
// },
|
||||||
heartbeat: (p) => {
|
heartbeat: (p) => {
|
||||||
socketConnectionOfflineCount.set(0);
|
socketConnectionOfflineCount.set(0);
|
||||||
socketAlreadySendHeartbeat.set(0);
|
socketAlreadySendHeartbeat.set(0);
|
||||||
|
|
@ -395,5 +484,14 @@ export function handleIncomingMessages(raw: string) {
|
||||||
addNotification('ERR:No response from server');
|
addNotification('ERR:No response from server');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// raw streaming type
|
||||||
|
// if (msg.type.startsWith('raw_stream')) {
|
||||||
|
// // convert
|
||||||
|
// let sub_type = msg.type.replace('raw_stream_', '');
|
||||||
|
// msg.payload.sub_type = sub_type;
|
||||||
|
// msg.type = 'raw_stream';
|
||||||
|
// }
|
||||||
|
|
||||||
handlers[msg.type]?.(msg.payload);
|
handlers[msg.type]?.(msg.payload);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
79
src/lib/core/handlers/sheetNotiHandler.ts
Normal file
79
src/lib/core/handlers/sheetNotiHandler.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { lastRequestSheetPrice } from '../stores/recipeStore';
|
||||||
|
|
||||||
|
export interface PayloadFromSheet {
|
||||||
|
header: string[];
|
||||||
|
key: string;
|
||||||
|
payload?: GristCell[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GristCell {
|
||||||
|
cells?: {
|
||||||
|
coord: {
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
};
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
row_index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRICE_SHEET_DEFINITION_BY_COUNTRY: any = {
|
||||||
|
ltu: {
|
||||||
|
expect_header: ['Name', 'Price in Euro'],
|
||||||
|
get_header_idx: (x: string[]) => {
|
||||||
|
let result = [];
|
||||||
|
for (const header of PRICE_SHEET_DEFINITION_BY_COUNTRY['ltu'].expect_header) {
|
||||||
|
let found = x.findIndex((y) => y == header);
|
||||||
|
result.push(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function handleSheetResponseFromNoti(raw_payload: any, ref: string, country?: string) {
|
||||||
|
switch (ref) {
|
||||||
|
case 'price':
|
||||||
|
let price_contents: PayloadFromSheet[] = raw_payload.content;
|
||||||
|
console.log(`price content length: ${price_contents.length}`);
|
||||||
|
let header_idx = PRICE_SHEET_DEFINITION_BY_COUNTRY[country ?? 'unknown'].get_header_idx(
|
||||||
|
price_contents[0].header
|
||||||
|
);
|
||||||
|
console.log(`header idx: ${header_idx}`);
|
||||||
|
|
||||||
|
let lastRequestSheetInstance = get(lastRequestSheetPrice);
|
||||||
|
let products = lastRequestSheetInstance[country ?? 'unknown'];
|
||||||
|
|
||||||
|
for (let c of price_contents) {
|
||||||
|
let curr_product_code = c.key;
|
||||||
|
// price idx should be last
|
||||||
|
let price_idx = header_idx[header_idx.length - 1];
|
||||||
|
let price_rows = c.payload;
|
||||||
|
if (!price_rows) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// get last because last row will always override
|
||||||
|
let expected_row = price_rows[price_rows.length - 1];
|
||||||
|
if (expected_row != undefined && expected_row.cells != undefined) {
|
||||||
|
let price_col = expected_row.cells[price_idx];
|
||||||
|
products[curr_product_code] = price_col;
|
||||||
|
console.log(`[handleSheetPrice][country] ${curr_product_code} --> ${price_col}`);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[handleSheetPrice][country] ${curr_product_code} not found cell, ${JSON.stringify(price_rows)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRequestSheetInstance[country ?? 'unknown'] = products;
|
||||||
|
lastRequestSheetPrice.set({
|
||||||
|
...lastRequestSheetInstance,
|
||||||
|
products
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,29 @@ export function requestCatalogs(country: string): boolean {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requestPriceSlots(country: string): boolean {
|
||||||
|
return sendCommandRequest('sheet', {
|
||||||
|
country: country,
|
||||||
|
param: 'priceslot'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePriceSlot(
|
||||||
|
country: string,
|
||||||
|
content: {
|
||||||
|
slot: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
products: { product_code: string; price: number | null; row_index?: number }[];
|
||||||
|
}
|
||||||
|
): boolean {
|
||||||
|
return sendCommandRequest('sheet', {
|
||||||
|
country: country,
|
||||||
|
content: content,
|
||||||
|
param: 'update/priceslot'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function enterRoom(country: string, catalog: string): boolean {
|
export function enterRoom(country: string, catalog: string): boolean {
|
||||||
return sendCommandRequest('sheet', {
|
return sendCommandRequest('sheet', {
|
||||||
country: country,
|
country: country,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,17 @@ export const recipeOverviewData = writable<RecipeOverview[] | null>(null);
|
||||||
export const materialData = writable<Material | undefined>();
|
export const materialData = writable<Material | undefined>();
|
||||||
// price from recipe repo
|
// price from recipe repo
|
||||||
export const priceRecipeData = writable<{ [key: string]: any }>({});
|
export const priceRecipeData = writable<{ [key: string]: any }>({});
|
||||||
|
export const lastRequestSheetPrice = writable<{ [key: string]: any }>({});
|
||||||
|
|
||||||
|
// Streaming raw
|
||||||
|
export const streamingRawData = writable<{ [key: string]: any }>({});
|
||||||
|
export const streamingRawMeta = writable<{
|
||||||
|
id: string;
|
||||||
|
total_size: number;
|
||||||
|
chunk_size: number;
|
||||||
|
progress: number;
|
||||||
|
country?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// machine recipe
|
// machine recipe
|
||||||
export const recipeFromMachine = writable<any>(null);
|
export const recipeFromMachine = writable<any>(null);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,24 @@ export interface CatalogsResponse {
|
||||||
export const sheetCatalogs = writable<Catalog[]>([]);
|
export const sheetCatalogs = writable<Catalog[]>([]);
|
||||||
export const sheetCatalogsLoading = writable<boolean>(false);
|
export const sheetCatalogsLoading = writable<boolean>(false);
|
||||||
|
|
||||||
|
export interface PriceSlotProduct {
|
||||||
|
product_code: string;
|
||||||
|
name: string;
|
||||||
|
price: number | null;
|
||||||
|
row_index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceSlot {
|
||||||
|
slot: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
products: PriceSlotProduct[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const priceSlots = writable<Record<string, PriceSlot[]>>({});
|
||||||
|
export const priceSlotsLoading = writable<boolean>(false);
|
||||||
|
export const priceSlotsError = writable<string | null>(null);
|
||||||
|
|
||||||
export const countryPrimaryLanguageMap: Record<string, string> = {
|
export const countryPrimaryLanguageMap: Record<string, string> = {
|
||||||
THAI: 'Thai',
|
THAI: 'Thai',
|
||||||
tha: 'Thai',
|
tha: 'Thai',
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { permission } from './permissions';
|
||||||
|
|
||||||
let socket: WebSocket | null = null;
|
let socket: WebSocket | null = null;
|
||||||
let reconnectTimeout: any;
|
let reconnectTimeout: any;
|
||||||
|
let socketCheck: any;
|
||||||
const ENABLE_WS_DEBUG: boolean = false;
|
const ENABLE_WS_DEBUG: boolean = false;
|
||||||
|
|
||||||
export const socketConnectionOfflineCount = writable<number>(0);
|
export const socketConnectionOfflineCount = writable<number>(0);
|
||||||
|
|
@ -95,7 +96,7 @@ export function connectToWebsocket(id_token?: string) {
|
||||||
console.log(socket);
|
console.log(socket);
|
||||||
|
|
||||||
// heartbeat 10s
|
// heartbeat 10s
|
||||||
setInterval(() => {
|
socketCheck = setInterval(() => {
|
||||||
if (get(socketAlreadySendHeartbeat) > 0) {
|
if (get(socketAlreadySendHeartbeat) > 0) {
|
||||||
let heartbeat_may_offline_count = get(socketConnectionOfflineCount);
|
let heartbeat_may_offline_count = get(socketConnectionOfflineCount);
|
||||||
|
|
||||||
|
|
@ -143,6 +144,8 @@ export function connectToWebsocket(id_token?: string) {
|
||||||
socketStore.set(null);
|
socketStore.set(null);
|
||||||
socket = null;
|
socket = null;
|
||||||
|
|
||||||
|
clearInterval(socketCheck);
|
||||||
|
|
||||||
if (auth.currentUser && !socket) {
|
if (auth.currentUser && !socket) {
|
||||||
console.log('try reconnect websocket ...');
|
console.log('try reconnect websocket ...');
|
||||||
// retry again
|
// retry again
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,10 @@ export type OutMessage =
|
||||||
| {
|
| {
|
||||||
type: 'save_recipe';
|
type: 'save_recipe';
|
||||||
payload: {
|
payload: {
|
||||||
user: string;
|
user_info: any;
|
||||||
country: string;
|
country: string;
|
||||||
values: any;
|
values: any;
|
||||||
|
plugins?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
|
||||||
18
src/lib/helpers/formatDate.ts
Normal file
18
src/lib/helpers/formatDate.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
export function formatCustomDate(date: Date): string {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract all the formatted parts into an object
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const partMap = Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
||||||
|
|
||||||
|
// Construct your exact string: 16-Feb-2026 10:31:18
|
||||||
|
return `${partMap.day}-${partMap.month}-${partMap.year} ${partMap.hour}:${partMap.minute}:${partMap.second}`;
|
||||||
|
}
|
||||||
|
|
@ -104,7 +104,7 @@
|
||||||
|
|
||||||
if (adbReconnectTriedForUid !== currentUser.uid && !adb.getAdbInstance()) {
|
if (adbReconnectTriedForUid !== currentUser.uid && !adb.getAdbInstance()) {
|
||||||
adbReconnectTriedForUid = currentUser.uid;
|
adbReconnectTriedForUid = currentUser.uid;
|
||||||
void tryAutoConnect();
|
// void tryAutoConnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@
|
||||||
console.log(get(departmentStore));
|
console.log(get(departmentStore));
|
||||||
departmentStore.set(cnt);
|
departmentStore.set(cnt);
|
||||||
|
|
||||||
if (refPage === 'sheet') {
|
if (refPage === 'priceslot') {
|
||||||
|
await goto(`/sheet/priceslot/${cnt}`);
|
||||||
|
} else if (refPage === 'sheet') {
|
||||||
await goto(`/sheet/overview/${cnt}`);
|
await goto(`/sheet/overview/${cnt}`);
|
||||||
} else {
|
} else {
|
||||||
await goto('/recipe/overview');
|
await goto('/recipe/overview');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,842 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
|
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||||
|
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||||
|
import * as adb from '$lib/core/adb/adb';
|
||||||
|
import { addNotification } from '$lib/core/stores/noti';
|
||||||
|
import { referenceFromPage } from '$lib/core/stores/recipeStore';
|
||||||
|
import type { Material } from '$lib/models/material.model';
|
||||||
|
|
||||||
|
const sourceDir = '/sdcard/coffeevending';
|
||||||
|
const recipePaths = [`${sourceDir}/cfg/recipe_branch_dev.json`, `${sourceDir}/coffeethai02.json`];
|
||||||
|
|
||||||
|
type MaterialChannel =
|
||||||
|
| 'BeanChannel'
|
||||||
|
| 'PowderChannel'
|
||||||
|
| 'SyrupChannel'
|
||||||
|
| 'FreshSyrupChannel'
|
||||||
|
| 'FrozenFruitChannel'
|
||||||
|
| 'LeavesChannel'
|
||||||
|
| 'SodaChannel'
|
||||||
|
| 'ItemChannel'
|
||||||
|
| 'IceScreamBingsuChannel';
|
||||||
|
|
||||||
|
type MaterialForm = {
|
||||||
|
id: number;
|
||||||
|
idAlternate: number;
|
||||||
|
isUse: boolean;
|
||||||
|
MaterialStatus: number;
|
||||||
|
materialName: string;
|
||||||
|
materialOtherName: string;
|
||||||
|
MaterialDescrption: string;
|
||||||
|
pathOtherName: string;
|
||||||
|
CanisterType: string;
|
||||||
|
channel: MaterialChannel;
|
||||||
|
LowToOffline: number;
|
||||||
|
AlarmIDWhenOffline: number;
|
||||||
|
DrainTimer: number;
|
||||||
|
ScheduleDrainType: number;
|
||||||
|
pay_rettry_max_count: number;
|
||||||
|
RawMaterialUnit: string;
|
||||||
|
RefillUnitGram: boolean;
|
||||||
|
RefillUnitMilliliters: boolean;
|
||||||
|
RefillUnitPCS: boolean;
|
||||||
|
IsEquipment: boolean;
|
||||||
|
MaterialParameter: string;
|
||||||
|
errorThai: string;
|
||||||
|
errorEnglish: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const channelOptions: {
|
||||||
|
value: MaterialChannel;
|
||||||
|
label: string;
|
||||||
|
canisterType: string;
|
||||||
|
unit: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: 'BeanChannel',
|
||||||
|
label: 'Bean',
|
||||||
|
canisterType: 'BeanType',
|
||||||
|
unit: 'refill=$bag,sum=#gram,rec=$gram'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'PowderChannel',
|
||||||
|
label: 'Powder',
|
||||||
|
canisterType: 'PowderType',
|
||||||
|
unit: 'refill=$bag,sum=$gram,rec=$gram'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'SyrupChannel',
|
||||||
|
label: 'Syrup',
|
||||||
|
canisterType: 'Bag In Box',
|
||||||
|
unit: 'refill=$bag,sum=$gram,rec=$gram'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'FreshSyrupChannel',
|
||||||
|
label: 'Fresh Syrup',
|
||||||
|
canisterType: 'Tank',
|
||||||
|
unit: 'refill=$bag,sum=$gram,rec=$gram'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'FrozenFruitChannel',
|
||||||
|
label: 'Frozen Fruit',
|
||||||
|
canisterType: '',
|
||||||
|
unit: 'refill=$L,sum=$ml,rec=$ml'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'LeavesChannel',
|
||||||
|
label: 'Leaves',
|
||||||
|
canisterType: '',
|
||||||
|
unit: 'refill=$bag,sum=#gram,rec=$gram'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'SodaChannel',
|
||||||
|
label: 'Soda',
|
||||||
|
canisterType: '',
|
||||||
|
unit: 'refill=$L,sum=$ml,rec=$ml'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ItemChannel',
|
||||||
|
label: 'Item',
|
||||||
|
canisterType: '',
|
||||||
|
unit: 'refill=$cup,sum=$pcs,rec=$pcs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'IceScreamBingsuChannel',
|
||||||
|
label: 'Machine / Ice Cream',
|
||||||
|
canisterType: 'Machine',
|
||||||
|
unit: 'refill=$bag,sum=$gram,rec=$gram'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let devRecipe: any = $state(null);
|
||||||
|
let loadedRecipePath = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let search = $state('');
|
||||||
|
let showMaterialForm = $state(false);
|
||||||
|
let deleteConfirmOpen = $state(false);
|
||||||
|
let pendingDeleteMaterial: Material | null = $state(null);
|
||||||
|
|
||||||
|
let form: MaterialForm = $state(createInitialForm());
|
||||||
|
|
||||||
|
let materials = $derived<Material[]>(devRecipe?.MaterialSetting ?? []);
|
||||||
|
let filteredMaterials = $derived(
|
||||||
|
materials.filter((material) => {
|
||||||
|
const text =
|
||||||
|
`${material.id} ${material.materialName ?? ''} ${material.materialOtherName ?? ''} ${material.pathOtherName ?? ''}`.toLowerCase();
|
||||||
|
return text.includes(search.toLowerCase());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let materialPreview = $derived(buildMaterialSetting());
|
||||||
|
let previewJson = $derived(JSON.stringify(materialPreview, null, 2));
|
||||||
|
let existingMaterial = $derived(
|
||||||
|
materials.find((material) => Number(material.id) === Number(form.id)) ?? null
|
||||||
|
);
|
||||||
|
let activeMaterialCount = $derived(
|
||||||
|
materials.filter((material) => (material.isUse as boolean) !== false).length
|
||||||
|
);
|
||||||
|
let channelSummary = $derived(
|
||||||
|
channelOptions
|
||||||
|
.map((option) => ({
|
||||||
|
...option,
|
||||||
|
count: materials.filter((material) => Boolean(material[option.value])).length
|
||||||
|
}))
|
||||||
|
.filter((option) => option.count > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
function createInitialForm(): MaterialForm {
|
||||||
|
return {
|
||||||
|
id: 1001,
|
||||||
|
idAlternate: 0,
|
||||||
|
isUse: true,
|
||||||
|
MaterialStatus: 0,
|
||||||
|
materialName: '',
|
||||||
|
materialOtherName: '',
|
||||||
|
MaterialDescrption: '',
|
||||||
|
pathOtherName: 'Bean box',
|
||||||
|
CanisterType: 'BeanType',
|
||||||
|
channel: 'BeanChannel',
|
||||||
|
LowToOffline: 30,
|
||||||
|
AlarmIDWhenOffline: 0,
|
||||||
|
DrainTimer: 0,
|
||||||
|
ScheduleDrainType: 0,
|
||||||
|
pay_rettry_max_count: 0,
|
||||||
|
RawMaterialUnit: 'refill=$bag,sum=#gram,rec=$gram',
|
||||||
|
RefillUnitGram: false,
|
||||||
|
RefillUnitMilliliters: false,
|
||||||
|
RefillUnitPCS: false,
|
||||||
|
IsEquipment: false,
|
||||||
|
MaterialParameter: '',
|
||||||
|
errorThai: '',
|
||||||
|
errorEnglish: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullTextWithRetry(path: string, timeoutMs = 15000, attempts = 2) {
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||||
|
const content = await adb.pull(path, timeoutMs);
|
||||||
|
if (content != undefined) return content;
|
||||||
|
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectAdb() {
|
||||||
|
try {
|
||||||
|
if (!adb.getAdbInstance()) {
|
||||||
|
if (!('usb' in navigator)) throw new Error('WebUSB not supported');
|
||||||
|
await adb.connectRecipeMenuViaWebUSB();
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadRecipeFromMachine();
|
||||||
|
} catch (error: any) {
|
||||||
|
addNotification(`ERR:${error?.message ?? error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecipeFromMachine() {
|
||||||
|
if (loading) return;
|
||||||
|
if (!adb.getAdbInstance()) {
|
||||||
|
addNotification('ERR:ADB is not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
referenceFromPage.set('material');
|
||||||
|
try {
|
||||||
|
for (const recipePath of recipePaths) {
|
||||||
|
const content = await pullTextWithRetry(recipePath);
|
||||||
|
if (!content || content.trim().length === 0) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
devRecipe = JSON.parse(content);
|
||||||
|
loadedRecipePath = recipePath;
|
||||||
|
setNextAvailableId();
|
||||||
|
addNotification(`INFO:Recipe loaded from ${recipePath}`);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('failed to parse recipe json', recipePath, error);
|
||||||
|
addNotification(`ERR:Invalid recipe JSON from ${recipePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addNotification('ERR:Cannot fetch recipe from machine');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNextAvailableId() {
|
||||||
|
const usedIds = new Set(materials.map((material) => Number(material.id)));
|
||||||
|
let nextId = Math.max(1001, ...[...usedIds].filter(Number.isFinite)) + 1;
|
||||||
|
while (usedIds.has(nextId)) nextId++;
|
||||||
|
form.id = nextId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyChannelPreset() {
|
||||||
|
const preset = channelOptions.find((option) => option.value === form.channel);
|
||||||
|
if (!preset) return;
|
||||||
|
|
||||||
|
form.CanisterType = preset.canisterType;
|
||||||
|
form.RawMaterialUnit = preset.unit;
|
||||||
|
if (!form.pathOtherName) form.pathOtherName = preset.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMaterialSetting(): Material {
|
||||||
|
const channelFlags = Object.fromEntries(channelOptions.map((option) => [option.value, false]));
|
||||||
|
channelFlags[form.channel] = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
AlarmIDWhenOffline: Number(form.AlarmIDWhenOffline) || 0,
|
||||||
|
BeanChannel: Boolean(channelFlags.BeanChannel),
|
||||||
|
CanisterType: form.CanisterType,
|
||||||
|
DrainTimer: Number(form.DrainTimer) || 0,
|
||||||
|
FreshSyrupChannel: Boolean(channelFlags.FreshSyrupChannel),
|
||||||
|
FrozenFruitChannel: Boolean(channelFlags.FrozenFruitChannel),
|
||||||
|
IceScreamBingsuChannel: Boolean(channelFlags.IceScreamBingsuChannel),
|
||||||
|
IsEquipment: form.IsEquipment,
|
||||||
|
ItemChannel: Boolean(channelFlags.ItemChannel),
|
||||||
|
LeavesChannel: Boolean(channelFlags.LeavesChannel),
|
||||||
|
LowToOffline: Number(form.LowToOffline) || 0,
|
||||||
|
MaterialDescrption: form.MaterialDescrption,
|
||||||
|
MaterialDescription: form.MaterialDescrption,
|
||||||
|
MaterialStatus: Number(form.MaterialStatus) || 0,
|
||||||
|
PowderChannel: Boolean(channelFlags.PowderChannel),
|
||||||
|
RefillUnitGram: form.RefillUnitGram,
|
||||||
|
RefillUnitMilliliters: form.RefillUnitMilliliters,
|
||||||
|
RefillUnitPCS: form.RefillUnitPCS,
|
||||||
|
ScheduleDrainType: Number(form.ScheduleDrainType) || 0,
|
||||||
|
SodaChannel: Boolean(channelFlags.SodaChannel),
|
||||||
|
StrTextShowError: [form.errorThai, form.errorEnglish, '', '', '', '', '', ''],
|
||||||
|
SyrupChannel: Boolean(channelFlags.SyrupChannel),
|
||||||
|
id: Number(form.id) || 0,
|
||||||
|
idAlternate: Number(form.idAlternate) || 0,
|
||||||
|
isUse: form.isUse,
|
||||||
|
materialOtherName: form.materialOtherName,
|
||||||
|
materialName: form.materialName,
|
||||||
|
pathOtherName: form.pathOtherName,
|
||||||
|
pay_rettry_max_count: Number(form.pay_rettry_max_count) || 0,
|
||||||
|
RawMaterialUnit: form.RawMaterialUnit,
|
||||||
|
MaterialParameter: form.MaterialParameter
|
||||||
|
} as Material;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMaterial() {
|
||||||
|
if (!devRecipe) return 'Load recipe from Android first';
|
||||||
|
if (!Array.isArray(devRecipe.MaterialSetting)) return 'Recipe has no MaterialSetting array';
|
||||||
|
if (!Number.isFinite(Number(form.id)) || Number(form.id) <= 0) return 'Material ID is required';
|
||||||
|
if (!form.materialName.trim()) return 'Thai material name is required';
|
||||||
|
if (!form.materialOtherName.trim()) return 'English material name is required';
|
||||||
|
if (!form.RawMaterialUnit.trim()) return 'RawMaterialUnit is required';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistRecipeToAndroid(nextRecipe: any) {
|
||||||
|
nextRecipe.Timestamp = new Date().toLocaleString('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetPath = loadedRecipePath || recipePaths[0];
|
||||||
|
const tempPath = `${targetPath}.tmp`;
|
||||||
|
await adb.push(tempPath, JSON.stringify(nextRecipe, null, 2));
|
||||||
|
const result = await adb.executeCmd(`mv ${tempPath} ${targetPath}`);
|
||||||
|
if (result?.error) throw new Error(String(result.error));
|
||||||
|
|
||||||
|
devRecipe = nextRecipe;
|
||||||
|
return targetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMaterialToAndroid() {
|
||||||
|
const error = validateMaterial();
|
||||||
|
if (error) {
|
||||||
|
addNotification(`ERR:${error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const material = buildMaterialSetting();
|
||||||
|
const nextRecipe = JSON.parse(JSON.stringify(devRecipe));
|
||||||
|
const materialIndex = nextRecipe.MaterialSetting.findIndex(
|
||||||
|
(item: Material) => Number(item.id) === Number(material.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (materialIndex >= 0) {
|
||||||
|
nextRecipe.MaterialSetting[materialIndex] = {
|
||||||
|
...nextRecipe.MaterialSetting[materialIndex],
|
||||||
|
...material
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
nextRecipe.MaterialSetting.push(material);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = await persistRecipeToAndroid(nextRecipe);
|
||||||
|
addNotification(
|
||||||
|
`INFO:Material ${material.id} ${materialIndex >= 0 ? 'updated' : 'created'} in ${targetPath}`
|
||||||
|
);
|
||||||
|
if (materialIndex < 0) setNextAvailableId();
|
||||||
|
showMaterialForm = false;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('failed to save material', error);
|
||||||
|
addNotification(`ERR:Failed to save material: ${error?.message ?? error}`);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMaterialIntoForm(material: Material) {
|
||||||
|
const activeChannel = channelOptions.find((option) => Boolean(material[option.value]));
|
||||||
|
form = {
|
||||||
|
...createInitialForm(),
|
||||||
|
id: Number(material.id) || 0,
|
||||||
|
idAlternate: Number(material.idAlternate) || 0,
|
||||||
|
isUse: (material.isUse as boolean) !== false,
|
||||||
|
MaterialStatus: Number(material.MaterialStatus) || 0,
|
||||||
|
materialName: material.materialName ?? '',
|
||||||
|
materialOtherName: material.materialOtherName ?? '',
|
||||||
|
MaterialDescrption: material.MaterialDescrption ?? material.MaterialDescription ?? '',
|
||||||
|
pathOtherName: material.pathOtherName ?? '',
|
||||||
|
CanisterType: material.CanisterType ?? '',
|
||||||
|
channel: activeChannel?.value ?? 'BeanChannel',
|
||||||
|
LowToOffline: Number(material.LowToOffline) || 0,
|
||||||
|
AlarmIDWhenOffline: Number(material.AlarmIDWhenOffline) || 0,
|
||||||
|
DrainTimer: Number(material.DrainTimer) || 0,
|
||||||
|
ScheduleDrainType: Number(material.ScheduleDrainType) || 0,
|
||||||
|
pay_rettry_max_count: Number(material.pay_rettry_max_count) || 0,
|
||||||
|
RawMaterialUnit: material.RawMaterialUnit ?? '',
|
||||||
|
RefillUnitGram: Boolean(material.RefillUnitGram),
|
||||||
|
RefillUnitMilliliters: Boolean(material.RefillUnitMilliliters),
|
||||||
|
RefillUnitPCS: Boolean(material.RefillUnitPCS),
|
||||||
|
IsEquipment: Boolean(material.IsEquipment),
|
||||||
|
MaterialParameter: material.MaterialParameter ?? '',
|
||||||
|
errorThai: material.StrTextShowError?.[0] ?? '',
|
||||||
|
errorEnglish: material.StrTextShowError?.[1] ?? ''
|
||||||
|
};
|
||||||
|
showMaterialForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form = createInitialForm();
|
||||||
|
if (devRecipe) setNextAvailableId();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddMaterialForm() {
|
||||||
|
resetForm();
|
||||||
|
showMaterialForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteConfirm(material: Material) {
|
||||||
|
pendingDeleteMaterial = material;
|
||||||
|
deleteConfirmOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteMaterial() {
|
||||||
|
if (!pendingDeleteMaterial) return;
|
||||||
|
if (!devRecipe || !Array.isArray(devRecipe.MaterialSetting)) {
|
||||||
|
addNotification('ERR:Load recipe from Android first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const materialId = Number(pendingDeleteMaterial.id);
|
||||||
|
const nextRecipe = JSON.parse(JSON.stringify(devRecipe));
|
||||||
|
const beforeCount = nextRecipe.MaterialSetting.length;
|
||||||
|
nextRecipe.MaterialSetting = nextRecipe.MaterialSetting.filter(
|
||||||
|
(material: Material) => Number(material.id) !== materialId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextRecipe.MaterialSetting.length === beforeCount) {
|
||||||
|
addNotification(`WARN:Material not found: ${materialId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistRecipeToAndroid(nextRecipe);
|
||||||
|
addNotification(`INFO:Material deleted: ${materialId}`);
|
||||||
|
deleteConfirmOpen = false;
|
||||||
|
pendingDeleteMaterial = null;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('failed to delete material', error);
|
||||||
|
addNotification(`ERR:Failed to delete material: ${error?.message ?? error}`);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
referenceFromPage.set('material');
|
||||||
|
if (adb.getAdbInstance()) void loadRecipeFromMachine();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto flex w-full max-w-7xl flex-col gap-6 p-8">
|
||||||
|
<div class="overflow-hidden rounded-2xl border bg-card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-5 bg-gradient-to-br from-muted/70 via-card to-card p-6 md:flex-row md:items-end md:justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold tracking-[0.2em] text-muted-foreground uppercase">
|
||||||
|
Android Recipe
|
||||||
|
</p>
|
||||||
|
<h1 class="mt-2 text-4xl font-bold tracking-tight">Material Setting</h1>
|
||||||
|
<!-- <p class="mt-2 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Browse existing Android <code>MaterialSetting</code> entries first, then add or edit a material
|
||||||
|
in a dialog without leaving this list.
|
||||||
|
</p> -->
|
||||||
|
{#if loadedRecipePath}
|
||||||
|
<p class="mt-3 font-mono text-xs text-muted-foreground">Loaded: {loadedRecipePath}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="outline" onclick={connectAdb} disabled={loading || saving}>
|
||||||
|
{#if loading}
|
||||||
|
<Spinner />
|
||||||
|
Loading
|
||||||
|
{:else if devRecipe}
|
||||||
|
Reload From Android
|
||||||
|
{:else}
|
||||||
|
Connect & Load
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2 md:grid-cols-3">
|
||||||
|
<div class="rounded-lg border border-sky-200 bg-card px-4 py-3 shadow-sm dark:border-sky-900">
|
||||||
|
<div class="mb-2 h-1 w-10 rounded-full bg-sky-500"></div>
|
||||||
|
<div class="text-xs text-muted-foreground">Total materials</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold">{materials.length}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-emerald-200 bg-card px-4 py-3 shadow-sm dark:border-emerald-900"
|
||||||
|
>
|
||||||
|
<div class="mb-2 h-1 w-10 rounded-full bg-emerald-500"></div>
|
||||||
|
<div class="text-xs text-muted-foreground">Active materials</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold">{activeMaterialCount}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-violet-200 bg-card px-4 py-3 shadow-sm dark:border-violet-900"
|
||||||
|
>
|
||||||
|
<div class="mb-2 h-1 w-10 rounded-full bg-violet-500"></div>
|
||||||
|
<div class="text-xs text-muted-foreground">Channels in use</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold">{channelSummary.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={showMaterialForm}>
|
||||||
|
<Dialog.Content class="max-h-[92vh] overflow-y-auto sm:max-w-6xl">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{existingMaterial ? 'Edit Material' : 'Add Material'}</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Create or update one <code>MaterialSetting</code> entry. The JSON preview shows the payload
|
||||||
|
before saving to Android.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>{existingMaterial ? 'Edit Material' : 'Add Material'}</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="grid gap-6">
|
||||||
|
{#if existingMaterial}
|
||||||
|
<div
|
||||||
|
class="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
Material ID {form.id} already exists. Saving will update this MaterialSetting.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="material-id">Material ID</Label>
|
||||||
|
<Input id="material-id" type="number" bind:value={form.id} />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="material-id-alt">Alternate ID</Label>
|
||||||
|
<Input id="material-id-alt" type="number" bind:value={form.idAlternate} />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="material-status">Status</Label>
|
||||||
|
<select
|
||||||
|
id="material-status"
|
||||||
|
class="h-9 rounded-md border bg-background px-3 text-sm"
|
||||||
|
bind:value={form.MaterialStatus}
|
||||||
|
>
|
||||||
|
<option value={0}>0 - Ready</option>
|
||||||
|
<option value={2}>2 - Obsolete</option>
|
||||||
|
<option value={11}>11 - Pending online</option>
|
||||||
|
<option value={12}>12 - Pending offline</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="material-name">Thai Name</Label>
|
||||||
|
<Input
|
||||||
|
id="material-name"
|
||||||
|
bind:value={form.materialName}
|
||||||
|
placeholder="เช่น กาแฟคั่วเข้ม"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="material-other-name">English Name</Label>
|
||||||
|
<Input
|
||||||
|
id="material-other-name"
|
||||||
|
bind:value={form.materialOtherName}
|
||||||
|
placeholder="e.g. dark-roasts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="material-description">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="material-description"
|
||||||
|
bind:value={form.MaterialDescrption}
|
||||||
|
placeholder="e.g. Barista Blend"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="path-other-name">Path / Canister Name</Label>
|
||||||
|
<Input
|
||||||
|
id="path-other-name"
|
||||||
|
bind:value={form.pathOtherName}
|
||||||
|
placeholder="e.g. Bean box"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="channel">Channel</Label>
|
||||||
|
<select
|
||||||
|
id="channel"
|
||||||
|
class="h-9 rounded-md border bg-background px-3 text-sm"
|
||||||
|
value={form.channel}
|
||||||
|
onchange={(event) => {
|
||||||
|
form.channel = event.currentTarget.value as MaterialChannel;
|
||||||
|
applyChannelPreset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#each channelOptions as option}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="canister-type">Canister Type</Label>
|
||||||
|
<Input id="canister-type" bind:value={form.CanisterType} />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="low-to-offline">Low To Offline</Label>
|
||||||
|
<Input id="low-to-offline" type="number" bind:value={form.LowToOffline} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="raw-material-unit">RawMaterialUnit</Label>
|
||||||
|
<Input id="raw-material-unit" bind:value={form.RawMaterialUnit} />
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Examples: <code>refill=$bag,sum=$gram,rec=$gram</code>,
|
||||||
|
<code>refill=$L,sum=$ml,rec=$ml</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="error-thai">Error Text TH</Label>
|
||||||
|
<Input id="error-thai" bind:value={form.errorThai} placeholder="เช่น กาแฟหมด" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="error-english">Error Text EN</Label>
|
||||||
|
<Input
|
||||||
|
id="error-english"
|
||||||
|
bind:value={form.errorEnglish}
|
||||||
|
placeholder="e.g. Out of Coffee bean"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-4">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="alarm-id">Alarm ID</Label>
|
||||||
|
<Input id="alarm-id" type="number" bind:value={form.AlarmIDWhenOffline} />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="drain-timer">Drain Timer</Label>
|
||||||
|
<Input id="drain-timer" type="number" bind:value={form.DrainTimer} />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="schedule-drain">Schedule Drain</Label>
|
||||||
|
<Input id="schedule-drain" type="number" bind:value={form.ScheduleDrainType} />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="pay-retry">Pay Retry Max</Label>
|
||||||
|
<Input id="pay-retry" type="number" bind:value={form.pay_rettry_max_count} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 rounded-md border p-4 md:grid-cols-5">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox bind:checked={form.isUse} />
|
||||||
|
Use material
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox bind:checked={form.IsEquipment} />
|
||||||
|
Equipment
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox bind:checked={form.RefillUnitGram} />
|
||||||
|
Refill gram
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox bind:checked={form.RefillUnitMilliliters} />
|
||||||
|
Refill ml
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox bind:checked={form.RefillUnitPCS} />
|
||||||
|
Refill pcs
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="material-parameter">MaterialParameter</Label>
|
||||||
|
<textarea
|
||||||
|
id="material-parameter"
|
||||||
|
class="min-h-20 rounded-md border bg-background px-3 py-2 font-mono text-sm"
|
||||||
|
bind:value={form.MaterialParameter}
|
||||||
|
placeholder="Optional extra Android parameter"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onclick={() => (showMaterialForm = false)} disabled={saving}
|
||||||
|
>Cancel</Button
|
||||||
|
>
|
||||||
|
<Button variant="outline" onclick={resetForm} disabled={saving}>Reset</Button>
|
||||||
|
<Button onclick={saveMaterialToAndroid} disabled={!devRecipe || loading || saving}>
|
||||||
|
{saving ? 'Saving...' : existingMaterial ? 'Update Material' : 'Create Material'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Preview JSON</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Payload that will be upserted into <code>MaterialSetting</code>.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<pre
|
||||||
|
class="max-h-[520px] overflow-auto rounded-md bg-muted p-4 text-xs">{previewJson}</pre>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<Card.Title>Existing Materials</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Use Edit to update a material, or Delete to remove it after confirmation.
|
||||||
|
</Card.Description>
|
||||||
|
</div>
|
||||||
|
<Button onclick={openAddMaterialForm} disabled={!devRecipe || loading || saving}
|
||||||
|
>Add Material</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="grid gap-4">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<Input class="lg:max-w-md" bind:value={search} placeholder="Search by id, name, path" />
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each channelSummary as channel}
|
||||||
|
<span class="rounded-full border bg-muted/40 px-3 py-1 text-xs text-muted-foreground">
|
||||||
|
{channel.label}: {channel.count}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden rounded-md border">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center gap-3 p-4 text-sm text-muted-foreground">
|
||||||
|
<Spinner />
|
||||||
|
Loading materials from Android...
|
||||||
|
</div>
|
||||||
|
{:else if !devRecipe}
|
||||||
|
<div class="p-4 text-sm text-muted-foreground">Connect and load recipe first.</div>
|
||||||
|
{:else if filteredMaterials.length === 0}
|
||||||
|
<div class="p-4 text-sm text-muted-foreground">No materials found.</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid md:grid-cols-[120px_minmax(0,1fr)_minmax(0,1fr)_150px_90px_150px] md:items-center"
|
||||||
|
>
|
||||||
|
<span>ID</span>
|
||||||
|
<span>Thai Name</span>
|
||||||
|
<span>English Name</span>
|
||||||
|
<span>Path</span>
|
||||||
|
<span>Use</span>
|
||||||
|
<span class="text-right">Actions</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid max-h-[70vh] overflow-auto">
|
||||||
|
{#each filteredMaterials as material}
|
||||||
|
<div
|
||||||
|
class="grid w-full gap-3 border-b p-4 text-sm transition-colors hover:bg-primary/5 md:grid-cols-[120px_minmax(0,1fr)_minmax(0,1fr)_150px_90px_150px] md:items-center"
|
||||||
|
>
|
||||||
|
<span class="font-mono font-medium text-primary">{material.id}</span>
|
||||||
|
<span class="font-medium">{material.materialName || '-'}</span>
|
||||||
|
<span class="text-muted-foreground">{material.materialOtherName || '-'}</span>
|
||||||
|
<span class="text-muted-foreground">{material.pathOtherName || '-'}</span>
|
||||||
|
<span
|
||||||
|
class="w-fit rounded-full px-2.5 py-1 text-xs {(material.isUse as boolean) !==
|
||||||
|
false
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
|
||||||
|
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'}"
|
||||||
|
>
|
||||||
|
{(material.isUse as boolean) !== false ? 'Use' : 'Not use'}
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-2 md:justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => loadMaterialIntoForm(material)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => openDeleteConfirm(material)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={deleteConfirmOpen}>
|
||||||
|
<Dialog.Content class="sm:max-w-md">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Delete Material?</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
This will remove the material from <code>MaterialSetting</code> in the Android recipe JSON.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
{#if pendingDeleteMaterial}
|
||||||
|
<div class="rounded-md border bg-muted/30 p-4 text-sm">
|
||||||
|
<div class="text-xs text-muted-foreground">Material</div>
|
||||||
|
<div class="mt-1 font-mono font-medium">{pendingDeleteMaterial.id}</div>
|
||||||
|
<div class="mt-1 font-medium">
|
||||||
|
{pendingDeleteMaterial.materialName ||
|
||||||
|
pendingDeleteMaterial.materialOtherName ||
|
||||||
|
'Unnamed'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
deleteConfirmOpen = false;
|
||||||
|
pendingDeleteMaterial = null;
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onclick={confirmDeleteMaterial} disabled={saving}>
|
||||||
|
{saving ? 'Deleting...' : 'Delete Material'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
</div>
|
||||||
File diff suppressed because it is too large
Load diff
586
src/routes/(authed)/sheet/priceslot/[country]/+page.svelte
Normal file
586
src/routes/(authed)/sheet/priceslot/[country]/+page.svelte
Normal file
|
|
@ -0,0 +1,586 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { addNotification } from '$lib/core/stores/noti.js';
|
||||||
|
import { departmentStore } from '$lib/core/stores/departments.js';
|
||||||
|
import { permission as currentPerms } from '$lib/core/stores/permissions.js';
|
||||||
|
import { referenceFromPage } from '$lib/core/stores/recipeStore.js';
|
||||||
|
import {
|
||||||
|
clearSheetPriceSentTypes,
|
||||||
|
getCountryPrimaryLanguage,
|
||||||
|
getPriceFromCells,
|
||||||
|
lastRequestSheetPrice,
|
||||||
|
sheetPriceLoading,
|
||||||
|
type PriceSlot,
|
||||||
|
type PriceSlotProduct
|
||||||
|
} from '$lib/core/stores/sheetStore.js';
|
||||||
|
import { requestSheetPrice } from '$lib/core/services/sheetService.js';
|
||||||
|
import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js';
|
||||||
|
|
||||||
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
|
import Input from '$lib/components/ui/input/input.svelte';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
|
import * as Select from '$lib/components/ui/select/index.js';
|
||||||
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||||
|
import { Calculator, RefreshCw, Save, RotateCcw, Search } from '@lucide/svelte/icons';
|
||||||
|
|
||||||
|
type AdjustmentMode =
|
||||||
|
| 'increase_percent'
|
||||||
|
| 'increase_amount'
|
||||||
|
| 'decrease_amount'
|
||||||
|
| 'decrease_percent';
|
||||||
|
|
||||||
|
const adjustmentModeLabels: Record<AdjustmentMode, string> = {
|
||||||
|
increase_percent: 'Increase by Percentage (%)',
|
||||||
|
increase_amount: 'Increase by Fixed Amount',
|
||||||
|
decrease_amount: 'Decrease by Fixed Amount',
|
||||||
|
decrease_percent: 'Decrease by Percentage (%)'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockProducts: PriceSlotProduct[] = [
|
||||||
|
{
|
||||||
|
product_code: '12-01-01-0001',
|
||||||
|
name: 'HOT ESPRESSO | เอสเพรสโซ่ร้อน',
|
||||||
|
price: 30,
|
||||||
|
row_index: 2
|
||||||
|
},
|
||||||
|
{ product_code: '12-01-01-0003', name: 'HOT AMERICANO | กาแฟดำร้อน', price: 35, row_index: 3 },
|
||||||
|
{ product_code: '12-01-01-0004', name: 'HOT LATTE | ลาเต้ร้อน', price: 40, row_index: 5 },
|
||||||
|
{ product_code: '12-01-01-0006', name: 'HOT MOCHA | มอคค่าร้อน', price: 55, row_index: 7 },
|
||||||
|
{
|
||||||
|
product_code: '12-01-02-0001',
|
||||||
|
name: 'Iced AMERICANO | กาแฟดำเย็น',
|
||||||
|
price: 40,
|
||||||
|
row_index: 16
|
||||||
|
},
|
||||||
|
{ product_code: '12-01-02-0002', name: 'ICED LATTE | ลาเต้เย็น', price: 50, row_index: 17 },
|
||||||
|
{ product_code: '12-01-02-0003', name: 'ICED MOCHA | มอคค่าเย็น', price: 60, row_index: 18 },
|
||||||
|
{
|
||||||
|
product_code: '12-02-01-0002',
|
||||||
|
name: 'Hot THAI MILK TEA | ชาไทยร้อน',
|
||||||
|
price: 40,
|
||||||
|
row_index: 27
|
||||||
|
},
|
||||||
|
{
|
||||||
|
product_code: '12-02-01-0004',
|
||||||
|
name: 'Hot MATCHA LATTE | มัทฉะลาเต้ร้อน',
|
||||||
|
price: 50,
|
||||||
|
row_index: 29
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildMockSlots(): PriceSlot[] {
|
||||||
|
return Array.from({ length: 10 }, (_, index) => {
|
||||||
|
const slot = index + 1;
|
||||||
|
const increase = slot === 1 ? 15 : slot === 2 ? 25 : slot * 5;
|
||||||
|
|
||||||
|
return {
|
||||||
|
slot,
|
||||||
|
name: slot <= 2 ? `ProfileIncrease${increase}` : `PriceSlot${slot}`,
|
||||||
|
description: slot <= 2 ? `increase price ${increase}%` : '',
|
||||||
|
products: mockProducts.map((product) => ({
|
||||||
|
...product,
|
||||||
|
price:
|
||||||
|
product.price === null
|
||||||
|
? null
|
||||||
|
: slot <= 2
|
||||||
|
? Math.ceil((product.price * (1 + increase / 100)) / 5) * 5
|
||||||
|
: product.price
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedCountry = $state<string>($page.params.country || get(departmentStore) || '');
|
||||||
|
let enabledCountries = $state<string[]>([]);
|
||||||
|
let selectedSlot = $state(1);
|
||||||
|
const initialSlots = buildMockSlots();
|
||||||
|
let slots = $state<PriceSlot[]>(initialSlots);
|
||||||
|
let savedSnapshot = $state<PriceSlot[]>(structuredClone(initialSlots));
|
||||||
|
let loading = $state(false);
|
||||||
|
let productCodeSearch = $state('');
|
||||||
|
let createDialogOpen = $state(false);
|
||||||
|
let adjustmentMode = $state<AdjustmentMode>('increase_percent');
|
||||||
|
let adjustmentValue = $state(15);
|
||||||
|
let createName = $state('ProfileIncrease15');
|
||||||
|
let createDescription = $state('increase price 15%');
|
||||||
|
|
||||||
|
let currentSlot = $derived(slots.find((slot) => slot.slot === selectedSlot) ?? slots[0]);
|
||||||
|
let selectedCountryLanguage = $derived(getCountryPrimaryLanguage(selectedCountry));
|
||||||
|
let basePriceCells = $derived(
|
||||||
|
$lastRequestSheetPrice[selectedCountry.toLowerCase()] ||
|
||||||
|
$lastRequestSheetPrice[selectedCountry] ||
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
let basePricesLoadedCount = $derived(
|
||||||
|
mockProducts.filter((product) => getBasePrice(product) !== null).length
|
||||||
|
);
|
||||||
|
let basePriceLoading = $derived($sheetPriceLoading);
|
||||||
|
let filteredProducts = $derived(
|
||||||
|
currentSlot.products.filter((product) => {
|
||||||
|
const keyword = productCodeSearch.trim().toLowerCase();
|
||||||
|
if (!keyword) return true;
|
||||||
|
|
||||||
|
return product.product_code.toLowerCase().includes(keyword);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let changedCount = $derived(countChangedProducts(currentSlot, savedSnapshot[selectedSlot - 1]));
|
||||||
|
let hasHeaderChanges = $derived(
|
||||||
|
currentSlot.name !== savedSnapshot[selectedSlot - 1]?.name ||
|
||||||
|
currentSlot.description !== savedSnapshot[selectedSlot - 1]?.description
|
||||||
|
);
|
||||||
|
let hasChanges = $derived(changedCount > 0 || hasHeaderChanges);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
referenceFromPage.set('priceslot');
|
||||||
|
|
||||||
|
if (selectedCountry) {
|
||||||
|
departmentStore.set(selectedCountry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPerms = get(currentPerms).filter((x) => x.startsWith('document.write'));
|
||||||
|
enabledCountries = userPerms.map((x) => x.split('.')[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getBasePrice(product: PriceSlotProduct): number | null {
|
||||||
|
const cells = basePriceCells[product.product_code];
|
||||||
|
if (cells?.length > 0) {
|
||||||
|
const price = getPriceFromCells(selectedCountry.toLowerCase(), cells, 'cash_price');
|
||||||
|
const parsed = Number(price);
|
||||||
|
if (!Number.isNaN(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return product.price;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProductNames(product: PriceSlotProduct) {
|
||||||
|
const [englishName = '', localName = ''] = product.name.split('|').map((name) => name.trim());
|
||||||
|
|
||||||
|
return {
|
||||||
|
english: englishName || product.name,
|
||||||
|
local: localName || englishName || product.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateAdjustedPrice(basePrice: number | null): number | null {
|
||||||
|
if (basePrice === null) return null;
|
||||||
|
|
||||||
|
const value = Number(adjustmentValue);
|
||||||
|
if (Number.isNaN(value)) return basePrice;
|
||||||
|
|
||||||
|
const nextPrice =
|
||||||
|
adjustmentMode === 'increase_percent'
|
||||||
|
? basePrice * (1 + value / 100)
|
||||||
|
: adjustmentMode === 'increase_amount'
|
||||||
|
? basePrice + value
|
||||||
|
: adjustmentMode === 'decrease_percent'
|
||||||
|
? basePrice * (1 - value / 100)
|
||||||
|
: basePrice - value;
|
||||||
|
|
||||||
|
return Math.max(0, Math.round(nextPrice));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBasePrices() {
|
||||||
|
const productCodes = mockProducts.map((product) => product.product_code);
|
||||||
|
if (productCodes.length === 0) return;
|
||||||
|
|
||||||
|
const socket = await waitForOpenSocket();
|
||||||
|
if (!socket) {
|
||||||
|
addNotification('WARN:WebSocket not connected. Using local base price sample.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSheetPriceSentTypes();
|
||||||
|
const sent = requestSheetPrice(selectedCountry, productCodes);
|
||||||
|
if (!sent) {
|
||||||
|
addNotification('ERR:Failed to request base prices');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCreateTemplate() {
|
||||||
|
const value = Number(adjustmentValue);
|
||||||
|
const formattedValue = Number.isNaN(value) ? 0 : value;
|
||||||
|
const isPercent =
|
||||||
|
adjustmentMode === 'increase_percent' || adjustmentMode === 'decrease_percent';
|
||||||
|
const isIncrease =
|
||||||
|
adjustmentMode === 'increase_percent' || adjustmentMode === 'increase_amount';
|
||||||
|
const action = isIncrease ? 'Increase' : 'Decrease';
|
||||||
|
const valueLabel = isPercent ? `${formattedValue}%` : `${formattedValue}`;
|
||||||
|
const nameSuffix = isPercent ? `${formattedValue}` : `Fixed${formattedValue}`;
|
||||||
|
|
||||||
|
createName = `Profile${action}${nameSuffix}`;
|
||||||
|
createDescription = `${isIncrease ? 'increase' : 'decrease'} price ${valueLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
applyCreateTemplate();
|
||||||
|
createDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPriceSlotFromBase() {
|
||||||
|
const nextSlotNumber = Math.max(0, ...slots.map((slot) => slot.slot)) + 1;
|
||||||
|
|
||||||
|
const products = mockProducts.map((product) => ({
|
||||||
|
...product,
|
||||||
|
price: calculateAdjustedPrice(getBasePrice(product))
|
||||||
|
}));
|
||||||
|
|
||||||
|
const nextSlot: PriceSlot = {
|
||||||
|
slot: nextSlotNumber,
|
||||||
|
name: createName.trim() || `PriceSlot${nextSlotNumber}`,
|
||||||
|
description: createDescription.trim(),
|
||||||
|
products
|
||||||
|
};
|
||||||
|
|
||||||
|
slots = [...slots, nextSlot];
|
||||||
|
savedSnapshot = [...savedSnapshot, structuredClone(nextSlot)];
|
||||||
|
selectedSlot = nextSlotNumber;
|
||||||
|
createDialogOpen = false;
|
||||||
|
addNotification(`INFO:Created PriceSlot${nextSlotNumber} from base prices`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countChangedProducts(current: PriceSlot, saved: PriceSlot | undefined): number {
|
||||||
|
if (!saved) return 0;
|
||||||
|
|
||||||
|
return current.products.filter((product) => {
|
||||||
|
const savedProduct = saved.products.find(
|
||||||
|
(item) => item.product_code === product.product_code
|
||||||
|
);
|
||||||
|
return savedProduct?.price !== product.price;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSlotField(field: 'name' | 'description', value: string) {
|
||||||
|
slots = slots.map((slot) => (slot.slot === selectedSlot ? { ...slot, [field]: value } : slot));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProductPrice(productCode: string, value: string) {
|
||||||
|
const price = value === '' ? null : Number(value);
|
||||||
|
|
||||||
|
slots = slots.map((slot) => {
|
||||||
|
if (slot.slot !== selectedSlot) return slot;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...slot,
|
||||||
|
products: slot.products.map((product) =>
|
||||||
|
product.product_code === productCode
|
||||||
|
? { ...product, price: Number.isNaN(price) ? product.price : price }
|
||||||
|
: product
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSlot() {
|
||||||
|
const savedSlot = savedSnapshot[selectedSlot - 1];
|
||||||
|
if (!savedSlot) return;
|
||||||
|
|
||||||
|
slots = slots.map((slot) => (slot.slot === selectedSlot ? structuredClone(savedSlot) : slot));
|
||||||
|
addNotification(`INFO:Reset PriceSlot${selectedSlot}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSlot() {
|
||||||
|
savedSnapshot = savedSnapshot.map((slot) =>
|
||||||
|
slot.slot === selectedSlot ? structuredClone(currentSlot) : slot
|
||||||
|
);
|
||||||
|
addNotification('WARN:PriceSlot backend is not ready. Changes are kept in this UI only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPriceSlots() {
|
||||||
|
loading = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
loading = false;
|
||||||
|
addNotification('WARN:PriceSlot backend is not ready. Showing UI mock data.');
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<div class="w-full px-6 py-8 lg:px-8">
|
||||||
|
<div class="mb-7 flex flex-wrap items-start justify-between gap-5">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="text-4xl leading-tight font-bold tracking-normal">
|
||||||
|
PriceSlot [ {selectedCountry.toUpperCase()} ]
|
||||||
|
</h1>
|
||||||
|
<p class="mt-4 text-muted-foreground">
|
||||||
|
Edit sheet PriceSlot names, descriptions, and product prices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
{#if enabledCountries.length > 0}
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={selectedCountry}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) {
|
||||||
|
selectedCountry = v;
|
||||||
|
goto(`/sheet/priceslot/${v}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
class="h-11 w-40 rounded-lg border-border/80 bg-card/70 px-4 font-semibold"
|
||||||
|
>
|
||||||
|
{selectedCountry.toUpperCase()}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each enabledCountries as country}
|
||||||
|
<Select.Item value={country}>
|
||||||
|
{country.toUpperCase()}
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="h-11 rounded-lg"
|
||||||
|
onclick={loadPriceSlots}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw class="mr-2 h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-5 flex gap-2 overflow-x-auto border-b">
|
||||||
|
{#each slots as slot}
|
||||||
|
<button
|
||||||
|
class={[
|
||||||
|
'min-w-28 border-b-2 px-4 py-3 text-sm font-semibold transition-colors',
|
||||||
|
selectedSlot === slot.slot
|
||||||
|
? 'border-emerald-500 text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
|
]}
|
||||||
|
onclick={() => (selectedSlot = slot.slot)}
|
||||||
|
>
|
||||||
|
PriceSlot{slot.slot}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-5 rounded-lg border border-border/80 bg-card p-4 shadow-sm">
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 items-end gap-4 xl:grid-cols-[180px_minmax(220px,1fr)_minmax(280px,1.25fr)_auto]"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-col justify-end gap-2 pb-1">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-xl font-bold tracking-normal">PriceSlot{selectedSlot}</h2>
|
||||||
|
<Badge variant={hasChanges ? 'default' : 'secondary'}>
|
||||||
|
{hasChanges ? `${changedCount} changes` : 'No changes'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<!-- <p class="text-sm text-muted-foreground">Column K/L</p> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-xs font-medium text-muted-foreground" for="priceslot-name">Name</label>
|
||||||
|
<Input
|
||||||
|
id="priceslot-name"
|
||||||
|
class="h-10"
|
||||||
|
value={currentSlot.name}
|
||||||
|
oninput={(event) => updateSlotField('name', event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-xs font-medium text-muted-foreground" for="priceslot-description">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="priceslot-description"
|
||||||
|
class="h-10"
|
||||||
|
value={currentSlot.description}
|
||||||
|
oninput={(event) => updateSlotField('description', event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-end justify-start gap-2 xl:justify-end">
|
||||||
|
<Button class="h-11 rounded-lg" onclick={openCreateDialog}>
|
||||||
|
<Calculator class="mr-2 h-4 w-4" />
|
||||||
|
Create PriceSlot
|
||||||
|
</Button>
|
||||||
|
<Button class="h-11 rounded-lg px-4" onclick={saveSlot} disabled={!hasChanges}>
|
||||||
|
<Save class="mr-2 h-4 w-4" />
|
||||||
|
Save Draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="h-11 rounded-lg px-4"
|
||||||
|
onclick={resetSlot}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
>
|
||||||
|
<RotateCcw class="mr-2 h-4 w-4" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto w-full max-w-6xl">
|
||||||
|
<div class="mb-3 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div class="w-full max-w-sm space-y-2">
|
||||||
|
<label class="text-sm font-medium" for="product-code-search">Search ProductCode</label>
|
||||||
|
<div class="relative">
|
||||||
|
<Search
|
||||||
|
class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="product-code-search"
|
||||||
|
class="pl-9 font-mono"
|
||||||
|
placeholder="12-01-01-0001"
|
||||||
|
bind:value={productCodeSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Showing {filteredProducts.length} of {currentSlot.products.length} products
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full overflow-hidden rounded-lg border">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head class="w-[190px]">ProductCode</Table.Head>
|
||||||
|
<Table.Head>ProductName [{selectedCountryLanguage}]</Table.Head>
|
||||||
|
<Table.Head>ProductNameEng</Table.Head>
|
||||||
|
<Table.Head class="w-[150px] text-right">Price</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each filteredProducts as product (product.product_code)}
|
||||||
|
{@const productNames = getProductNames(product)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-mono text-sm font-semibold">
|
||||||
|
{product.product_code}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="min-w-64 font-medium">{productNames.local}</Table.Cell>
|
||||||
|
<Table.Cell class="min-w-64 text-muted-foreground">{productNames.english}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
class="ml-auto w-32 text-right font-semibold"
|
||||||
|
value={product.price ?? ''}
|
||||||
|
oninput={(event) =>
|
||||||
|
updateProductPrice(product.product_code, event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
{#if filteredProducts.length === 0}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell colspan={4} class="h-28 text-center text-muted-foreground">
|
||||||
|
No product code found.
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/if}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={createDialogOpen}>
|
||||||
|
<Dialog.Content class="sm:max-w-2xl">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Create PriceSlot</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Choose how to adjust base prices before creating a new PriceSlot.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<!-- <div class="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold">Base prices</p>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{basePriceLoading ? 'Loading prices from backend' : `${basePricesLoadedCount} products ready`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onclick={loadBasePrices} disabled={basePriceLoading}>
|
||||||
|
<RefreshCw class="mr-2 h-4 w-4" />
|
||||||
|
Load Base
|
||||||
|
</Button>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium" for="adjustment-mode">Adjustment Mode</label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={adjustmentMode}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) adjustmentMode = v as AdjustmentMode;
|
||||||
|
applyCreateTemplate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger id="adjustment-mode" class="h-10 rounded-lg">
|
||||||
|
{adjustmentModeLabels[adjustmentMode]}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Item value="increase_percent">Increase by Percentage (%)</Select.Item>
|
||||||
|
<Select.Item value="increase_amount">Increase by Fixed Amount</Select.Item>
|
||||||
|
<Select.Item value="decrease_percent">Decrease by Percentage (%)</Select.Item>
|
||||||
|
<Select.Item value="decrease_amount">Decrease by Fixed Amount</Select.Item>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium" for="adjustment-value">
|
||||||
|
Adjustment Value
|
||||||
|
{adjustmentMode === 'increase_percent' || adjustmentMode === 'decrease_percent'
|
||||||
|
? '(%)'
|
||||||
|
: ''}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="adjustment-value"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={adjustmentValue}
|
||||||
|
oninput={(event) => {
|
||||||
|
adjustmentValue = Number(event.currentTarget.value);
|
||||||
|
applyCreateTemplate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium" for="create-name">Name</label>
|
||||||
|
<Input
|
||||||
|
id="create-name"
|
||||||
|
value={createName}
|
||||||
|
oninput={(event) => (createName = event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium" for="create-description">Description</label>
|
||||||
|
<Input
|
||||||
|
id="create-description"
|
||||||
|
value={createDescription}
|
||||||
|
oninput={(event) => (createDescription = event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (createDialogOpen = false)}>Cancel</Button>
|
||||||
|
<Button onclick={createPriceSlotFromBase}>
|
||||||
|
<Calculator class="mr-2 h-4 w-4" />
|
||||||
|
Confirm Create
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
@ -28,8 +28,8 @@
|
||||||
// pushes the selected .mp4 (from the browser), then
|
// pushes the selected .mp4 (from the browser), then
|
||||||
// `ls -l > sync_1.file` on the machine, pulls it, uploads it.
|
// `ls -l > sync_1.file` on the machine, pulls it, uploads it.
|
||||||
// ⚠️ FULL REPLACE — requires ADB; select the COMPLETE adv set.
|
// ⚠️ FULL REPLACE — requires ADB; select the COMPLETE adv set.
|
||||||
//const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'ftp_listdir';
|
const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'ftp_listdir';
|
||||||
const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'machine';
|
//const MANIFEST_MODE: 'ftp_listdir' | 'machine' = 'machine';
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// adv folder on the machine. Domestic Thailand uses the flat folder; every
|
// adv folder on the machine. Domestic Thailand uses the flat folder; every
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,7 @@
|
||||||
await startFetchRecipeFromMachine();
|
await startFetchRecipeFromMachine();
|
||||||
await loadEssentialFiles();
|
await loadEssentialFiles();
|
||||||
await loadStagedMenusFromAndroid();
|
await loadStagedMenusFromAndroid();
|
||||||
|
await openBrewApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tryAutoConnect() {
|
async function tryAutoConnect() {
|
||||||
|
|
@ -233,6 +234,55 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openBrewApp() {
|
||||||
|
try {
|
||||||
|
let instance = adb.getAdbInstance();
|
||||||
|
if (instance) {
|
||||||
|
try {
|
||||||
|
// bypass
|
||||||
|
await adb.executeCmd('echo -n hurr > /sdcard/coffeevending/ignore_pass');
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
let result = await adb.executeCmd(
|
||||||
|
'am start -n com.forthvending.coffeemain/com.forthvending.coffeemain.MainActivity'
|
||||||
|
);
|
||||||
|
// if (result?.output) {
|
||||||
|
// toast.success('Open app success!');
|
||||||
|
// machineStatus = 'open app success, check the screen and put the password';
|
||||||
|
// } else if (result?.error) {
|
||||||
|
// // case usb connection cutoff
|
||||||
|
// if (result.error === 'ExactReadableEndedError') {
|
||||||
|
// toast.warning('Connection unstable');
|
||||||
|
// machineStatus = 'app maybe opened, check the screen';
|
||||||
|
// } else {
|
||||||
|
// throw new Error(`Exit ${result.exitCode}. ${result.error}`);
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// throw new Error('Instance not found or error while executing');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// hasOpenedBrewOnce = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// bypass
|
||||||
|
await adb.executeCmd('echo -n hurr > /sdcard/coffeevending/ignore_pass');
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
// bypass
|
||||||
|
await adb.executeCmd('input tap 336 795');
|
||||||
|
} catch (e) {}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// machineStatus = 'Cannot open brew app';
|
||||||
|
// toast.error('Error while trying to open brew app, please check the screen. ', {
|
||||||
|
// description: e.toString()
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureAndroidSocket() {
|
async function ensureAndroidSocket() {
|
||||||
if (isAdbWriterAvailable()) return true;
|
if (isAdbWriterAvailable()) return true;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue