feat: save recipe in progress

Signed-off-by: pakintada@gmail.com <Pakin>
This commit is contained in:
pakintada@gmail.com 2026-04-20 10:37:02 +07:00
parent 916e056389
commit 230d4abe0c
11 changed files with 310 additions and 41 deletions

20
ISSUES.txt Normal file
View file

@ -0,0 +1,20 @@
Idea, Issue, Work Tracking
[TODO]
[Pending]
- [] #2: Send change value from editing in recipe to machine
- [] #3: Save value to recipe
- [] #5: revert value on close dialog recipe
[Rejected]
- [] #4: From #1, will do sync value from server, so that user could save their current edit too
[Done]
- [x] #1: Topping value saving bug, fix by snapshot value

View file

@ -47,9 +47,12 @@
authStore.set(null);
let socket = get(socketStore);
if (socket) {
socket.close(1000, 'logout');
}
try {
if (socket) {
socket.close(1000, 'logout');
}
} catch (e) {}
socketStore.set(null);
if (browser && 'cookieStore' in window) await cookieStore.delete('logged_in');

View file

@ -112,9 +112,11 @@ export const columns: ColumnDef<RecipelistMaterial>[] = [
row_uid: row.original.id,
mat_id: row.original.material_id,
onEditValue: (changes: any) => {
// console.log('triggered on edit value', changes);
recipeDataEvent.set({
event_type: 'edit_change_value_rpl',
payload: changes,
payload: JSON.parse(changes),
index: row.original.id
});

View file

@ -11,6 +11,7 @@
import { columns, type RecipelistMaterial } from './columns';
import { get, readable, writable } from 'svelte/store';
import {
currentEditingRecipeProductCode,
latestRecipeToppingData,
materialFromMachineQuery,
materialFromServerQuery
@ -29,8 +30,9 @@
let {
recipeData,
onPendingChange,
productCode,
refPage
}: { recipeData: any; onPendingChange: any; refPage: string } = $props();
}: { recipeData: any; onPendingChange: any; productCode: string; refPage: string } = $props();
let menuName: string = $state('');
@ -126,7 +128,9 @@
}
}
async function saveRecipe() {}
async function saveRecipe() {
recipeDetailDispatch('saveRecipe');
}
async function sendTriggerBrewNow() {
// check queue ready
@ -155,8 +159,8 @@
}
}
async function checkChanges(original: any) {
console.log('old', original, 'updated', recipeListMatState);
async function checkChanges(productCode: string, original: any) {
// console.log('old', original, 'updated', recipeListMatState);
if (recipeListOriginal.length == 0) {
recipeListOriginal = original;
}
@ -164,6 +168,7 @@
if (original !== recipeListMatState) {
await onPendingChange({
target: 'recipeList',
ref_pd: productCode,
value: original
});
}
@ -187,6 +192,8 @@
latestRecipeToppingData.set(toppingSlotState);
currentEditingRecipeProductCode.set(productCode);
// save old value\
}
});
@ -237,7 +244,12 @@
</Tabs.Content>
<Tabs.Content value="details">
<RecipelistTable data={recipeListMatState} {columns} onStateChange={checkChanges} />
<RecipelistTable
data={recipeListMatState}
{columns}
onStateChange={checkChanges}
{productCode}
/>
</Tabs.Content>
</Tabs.Root>
</div>

View file

@ -42,11 +42,13 @@
let {
data,
columns,
onStateChange
onStateChange,
productCode
}: {
data: RecipelistMaterial[];
columns: ColumnDef<any, any>[];
onStateChange: any;
productCode: string;
} = $props();
let sorting = $state<SortingState>([]);
@ -75,7 +77,10 @@
getFacetedRowModel: getFacetedRowModel(),
onStateChange: async (updater) => {
console.log('table state change', data);
await onStateChange(table.getRowModel().rows.map((x) => x.original));
await onStateChange(
productCode,
table.getRowModel().rows.map((x) => x.original)
);
},
onSortingChange: async (updater) => {
console.log('triggering sorting');
@ -84,7 +89,10 @@
} else {
sorting = updater;
}
await onStateChange(table.getRowModel().rows.map((x) => x.original));
await onStateChange(
productCode,
table.getRowModel().rows.map((x) => x.original)
);
},
onRowSelectionChange: async (updater) => {
// table.getRowModel().rows.find((x) => x.original.id == )
@ -92,11 +100,14 @@
rowSelection = updater(rowSelection);
let rows = table.getRowModel().rows;
console.log('state size', data, rows);
// console.log('state size', data, rows);
} else {
rowSelection = updater;
}
await onStateChange(table.getRowModel().rows.map((x) => x.original));
await onStateChange(
productCode,
table.getRowModel().rows.map((x) => x.original)
);
}
});
</script>

