Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts
T

593 lines
20 KiB
TypeScript

/* =============================================================================
* M02_DF_InputPanel_Material.ts
* 입력 판 — 재료 요소 방식 셋(고정 · 선택 자리 · 조건 찾기) · 자재 찾기(띄어쓰기 AND · 규격까지 · 쪽 넘기기 · 전체 수)
* 서버 `/api/m02/df/materials` 를 먼저 부르고 아직 없으면 M01 `/pick?kind=price` 로 같은 모양을 만듦
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
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 { 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[];
}
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
if (!res.ok) throw new Error(String(res.status));
return (await res.json()) as T;
}
/** 서버가 없으면 M01 값 줄에서 같은 모양으로 — 띄어쓰기 AND(이름 + 규격) · 쪽은 앞에서 잘라 줌 */
async function fallback(q: MatQuery): Promise<MatPage> {
const r = await getJson<{ items: Record<string, unknown>[] }>(
`/m01/pick?${new URLSearchParams({ kind: "price", q: q.q, limit: "200" })}`,
);
const rows = r.items.filter((i) => !q.sub || i["구분"] === q.sub);
const items = rows.map((i): MatItem => ({
ref: String(i["ref"]),
name: String(i["이름"] ?? ""),
spec: String(i["규격"] ?? ""),
unit: String(i["단위"] ?? ""),
price: typeof i["값"] === "number" ? i["값"] : null,
구분: String(i["구분"] ?? ""),
}));
const from = (q.page - 1) * q.size;
return {
total: items.length,
page: q.page,
size: q.size,
items: items.slice(from, from + q.size),
};
}
export async function searchMaterials(q: MatQuery): Promise<MatPage> {
const params = new URLSearchParams({
q: q.q,
page: String(q.page),
size: String(q.size),
sub: q.sub ?? "",
detail: q.detail ?? "",
});
try {
return await getJson<MatPage>(`/m02/df/materials?${params}`);
} catch {
return fallback(q);
}
}
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();
}