Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
553 lines
19 KiB
TypeScript
553 lines
19 KiB
TypeScript
/* =============================================================================
|
|
* M02_DF_InputPanel_Material.ts
|
|
* 입력 판 — 재료 요소 방식 셋(고정 · 선택 자리 · 조건 찾기) · 자재 찾기(띄어쓰기 AND · 규격까지 · 쪽 넘기기 · 전체 수)
|
|
* 서버 `/api/m02/df/materials`(cfMaterials)
|
|
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
|
|
* ========================================================================== */
|
|
|
|
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
|
import {
|
|
createButton,
|
|
createInputField,
|
|
createSelectField,
|
|
el,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import { fetchFiles, fetchSubs, type SubInfo } from "../../M01_MasterData/M01_MasterData_Api_Fetch";
|
|
import { cfMaterials, cfValues } from "./M02_DF_Api";
|
|
import type { CfElementRef, CfLogic, CfMaterialMode, CfMaterialRule } from "./M02_DF_Model";
|
|
import {
|
|
catchNote,
|
|
checkMaterial,
|
|
elementOf,
|
|
matches,
|
|
materialElement,
|
|
pageRange,
|
|
removeElementNamed,
|
|
variablePills,
|
|
type MatItem,
|
|
} from "./M02_DF_InputPanel_Build";
|
|
|
|
const TEXT = {
|
|
Chosen: ["넣은 재료 — 알약마다 이름 · 값 · 단위", "Chosen materials"],
|
|
None: ["아직 고른 재료가 없음", "Nothing chosen yet"],
|
|
Remove: ["빼기", "Remove"],
|
|
Fixed: ["고정", "Fixed"],
|
|
Choice: ["선택 자리", "Choice slot"],
|
|
Rule: ["조건 찾기", "Find by rule"],
|
|
FixedHelp: ["품목 하나를 못 박음", "One fixed item"],
|
|
ChoiceHelp: [
|
|
"자리만 두고 프로젝트에서 품목을 고름(기본 품목 하나)",
|
|
"A slot — pick the item per project",
|
|
],
|
|
RuleHelp: [
|
|
"변수 알약 값으로 재료를 잡음 — 예: 관경 알약 값이 규격에 들어간 흄관",
|
|
"Find by a variable pill",
|
|
],
|
|
Search: ["찾는 말 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
|
|
AllSub: ["전체", "All"],
|
|
SubLabel: ["구분", "Group"],
|
|
DetailLabel: ["상세구분", "Detail"],
|
|
Loading: ["찾는 중…", "Searching…"],
|
|
NoRow: ["찾는 재료 없음", "Nothing found"],
|
|
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
|
|
Prev: ["이전", "Prev"],
|
|
Next: ["다음", "Next"],
|
|
SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"],
|
|
Scope: ["범위", "Scope"],
|
|
Word: ["찾는 말(범위 안에서)", "Word in scope"],
|
|
Default: ["기본 품목", "Default item"],
|
|
DefaultNone: ["아직 안 고름 — 아래 목록에서 눌러 고름", "Not chosen — pick one below"],
|
|
DefaultClear: ["기본 품목 비움", "Clear"],
|
|
RuleField: ["찾는 칸", "Field"],
|
|
FieldSpec: ["규격", "Spec"],
|
|
FieldName: ["이름", "Name"],
|
|
RuleOp: ["조건", "Condition"],
|
|
OpIn: ["에 알약 값이 들어 있음", "contains the pill value"],
|
|
OpEq: ["이 알약 값과 같음", "equals the pill value"],
|
|
RulePill: ["변수 알약", "Variable pill"],
|
|
NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"],
|
|
Put: ["넣기", "Add"],
|
|
Pick: ["넣기", "Add"],
|
|
Preview: ["지금 잡히는 재료", "Items caught now"],
|
|
Price: ["가장 싼 값 {p} {u}", "cheapest {p} {u}"],
|
|
RuleLine: ["{f}{o} · 알약 「{n}」 = {v}", "{f} {o} · pill “{n}” = {v}"],
|
|
ScopeLine: ["범위 {s}", "scope {s}"],
|
|
Failed: ["불러오지 못함", "Could not load"],
|
|
} as const;
|
|
|
|
type TKey = keyof typeof TEXT;
|
|
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
|
Object.entries(v).reduce(
|
|
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
|
|
TEXT[key][currentLanguageIndex] as string,
|
|
);
|
|
|
|
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
|
|
|
|
/* ── 자재 찾기 ── */
|
|
export interface MatQuery {
|
|
q: string;
|
|
sub?: string;
|
|
detail?: string;
|
|
page: number;
|
|
size: number;
|
|
}
|
|
export interface MatPage {
|
|
total: number;
|
|
page: number;
|
|
size: number;
|
|
items: MatItem[];
|
|
}
|
|
|
|
/** 서버 자재 찾기 — 띄어쓰기로 나눈 말을 모두 포함 · 쪽 넘기기 · 전체 수 */
|
|
export async function searchMaterials(q: MatQuery): Promise<MatPage> {
|
|
return cfMaterials({ q: q.q, sub: q.sub, detail: q.detail, page: q.page, size: q.size });
|
|
}
|
|
|
|
let subsCache: SubInfo[] | null = null;
|
|
async function materialSubs(): Promise<SubInfo[]> {
|
|
if (subsCache) return subsCache;
|
|
const files = await fetchFiles("재료");
|
|
const f = files.find((x) => x.file.includes("자재품목")) ?? files[0];
|
|
subsCache = await fetchSubs({ file: f.file });
|
|
return subsCache;
|
|
}
|
|
|
|
/** 구분 · 상세구분 고르기 칸 — 바뀌면 onChange */
|
|
function scopeBoxes(
|
|
init: { sub: string; detail: string },
|
|
onChange: () => void,
|
|
): { root: HTMLElement; get(): { sub: string; detail: string } } {
|
|
const state = { ...init };
|
|
const all = { value: "", text: t("AllSub") };
|
|
const subBox = createSelectField({
|
|
options: [all],
|
|
value: "",
|
|
compact: true,
|
|
ariaLabel: t("SubLabel"),
|
|
onChange: (v) => ((state.sub = v), (state.detail = ""), fill(), onChange()),
|
|
});
|
|
const detailBox = createSelectField({
|
|
options: [all],
|
|
value: "",
|
|
compact: true,
|
|
ariaLabel: t("DetailLabel"),
|
|
onChange: (v) => ((state.detail = v), onChange()),
|
|
});
|
|
let subs: SubInfo[] = [];
|
|
const fill = (): void => {
|
|
const d = subs.find((s) => s.name === state.sub)?.details ?? [];
|
|
detailBox.root.hidden = !d.length;
|
|
detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], state.detail);
|
|
};
|
|
fill();
|
|
void materialSubs().then(
|
|
(s) => {
|
|
subs = s;
|
|
subBox.setOptions([all, ...s.map((x) => ({ value: x.name, text: x.name }))], state.sub);
|
|
fill();
|
|
},
|
|
() => undefined,
|
|
);
|
|
return {
|
|
root: el("div", { className: "m02-dfip__bar", children: [subBox.root, detailBox.root] }),
|
|
get: () => ({ ...state }),
|
|
};
|
|
}
|
|
|
|
/** 품목 찾기 목록 — 찾는 말 · 구분 · 쪽 넘기기 · 눌러 고르기 */
|
|
function itemList(
|
|
onPick: (item: MatItem) => void,
|
|
base?: () => { sub: string; detail: string },
|
|
): HTMLElement {
|
|
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
|
const list = el("div", { className: "m02-dfip__list" });
|
|
const info = el("span", { className: "m02-dfip__muted" });
|
|
let page = 1;
|
|
let seq = 0;
|
|
const own = base ? null : scopeBoxes({ sub: "", detail: "" }, () => ((page = 1), void load()));
|
|
const scope = (): { sub: string; detail: string } => (base ? base() : own!.get());
|
|
const prev = createButton({
|
|
label: t("Prev"),
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => ((page -= 1), void load()),
|
|
});
|
|
const next = createButton({
|
|
label: t("Next"),
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => ((page += 1), void load()),
|
|
});
|
|
const load = async (): Promise<void> => {
|
|
const mine = ++seq;
|
|
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
|
|
try {
|
|
const r = await searchMaterials({ q: q.input.value.trim(), ...scope(), page, size: 12 });
|
|
if (mine !== seq) return;
|
|
const range = pageRange(r.total, page, 12);
|
|
info.textContent = t("Total", {
|
|
n: r.total,
|
|
a: range.from,
|
|
b: range.to,
|
|
p: Math.min(page, range.pages),
|
|
m: range.pages,
|
|
});
|
|
prev.disabled = page <= 1;
|
|
next.disabled = page >= range.pages;
|
|
list.replaceChildren(
|
|
...(r.items.length
|
|
? r.items.map((it) => {
|
|
const b = el("button", {
|
|
className: "m02-dfip__row",
|
|
attrs: { type: "button" },
|
|
children: [
|
|
el("span", {
|
|
className: "m02-dfip__name",
|
|
children: [
|
|
el("strong", { text: it.name }),
|
|
el("span", { className: "m02-dfip__muted", text: ` ${it.spec}` }),
|
|
],
|
|
}),
|
|
el("span", {
|
|
className: "m02-dfip__muted",
|
|
text: `${it.price === null ? "" : fmt(it.price)} ${it.unit}`.trim(),
|
|
}),
|
|
],
|
|
});
|
|
b.addEventListener("click", () => onPick(it));
|
|
return b;
|
|
})
|
|
: [el("p", { className: "m02-dfip__muted", text: t("NoRow") })]),
|
|
);
|
|
} catch {
|
|
if (mine === seq)
|
|
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Failed") }));
|
|
}
|
|
};
|
|
let timer = 0;
|
|
q.input.addEventListener(
|
|
"input",
|
|
() => (
|
|
window.clearTimeout(timer),
|
|
(timer = window.setTimeout(() => ((page = 1), void load()), 250))
|
|
),
|
|
);
|
|
void load();
|
|
return el("div", {
|
|
className: "m02-dfip__find",
|
|
children: [
|
|
...(own ? [own.root] : []),
|
|
q.root,
|
|
info,
|
|
list,
|
|
el("div", { className: "m02-dfip__chips", children: [prev, next] }),
|
|
],
|
|
});
|
|
}
|
|
|
|
/* ── 잡히는 재료 미리 보기 ── */
|
|
export async function previewOf(
|
|
el1: CfElementRef,
|
|
lg: CfLogic,
|
|
): Promise<{ found: number; names: string[]; cheapest: MatItem | null; unit: string }> {
|
|
const scope = el1.scope ?? {};
|
|
const word = scope.찾는말 ?? "";
|
|
if (el1.mode === "rule" && el1.rule) {
|
|
const pill = variablePills(lg).find(
|
|
(p) => p.port.box === el1.rule!.from.box && p.port.name === el1.rule!.from.name,
|
|
);
|
|
const value = pill?.example;
|
|
if (value === null || value === undefined || value === "")
|
|
return { found: 0, names: [], cheapest: null, unit: "" };
|
|
const r = await searchMaterials({
|
|
q: `${word} ${value}`.trim(),
|
|
sub: scope.구분,
|
|
detail: scope.상세구분,
|
|
page: 1,
|
|
size: 200,
|
|
});
|
|
const hit = r.items.filter((i) => matches(i, el1.rule!, value));
|
|
return {
|
|
found: hit.length,
|
|
names: hit.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()),
|
|
cheapest: cheapestOf(hit),
|
|
unit: commonUnit(hit),
|
|
};
|
|
}
|
|
const r = await searchMaterials({
|
|
q: word,
|
|
sub: scope.구분,
|
|
detail: scope.상세구분,
|
|
page: 1,
|
|
size: 200,
|
|
});
|
|
return {
|
|
found: r.total,
|
|
names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()),
|
|
cheapest: cheapestOf(r.items),
|
|
unit: commonUnit(r.items),
|
|
};
|
|
}
|
|
|
|
/** 잡힌 품목 단위가 모두 같으면 그 단위 · 아니면 빈 글 */
|
|
const commonUnit = (items: MatItem[]): string =>
|
|
items.length && items.every((i) => i.unit === items[0].unit) ? items[0].unit : "";
|
|
|
|
const cheapestOf = (items: MatItem[]): MatItem | null =>
|
|
items
|
|
.filter((i) => i.price !== null)
|
|
.sort((a, b) => (a.price as number) - (b.price as number))[0] ?? null;
|
|
|
|
/** 요소 한 줄 요약 — 방식 · 범위 · 규칙 */
|
|
function summaryOf(e: CfElementRef, lg: CfLogic): string {
|
|
const parts: string[] = [];
|
|
const scope = Object.entries(e.scope ?? {})
|
|
.map(([, v]) => v)
|
|
.join(" / ");
|
|
if (scope) parts.push(t("ScopeLine", { s: scope }));
|
|
if (e.rule) {
|
|
const p = variablePills(lg).find(
|
|
(x) => x.port.box === e.rule!.from.box && x.port.name === e.rule!.from.name,
|
|
);
|
|
parts.push(
|
|
t("RuleLine", {
|
|
f: e.rule.field,
|
|
o: e.rule.op === "포함" ? t("OpIn") : t("OpEq"),
|
|
n: e.rule.from.name,
|
|
v: p?.example ?? "—",
|
|
}),
|
|
);
|
|
}
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
/** 재료 판 — 넣은 재료 목록 + 방식 셋 */
|
|
export function renderMaterial(
|
|
host: HTMLElement,
|
|
lg: CfLogic,
|
|
boxId: string,
|
|
on: { change(lg: CfLogic): void },
|
|
addEl: (lg: CfLogic, el: CfElementRef) => CfLogic,
|
|
): void {
|
|
const box = lg.boxes.find((b) => b.id === boxId);
|
|
const els = (box?.input?.elements ?? []) as CfElementRef[];
|
|
const chosen = el("div", { className: "m02-dfip__chosen" });
|
|
const body = el("div", { className: "m02-dfip__stack" });
|
|
let mode: CfMaterialMode = "fixed";
|
|
|
|
const drawChosen = (): void => {
|
|
chosen.replaceChildren(
|
|
el("h5", { text: `${t("Chosen")} · ${els.length}` }),
|
|
...(els.length
|
|
? els.map((e) => {
|
|
const val = el("strong", { className: "m02-dfip__value", text: "—" });
|
|
const note = el("span", { className: "m02-dfip__muted" });
|
|
const kind =
|
|
e.mode === "choice" ? t("Choice") : e.mode === "rule" ? t("Rule") : t("Fixed");
|
|
const row = el("div", {
|
|
className: "m02-dfip__item",
|
|
children: [
|
|
el("span", {
|
|
className: "m02-dfip__name",
|
|
children: [
|
|
el("span", { className: "m02-dfip__tag", text: kind }),
|
|
el("strong", { text: ` ${e.name}` }),
|
|
el("div", { className: "m02-dfip__muted", text: summaryOf(e, lg) }),
|
|
note,
|
|
],
|
|
}),
|
|
val,
|
|
el("span", { className: "m02-dfip__muted", text: e.unit || "-" }),
|
|
createButton({
|
|
label: t("Remove"),
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => on.change(removeElementNamed(lg, boxId, e.name)),
|
|
}),
|
|
],
|
|
});
|
|
const ref = e.mode === "choice" ? e.default : e.mode === "rule" ? "" : e.ref;
|
|
if (ref)
|
|
void cfValues([{ id: ref, group: "재료", ref }] as never)
|
|
.then((a) =>
|
|
a.forEach(
|
|
(x) => x.ok && typeof x.value === "number" && (val.textContent = fmt(x.value)),
|
|
),
|
|
)
|
|
.catch(() => undefined);
|
|
if (e.mode && e.mode !== "fixed")
|
|
void previewOf(e, lg).then(
|
|
(p) => {
|
|
const n = catchNote(p.found, p.names[0]);
|
|
note.textContent = `${t("Preview")}: ${n.text}`;
|
|
note.classList.toggle("is-bad", n.kind !== "one");
|
|
if (p.cheapest?.price != null && !ref) val.textContent = fmt(p.cheapest.price);
|
|
},
|
|
() => undefined,
|
|
);
|
|
return row;
|
|
})
|
|
: [el("p", { className: "m02-dfip__muted", text: t("None") })]),
|
|
);
|
|
};
|
|
|
|
const chips = el("div", { className: "m02-dfip__chips" });
|
|
const drawChips = (): void =>
|
|
chips.replaceChildren(
|
|
...(["fixed", "choice", "rule"] as CfMaterialMode[]).map((m) =>
|
|
createButton({
|
|
label: m === "fixed" ? t("Fixed") : m === "choice" ? t("Choice") : t("Rule"),
|
|
variant: mode === m ? "filled" : "pill",
|
|
size: "sm",
|
|
onClick: () => ((mode = m), drawChips(), drawBody()),
|
|
}),
|
|
),
|
|
);
|
|
|
|
const drawBody = (): void => {
|
|
if (mode === "fixed") {
|
|
body.replaceChildren(
|
|
el("p", { className: "m02-dfip__muted", text: t("FixedHelp") }),
|
|
itemList((it) =>
|
|
on.change(
|
|
addEl(lg, {
|
|
...elementOf("재료", { 키: it.ref, 이름: it.name, 규격: it.spec, 단위: it.unit }),
|
|
mode: "fixed",
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
// 선택 자리 · 조건 찾기 — 이름 · 범위 · (기본 품목 | 규칙) · 미리 보기
|
|
const name = createInputField({
|
|
type: "text",
|
|
label: t("SlotName"),
|
|
compact: true,
|
|
placeholder: "예: 흄관",
|
|
});
|
|
const word = createInputField({ type: "text", label: t("Word"), compact: true });
|
|
let def: MatItem | null = null;
|
|
const scope = scopeBoxes({ sub: "", detail: "" }, () => void refresh());
|
|
const note = el("p", { className: "m02-dfip__note" });
|
|
const pills = variablePills(lg);
|
|
const fieldBox = createSelectField({
|
|
label: t("RuleField"),
|
|
options: [
|
|
{ value: "규격", text: t("FieldSpec") },
|
|
{ value: "이름", text: t("FieldName") },
|
|
],
|
|
value: "규격",
|
|
compact: true,
|
|
onChange: () => void refresh(),
|
|
});
|
|
const opBox = createSelectField({
|
|
label: t("RuleOp"),
|
|
options: [
|
|
{ value: "포함", text: t("OpIn") },
|
|
{ value: "같음", text: t("OpEq") },
|
|
],
|
|
value: "포함",
|
|
compact: true,
|
|
onChange: () => void refresh(),
|
|
});
|
|
const pillBox = createSelectField({
|
|
label: t("RulePill"),
|
|
options: pills.map((p, i) => ({
|
|
value: String(i),
|
|
text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`,
|
|
})),
|
|
value: "0",
|
|
compact: true,
|
|
onChange: () => void refresh(),
|
|
});
|
|
const defNote = el("p", { className: "m02-dfip__muted" });
|
|
const build = (): CfElementRef => {
|
|
const sc = scope.get();
|
|
const rule: CfMaterialRule | undefined =
|
|
mode === "rule" && pills[Number(pillBox.select.value)]
|
|
? {
|
|
field: fieldBox.select.value as CfMaterialRule["field"],
|
|
op: opBox.select.value as CfMaterialRule["op"],
|
|
from: pills[Number(pillBox.select.value)].port,
|
|
}
|
|
: undefined;
|
|
return materialElement(
|
|
mode,
|
|
name.input.value,
|
|
{ 구분: sc.sub, 상세구분: sc.detail, 찾는말: word.input.value.trim() },
|
|
{ rule, item: def ?? undefined },
|
|
);
|
|
};
|
|
let seq = 0;
|
|
const refresh = async (): Promise<void> => {
|
|
const mine = ++seq;
|
|
defNote.textContent = def ? `${t("Default")}: ${def.name} ${def.spec}` : t("DefaultNone");
|
|
const cand = build();
|
|
note.textContent = `${t("Preview")}: …`;
|
|
try {
|
|
const p = await previewOf(cand, lg);
|
|
if (mine !== seq) return;
|
|
const n = catchNote(p.found, p.names[0]);
|
|
note.textContent = `${t("Preview")}: ${n.text}${p.cheapest?.price != null ? ` · ${t("Price", { p: fmt(p.cheapest.price), u: p.cheapest.unit })}` : ""}`;
|
|
note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`;
|
|
} catch {
|
|
if (mine === seq) note.textContent = t("Failed");
|
|
}
|
|
};
|
|
name.input.addEventListener("input", () => name.setError(undefined));
|
|
word.input.addEventListener("input", () => void refresh());
|
|
const add = createButton({
|
|
label: t("Put"),
|
|
size: "sm",
|
|
onClick: () => {
|
|
const cand = build();
|
|
const why = checkMaterial(cand, els);
|
|
name.setError(why ?? undefined);
|
|
if (why) return void showToast(why, "error");
|
|
// 잡히는 재료의 단위가 하나로 같으면 그 단위를 알약 단위로
|
|
void previewOf(cand, lg).then(
|
|
(p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)),
|
|
() => on.change(addEl(lg, cand)),
|
|
);
|
|
},
|
|
});
|
|
const clear = createButton({
|
|
label: t("DefaultClear"),
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => ((def = null), void refresh()),
|
|
});
|
|
body.replaceChildren(
|
|
el("p", {
|
|
className: "m02-dfip__muted",
|
|
text: mode === "choice" ? t("ChoiceHelp") : t("RuleHelp"),
|
|
}),
|
|
name.root,
|
|
el("h5", { text: t("Scope") }),
|
|
scope.root,
|
|
word.root,
|
|
...(mode === "rule"
|
|
? pills.length
|
|
? [fieldBox.root, opBox.root, pillBox.root]
|
|
: [el("p", { className: "m02-dfip__muted is-bad", text: t("NoPill") })]
|
|
: [
|
|
el("h5", { text: t("Default") }),
|
|
defNote,
|
|
clear,
|
|
itemList((it) => ((def = it), void refresh())),
|
|
]),
|
|
note,
|
|
add,
|
|
);
|
|
void refresh();
|
|
};
|
|
|
|
host.replaceChildren(chosen, chips, body);
|
|
drawChosen();
|
|
drawChips();
|
|
drawBody();
|
|
}
|