feat(m01-easycalc): 쉬운 단가산출 고르기 창 Pick 올림 — 사람 · 재료 · 기계 · 다른 일위대가
물음 셋(무엇 → 어떤 것 → 얼마) · openEasyPick(kind?) → PickResult | null Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
.m01-easypick {
|
||||
--modal-max: 86vh;
|
||||
width: min(760px, 94vw);
|
||||
font-size: var(--text-body-lg, 1.125rem);
|
||||
}
|
||||
.m01-easypick__ask {
|
||||
margin: 0;
|
||||
font-size: 1.35em;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.m01-easypick__muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-easypick__kinds {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
.m01-easypick__kind,
|
||||
.m01-easypick__row {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m01-easypick__kind {
|
||||
flex-direction: column;
|
||||
min-height: 96px;
|
||||
}
|
||||
.m01-easypick__kind strong {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.m01-easypick__kind span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-easypick__kind:hover,
|
||||
.m01-easypick__row:hover,
|
||||
.m01-easypick__kind:focus-visible,
|
||||
.m01-easypick__row:focus-visible {
|
||||
border-color: var(--color-accent);
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.m01-easypick__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
.m01-easypick__bar > :last-child {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.m01-easypick__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
max-height: 46vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.m01-easypick__row {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 52px;
|
||||
}
|
||||
.m01-easypick__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.m01-easypick__unit,
|
||||
.m01-easypick__price {
|
||||
flex: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-easypick__tag {
|
||||
margin-left: var(--spacing-8);
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
.m01-easypick__chosen {
|
||||
margin: 0;
|
||||
font-size: 1.15em;
|
||||
}
|
||||
.m01-easypick__rough {
|
||||
min-height: 1.4em;
|
||||
margin: 0;
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Pick.ts
|
||||
* 쉬운 단가산출 고르기 창 — 물음 셋(① 무엇 → ② 어떤 것 → ③ 얼마) · 한 화면 한 물음.
|
||||
* 돌려주는 PickResult.element = 호표 「요소」 칸에 들어갈 값 그대로. 모양은 설계.md 3절.
|
||||
* 글 [한, 영] 짝은 아래 TEXT. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchPick, fetchSubs, type SubInfo } from "../M01_MasterData_Api_Fetch";
|
||||
import { fetchMaterials } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { listEasy, listMachines, machineElement } from "./M01_EasyCalc_Api";
|
||||
import type { PickResult } from "./M01_EasyCalc_Model";
|
||||
import "./M01_EasyCalc_Pick.css";
|
||||
|
||||
type Kind = PickResult["kind"];
|
||||
|
||||
const TEXT = {
|
||||
Title: ["넣을 것 고르기", "Pick what to add"],
|
||||
Close: ["닫기", "Close"],
|
||||
Back: ["← 뒤로", "← Back"],
|
||||
AskKind: ["무엇을 넣을까요?", "What do you want to add?"],
|
||||
AskWhich: ["어떤 것인가요?", "Which one?"],
|
||||
AskQty: ["얼마나 쓰나요?", "How much?"],
|
||||
Person: ["사람", "People"],
|
||||
Material: ["재료", "Material"],
|
||||
Machine: ["기계", "Machine"],
|
||||
Logic: ["다른 일위대가", "Another unit-price job"],
|
||||
PersonDesc: ["일하는 사람 — 석공 · 보통인부", "Workers — mason, laborer"],
|
||||
MaterialDesc: ["쓰는 물건 — 돌 · 시멘트 · 관", "Things used — stone, cement, pipe"],
|
||||
MachineDesc: ["쓰는 기계 — 굴삭기 · 덤프트럭", "Machines used — excavator, truck"],
|
||||
LogicDesc: ["이미 만들어 둔 일을 가져다 씀", "Reuse a job already made"],
|
||||
Search: ["이름을 쳐서 찾기", "Type a name to search"],
|
||||
AllSub: ["전체", "All"],
|
||||
SubLabel: ["구분", "Group"],
|
||||
DetailLabel: ["상세구분", "Detail"],
|
||||
Hint: ["구분을 고르거나 이름을 쳐 보세요", "Choose a group or type a name"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
None: ["찾는 것 없음", "Nothing found"],
|
||||
Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"],
|
||||
NoPrice: ["단가 없음", "No price"],
|
||||
PerDay: ["하루 {p} 원", "{p} won per day"],
|
||||
PerUnit: ["{u} 당 {p} 원", "{p} won per {u}"],
|
||||
Picked: ["고른 것", "Chosen"],
|
||||
QtyPlace: ["예: 0.072", "e.g. 0.072"],
|
||||
QtyLabel: ["{u} (숫자)", "{u} (number)"],
|
||||
Rough: ["금액 어림 약 {m} 원", "About {m} won"],
|
||||
Add: ["넣기", "Add"],
|
||||
QtyBad: ["0 보다 큰 숫자를 쳐 주세요", "Enter a number above 0"],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
} as const;
|
||||
|
||||
const te = (key: keyof typeof TEXT, vars: Record<string, string | number> = {}): string =>
|
||||
Object.entries(vars).reduce(
|
||||
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
const won = (n: number): string => Math.round(n).toLocaleString("ko-KR");
|
||||
const MAT_FILE = "재료_자재품목.json";
|
||||
|
||||
/** 목록 한 줄 — 고르면 PickResult(수량 빼고) 를 만들어 줌 */
|
||||
interface Candidate {
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
/** 줄 단위 1 당 단가 · 모르면 null — 금액 어림용 */
|
||||
price: number | null;
|
||||
/** 줄 오른쪽 글(단가 표시) */
|
||||
priceText: string;
|
||||
tag?: string;
|
||||
make: () => Promise<Omit<PickResult, "qty">>;
|
||||
}
|
||||
|
||||
interface Filter {
|
||||
q: string;
|
||||
sub: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface Source {
|
||||
title: string;
|
||||
desc: string;
|
||||
/** 구분 · 상세구분 칸을 보일지(재료만) */
|
||||
grouped?: boolean;
|
||||
/** 이름 · 구분이 비면 목록을 안 부르고 null(안내 글만) */
|
||||
load: (f: Filter) => Promise<{ total: number; items: Candidate[] } | null>;
|
||||
}
|
||||
|
||||
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
|
||||
const SOURCES: Partial<Record<Kind, Source>> = {
|
||||
person: {
|
||||
title: te("Person"),
|
||||
desc: te("PersonDesc"),
|
||||
load: async (f) => {
|
||||
const { total, items } = await fetchPick("job", f.q);
|
||||
return {
|
||||
total,
|
||||
items: items.map((it) => {
|
||||
const price = num(it.값);
|
||||
return {
|
||||
name: it.이름,
|
||||
spec: it.규격,
|
||||
unit: "인",
|
||||
price,
|
||||
priceText: price === null ? te("NoPrice") : te("PerDay", { p: won(price) }),
|
||||
make: async () => ({
|
||||
kind: "person",
|
||||
name: it.이름,
|
||||
spec: it.규격,
|
||||
unit: "인",
|
||||
element: it.ref,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
material: {
|
||||
title: te("Material"),
|
||||
desc: te("MaterialDesc"),
|
||||
grouped: true,
|
||||
load: async (f) => {
|
||||
if (!f.q && !f.sub) return null;
|
||||
const found = f.q
|
||||
? await fetchPick("price", f.q).then((r) => ({
|
||||
total: r.total,
|
||||
items: r.items
|
||||
.filter((it) => !f.sub || (it as { 구분?: string }).구분 === f.sub)
|
||||
.map((it) => ({ ...it, 구분: (it as { 구분?: string }).구분, 상세구분: undefined })),
|
||||
}))
|
||||
: await fetchMaterials({ sub: f.sub, detail: f.detail || undefined }).then((r) => ({
|
||||
total: r.total,
|
||||
items: r.items.map((it) => ({ ...it, 구분: it.구분 ?? f.sub })),
|
||||
}));
|
||||
return {
|
||||
total: found.total,
|
||||
items: found.items.map((it) => {
|
||||
const name = it.이름 ?? "";
|
||||
const spec = it.규격 ?? "";
|
||||
const unit = typeof it.단위 === "string" ? it.단위 : "";
|
||||
const price = num(it.값);
|
||||
const element: Record<string, string> = { 구분: it.구분 ?? f.sub };
|
||||
const detail = (it as { 상세구분?: string }).상세구분 ?? f.detail;
|
||||
if (detail) element["상세구분"] = detail;
|
||||
element["검색어"] = `${name} ${spec}`.trim();
|
||||
return {
|
||||
name,
|
||||
spec,
|
||||
unit,
|
||||
price,
|
||||
priceText:
|
||||
price === null ? te("NoPrice") : te("PerUnit", { u: unit || "1", p: won(price) }),
|
||||
tag: element["구분"],
|
||||
make: async () => ({ kind: "material", name, spec, unit, element }),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
machine: {
|
||||
title: te("Machine"),
|
||||
desc: te("MachineDesc"),
|
||||
load: async (f) => {
|
||||
const items = await listMachines(f.q);
|
||||
return {
|
||||
total: items.length,
|
||||
items: items.map((m) => ({
|
||||
name: m.이름,
|
||||
spec: m.규격,
|
||||
unit: m.단위,
|
||||
price: null,
|
||||
priceText: "",
|
||||
make: async () => {
|
||||
const { element, needInputs } = await machineElement(m.분류번호);
|
||||
return {
|
||||
kind: "machine",
|
||||
name: m.이름,
|
||||
spec: m.규격,
|
||||
unit: m.단위,
|
||||
element,
|
||||
needInputs,
|
||||
};
|
||||
},
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
logic: {
|
||||
title: te("Logic"),
|
||||
desc: te("LogicDesc"),
|
||||
load: async (f) => {
|
||||
// 돈으로 나오는 일위대가만 — 「원/㎡」 꼴
|
||||
const items = (await listEasy(f.q)).filter((e) => e.resultUnit.startsWith("원/"));
|
||||
return {
|
||||
total: items.length,
|
||||
items: items.map((e) => {
|
||||
const unit = e.resultUnit.slice(2);
|
||||
return {
|
||||
name: e.name,
|
||||
spec: "",
|
||||
unit,
|
||||
price: null,
|
||||
priceText: "",
|
||||
tag: e.owner,
|
||||
make: async () => ({
|
||||
kind: "logic",
|
||||
name: e.name,
|
||||
spec: "",
|
||||
unit,
|
||||
element: `로직(${e.key})`,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** 물음 셋 — 고른 것 · 취소는 null */
|
||||
export function openEasyPick(kind?: Kind): Promise<PickResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (r: PickResult | null): void => {
|
||||
if (!settled) resolve(r);
|
||||
settled = true;
|
||||
};
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
closeLabel: te("Close"),
|
||||
dialogClass: "m01-easypick",
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body, close) => {
|
||||
const done = (r: PickResult): void => (finish(r), close());
|
||||
const stepKinds = (): void => {
|
||||
body.replaceChildren(
|
||||
el("h4", { className: "m01-easypick__ask", text: te("AskKind") }),
|
||||
kindButtons(),
|
||||
);
|
||||
body.querySelector<HTMLElement>("button")?.focus();
|
||||
};
|
||||
const kindButtons = (): HTMLElement => {
|
||||
const order: Kind[] = ["person", "material", "machine", "logic"];
|
||||
const grid = el("div", { className: "m01-easypick__kinds" });
|
||||
order.forEach((k, i) => {
|
||||
const src = SOURCES[k];
|
||||
const b = el("button", {
|
||||
className: "m01-easypick__kind",
|
||||
attrs: { type: "button", "data-kind": k, "aria-keyshortcuts": String(i + 1) },
|
||||
children: [
|
||||
el("strong", { text: src?.title ?? labelOf(k) }),
|
||||
el("span", { text: src?.desc ?? descOf(k) }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => stepList(k, true));
|
||||
grid.append(b);
|
||||
});
|
||||
grid.addEventListener("keydown", (ev) => {
|
||||
const k = order[Number(ev.key) - 1];
|
||||
if (k) stepList(k, true);
|
||||
});
|
||||
return grid;
|
||||
};
|
||||
const stepList = (k: Kind, canBack: boolean): void => {
|
||||
const src = SOURCES[k];
|
||||
if (!src) return;
|
||||
const back = canBack ? stepKinds : null;
|
||||
const search = createInputField({ type: "search", placeholder: te("Search") });
|
||||
const filter: Filter = { q: "", sub: "", detail: "" };
|
||||
const list = el("div", { className: "m01-easypick__list", attrs: { role: "listbox" } });
|
||||
const count = el("span", { className: "m01-easypick__muted" });
|
||||
const bar: HTMLElement[] = [search.root];
|
||||
let seq = 0;
|
||||
const load = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
count.textContent = "";
|
||||
list.replaceChildren(
|
||||
el("p", { className: "m01-easypick__muted", text: te("Loading") }),
|
||||
);
|
||||
try {
|
||||
const found = await src.load(filter);
|
||||
if (mine !== seq) return; // 최신 응답만
|
||||
if (!found)
|
||||
return void list.replaceChildren(
|
||||
el("p", { className: "m01-easypick__muted", text: te("Hint") }),
|
||||
);
|
||||
count.textContent = te("Count", { n: found.total, m: found.items.length });
|
||||
list.replaceChildren(
|
||||
...(found.items.length
|
||||
? found.items.map((c) =>
|
||||
rowOf(c, () => stepQty(k, c, back ? () => stepList(k, canBack) : null)),
|
||||
)
|
||||
: [el("p", { className: "m01-easypick__muted", text: te("None") })]),
|
||||
);
|
||||
} catch {
|
||||
if (mine === seq) showToast(te("Failed"), "error");
|
||||
}
|
||||
};
|
||||
if (src.grouped) bar.unshift(...subFilters(filter, load));
|
||||
let timer = 0;
|
||||
search.input.addEventListener("input", () => {
|
||||
filter.q = search.input.value.trim();
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(load, 250);
|
||||
});
|
||||
body.replaceChildren(
|
||||
...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []),
|
||||
el("h4", { className: "m01-easypick__ask", text: `${src.title} — ${te("AskWhich")}` }),
|
||||
el("div", { className: "m01-easypick__bar", children: bar }),
|
||||
count,
|
||||
list,
|
||||
);
|
||||
body.onkeydown = (ev) => {
|
||||
if (back && ev.altKey && ev.key === "ArrowLeft") back();
|
||||
else if (ev.key === "ArrowDown" || ev.key === "ArrowUp") moveFocus(body, ev);
|
||||
};
|
||||
search.input.focus();
|
||||
void load();
|
||||
};
|
||||
const stepQty = (k: Kind, c: Candidate, back: (() => void) | null): void => {
|
||||
const qty = createInputField({
|
||||
type: "text",
|
||||
label: te("QtyLabel", { u: c.unit || "—" }),
|
||||
placeholder: te("QtyPlace"),
|
||||
});
|
||||
qty.input.inputMode = "decimal";
|
||||
const rough = el("p", { className: "m01-easypick__rough" });
|
||||
const parse = (): number | null => {
|
||||
const n = Number(qty.input.value.trim().replace(/,/g, ""));
|
||||
return qty.input.value.trim() !== "" && Number.isFinite(n) && n > 0 ? n : null;
|
||||
};
|
||||
const refresh = (): void => {
|
||||
const n = parse();
|
||||
rough.textContent =
|
||||
n !== null && c.price !== null ? te("Rough", { m: won(n * c.price) }) : "";
|
||||
qty.setError(undefined);
|
||||
};
|
||||
const submit = async (): Promise<void> => {
|
||||
const n = parse();
|
||||
if (n === null) return qty.setError(te("QtyBad"));
|
||||
done({ ...(await c.make()), qty: n });
|
||||
};
|
||||
qty.input.addEventListener("input", refresh);
|
||||
qty.input.addEventListener("keydown", (ev) => ev.key === "Enter" && void submit());
|
||||
body.replaceChildren(
|
||||
...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []),
|
||||
el("h4", { className: "m01-easypick__ask", text: te("AskQty") }),
|
||||
el("p", {
|
||||
className: "m01-easypick__chosen",
|
||||
children: [
|
||||
el("span", { className: "m01-easypick__muted", text: te("Picked") }),
|
||||
el("strong", { text: ` ${c.name} ${c.spec}`.trimEnd() }),
|
||||
],
|
||||
}),
|
||||
qty.root,
|
||||
rough,
|
||||
createButton({ label: te("Add"), variant: "filled", onClick: () => void submit() }),
|
||||
);
|
||||
body.onkeydown = (ev) => ev.altKey && ev.key === "ArrowLeft" && back?.();
|
||||
qty.input.focus();
|
||||
void k;
|
||||
};
|
||||
if (kind) stepList(kind, false);
|
||||
else stepKinds();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const labelOf = (k: Kind): string =>
|
||||
te(({ person: "Person", material: "Material", machine: "Machine", logic: "Logic" } as const)[k]);
|
||||
const descOf = (k: Kind): string =>
|
||||
te(
|
||||
(
|
||||
{
|
||||
person: "PersonDesc",
|
||||
material: "MaterialDesc",
|
||||
machine: "MachineDesc",
|
||||
logic: "LogicDesc",
|
||||
} as const
|
||||
)[k],
|
||||
);
|
||||
|
||||
/** 목록 줄 — 이름 · 크기 · 단위 · 단가 */
|
||||
function rowOf(c: Candidate, onPick: () => void): HTMLElement {
|
||||
const b = el("button", {
|
||||
className: "m01-easypick__row",
|
||||
attrs: { type: "button", role: "option" },
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m01-easypick__name",
|
||||
children: [
|
||||
el("strong", { text: c.name }),
|
||||
...(c.spec ? [el("span", { className: "m01-easypick__muted", text: ` ${c.spec}` })] : []),
|
||||
...(c.tag ? [el("span", { className: "m01-easypick__tag", text: c.tag })] : []),
|
||||
],
|
||||
}),
|
||||
el("span", { className: "m01-easypick__unit", text: c.unit }),
|
||||
el("span", { className: "m01-easypick__price", text: c.priceText }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", onPick);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 위·아래 화살표로 찾기 칸 ↔ 줄 사이 오감 */
|
||||
function moveFocus(body: HTMLElement, ev: KeyboardEvent): void {
|
||||
const items = [...body.querySelectorAll<HTMLElement>("input[type=search], .m01-easypick__row")];
|
||||
const at = items.indexOf(document.activeElement as HTMLElement);
|
||||
if (at < 0) return;
|
||||
const next = items[at + (ev.key === "ArrowDown" ? 1 : -1)];
|
||||
if (!next) return;
|
||||
ev.preventDefault();
|
||||
next.focus();
|
||||
}
|
||||
|
||||
/** 재료 구분 → 상세구분 고르기 칸 둘 — 바꾸면 load 다시 */
|
||||
function subFilters(filter: Filter, load: () => Promise<void>): HTMLElement[] {
|
||||
let subs: SubInfo[] = [];
|
||||
const all = { value: "", text: te("AllSub") };
|
||||
const subBox = createSelectField({
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
ariaLabel: te("SubLabel"),
|
||||
onChange: (v) => ((filter.sub = v), (filter.detail = ""), fillDetail(), void load()),
|
||||
});
|
||||
const detailBox = createSelectField({
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
ariaLabel: te("DetailLabel"),
|
||||
onChange: (v) => ((filter.detail = v), void load()),
|
||||
});
|
||||
const fillDetail = (): void => {
|
||||
const details = subs.find((s) => s.name === filter.sub)?.details ?? [];
|
||||
detailBox.root.hidden = !details.length;
|
||||
detailBox.setOptions([all, ...details.map((d) => ({ value: d, text: d }))], "");
|
||||
};
|
||||
fillDetail();
|
||||
void fetchSubs({ file: MAT_FILE })
|
||||
.then((r) => {
|
||||
subs = r;
|
||||
subBox.setOptions([all, ...r.map((s) => ({ value: s.name, text: s.name }))], filter.sub);
|
||||
})
|
||||
.catch(() => showToast(te("Failed"), "error"));
|
||||
return [subBox.root, detailBox.root];
|
||||
}
|
||||
Reference in New Issue
Block a user