This commit is contained in:
thanawat saiyota 2026-03-26 15:47:02 +07:00
commit f4b8df2c27
29 changed files with 693 additions and 97 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

View file

@ -11,7 +11,8 @@
DiamondIcon,
BugIcon,
CupSodaIcon,
Shield
Shield,
FileSpreadsheet
} from '@lucide/svelte/icons';
import TaobinLogo from '$lib/assets/logo.svelte';
import { goto } from '$app/navigation';
@ -19,6 +20,7 @@
import { sidebarStore } from '$lib/core/stores/sidebar';
import { auth } from '$lib/core/stores/auth';
import { isUserAdmin } from '$lib/core/admin/adminService';
import { referenceFromPage } from '$lib/core/stores/recipeStore';
let sideBar: HTMLElement | null = $state(null);
let isSideBarOpen: boolean = $state(true);
@ -70,6 +72,17 @@
icon: BugIcon
}
]
},
{
title: 'Sheet',
items: [
{
title: 'Overview',
url: '/departments',
icon: FileSpreadsheet
}
]
}
]
};
@ -138,7 +151,17 @@
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props })}
<a href={sub.url} {...props}>
<a
href={sub.url}
{...props}
onclick={(e) => {
if (nav.title === 'Sheet') {
e.preventDefault();
referenceFromPage.set('sheet');
goto(sub.url);
}
}}
>
{#if sub.icon}
<sub.icon />
{/if}
@ -162,7 +185,17 @@
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props })}
<a href={sub.url} {...props}>
<a
href={sub.url}
{...props}
onclick={(e) => {
if (nav.title === 'Sheet') {
e.preventDefault();
referenceFromPage.set('sheet');
goto(sub.url);
}
}}
>
{#if sub.icon}
<sub.icon />
{/if}

View file

@ -112,7 +112,14 @@ export const columns: ColumnDef<RecipelistMaterial>[] = [
row_uid: row.original.id,
mat_id: row.original.material_id,
onEditValue: (changes: any) => {
recipeDataEvent.set({
event_type: 'edit_change_value_rpl',
payload: changes,
index: row.original.id
});
// get change parameters
row.toggleSelected(row.original.is_use);
},
onDetectMixOrder: () => {
// set next

View file

@ -20,13 +20,23 @@
import { onMount } from 'svelte';
import { addNotification } from '$lib/core/stores/noti';
import { get } from 'svelte/store';
import { ValueEvent } from './value_event';
import { actionReport, ValueEvent } from './value_event';
import ScrollArea from '../ui/scroll-area/scroll-area.svelte';
import { sendMessage } from '$lib/core/handlers/ws_messageSender';
import { auth } from '$lib/core/stores/auth';
import { departmentStore } from '$lib/core/stores/departments';
import { machineInfoStore } from '$lib/core/stores/machineInfoStore';
import type { setDefaultAutoSelectFamily } from 'node:net';
let { row_id }: { row_id: number } = $props();
let current_editing_data: any = $state();
let changed_data: any = $state();
let changed_data: any = $state({});
let sheetOpenState = $state(false);
let currentRef = $state('');
let warnUserNotSaveChange = $state(false);
// --------------------------------------------------
@ -76,7 +86,7 @@
// update value, do re-render
if (event.payload) {
current_editing_data = event.payload;
console.log(`GET requested data: ${JSON.stringify(current_editing_data)}`);
// console.log(`GET requested data: ${JSON.stringify(current_editing_data)}`);
// default topping
if (
@ -105,11 +115,15 @@
current_editing_data.water.yield > 0 || current_editing_data.water.cold > 0;
toggledOpenFeed =
current_editing_data.feed.pattern > 0 || current_editing_data.feed.parameter > 0;
sheetOpenState = true;
}
}
}
function requestDataFromDisplay() {
// pause show until have data
sheetOpenState = false;
console.log('sending request edit', row_id);
recipeDataEvent.set({
event_type: 'edit_mat_field',
@ -120,13 +134,12 @@
setTimeout(() => {
if (current_editing_data === undefined) {
addNotification('ERR:Unable to edit');
sheetOpenState = false;
}
}, 5000);
}
function onFieldValueChange(field_name: string[], new_value: any) {
console.log('change on', JSON.stringify(field_name), new_value);
// validate if field value changes
let curr_val;
for (let field of field_name) {
@ -147,43 +160,133 @@
if (has_changed) {
if (value_event_state === ValueEvent.NONE) {
value_event_state = ValueEvent.EDITED;
// save change
let single_key = '';
for (let field of field_name) {
single_key += field + '_';
}
single_key = single_key.slice(0, single_key.length - 1);
console.log('save to key', single_key);
changed_data[single_key] = new_value;
}
// save change
let single_key = '';
for (let field of field_name) {
single_key += field + '_';
}
single_key = single_key.slice(0, single_key.length - 1);
changed_data[single_key] = new_value;
} else {
// revert value in key
}
}
function saveEditingValue() {
console.log('saving value ...');
console.log('saving value ...', value_event_state);
if (value_event_state === ValueEvent.EDITED) {
let payload = {
source: current_editing_data,
change: changed_data
};
recipeDataEvent.set({
event_type: 'save_mat_field',
payload: current_editing_data,
payload,
index: row_id
});
value_event_state = ValueEvent.SAVED;
actionReport(
'change_mat_field',
{
index: row_id,
...payload
},
currentRef
);
sheetOpenState = false;
} else {
// set noti
addNotification(`WARN:Cannot save, please retry ...`);
}
}
function handleToppingGroupChange(v: any) {
console.log('change topping group', JSON.stringify(v));
console.log('change topping group');
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']) {
let toppings_length = current_editing_data['toppings'].length;
if (!changed_data['toppings']) {
changed_data['toppings'] = new Array<any>(toppings_length);
// console.log('filling change topping', JSON.stringify(changed_data));
}
}
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));
if (current_selection['groupID'] !== undefined || current_selection['groupID'] !== null) {
try {
changed_data['toppings'][idx] = current_selection;
changed_data['toppings'][idx]['groupID'] = `${selected_category_id}`;
changed_data['toppings'][idx]['defaultIDSelect'] = selected_topping_list_id;
changed_data['toppings'][idx]['ListGroupID'][0] = selected_topping_list_id;
} catch (topping_group_exception) {
console.error('Error on topping group select', topping_group_exception);
}
}
}
function handleToppingListChange(v: any) {
console.log('Topping list chose: ', JSON.stringify(v));
console.log('Topping list chose ');
selected_topping_list_id = v.id;
// 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']) {
let toppings_length = current_editing_data['toppings'].length;
// case: topping not init
if (!changed_data['toppings']) {
changed_data['toppings'] = new Array<any>(toppings_length);
}
}
let current_selection = current_editing_data['toppings'][idx];
if (current_selection['groupID'] !== undefined || current_selection['groupID'] !== null) {
try {
changed_data['toppings'][idx] = current_selection;
// changed_data['toppings'][idx]['groupID'] = `${selected_category_id}`;
changed_data['toppings'][idx]['defaultIDSelect'] = selected_topping_list_id;
changed_data['toppings'][idx]['ListGroupID'][0] = selected_topping_list_id;
} catch (topping_list_exception) {
console.error('Error on topping group select', topping_list_exception);
}
}
}
function beforeClosing() {
if (value_event_state === ValueEvent.EDITED) {
if (warnUserNotSaveChange) {
// Discard all
warnUserNotSaveChange = false;
// sheetOpenState = false;
return true;
} else {
// show no save warning
warnUserNotSaveChange = true;
return false;
}
} else {
sheetOpenState = false;
return true;
}
}
// recipelist-value-editor.svelte?t=1773541064272:57 GET requested data: {"mat_id":1002,"mat_type":"bean","mat_name":"medium-roasts (1002)","params":{"esp-v2-press-value":"24"},"toppings":[{"ListGroupID":[1,0,0,0],"defaultIDSelect":1,"groupID":"1","isUse":true},{"ListGroupID":[6,0,0,0],"defaultIDSelect":31,"groupID":"6","isUse":true},{"ListGroupID":[7,0,0,0],"defaultIDSelect":33,"groupID":"7","isUse":true},{"ListGroupID":[0,0,0,0],"defaultIDSelect":0,"groupID":"0","isUse":false},{"ListGroupID":[0,0,0,0],"defaultIDSelect":0,"groupID":"0","isUse":false},{"ListGroupID":[0,0,0,0],"defaultIDSelect":0,"groupID":"0","isUse":false},{"ListGroupID":[530,0,0,0],"defaultIDSelect":532,"groupID":"530","isUse":true},{"ListGroupID":[500,0,0,0],"defaultIDSelect":502,"groupID":"500","isUse":true}],"current_topping_list":[],"has_mix_ord":false,"feed":{"pattern":0,"parameter":0},"water":{"cold":0,"yield":30},"powder":{"gram":7,"time":9},"syrup":{"gram":0,"time":0},"stir_time":120}
@ -192,6 +295,9 @@
// recipelist-value-editor.svelte?t=1773541064272:57 GET requested data: {"mat_id":9501,"mat_type":"cup","mat_name":"CUP paper (9501)","params":{},"toppings":[{"ListGroupID":[1,0,0,0],"defaultIDSelect":1,"groupID":"1","isUse":true},{"ListGroupID":[6,0,0,0],"defaultIDSelect":31,"groupID":"6","isUse":true},{"ListGroupID":[7,0,0,0],"defaultIDSelect":33,"groupID":"7","isUse":true},{"ListGroupID":[0,0,0,0],"defaultIDSelect":0,"groupID":"0","isUse":false},{"ListGroupID":[0,0,0,0],"defaultIDSelect":0,"groupID":"0","isUse":false},{"ListGroupID":[0,0,0,0],"defaultIDSelect":0,"groupID":"0","isUse":false},{"ListGroupID":[530,0,0,0],"defaultIDSelect":532,"groupID":"530","isUse":true},{"ListGroupID":[500,0,0,0],"defaultIDSelect":502,"groupID":"500","isUse":true}],"current_topping_list":[],"has_mix_ord":false,"feed":{"pattern":0,"parameter":0},"water":{"cold":0,"yield":0},"powder":{"gram":0,"time":0},"syrup":{"gram":0,"time":0},"stir_time":0}
onMount(() => {
sheetOpenState = false;
console.log('sheet open? ', sheetOpenState);
let refFrom = get(referenceFromPage);
categories = get(
refFrom === 'overview' ? toppingGroupFromServerQuery : toppingGroupFromMachineQuery
@ -200,6 +306,9 @@
refFrom === 'overview' ? toppingListFromServerQuery : toppingListFromMachineQuery
);
// save ref
currentRef = refFrom;
return recipeDataEvent.subscribe((event) => {
if (event !== null && event.index !== undefined && event.index === row_id) {
handleEvents(event);
@ -208,7 +317,16 @@
});
</script>
<Sheet.Root>
<Sheet.Root
bind:open={sheetOpenState}
onOpenChange={(next) => {
if (!next) {
beforeClosing();
} else {
sheetOpenState = true;
}
}}
>
<Sheet.Trigger>
<Button
variant="default"
@ -256,6 +374,15 @@
</Field.Set>
<ScrollArea class="h-[60vh] w-full" type="always">
{#if warnUserNotSaveChange}
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-lg font-bold text-red-600">Unsaved changes may be lost.</h1>
<p class="text-balance text-red-700">
Click on "Discard Changes" to revert back values
</p>
</div>
{/if}
<!-- topping layout -->
<div
class={current_editing_data.mat_type === 'topping'
@ -487,10 +614,13 @@
</Field.Group>
</Field.Set>
</ScrollArea>
<!-- final -->
<Field.Field orientation="horizontal">
<Button type="button" onclick={() => saveEditingValue()}>Save</Button>
<Button variant="outline" type="button">Cancel</Button>
<Button variant="outline" type="button" onclick={() => beforeClosing()}
>{warnUserNotSaveChange ? 'Discard Changes' : 'Cancel'}</Button
>
</Field.Field>
</Field.Group>
</form>

View file

@ -170,7 +170,7 @@
if (currentToppings[getToppingSlot()]['ListGroupID'][0] === 0) {
return 'Empty';
}
if (!current_selected) {
if (current_selected === undefined || current_selected === null) {
return 'Unknown';
}
@ -237,11 +237,14 @@
},
index: row_uid
});
}
}
} else if (event.event_type === 'save_mat_field') {
console.log('receive saving process mat, do refresh...');
let change_values = event.payload.change;
function triggerEditChange(value: any) {
console.log('triggered on change editing', JSON.stringify(value));
// apply now
let keys = Object.keys(change_values);
console.log('change keys', JSON.stringify(keys));
}
}
onMount(() => {

View file

@ -1,6 +1,41 @@
import { get } from 'svelte/store';
import { auth } from '$lib/core/stores/auth';
import { departmentStore } from '$lib/core/stores/departments';
import { machineInfoStore } from '$lib/core/stores/machineInfoStore';
import { addNotification } from '$lib/core/stores/noti';
import { sendMessage } from '$lib/core/handlers/ws_messageSender';
enum ValueEvent {
NONE,
EDITED
EDITED,
SAVED
}
export { ValueEvent };
function actionReport(action_name: string, values: any, currentRef: string) {
let country = get(departmentStore) ?? 'unknown dep';
if (currentRef === 'brew') {
// from machine
let machine_info = get(machineInfoStore);
if (machine_info) {
let bid = machine_info.boxId ?? 'BOX_UNK';
let cc = machine_info.country;
country = `${bid}-${cc}`;
} else {
addNotification('WARN:Saving as unknown department, please check setting on machine');
}
}
sendMessage({
type: 'log_report',
payload: {
user: get(auth)?.email ?? 'unknown',
action: action_name,
country,
values
}
});
}
export { ValueEvent, actionReport };

View file

@ -48,7 +48,7 @@
// currentData.recipes = newChange.value;
//
// TODO: build into structure, flatten fields into 1 layer, strip off `id` (row id)
console.log(newChange);
console.log('pending change recipe list', newChange);
}
// await adb.push('/sdcard/coffeevending/.curr.brewing.json', JSON.stringify(currentData));
@ -103,4 +103,6 @@
<RecipeDetail recipeData={currentData} {onPendingChange} {refPage} />
</Dialog.Content>
</Dialog.Root>
{:else}{/if}
{:else}
<!-- TODO: handle case open on mobile? -->
{/if}

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import {
buttonVariants,
type ButtonVariant,
type ButtonSize,
} from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
size = "default",
...restProps
}: AlertDialogPrimitive.ActionProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-action", className)}
{...restProps}
/>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import {
buttonVariants,
type ButtonVariant,
type ButtonSize,
} from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "outline",
size = "default",
...restProps
}: AlertDialogPrimitive.CancelProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-cancel", className)}
{...restProps}
/>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import AlertDialogPortal from "./alert-dialog-portal.svelte";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
size = "default",
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
size?: "default" | "sm";
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
} = $props();
</script>
<AlertDialogPortal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
data-size={size}
class={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...restProps}
/>
</AlertDialogPortal>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3", className)}
{...restProps}
/>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-footer"
class={cn(
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-media"
class={cn("bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-dialog-overlay"
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
{...restProps}
/>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
</script>
<AlertDialogPrimitive.Portal {...restProps} />

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn("text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className)}
{...restProps}
/>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script>
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
</script>
<AlertDialogPrimitive.Root bind:open {...restProps} />

View file

@ -0,0 +1,40 @@
import Root from "./alert-dialog.svelte";
import Portal from "./alert-dialog-portal.svelte";
import Trigger from "./alert-dialog-trigger.svelte";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
import Media from "./alert-dialog-media.svelte";
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Media,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
Media as AlertDialogMedia,
};

View file

@ -4,25 +4,25 @@
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {

View file

@ -35,7 +35,7 @@ const handlers: Record<string, (payload: any) => void> = {
if (stream_id) {
addNotification('INFO:Start streaming data');
recipeLoading.set(true);
// recipeLoading.set(true);
recipeStreamMeta.set({
id: stream_id,
total_size: total_size,
@ -82,6 +82,7 @@ const handlers: Record<string, (payload: any) => void> = {
if (percent == 100) {
addNotification(`INFO:Current progress ${percent}%`);
}
buildOverviewFromServer();
}
}
},

View file

@ -9,6 +9,8 @@ export function sendMessage(msg: OutMessage): boolean {
const socket = get(socketStore);
const data = JSON.stringify(msg);
// console.log('try sending ', data);
if (!socket || socket.readyState !== WebSocket.OPEN) {
console.warn('WebSocket not connected, put to queue');

View file

@ -7,49 +7,56 @@ import { auth } from '../client/firebase';
import { addNotification } from './noti';
let socket: WebSocket | null = null;
const ENABLE_WS_DEBUG: boolean = false;
export const socketStore = writable<WebSocket | null>(null);
export function connectToWebsocket() {
if (browser) {
console.log('connecting to ', env.PUBLIC_WSS);
socket = new WebSocket(`${env.PUBLIC_WSS}`);
// console.log('connecting to ', env.PUBLIC_WSS);
try {
socket = new WebSocket(`${env.PUBLIC_WSS}`);
socket.addEventListener('open', () => {
socketStore.set(socket);
addNotification('INFO:Connected!');
socket.addEventListener('open', () => {
socketStore.set(socket);
addNotification('INFO:Connected!');
// recover messages on connect, flushing
while (get(msgQueue).length) {
let queue = get(msgQueue);
let current = queue.shift();
if (current && socket) {
socket.send(current);
// set next
msgQueue.set(queue);
// recover messages on connect, flushing
while (get(msgQueue).length) {
let queue = get(msgQueue);
let current = queue.shift();
if (current && socket) {
socket.send(current);
// set next
msgQueue.set(queue);
}
}
});
socket.addEventListener('message', (event) => {
handleIncomingMessages(event.data);
});
socket.addEventListener('close', () => {
socketStore.set(null);
socket = null;
if (auth.currentUser) {
// console.log('try reconnect websocket ...');
// retry again
setTimeout(() => connectToWebsocket(), 5000);
}
});
socket.addEventListener('error', (e) => {
// console.log('WebSocket error: ', e);
socketStore.set(null);
});
} catch (socket_error: any) {
if (ENABLE_WS_DEBUG) {
console.error('WS_ERR', socket_error);
}
});
socket.addEventListener('message', (event) => {
handleIncomingMessages(event.data);
});
socket.addEventListener('close', () => {
socketStore.set(null);
socket = null;
if (auth.currentUser) {
console.log('try reconnect websocket ...');
// retry again
setTimeout(() => connectToWebsocket(), 5000);
}
});
socket.addEventListener('error', (e) => {
console.log('WebSocket error: ', e);
socketStore.set(null);
});
}
return () => {
if (socket?.readyState === WebSocket.OPEN) {

View file

@ -22,4 +22,13 @@ export type OutMessage =
permissions: string;
};
};
}
| {
type: 'log_report';
payload: {
user: string;
action: string;
country: string;
values: any;
};
};

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { asset } from '$app/paths';
import { permission as currentPerms } from '$lib/core/stores/permissions';
import { page } from '$app/stores';
import { get } from 'svelte/store';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { departmentStore } from '$lib/core/stores/departments';
@ -9,9 +10,12 @@
import { addNotification } from '$lib/core/stores/noti';
import { browser } from '$app/environment';
import { setCookieOnNonBrowser } from '$lib/helpers/cookie';
import { referenceFromPage} from '$lib/core/stores/recipeStore';
let enabledAccessibleCountries: string[] = $state([]);
const refPage = get(referenceFromPage);
function onCountrySelected(cnt: string) {
departmentStore.set(cnt);
if (browser && 'cookieStore' in window) cookieStore.set('department', cnt);
@ -19,30 +23,37 @@
addNotification(`INFO:Selected ${cnt}`);
setTimeout(async () => {
console.log(get(departmentStore));
await goto('/recipe/overview');
if (refPage === 'sheet') {
await goto('/sheet/overview');
} else {
await goto('/recipe/overview');
}
}, 1000);
}
// read or write permission
let userCurrentPerms = get(currentPerms).filter(
(x) => x.startsWith('document.read') || x.startsWith('document.write')
);
let userCurrentPerms = get(currentPerms).filter((x) => {
if (refPage === 'sheet') {
return x.startsWith('document.write');
}
return x.startsWith('document.read');
});
// show country by enabled `document.read.{country}`
enabledAccessibleCountries = userCurrentPerms
.filter((x) => x.startsWith('document.read'))
.map((x) => x.split('.')[2]);
enabledAccessibleCountries = userCurrentPerms.map((x) => x.split('.')[2]);
// update every 30s
if (enabledAccessibleCountries.length == 0) {
setTimeout(() => {
// read or write permission
let userCurrentPerms = get(currentPerms).filter(
(x) => x.startsWith('document.read') || x.startsWith('document.write')
);
let userCurrentPerms = get(currentPerms).filter((x) => {
if (refPage === 'sheet') {
return x.startsWith('document.write');
}
return x.startsWith('document.read');
});
// show country by enabled `document.read.{country}`
enabledAccessibleCountries = userCurrentPerms
.filter((x) => x.startsWith('document.read'))
.map((x) => x.split('.')[2]);
enabledAccessibleCountries = userCurrentPerms.map((x) => x.split('.')[2]);
if (enabledAccessibleCountries.length == 1) {
onCountrySelected(enabledAccessibleCountries[0]);

View file

@ -1,6 +1,7 @@
<script lang="ts">
import RecipeModuleBtn from '$lib/assets/modules/recipe_btn.png';
import MachineInspectBtn from '$lib/assets/modules/monitoring_btn.png';
import SheetModuleBtn from '$lib/assets/modules/sheet_btn.png';
import { animate, JSAnimation, remove as removeAnime, stagger } from 'animejs';
import { goto } from '$app/navigation';
import Button from '$lib/components/ui/button/button.svelte';
@ -9,9 +10,11 @@
import { permission as currentPermissions } from '$lib/core/stores/permissions';
import { get } from 'svelte/store';
import { onMount } from 'svelte';
import { referenceFromPage} from '$lib/core/stores/recipeStore';
let recipeModBtn = $state<HTMLElement | null>(null);
let monitorModBtn = $state<HTMLElement | null>(null);
let sheetModBtn = $state<HTMLElement | null>(null);
let gotoDashboardBtn = $state<HTMLElement | null>(null);
@ -78,6 +81,36 @@
>
<img src={RecipeModuleBtn} alt="Recipes" loading="lazy" />
</button>
<button
class="button"
id="sheet_mod_btn"
bind:this={sheetModBtn}
onclick={() => {
referenceFromPage.set('sheet');
goto('/departments');
}}
onmouseenter={() => {
if (sheetModBtn) {
animate(sheetModBtn, {
scale: 1.1,
duration: 300,
ease: 'inOutSine'
});
}
}}
onmouseleave={() => {
if (sheetModBtn) {
animate(sheetModBtn, {
scale: 1.0,
duration: 200,
ease: 'inOutSine'
});
}
}}
>
<img src={SheetModuleBtn} alt="Sheets" loading="lazy" />
</button>
{/if}
<!-- need permission `tools` -->

View file

@ -50,11 +50,11 @@
// schedule check if recipe is empty
if (data.recipes.length == 0) {
console.log('loading recipe ....');
recipeLoading.set(true);
// recipeLoading.set(true);
// empty
await getRecipes();
setTimeout(() => recipeLoading.set(false), 3000);
// setTimeout(() => recipeLoading.set(false), 3000);
}
}, 30000);
return () => {

View file

@ -0,0 +1,62 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import { SearchIcon } from '@lucide/svelte/icons';
import { onDestroy, onMount } from 'svelte';
import {
loadRecipe,
recipeData,
recipeFromServerQuery,
recipeOverviewData,
referenceFromPage
} from '$lib/core/stores/recipeStore.js';
import { sendMessage } from '$lib/core/handlers/ws_messageSender.js';
import { auth } from '$lib/core/stores/auth.js';
import { get } from 'svelte/store';
import { getRecipes } from '$lib/core/client/server.js';
import { departmentStore } from '$lib/core/stores/departments';
let refDepartment: string | undefined = $state();
onMount(async () => {
// do load recipe
// loadRecipe();
refDepartment = get(departmentStore);
referenceFromPage.set('overview');
// await getRecipes();
});
// onDestroy(() => {
// unsubRecipeData();
// });
</script>
<div class="mx-8 flex">
<!-- header -->
<div class="w-full">
<div class="mb-4 flex items-center justify-between">
<div>
<h1 class="m-8 text-4xl font-bold">Layout overview [ {refDepartment} ]</h1>
<p class="mx-8 my-0 text-muted-foreground">
Display menus from the spreadsheet current selected country
</p>
</div>
<div class="mx-8 my-4 flex gap-2">
<Button variant="default">+ Create Menu</Button>
</div>
</div>
<!-- search bar -->
<!-- <div class="mx-4 my-8 flex w-full items-center justify-center gap-2">
<SearchIcon />
<Input type="text" placeholder="Search by id, product code, name or material" class="" />
</div> -->
<!-- filter -->
<!-- table -->
<!-- <div class="w-full overflow-auto">
<DataTable data={data.recipes} refPage="overview" {columns} />
</div> -->
</div>
</div>