1436 lines
45 KiB
Svelte
1436 lines
45 KiB
Svelte
<script lang="ts">
|
|
import { logger } from '$lib/core/utils/logger';
|
|
import { onDestroy, 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 { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
|
import { addNotification } from '$lib/core/stores/noti';
|
|
import { materialFromServerQuery, referenceFromPage } from '$lib/core/stores/recipeStore';
|
|
import { getRecipes } from '$lib/core/client/server';
|
|
import type { Material } from '$lib/models/material.model';
|
|
import { get } from 'svelte/store';
|
|
import { permission } from '$lib/core/stores/permissions';
|
|
import { departmentStore } from '$lib/core/stores/departments';
|
|
|
|
const sourceDir = '/sdcard/coffeevending';
|
|
const recipePaths = [`${sourceDir}/cfg/recipe_branch_dev.json`, `${sourceDir}/coffeethai02.json`];
|
|
const machineCountryShortPath = '/mnt/sdcard/coffeevending/country/short';
|
|
const machineCounterStrikeModePath = '/mnt/sdcard/coffeevending/enable_counter_strike_mode';
|
|
const countryMaterialPrefixes: Record<string, number> = {
|
|
aus: 51,
|
|
gbr: 55,
|
|
hkg: 54,
|
|
ltu: 59,
|
|
mys: 12,
|
|
rou: 56,
|
|
sgp: 52,
|
|
tha: 0,
|
|
uae_dubai: 53
|
|
};
|
|
|
|
type MaterialChannel =
|
|
| 'BeanChannel'
|
|
| 'PowderChannel'
|
|
| 'SyrupChannel'
|
|
| 'FreshSyrupChannel'
|
|
| 'FrozenFruitChannel'
|
|
| 'LeavesChannel'
|
|
| 'SodaChannel'
|
|
| 'ItemChannel'
|
|
| 'IceScreamBingsuChannel'
|
|
| 'Water'
|
|
| 'Ice'
|
|
| 'Cup'
|
|
| 'Lid'
|
|
| 'Straw'
|
|
| 'Whipper'
|
|
| 'VegetableForFry'
|
|
| 'MeatForFry'
|
|
| 'FruitForFry'
|
|
| 'NoChannel';
|
|
|
|
type MaterialChannelFlag =
|
|
| '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;
|
|
};
|
|
|
|
const channelOptions: {
|
|
value: MaterialChannel;
|
|
label: string;
|
|
typeId: number;
|
|
channelFlag?: MaterialChannelFlag;
|
|
canisterType: string;
|
|
unit: string;
|
|
ranges: { start: number; end: number; prefixable: boolean }[];
|
|
}[] = [
|
|
{
|
|
value: 'BeanChannel',
|
|
label: 'Bean',
|
|
typeId: 1,
|
|
channelFlag: 'BeanChannel',
|
|
canisterType: 'BeanType',
|
|
unit: 'refill=$bag,sum=#gram,rec=$gram',
|
|
ranges: [
|
|
{ start: 1001, end: 1009, prefixable: true },
|
|
{ start: 1100, end: 1199, prefixable: true }
|
|
]
|
|
},
|
|
{
|
|
value: 'PowderChannel',
|
|
label: 'Powder',
|
|
typeId: 4,
|
|
channelFlag: 'PowderChannel',
|
|
canisterType: 'PowderType',
|
|
unit: 'refill=$bag,sum=$gram,rec=$gram',
|
|
ranges: [
|
|
{ start: 1040, end: 1080, prefixable: true },
|
|
{ start: 1300, end: 1399, prefixable: true }
|
|
]
|
|
},
|
|
{
|
|
value: 'SyrupChannel',
|
|
label: 'Syrup',
|
|
typeId: 2,
|
|
channelFlag: 'SyrupChannel',
|
|
canisterType: 'Bag In Box',
|
|
unit: 'refill=$bag,sum=$gram,rec=$gram',
|
|
ranges: [
|
|
{ start: 1032, end: 1039, prefixable: true },
|
|
{ start: 1020, end: 1030, prefixable: true },
|
|
{ start: 1200, end: 1299, prefixable: true },
|
|
{ start: 1400, end: 1499, prefixable: true }
|
|
]
|
|
},
|
|
{
|
|
value: 'FreshSyrupChannel',
|
|
label: 'Fresh Syrup',
|
|
typeId: 13,
|
|
channelFlag: 'FreshSyrupChannel',
|
|
canisterType: 'Tank',
|
|
unit: 'refill=$bag,sum=$gram,rec=$gram',
|
|
ranges: [{ start: 2201, end: 2299, prefixable: true }]
|
|
},
|
|
{
|
|
value: 'FrozenFruitChannel',
|
|
label: 'Frozen Fruit',
|
|
typeId: 17,
|
|
channelFlag: 'FrozenFruitChannel',
|
|
canisterType: '',
|
|
unit: 'refill=$L,sum=$ml,rec=$ml',
|
|
ranges: [{ start: 2301, end: 2399, prefixable: true }]
|
|
},
|
|
{
|
|
value: 'LeavesChannel',
|
|
label: 'Leaves',
|
|
typeId: 11,
|
|
channelFlag: 'LeavesChannel',
|
|
canisterType: '',
|
|
unit: 'refill=$bag,sum=#gram,rec=$gram',
|
|
ranges: [{ start: 1600, end: 1799, prefixable: true }]
|
|
},
|
|
{
|
|
value: 'SodaChannel',
|
|
label: 'Soda',
|
|
typeId: 3,
|
|
channelFlag: 'SodaChannel',
|
|
canisterType: '',
|
|
unit: 'refill=$L,sum=$ml,rec=$ml',
|
|
ranges: [
|
|
{ start: 1031, end: 1031, prefixable: false },
|
|
{ start: 2400, end: 2499, prefixable: true }
|
|
]
|
|
},
|
|
{
|
|
value: 'ItemChannel',
|
|
label: 'Item Box',
|
|
typeId: 18,
|
|
channelFlag: 'ItemChannel',
|
|
canisterType: '',
|
|
unit: 'refill=$cup,sum=$pcs,rec=$pcs',
|
|
ranges: [{ start: 2501, end: 2699, prefixable: true }]
|
|
},
|
|
{
|
|
value: 'IceScreamBingsuChannel',
|
|
label: 'Machine / Ice Cream',
|
|
typeId: 12,
|
|
channelFlag: 'IceScreamBingsuChannel',
|
|
canisterType: 'Machine',
|
|
unit: 'refill=$bag,sum=$gram,rec=$gram',
|
|
ranges: [{ start: 2100, end: 2199, prefixable: true }]
|
|
},
|
|
{
|
|
value: 'Water',
|
|
label: 'Water',
|
|
typeId: 5,
|
|
canisterType: '',
|
|
unit: 'refill=$L,sum=$ml,rec=$ml',
|
|
ranges: [{ start: 1, end: 1, prefixable: false }]
|
|
},
|
|
{
|
|
value: 'Ice',
|
|
label: 'Ice',
|
|
typeId: 6,
|
|
canisterType: '',
|
|
unit: 'refill=$bag,sum=$gram,rec=$gram',
|
|
ranges: [{ start: 9100, end: 9100, prefixable: false }]
|
|
},
|
|
{
|
|
value: 'Cup',
|
|
label: 'Cup',
|
|
typeId: 7,
|
|
canisterType: '',
|
|
unit: 'refill=$cup,sum=$pcs,rec=$pcs',
|
|
ranges: [{ start: 9500, end: 9549, prefixable: true }]
|
|
},
|
|
{
|
|
value: 'Lid',
|
|
label: 'Lid',
|
|
typeId: 8,
|
|
canisterType: '',
|
|
unit: 'refill=$lid,sum=$pcs,rec=$pcs',
|
|
ranges: [{ start: 9600, end: 9649, prefixable: true }]
|
|
},
|
|
{
|
|
value: 'Straw',
|
|
label: 'Straw',
|
|
typeId: 9,
|
|
canisterType: '',
|
|
unit: 'refill=$straw,sum=$pcs,rec=$pcs',
|
|
ranges: [{ start: 9700, end: 9749, prefixable: true }]
|
|
},
|
|
{
|
|
value: 'Whipper',
|
|
label: 'Whipper',
|
|
typeId: 10,
|
|
canisterType: 'Machine',
|
|
unit: '',
|
|
ranges: [
|
|
{ start: 8102, end: 8102, prefixable: false },
|
|
{ start: 8103, end: 8103, prefixable: false }
|
|
]
|
|
},
|
|
{
|
|
value: 'VegetableForFry',
|
|
label: 'Vegetable For Fry',
|
|
typeId: 14,
|
|
canisterType: '',
|
|
unit: '',
|
|
ranges: []
|
|
},
|
|
{
|
|
value: 'MeatForFry',
|
|
label: 'Meat For Fry',
|
|
typeId: 15,
|
|
canisterType: '',
|
|
unit: '',
|
|
ranges: []
|
|
},
|
|
{
|
|
value: 'FruitForFry',
|
|
label: 'Fruit For Fry',
|
|
typeId: 16,
|
|
canisterType: '',
|
|
unit: '',
|
|
ranges: []
|
|
},
|
|
{
|
|
value: 'NoChannel',
|
|
label: 'Topping / No Channel',
|
|
typeId: 0,
|
|
canisterType: '',
|
|
unit: '',
|
|
ranges: [{ start: 8110, end: 8129, prefixable: false }]
|
|
}
|
|
];
|
|
|
|
let devRecipe: any = $state(null);
|
|
let loadedRecipePath = $state('');
|
|
let loadedFromServer = $state(false);
|
|
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 editingMaterialId: number | null = $state(null);
|
|
let serverCountries: string[] = $state([]);
|
|
let selectedServerCountry = $state(get(departmentStore) ?? '');
|
|
let machineCountryShort = $state('');
|
|
let machineCounterStrikeMode = $state(false);
|
|
let serverCountryDialogOpen = $state(false);
|
|
let machineNotConnectedDialogOpen = $state(false);
|
|
let materialTypeDialogOpen = $state(false);
|
|
|
|
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 isEditingMaterial = $derived(editingMaterialId != null);
|
|
let duplicateMaterialOnCreate = $derived(!isEditingMaterial && existingMaterial != null);
|
|
let activeMaterialCount = $derived(
|
|
materials.filter((material) => (material.isUse as boolean) !== false).length
|
|
);
|
|
let canWriteToAndroid = $derived(!loadedFromServer && Boolean(adb.getAdbInstance()));
|
|
let primaryLanguageLabel = $derived(getPrimaryLanguageLabel(getActiveRecipeCountry()));
|
|
let materialListGridClass = 'md:grid-cols-[120px_minmax(0,1fr)_minmax(0,1fr)_150px_90px_150px]';
|
|
let selectableMaterialTypeOptions = $derived(
|
|
channelOptions
|
|
.filter(
|
|
(option) =>
|
|
[1, 2, 4].includes(option.typeId) || (option.typeId === 12 && machineCounterStrikeMode)
|
|
)
|
|
.sort((a, b) => [1, 2, 4, 12].indexOf(a.typeId) - [1, 2, 4, 12].indexOf(b.typeId))
|
|
);
|
|
let channelSummary = $derived(
|
|
channelOptions
|
|
.map((option) => ({
|
|
...option,
|
|
count: materials.filter((material) => {
|
|
if (!option.channelFlag) {
|
|
return getChannelForMaterialId(Number(material.id))?.value === option.value;
|
|
}
|
|
return Boolean(material[option.channelFlag]);
|
|
}).length
|
|
}))
|
|
.filter((option) => option.count > 0)
|
|
);
|
|
|
|
function getSupportedServerCountries(permissions: string[]) {
|
|
return [
|
|
...new Set(
|
|
permissions
|
|
.filter((item) => item.startsWith('document.read.'))
|
|
.map((item) => item.split('.')[2])
|
|
.filter(Boolean)
|
|
)
|
|
].sort();
|
|
}
|
|
|
|
function isThaiCountry(country: string) {
|
|
return ['tha', 'thai'].includes(country.trim().toLowerCase());
|
|
}
|
|
|
|
function getActiveRecipeCountry() {
|
|
return loadedFromServer ? selectedServerCountry : machineCountryShort;
|
|
}
|
|
|
|
function getPrimaryLanguageLabel(country: string) {
|
|
const normalized = country.trim().toUpperCase();
|
|
const languageByCountry: Record<string, string> = {
|
|
THAI: 'Thai',
|
|
THA: 'Thai',
|
|
MYS: 'Malay',
|
|
IDR: 'Indonesian',
|
|
AUS: 'English',
|
|
SGP: 'English',
|
|
SG: 'English',
|
|
UAE_DUBAI: 'Arabic',
|
|
DUBAI: 'Arabic',
|
|
HKG: 'Chinese',
|
|
GBR: 'English',
|
|
ROU: 'Romanian',
|
|
LVA: 'Latvian',
|
|
EST: 'Estonian',
|
|
LTU: 'Lithuanian',
|
|
USA_PEPSI: 'English'
|
|
};
|
|
|
|
return languageByCountry[normalized] ?? (normalized ? normalized : 'Primary');
|
|
}
|
|
|
|
function normalizeCountryShort(country: string) {
|
|
return country.trim().toLowerCase();
|
|
}
|
|
|
|
function getCountryMaterialPrefix(country: string) {
|
|
return countryMaterialPrefixes[normalizeCountryShort(country)] ?? 0;
|
|
}
|
|
|
|
function getActiveMaterialCountryPrefix() {
|
|
return loadedFromServer
|
|
? getCountryMaterialPrefix(selectedServerCountry)
|
|
: getCountryMaterialPrefix(machineCountryShort);
|
|
}
|
|
|
|
const unsubscribePermission = permission.subscribe((permissions) => {
|
|
serverCountries = getSupportedServerCountries(permissions);
|
|
|
|
if (!selectedServerCountry && serverCountries.length > 0) {
|
|
selectedServerCountry = serverCountries[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: ''
|
|
};
|
|
}
|
|
|
|
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 loadMachineCountryShort() {
|
|
const content = await pullTextWithRetry(machineCountryShortPath, 5000, 1);
|
|
return normalizeCountryShort(content ?? '');
|
|
}
|
|
|
|
async function hasMachineCounterStrikeMode() {
|
|
const content = await pullTextWithRetry(machineCounterStrikeModePath, 5000, 1);
|
|
return content != undefined;
|
|
}
|
|
|
|
async function connectAdb() {
|
|
try {
|
|
if (!adb.getAdbInstance()) {
|
|
if (!('usb' in navigator)) throw new Error('WebUSB not supported');
|
|
await adb.connectRecipeMenuViaWebUSB();
|
|
}
|
|
|
|
addNotification('INFO:Machine connected');
|
|
} catch (error: any) {
|
|
addNotification(`ERR:${error?.message ?? error}`);
|
|
}
|
|
}
|
|
|
|
async function loadFromMachineSource() {
|
|
if (!adb.getAdbInstance()) {
|
|
machineNotConnectedDialogOpen = true;
|
|
return;
|
|
}
|
|
|
|
await loadRecipeFromMachine();
|
|
}
|
|
|
|
async function waitForServerMaterials(timeoutMs = 20000, quietMs = 800) {
|
|
const current = get(materialFromServerQuery);
|
|
if (Array.isArray(current) && current.length > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, quietMs));
|
|
const latest = get(materialFromServerQuery);
|
|
if (Array.isArray(latest) && latest.length === current.length) return latest;
|
|
}
|
|
|
|
return await new Promise<any[]>((resolve) => {
|
|
let quietTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
const timeout = setTimeout(() => {
|
|
if (quietTimeout) clearTimeout(quietTimeout);
|
|
unsubscribe();
|
|
const latest = get(materialFromServerQuery);
|
|
resolve(Array.isArray(latest) ? latest : []);
|
|
}, timeoutMs);
|
|
|
|
const unsubscribe = materialFromServerQuery.subscribe((value) => {
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
if (quietTimeout) clearTimeout(quietTimeout);
|
|
quietTimeout = setTimeout(() => {
|
|
clearTimeout(timeout);
|
|
unsubscribe();
|
|
resolve(value);
|
|
}, quietMs);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadRecipeFromServer() {
|
|
if (loading) return;
|
|
if (!selectedServerCountry) {
|
|
addNotification('ERR:Select country before loading from server');
|
|
return;
|
|
}
|
|
|
|
loading = true;
|
|
loadedFromServer = false;
|
|
machineCountryShort = '';
|
|
machineCounterStrikeMode = false;
|
|
referenceFromPage.set('material');
|
|
departmentStore.set(selectedServerCountry);
|
|
materialFromServerQuery.set([]);
|
|
try {
|
|
await getRecipes();
|
|
const serverMaterials = await waitForServerMaterials();
|
|
if (serverMaterials.length === 0) {
|
|
addNotification('ERR:Cannot fetch material data from server');
|
|
return;
|
|
}
|
|
|
|
devRecipe = { MaterialSetting: serverMaterials };
|
|
loadedRecipePath = `Server recipe (${selectedServerCountry})`;
|
|
loadedFromServer = true;
|
|
setNextAvailableId();
|
|
addNotification('INFO:Material data loaded from server');
|
|
} catch (error: any) {
|
|
console.error('failed to load materials from server', error);
|
|
addNotification(`ERR:Failed to load materials from server: ${error?.message ?? error}`);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function openServerCountryDialog() {
|
|
if (serverCountries.length === 0) {
|
|
addNotification('ERR:No readable countries');
|
|
return;
|
|
}
|
|
|
|
serverCountryDialogOpen = true;
|
|
}
|
|
|
|
async function selectServerCountry(country: string) {
|
|
selectedServerCountry = country;
|
|
serverCountryDialogOpen = false;
|
|
await loadRecipeFromServer();
|
|
}
|
|
|
|
async function loadRecipeFromMachine() {
|
|
if (loading) return;
|
|
if (!adb.getAdbInstance()) {
|
|
addNotification('ERR:ADB is not connected');
|
|
return;
|
|
}
|
|
|
|
loading = true;
|
|
loadedFromServer = false;
|
|
referenceFromPage.set('material');
|
|
try {
|
|
machineCountryShort = await loadMachineCountryShort();
|
|
machineCounterStrikeMode = await hasMachineCounterStrikeMode();
|
|
if (!machineCountryShort) addNotification('WARN:Cannot read machine country short');
|
|
|
|
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) {
|
|
logger.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 nextId = getNextAvailableIdForChannel(form.channel);
|
|
form.id = nextId ?? getDefaultIdForChannel(form.channel) ?? form.id;
|
|
}
|
|
|
|
function getNextAvailableIdForChannel(channel: MaterialChannel) {
|
|
const preset = channelOptions.find((option) => option.value === channel);
|
|
if (!preset) return null;
|
|
|
|
const usedIds = materials.map((material) => Number(material.id)).filter(Number.isFinite);
|
|
const countryPrefix = getActiveMaterialCountryPrefix();
|
|
for (const range of preset.ranges) {
|
|
const { start, end, prefixable } = range;
|
|
const usedRangeIds = usedIds
|
|
.filter((id) => {
|
|
if (countryPrefix > 0 && prefixable) return Math.floor(id / 10000) === countryPrefix;
|
|
return id < 10000;
|
|
})
|
|
.map(getMaterialBaseId)
|
|
.filter((id) => id >= start && id <= end);
|
|
|
|
const nextBaseId = usedRangeIds.length > 0 ? Math.max(...usedRangeIds) + 1 : start;
|
|
if (nextBaseId <= end) {
|
|
return countryPrefix > 0 && prefixable ? countryPrefix * 10000 + nextBaseId : nextBaseId;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getMaterialBaseId(id: number) {
|
|
if (!Number.isFinite(id)) return 0;
|
|
return id >= 10000 ? id % 10000 : id;
|
|
}
|
|
|
|
function getDefaultIdForChannel(channel: MaterialChannel) {
|
|
const preset = channelOptions.find((option) => option.value === channel);
|
|
const firstRange = preset?.ranges[0];
|
|
if (!firstRange) return null;
|
|
|
|
const countryPrefix = getActiveMaterialCountryPrefix();
|
|
return countryPrefix > 0 && firstRange.prefixable
|
|
? countryPrefix * 10000 + firstRange.start
|
|
: firstRange.start;
|
|
}
|
|
|
|
function getChannelForMaterialId(id: number) {
|
|
const baseId = getMaterialBaseId(id);
|
|
return channelOptions.find((option) =>
|
|
option.ranges.some(({ start, end }) => baseId >= start && baseId <= end)
|
|
);
|
|
}
|
|
|
|
function getRangeText(channel: MaterialChannel) {
|
|
const preset = channelOptions.find((option) => option.value === channel);
|
|
return preset?.ranges.map(({ start, end }) => `${start}-${end}`).join(', ') ?? '-';
|
|
}
|
|
|
|
function getDisplayRangeText(channel: MaterialChannel) {
|
|
const preset = channelOptions.find((option) => option.value === channel);
|
|
if (!preset) return '-';
|
|
const countryPrefix = getActiveMaterialCountryPrefix();
|
|
|
|
return preset.ranges
|
|
.map(({ start, end, prefixable }) => {
|
|
if (countryPrefix > 0 && prefixable) {
|
|
return `${countryPrefix * 10000 + start}-${countryPrefix * 10000 + end}`;
|
|
}
|
|
return `${start}-${end}`;
|
|
})
|
|
.join(', ');
|
|
}
|
|
|
|
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;
|
|
const idChannel = getChannelForMaterialId(Number(form.id));
|
|
if (!idChannel || idChannel.value !== form.channel) setNextAvailableId();
|
|
}
|
|
|
|
function selectMaterialType(type: MaterialChannel) {
|
|
const option = selectableMaterialTypeOptions.find((item) => item.value === type);
|
|
if (!option) return;
|
|
if (option.ranges.length === 0) {
|
|
addNotification(`WARN:${option.label} has no material ID range defined yet`);
|
|
return;
|
|
}
|
|
|
|
form.channel = type;
|
|
applyChannelPreset();
|
|
materialTypeDialogOpen = false;
|
|
}
|
|
|
|
function getSelectedMaterialTypeLabel() {
|
|
const option = channelOptions.find((item) => item.value === form.channel);
|
|
return option ? `TYPE ${option.typeId} - ${option.label}` : 'Select material type';
|
|
}
|
|
|
|
function buildMaterialSetting(): Material {
|
|
const channelFlags = Object.fromEntries(
|
|
channelOptions
|
|
.filter((option) => option.channelFlag)
|
|
.map((option) => [option.channelFlag, false])
|
|
);
|
|
const preset = channelOptions.find((option) => option.value === form.channel);
|
|
if (preset?.channelFlag) channelFlags[preset.channelFlag] = 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: ['', '', '', '', '', '', '', ''],
|
|
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 first';
|
|
if (loadedFromServer) return 'Connect ADB before saving material changes';
|
|
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 (duplicateMaterialOnCreate) return `Material ID ${form.id} already exists`;
|
|
if (isEditingMaterial && Number(form.id) !== Number(editingMaterialId) && existingMaterial) {
|
|
return `Material ID ${form.id} already exists`;
|
|
}
|
|
const idChannel = getChannelForMaterialId(Number(form.id));
|
|
if (!idChannel || idChannel.value !== form.channel) {
|
|
return `${form.channel} material ID must be in range ${getDisplayRangeText(form.channel)}`;
|
|
}
|
|
if (!form.materialName.trim()) return `${primaryLanguageLabel} material name is required`;
|
|
if (!form.materialOtherName.trim()) return 'English material name is required';
|
|
if (form.channel !== 'NoChannel' && !form.RawMaterialUnit.trim()) {
|
|
return 'RawMaterialUnit is required';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
async function persistRecipeToAndroid(nextRecipe: any) {
|
|
if (!adb.getAdbInstance() || loadedFromServer) {
|
|
throw new Error('ADB is required to save recipe changes to Android');
|
|
}
|
|
|
|
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(isEditingMaterial ? editingMaterialId : 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} ${isEditingMaterial ? 'updated' : 'created'} in ${targetPath}`
|
|
);
|
|
if (!isEditingMaterial) setNextAvailableId();
|
|
editingMaterialId = null;
|
|
showMaterialForm = false;
|
|
} catch (error: any) {
|
|
logger.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) => option.channelFlag && Boolean(material[option.channelFlag])
|
|
);
|
|
const inferredChannel = getChannelForMaterialId(Number(material.id));
|
|
editingMaterialId = Number(material.id) || null;
|
|
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 ?? inferredChannel?.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 ?? ''
|
|
};
|
|
showMaterialForm = true;
|
|
}
|
|
|
|
function resetForm() {
|
|
form = createInitialForm();
|
|
if (devRecipe) setNextAvailableId();
|
|
}
|
|
|
|
function openAddMaterialForm() {
|
|
editingMaterialId = null;
|
|
resetForm();
|
|
showMaterialForm = true;
|
|
}
|
|
|
|
function closeMaterialForm() {
|
|
showMaterialForm = false;
|
|
editingMaterialId = null;
|
|
}
|
|
|
|
function openDeleteConfirm(material: Material) {
|
|
pendingDeleteMaterial = material;
|
|
deleteConfirmOpen = true;
|
|
}
|
|
|
|
async function confirmDeleteMaterial() {
|
|
if (!pendingDeleteMaterial) return;
|
|
if (!devRecipe || !Array.isArray(devRecipe.MaterialSetting)) {
|
|
addNotification('ERR:Load recipe first');
|
|
return;
|
|
}
|
|
if (loadedFromServer) {
|
|
addNotification('ERR:Connect ADB before deleting material');
|
|
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) {
|
|
logger.error('failed to delete material', error);
|
|
addNotification(`ERR:Failed to delete material: ${error?.message ?? error}`);
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
referenceFromPage.set('material');
|
|
});
|
|
|
|
onDestroy(() => {
|
|
unsubscribePermission();
|
|
});
|
|
</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 flex-col gap-2 rounded-xl border bg-background p-2 shadow-sm">
|
|
<div class="px-2 text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
|
Recipe Source
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
class="min-w-32 justify-center self-center text-xs font-medium text-muted-foreground"
|
|
onclick={connectAdb}
|
|
disabled={loading || saving}
|
|
>
|
|
<span
|
|
class="h-2.5 w-2.5 rounded-full {adb.getAdbInstance()
|
|
? 'bg-emerald-500'
|
|
: 'bg-destructive'}"
|
|
></span>
|
|
{adb.getAdbInstance() ? 'Machine Connected' : 'Connect Machine'}
|
|
</Button>
|
|
<Button
|
|
variant={!loadedFromServer && devRecipe ? 'default' : 'ghost'}
|
|
class="min-w-36 justify-center px-5 py-5 text-base font-semibold"
|
|
onclick={loadFromMachineSource}
|
|
disabled={loading || saving}
|
|
>
|
|
{#if loading}
|
|
<Spinner />
|
|
Loading
|
|
{:else}
|
|
Machine
|
|
{/if}
|
|
</Button>
|
|
<Button
|
|
variant={loadedFromServer ? 'default' : 'ghost'}
|
|
class="min-w-36 justify-center px-5 py-5 text-base font-semibold"
|
|
onclick={openServerCountryDialog}
|
|
disabled={loading || saving}
|
|
>
|
|
Server{selectedServerCountry ? `: ${selectedServerCountry}` : ''}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog.Root bind:open={serverCountryDialogOpen}>
|
|
<Dialog.Content class="sm:max-w-lg">
|
|
<Dialog.Header>
|
|
<Dialog.Title>Load Recipe From Server</Dialog.Title>
|
|
<Dialog.Description>Select a country to load material data from server.</Dialog.Description>
|
|
</Dialog.Header>
|
|
|
|
<div class="grid gap-2 py-2">
|
|
{#each serverCountries as country}
|
|
<Button
|
|
variant={country === selectedServerCountry ? 'default' : 'outline'}
|
|
class="h-12 justify-start text-base font-semibold"
|
|
onclick={() => selectServerCountry(country)}
|
|
disabled={loading || saving}
|
|
>
|
|
{country}
|
|
</Button>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="flex justify-end">
|
|
<Button variant="outline" onclick={() => (serverCountryDialogOpen = false)}>Cancel</Button>
|
|
</div>
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|
|
|
|
<Dialog.Root bind:open={machineNotConnectedDialogOpen}>
|
|
<Dialog.Content class="sm:max-w-md">
|
|
<Dialog.Header>
|
|
<Dialog.Title>Machine Not Connected</Dialog.Title>
|
|
<Dialog.Description>
|
|
Connect to the machine with ADB/WebUSB before loading recipe data from Machine.
|
|
</Dialog.Description>
|
|
</Dialog.Header>
|
|
|
|
<div class="flex justify-end">
|
|
<Button variant="outline" onclick={() => (machineNotConnectedDialogOpen = false)}>OK</Button
|
|
>
|
|
</div>
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|
|
|
|
<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>{isEditingMaterial ? 'Edit Material' : 'Add Material'}</Dialog.Title>
|
|
<Dialog.Description>
|
|
Create or update one <code>MaterialSetting</code> entry. Server-loaded data is read-only until
|
|
ADB is connected.
|
|
</Dialog.Description>
|
|
</Dialog.Header>
|
|
|
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
|
<Card.Root>
|
|
<Card.Header>
|
|
<Card.Title>{isEditingMaterial ? 'Edit Material' : 'Add Material'}</Card.Title>
|
|
</Card.Header>
|
|
<Card.Content class="grid gap-6">
|
|
{#if duplicateMaterialOnCreate}
|
|
<div
|
|
class="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive"
|
|
>
|
|
Material ID {form.id} already exists. Choose another ID before creating.
|
|
</div>
|
|
{:else if isEditingMaterial && Number(form.id) !== Number(editingMaterialId) && existingMaterial}
|
|
<div
|
|
class="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive"
|
|
>
|
|
Material ID {form.id} already exists. Choose another ID before saving.
|
|
</div>
|
|
{:else if isEditingMaterial}
|
|
<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"
|
|
>
|
|
Editing Material ID {editingMaterialId}.
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="grid gap-2">
|
|
<Label>Material Type</Label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
class="h-auto min-h-12 justify-start px-3 py-2 text-left font-semibold"
|
|
onclick={() => (materialTypeDialogOpen = true)}
|
|
>
|
|
<div class="grid gap-1">
|
|
<span>{getSelectedMaterialTypeLabel()}</span>
|
|
</div>
|
|
</Button>
|
|
</div>
|
|
|
|
<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} readonly />
|
|
<p class="text-xs text-muted-foreground">
|
|
Generated from the highest existing ID in this type + 1.
|
|
</p>
|
|
</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">Name ({primaryLanguageLabel})</Label>
|
|
<Input
|
|
id="material-name"
|
|
bind:value={form.materialName}
|
|
placeholder={`Material name in ${primaryLanguageLabel}`}
|
|
/>
|
|
</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-2">
|
|
<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-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={closeMaterialForm} disabled={saving}>Cancel</Button
|
|
>
|
|
<Button variant="outline" onclick={resetForm} disabled={saving}>Reset</Button>
|
|
<Button
|
|
onclick={saveMaterialToAndroid}
|
|
disabled={!devRecipe ||
|
|
loading ||
|
|
saving ||
|
|
!canWriteToAndroid ||
|
|
duplicateMaterialOnCreate}
|
|
>
|
|
{saving ? 'Saving...' : isEditingMaterial ? '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>
|
|
|
|
<Dialog.Root bind:open={materialTypeDialogOpen}>
|
|
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
|
<Dialog.Header>
|
|
<Dialog.Title>Select Material Type</Dialog.Title>
|
|
<Dialog.Description>
|
|
Choose the type for the new material. Material ID will be generated from existing IDs in
|
|
that type + 1.
|
|
</Dialog.Description>
|
|
</Dialog.Header>
|
|
|
|
<div class="grid gap-2 py-2 sm:grid-cols-2 xl:grid-cols-3">
|
|
{#each selectableMaterialTypeOptions as option}
|
|
<Button
|
|
type="button"
|
|
variant={form.channel === option.value ? 'default' : 'outline'}
|
|
class="h-auto min-h-12 justify-start px-3 py-2 text-left"
|
|
onclick={() => selectMaterialType(option.value)}
|
|
>
|
|
<div class="grid gap-1">
|
|
<div class="font-semibold">TYPE {option.typeId} - {option.label}</div>
|
|
</div>
|
|
</Button>
|
|
{/each}
|
|
</div>
|
|
|
|
<Dialog.Footer>
|
|
<Button variant="outline" onclick={() => (materialTypeDialogOpen = false)}>Cancel</Button>
|
|
</Dialog.Footer>
|
|
</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. Connect ADB to
|
|
write changes back to Android.
|
|
</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...
|
|
</div>
|
|
{:else if !devRecipe}
|
|
<div class="p-4 text-sm text-muted-foreground">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 {materialListGridClass} md:items-center"
|
|
>
|
|
<span>ID</span>
|
|
<span>Name ({primaryLanguageLabel})</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 {materialListGridClass} 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 || !canWriteToAndroid}
|
|
>
|
|
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 || !canWriteToAndroid}
|
|
>
|
|
{saving ? 'Deleting...' : 'Delete Material'}
|
|
</Button>
|
|
</div>
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|
|
</div>
|