View file

@ -176,7 +176,7 @@
function saveEditingValue() {
console.log('saving value ...', value_event_state);
if (value_event_state === ValueEvent.EDITED) {
if (value_event_state === ValueEvent.EDITED || value_event_state === ValueEvent.SAVED) {
let payload = {
source: current_editing_data,
change: changed_data
@ -209,28 +209,36 @@
function handleToppingGroupChange(v: any) {
console.log('change topping group');
// TODO: clear topping list, otherwise, it will continue to append filter to list by each navigate back and forth
selected_category_id = v.groupID;
// get default
selected_topping_list_id = v.idDefault ?? 0;
// set to edit state even if selected same group again
value_event_state = ValueEvent.EDITED;
if (current_editing_data['toppings'] == undefined || current_editing_data['toppings'] == null) {
if (
current_editing_data['toppings'] !== undefined ||
current_editing_data['toppings'] !== null
) {
let toppings_length = current_editing_data['toppings'].length;
if (changed_data['toppings'] == undefined || changed_data['toppings'] == null) {
changed_data['toppings'] = new Array<any>(toppings_length);
// console.log('filling change topping', JSON.stringify(changed_data));
console.log('filling change topping', JSON.stringify(changed_data));
}
}
console.log('current editing data', current_editing_data['toppings']);
let idx = getToppingSlotIndex(current_editing_data['mat_id']);
// get old state topping idx
let current_selection = current_editing_data['toppings'][idx];
// let default_from_recipe = current_editing_data['toppings'][idx]['ListGroupID'][0];
// console.log(`Current TG: `, JSON.stringify(current_selection));
console.log(`Current TG: `, JSON.stringify(current_selection));
if (current_selection['groupID'] !== undefined || current_selection['groupID'] !== null) {
try {
changed_data['toppings'][idx] = current_selection;
@ -250,7 +258,10 @@
// set to edit state even if selected same group again
value_event_state = ValueEvent.EDITED;
let idx = getToppingSlotIndex(current_editing_data['mat_id']);
if (current_editing_data['toppings'] == undefined || current_editing_data['toppings'] == null) {
if (
current_editing_data['toppings'] !== undefined ||
current_editing_data['toppings'] !== null
) {
let toppings_length = current_editing_data['toppings'].length;
// case: topping not init
if (changed_data['toppings'] == null || changed_data['toppings'] == undefined) {
@ -590,12 +601,12 @@
</ScrollArea>
<!-- final -->
<!-- <Field.Field orientation="horizontal">
<Button type="button" onclick={() => saveEditingValue()}>Save</Button>
<Button variant="outline" type="button" onclick={() => (sheetOpenState = false)}
<Field.Field orientation="horizontal">
<Button type="button" onclick={() => saveEditingValue()}>Apply</Button>
<!-- <Button variant="outline" type="button" onclick={() => (sheetOpenState = false)}
>{warnUserNotSaveChange ? 'Discard Changes' : 'Cancel'}</Button
>
</Field.Field> -->
> -->
</Field.Field>
</Field.Group>
</form>
</div>

View file

@ -10,7 +10,7 @@
import Input from '../ui/input/input.svelte';
import Separator from '$lib/components/ui/separator/separator.svelte';
import Button from '../ui/button/button.svelte';
import { PencilIcon } from '@lucide/svelte/icons';
import { PencilIcon, Undo } from '@lucide/svelte/icons';
import * as Tooltip from '../ui/tooltip/index';
import {
latestRecipeToppingData,
@ -22,6 +22,9 @@
referenceFromPage
} from '$lib/core/stores/recipeStore';
import { get } from 'svelte/store';
import { sendMessage } from '$lib/core/handlers/ws_messageSender';
import { auth } from '$lib/core/stores/auth';
import { departmentStore } from '$lib/core/stores/departments';
let {
row_uid,
@ -51,11 +54,16 @@
// toppings
let currentToppings: any = $state([]);
let oldToppings: any = $state(null);
// current topping of this row
let currentToppingInRow: any = $state();
let selectableToppingInGroup: any[] = $state([]);
let currentToppingNamesOnly = $state('');
let oldToppingNamesOnly = $state(null);
let unsubRecipeDataEvent: any;
let unsubRecipeTopping: any;
function isToppingId(mat_id: string): boolean {
let mat_num = extractMaterialIdFromDisplay(mat_id);
@ -118,21 +126,54 @@
refFrom === 'overview' ? toppingListFromServerQuery : toppingListFromMachineQuery
);
// console.log(JSON.stringify(groupQuery[0]));
// console.log(JSON.stringify(listQuery[0]));
console.log('old topping', oldToppings[getToppingSlot()]);
console.log('current topping', current_row_topping);
oldToppingNamesOnly = null; // reset first
if (oldToppings[getToppingSlot()] != current_row_topping) {
console.log('detect change on topping row', row_uid);
let groupIDchange =
oldToppings[getToppingSlot()]['groupID'] !== current_row_topping['groupID'];
let defaultIDchange =
oldToppings[getToppingSlot()]['defaultIDSelect'] !== current_row_topping['defaultIDSelect'];
console.log('group change', groupIDchange, 'default id change', defaultIDchange);
if (!groupIDchange && !defaultIDchange) {
oldToppingNamesOnly = null;
} else {
// has change, display old name
let oldGroupData = groupQuery.find(
(x: any) => x.groupID.toString() === oldToppings[getToppingSlot()]['groupID']
);
let oldListData = listQuery.filter(
(x: any) =>
oldGroupData != undefined &&
Object.keys(oldGroupData).includes('idInGroup') &&
oldGroupData['idInGroup'].split(',').includes(x.id.toString())
);
oldToppingNamesOnly = oldGroupData['otherName'];
}
} else {
oldToppingNamesOnly = null;
}
let groupData = groupQuery.find(
(x: any) => x.groupID.toString() === current_row_topping['groupID']
);
let listData = listQuery.filter(
(x: any) =>
groupData &&
groupData != undefined &&
Object.keys(groupData).includes('idInGroup') &&
groupData['idInGroup'].split(',').includes(x.id.toString())
);
console.log('topping data', JSON.stringify(groupData), 'list', listData.length);
currentToppingNamesOnly = groupData ? (groupData['otherName'] ?? '-') : '-';
// NOTE: send topping data to value editor
currentToppingInRow =
current_row_topping['groupID'] === 0 || current_row_topping['groupID'] === '0'
? {
@ -178,13 +219,20 @@
}
function initialize() {
currentToppings = get(latestRecipeToppingData);
hasMixOrder = mix_order == 1;
extractStringParam();
if (isToppingId(mat_id) && onDetectToppingSlot) {
// FIXME: this should not be updated yet, must be updated after user pressed save only
let latest_topping_data_snapshot = $state.snapshot(get(latestRecipeToppingData));
currentToppings = latest_topping_data_snapshot;
// console.log('current topping in initialize', currentToppings);
if (oldToppings == null) {
// console.log('saving original topping', oldToppings);
oldToppings = $state.snapshot(currentToppings);
}
currentMaterialType = 'topping';
getToppingDisplay();
@ -200,9 +248,9 @@
// console.log('type get', mat_type_t1, mat_type_t2);
currentMaterialType = mat_type_t1 === mat_type_t2 ? mat_type_t1 : mat_type_t2;
if (hasMixOrder) {
console.log('detect mix order', mat_num);
}
// if (hasMixOrder) {
// console.log('detect mix order', mat_num);
// }
// if (feed.parameter > 0 || feed.pattern > 0) {
// console.log('has feed fields', JSON.stringify(feed));
@ -210,6 +258,19 @@
}
}
function applyChanges(value: any) {
let keys = Object.keys(value);
for (const key of keys) {
if (key == 'toppings' && currentMaterialType == 'topping') {
let topping_change = value[key][getToppingSlot()];
console.log('topping applying', topping_change);
currentToppings[getToppingSlot()] = topping_change;
} else {
}
}
}
function handleEvents(event: { event_type: string; payload: any; index: number | undefined }) {
// console.log('triggered event', event.event_type, JSON.stringify(event.payload));
if (event.event_type === 'mat_change') {
@ -217,6 +278,10 @@
initialize();
} else if (event.event_type === 'edit_mat_field') {
console.log('request edit mat');
// fix bug: topping instant change by unlink topping
let _current_toppings = $state.snapshot(currentToppings);
// pack all shown data
recipeDataEvent.set({
event_type: 'edit_mat_field_prep',
@ -225,7 +290,7 @@
mat_type: currentMaterialType,
mat_name: mat_id,
params: currentStringParams,
toppings: currentToppings,
toppings: _current_toppings,
current_topping_group: currentToppingInRow,
current_topping_list: selectableToppingInGroup,
has_mix_ord: hasMixOrder,
@ -244,6 +309,23 @@
// apply now
let keys = Object.keys(change_values);
console.log('change keys', JSON.stringify(keys));
let _current_toppings = $state.snapshot(currentToppings);
// initialize();
applyChanges(change_values);
event.payload = {
current_toppings: _current_toppings,
...event.payload
};
// console.log('check on change before trigger', change_values);
if (onEditValue) onEditValue(JSON.stringify(event.payload));
} else if (event.event_type === 'revert_change') {
// console.log('revert back ...', row_uid);
currentToppings = oldToppings;
}
}
@ -255,11 +337,23 @@
handleEvents(event);
}
});
unsubRecipeTopping = latestRecipeToppingData.subscribe((payload) => {
// console.log('topping data subscribe', payload);
});
});
onDestroy(() => {
if (unsubRecipeDataEvent) {
// do last event before destroy
// console.log('trigger last event before destroy', get(latestRecipeToppingData));
handleEvents(get(recipeDataEvent) ?? { event_type: '', payload: {}, index: -1 });
unsubRecipeDataEvent();
// console.log('destroy recipe data event listener');
}
if (unsubRecipeTopping) {
unsubRecipeTopping();
}
});
</script>
@ -275,6 +369,14 @@
{getCurrentSelectedToppingGroup()}
</b>, {getCurrentSelectedToppingList()}
</p>
{#if oldToppingNamesOnly != null}
<br />
<Button variant="outline" class="font-bold text-red-500">
<!-- old topping -->
<Undo />
<!-- {oldToppingNamesOnly} -->
</Button>
{/if}
</div>
</div>
{:else}

View file

@ -1,5 +1,11 @@
<script lang="ts">
import { recipeFromMachineQuery, recipeFromServerQuery } from '$lib/core/stores/recipeStore';
import {
currentEditingRecipeProductCode,
latestRecipeToppingData,
recipeFromMachineQuery,
recipeFromServerQuery,
referenceFromPage
} from '$lib/core/stores/recipeStore';
import { onMount } from 'svelte';
import { MediaQuery } from 'svelte/reactivity';
import { get } from 'svelte/store';
@ -12,6 +18,12 @@
import { addNotification } from '$lib/core/stores/noti';
import { sendToAndroid } from '$lib/core/stores/adbWriter';
import { env } from '$env/dynamic/public';
import { recipeDataEvent } from '$lib/core/stores/recipeStore';
import { sendMessage } from '$lib/core/handlers/ws_messageSender';
import { auth } from '$lib/core/stores/auth';
import { departmentStore } from '$lib/core/stores/departments';
const isDesktop = new MediaQuery('(min-width: 768px)');
let currentData: any = $state();
@ -30,7 +42,11 @@
let ready_to_send_brew: any[] = $state([]);
async function onPendingChange(newChange: { target: string; value: any }) {
let change_value_rpl: any = $state(null);
let callback_revert_value_if_not_save: any = $state(() => {});
let save_change: boolean = $state(false);
async function onPendingChange(newChange: { target: string; value: any; ref_pd: string }) {
console.log('detect pending change', matchMenuStatus(currentData.MenuStatus));
hasPendingChange = true;
@ -52,10 +68,65 @@
//
// TODO: build into structure, flatten fields into 1 layer, strip off `id` (row id)
console.log('pending change recipe list', newChange);
}
ready_to_send_brew = [];
ready_to_send_brew.push([env.PUBLIC_BREW_CURRENT_RECIPE, JSON.stringify(currentData)]);
let isMatchedProduct = newChange.ref_pd == productCode;
// get latest edit
let latest_event = get(recipeDataEvent);
// expect edit_change_value_rpl
console.log('latest data event', latest_event);
let topping_value_for_revert = latest_event?.payload.current_toppings;
let new_topping_value_for_save = latest_event?.payload.source.toppings;
// console.log(
// 'topping_data_latest',
// get(latestRecipeToppingData),
// 'topping_old',
// topping_value_for_revert,
// 'current product code',
// get(currentEditingRecipeProductCode)
// );
callback_revert_value_if_not_save = (save: any) => {
if (!save) {
latestRecipeToppingData.set(topping_value_for_revert);
console.log('revert change', get(latestRecipeToppingData));
recipeDataEvent.set({
event_type: 'revert_change',
payload: {},
index: -1
});
} else {
// topping part
latestRecipeToppingData.set(new_topping_value_for_save);
console.log('save change', get(latestRecipeToppingData));
currentData['ToppingSet'] = latestRecipeToppingData;
console.log('current data', currentData);
if (get(referenceFromPage) == 'brew') {
// send change to machine
sendToAndroid({
type: 'save_recipe_machine',
payload: {
time: new Date().toLocaleTimeString(),
data: currentData
}
});
} else if (get(referenceFromPage) == 'overview') {
sendMessage({
type: 'save_recipe',
payload: {
user: get(auth)?.displayName ?? 'unknown',
country: get(departmentStore) ?? 'unknown',
values: currentData
}
});
}
}
};
}
// await adb.push('/sdcard/coffeevending/.curr.brewing.json', JSON.stringify(currentData));
//
@ -82,7 +153,17 @@
}
}
function onCloseDialog() {
currentEditingRecipeProductCode.set('');
callback_revert_value_if_not_save(save_change);
// reset back
save_change = false;
callback_revert_value_if_not_save = () => {};
}
onMount(() => {
save_change = false;
//
if (refPage === 'brew') {
// fetch from store
@ -109,7 +190,7 @@
</script>
{#if isDesktop.current}
<Dialog.Root>
<Dialog.Root onOpenChangeComplete={(_cc) => onCloseDialog()}>
<Dialog.Trigger class="w-full text-start" onselect={(e) => e.preventDefault()}
>View</Dialog.Trigger
>
@ -126,8 +207,16 @@
<RecipeDetail
recipeData={currentData}
{onPendingChange}
{productCode}
{refPage}
on:brewNow={async () => sendBrewNow()}
on:saveRecipe={async () => {
save_change = true;
console.log('save change, check state', callback_revert_value_if_not_save);
addNotification('INFO:Save recipe');
}}
/>
</Dialog.Content>
</Dialog.Root>

View file

@ -44,6 +44,7 @@ export const recipeFromMachineQuery = writable<any>({});
export const materialFromMachineQuery = writable<any>({});
export const referenceFromPage = writable<string>('');
export const currentEditingRecipeProductCode = writable<string>('');
let worker: Worker | null = null;
let initialized = false;

View file

@ -2,7 +2,7 @@ import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import { get, writable } from 'svelte/store';
import { handleIncomingMessages } from '../handlers/messageHandler';
import { queue as msgQueue } from '../handlers/ws_messageSender';
import { queue as msgQueue, sendMessage } from '../handlers/ws_messageSender';
import { auth } from '../client/firebase';
import { addNotification } from './noti';
@ -31,6 +31,16 @@ export function connectToWebsocket() {
msgQueue.set(queue);
}
}
// heartbeat 10s
setInterval(() => {
if (socket) {
sendMessage({
type: 'heartbeat',
payload: {}
});
}
}, 10000);
});
socket.addEventListener('message', (event) => {

View file

@ -32,6 +32,14 @@ export type OutMessage =
values: any;
};
}
| {
type: 'save_recipe';
payload: {
user: string;
country: string;
values: any;
};
}
| {
type: 'heartbeat';
payload: {};