Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
496 lines
19 KiB
TypeScript
496 lines
19 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 { fetchLogic, fetchMaterials, type ElementBrief } 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"];
|
|
/** 후보 줄 — 서버는 구분 · 상세구분도 주지만 ElementBrief 타입에는 없음 */
|
|
type Brief = ElementBrief & { 구분?: string; 상세구분?: string };
|
|
|
|
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>;
|
|
}
|
|
|
|
/** 찾는 말과 이름이 똑같으면 0 · 그 말로 시작하고 말이 거기서 끝나면(「모래, 서울」 「모래(건조)」) 1 ·
|
|
* 더 이어지면(「모래적사장」) 2 · 나머지 3 — 앞에 놓음(같은 차례끼리 순서는 그대로) */
|
|
const byName = <T extends { 이름?: string }>(items: T[], q: string): T[] => {
|
|
const squash = (t: string): string => t.replace(/\s+/g, "").toLowerCase();
|
|
const w = squash(q);
|
|
const rank = (it: T): number => {
|
|
const n = squash(it.이름 ?? "");
|
|
if (n === w) return 0;
|
|
if (!n.startsWith(w)) return 3;
|
|
return /[가-힣a-z0-9]/.test(n.charAt(w.length)) ? 2 : 1;
|
|
};
|
|
return items
|
|
.map((it, i) => ({ it, i, r: rank(it) }))
|
|
.sort((a, b) => a.r - b.r || a.i - b.i)
|
|
.map((x) => x.it);
|
|
};
|
|
|
|
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: byName(r.items, f.q)
|
|
.filter((it) => !f.sub || (it as Brief).구분 === f.sub)
|
|
.map((it) => ({ ...it, 구분: (it as Brief).구분 })),
|
|
}))
|
|
: await fetchMaterials({ sub: f.sub, ...(f.detail ? { detail: f.detail } : {}) }).then(
|
|
(r) => ({
|
|
total: r.total,
|
|
items: r.items.map((it) => ({ ...it, 구분: (it as Brief).구분 ?? 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 Brief).상세구분;
|
|
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 () => {
|
|
// 그 일위대가가 받는 값은 같은 이름으로 넘기고 「설계 때 정하는 값」으로 돌려줌(기계와 같은 꼴)
|
|
const needInputs = (await fetchLogic(e.key)).logic.입력;
|
|
const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`);
|
|
const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join("");
|
|
return {
|
|
kind: "logic",
|
|
name: e.name,
|
|
spec: "",
|
|
unit,
|
|
element: `로직(${e.key}${rest})`,
|
|
needInputs,
|
|
};
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
},
|
|
},
|
|
};
|
|
|
|
/** 물음 셋 — 고른 것 · 취소는 null */
|
|
export function openEasyPick(
|
|
kind?: Kind,
|
|
opts: { qty?: number | null } = {},
|
|
): 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";
|
|
if (opts.qty != null) qty.input.value = String(opts.qty); // 다시 고르기 — 지금 수량 미리 채움
|
|
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];
|
|
}
|