Files
Aislo/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts
T

466 lines
17 KiB
TypeScript

/* =============================================================================
* 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, plainError } 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 SHOW = 100;
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, ...(f.detail ? { detail: f.detail } : {}) }).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 }).상세구분;
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") }),
);
const shown = found.items.slice(0, SHOW);
count.textContent = te("Count", { n: found.total, m: shown.length });
list.replaceChildren(
...(shown.length
? shown.map((c) =>
rowOf(c, () => stepQty(k, c, back ? () => stepList(k, canBack) : null)),
)
: [el("p", { className: "m01-easypick__muted", text: te("None") })]),
);
} catch (e) {
if (mine === seq) showToast(plainError(e), "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"));
try {
done({ ...(await c.make()), qty: n });
} catch (e) {
qty.setError(plainError(e));
}
};
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];
}