Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
419 lines
15 KiB
TypeScript
419 lines
15 KiB
TypeScript
/* =============================================================================
|
|
* M02_CF_InputPanel.ts
|
|
* 단가산출 다이어그램 — 입력 상자 판: 갈래(그룹 · 변수) 고르기 · M01 그룹 요소 고르기 · 새 요소(사용자 요소) ·
|
|
* 소요량 · 계수 표 고르기(Table.ts) · 변수 정의(Vars.ts) · 상자 단위
|
|
* M01 Api(`Api_Fetch`)만 가져다 씀 — 마스터에 쓰지 않음(새 요소는 서버 `/api/m02/cf/user-elements` = 시험 폴더 `_data`)
|
|
* 글 [한, 영] 짝은 아래 TEXT · 용어는 정함(입력 · 함수 · 출력 · 다이어그램 · 알약 · 변수 · 단위)
|
|
* ========================================================================== */
|
|
|
|
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
|
import {
|
|
createButton,
|
|
createInputField,
|
|
createSelectField,
|
|
el,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import {
|
|
fetchFiles,
|
|
fetchRows,
|
|
fetchSubs,
|
|
type SubInfo,
|
|
} from "../../M01_MasterData/M01_MasterData_Api_Fetch";
|
|
import { cfPlain, cfUserElementAdd, cfUserElements, cfValues, type CfQuery } from "./M02_CF_Api";
|
|
import type { CfBox, CfGroup, CfLogic } from "./M02_CF_Model";
|
|
import {
|
|
GROUPS,
|
|
M01_OF,
|
|
USER_PREFIX,
|
|
addElement,
|
|
elementOf,
|
|
machineFields,
|
|
queryOf,
|
|
removeElement,
|
|
setKind,
|
|
valueOf,
|
|
type M01Row,
|
|
} from "./M02_CF_InputPanel_Build";
|
|
import { renderTablePick } from "./M02_CF_InputPanel_Table";
|
|
import { renderVariables } from "./M02_CF_InputPanel_Vars";
|
|
import "./M02_CF_InputPanel.css";
|
|
|
|
const TEXT = {
|
|
Kind: ["무엇을 넣을까요?", "What goes in?"],
|
|
Variable: ["변수", "Variable"],
|
|
인력: ["인력", "Labor"],
|
|
재료: ["재료", "Material"],
|
|
유가전력: ["유가·전력", "Fuel · power"],
|
|
기계: ["기계", "Machine"],
|
|
소요량: ["소요량", "Quantity table"],
|
|
계수: ["계수", "Factor table"],
|
|
환율: ["환율", "Exchange rate"],
|
|
요율: ["요율", "Rate"],
|
|
Chosen: [
|
|
"넣은 요소 — 알약마다 이름 · 값 · 단위",
|
|
"Chosen elements — name · value · unit per pill",
|
|
],
|
|
NoneChosen: ["아직 고른 요소가 없음", "Nothing chosen yet"],
|
|
Remove: ["빼기", "Remove"],
|
|
Put: ["넣기", "Add"],
|
|
Search: ["이름으로 찾기", "Search by name"],
|
|
AllSub: ["전체", "All"],
|
|
SubLabel: ["구분", "Group"],
|
|
DetailLabel: ["상세구분", "Detail"],
|
|
Loading: ["찾는 중…", "Searching…"],
|
|
NoRow: ["찾는 요소 없음", "Nothing found"],
|
|
Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"],
|
|
Mine: ["내가 만든 것", "Mine"],
|
|
NewHead: ["새 요소 — 마스터에는 안 쓰고 내 것으로만 저장", "New element — saved as mine only"],
|
|
NewName: ["이름", "Name"],
|
|
NewSpec: ["규격", "Spec"],
|
|
NewUnit: ["단위", "Unit"],
|
|
NewValue: ["값(숫자)", "Value (number)"],
|
|
Make: ["만들어 넣기", "Create and add"],
|
|
NeedName: ["이름을 적어 주세요", "Enter a name"],
|
|
NeedValue: ["값은 숫자로", "Value must be a number"],
|
|
BoxUnit: ["상자 단위", "Box unit"],
|
|
Field: ["값 칸", "Value"],
|
|
NoValue: ["—", "—"],
|
|
Failed: ["불러오지 못함", "Could not load"],
|
|
Pills: ["알약 {n} 개", "{n} pills"],
|
|
NotInput: ["입력 상자를 고르면 이 판이 뜸", "Pick an input box"],
|
|
} 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");
|
|
|
|
/** 서버 실패 → 쉬운 말(Api 쉬운 말 길이 아직 없으면 그 글 그대로) */
|
|
const plain = (e: unknown): string => {
|
|
try {
|
|
return cfPlain(e);
|
|
} catch {
|
|
return e instanceof Error ? e.message : t("Failed");
|
|
}
|
|
};
|
|
|
|
const fileCache = new Map<string, string>();
|
|
/** 그룹의 M01 파일 이름 */
|
|
async function fileOf(group: CfGroup): Promise<string> {
|
|
const hit = fileCache.get(group);
|
|
if (hit) return hit;
|
|
const { group: g, part } = M01_OF[group];
|
|
const files = await fetchFiles(g);
|
|
const f = files.find((x) => !part || x.file.includes(part)) ?? files[0];
|
|
fileCache.set(group, f.file);
|
|
return f.file;
|
|
}
|
|
|
|
/** 입력 상자 판 — 고른 입력 상자의 갈래 · 요소 · 표 · 변수를 고침 · 바뀌면 on.change(새 CfLogic) */
|
|
export function renderInputPanel(
|
|
host: HTMLElement,
|
|
lg: CfLogic,
|
|
boxId: string,
|
|
on: { change(lg: CfLogic): void },
|
|
): void {
|
|
host.className = "m02-cfip";
|
|
const box = lg.boxes.find((b) => b.id === boxId);
|
|
if (!box || box.kind !== "input") {
|
|
host.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("NotInput") }));
|
|
return;
|
|
}
|
|
const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] };
|
|
const kind: CfGroup | "변수" = input.mode === "variable" ? "변수" : (input.group ?? "인력");
|
|
const body = el("div", { className: "m02-cfip__body" });
|
|
|
|
// ① 갈래 — 그룹 여덟 + 변수
|
|
const kinds = el("div", {
|
|
className: "m02-cfip__chips",
|
|
attrs: { role: "tablist", "aria-label": t("Kind") },
|
|
children: [...GROUPS, "변수" as const].map((k) => {
|
|
const b = el("button", {
|
|
className: `m02-cfip__chip${k === kind && (k !== "변수" ? input.mode === "group" : true) ? " is-on" : ""}`,
|
|
attrs: { type: "button", role: "tab" },
|
|
text: k === "변수" ? t("Variable") : t(k),
|
|
});
|
|
b.addEventListener("click", () => k !== kind && on.change(setKind(lg, boxId, k)));
|
|
return b;
|
|
}),
|
|
});
|
|
const unit = el("p", {
|
|
className: "m02-cfip__unit",
|
|
text: `${t("BoxUnit")}: ${box.unit || "-"}`,
|
|
});
|
|
host.replaceChildren(el("h4", { text: t("Kind") }), kinds, unit, body);
|
|
|
|
if (kind === "변수") renderVariables(body, lg, boxId, on);
|
|
else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on);
|
|
else renderGroup(body, lg, box, kind, on);
|
|
}
|
|
|
|
/** 그룹 요소 — 고른 것 목록 · 찾기 · 새 요소 */
|
|
function renderGroup(
|
|
host: HTMLElement,
|
|
lg: CfLogic,
|
|
box: CfBox,
|
|
group: CfGroup,
|
|
on: { change(lg: CfLogic): void },
|
|
): void {
|
|
const chosen = el("div", { className: "m02-cfip__chosen" });
|
|
const find = el("div", { className: "m02-cfip__find" });
|
|
host.replaceChildren(chosen, find, newForm(lg, box, group, on));
|
|
drawChosen(chosen, lg, box, group, on);
|
|
void drawFind(find, lg, box, group, on);
|
|
}
|
|
|
|
/** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */
|
|
function drawChosen(
|
|
host: HTMLElement,
|
|
lg: CfLogic,
|
|
box: CfBox,
|
|
group: CfGroup,
|
|
on: { change(lg: CfLogic): void },
|
|
): void {
|
|
const els = box.input?.elements ?? [];
|
|
const values = new Map<string, HTMLElement>();
|
|
host.replaceChildren(
|
|
el("h5", { text: `${t("Chosen")} · ${t("Pills", { n: els.length })}` }),
|
|
...(els.length
|
|
? els.map((e) => {
|
|
const val = el("strong", { className: "m02-cfip__value", text: t("NoValue") });
|
|
values.set(e.ref, val);
|
|
return el("div", {
|
|
className: "m02-cfip__item",
|
|
children: [
|
|
el("span", { className: "m02-cfip__name", text: e.name }),
|
|
val,
|
|
el("span", { className: "m02-cfip__muted", text: e.unit || "-" }),
|
|
createButton({
|
|
label: t("Remove"),
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => on.change(removeElement(lg, box.id, e.ref)),
|
|
}),
|
|
],
|
|
});
|
|
})
|
|
: [el("p", { className: "m02-cfip__muted", text: t("NoneChosen") })]),
|
|
);
|
|
if (!els.length) return;
|
|
const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) }) as unknown as CfQuery);
|
|
void cfValues(items).then(
|
|
(answers) =>
|
|
answers.forEach((a) => {
|
|
const node = values.get(a.id);
|
|
if (node && a.ok && a.value !== null && a.value !== undefined)
|
|
node.textContent = typeof a.value === "number" ? fmt(a.value) : String(a.value);
|
|
}),
|
|
() => undefined, // 값을 못 받아도 요소는 넣을 수 있음 — 값 칸만 「—」
|
|
);
|
|
}
|
|
|
|
/** M01 줄 찾기 — 구분 · 상세구분 · 이름 · 내가 만든 것도 함께 */
|
|
async function drawFind(
|
|
host: HTMLElement,
|
|
lg: CfLogic,
|
|
box: CfBox,
|
|
group: CfGroup,
|
|
on: { change(lg: CfLogic): void },
|
|
): Promise<void> {
|
|
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
|
const list = el("div", { className: "m02-cfip__list" });
|
|
const count = el("span", { className: "m02-cfip__muted" });
|
|
const bar: HTMLElement[] = [];
|
|
let file = "";
|
|
let sub = "";
|
|
let detail = "";
|
|
let subs: SubInfo[] = [];
|
|
const all = { value: "", text: t("AllSub") };
|
|
const subBox = createSelectField({
|
|
options: [all],
|
|
value: "",
|
|
compact: true,
|
|
ariaLabel: t("SubLabel"),
|
|
onChange: (v) => ((sub = v), (detail = ""), fillDetail(), void load()),
|
|
});
|
|
const detailBox = createSelectField({
|
|
options: [all],
|
|
value: "",
|
|
compact: true,
|
|
ariaLabel: t("DetailLabel"),
|
|
onChange: (v) => ((detail = v), void load()),
|
|
});
|
|
const fillDetail = (): void => {
|
|
const d = subs.find((s) => s.name === sub)?.details ?? [];
|
|
detailBox.root.hidden = !d.length;
|
|
detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], "");
|
|
};
|
|
fillDetail();
|
|
let seq = 0;
|
|
const load = async (): Promise<void> => {
|
|
const mine = ++seq;
|
|
list.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("Loading") }));
|
|
try {
|
|
const word = q.input.value.trim();
|
|
const [page, users] = await Promise.all([
|
|
fetchRows(file, 1, 40, word, false, sub, detail),
|
|
cfUserElements()
|
|
.then((u) =>
|
|
u.filter((x) => (x.group as string) === group || (x.group as string) === "사용자"),
|
|
)
|
|
.catch(() => []),
|
|
]);
|
|
if (mine !== seq) return;
|
|
const mineRows: M01Row[] = users
|
|
.filter((u) => !word || `${u.이름} ${u.규격}`.includes(word))
|
|
.map((u) => ({
|
|
키: u.key,
|
|
이름: u.이름,
|
|
규격: u.규격,
|
|
단위: u.단위,
|
|
값: u.값,
|
|
__mine: true,
|
|
}));
|
|
const rows = [...mineRows, ...(page.rows as M01Row[])];
|
|
count.textContent = t("Count", { n: page.total + mineRows.length, m: rows.length });
|
|
list.replaceChildren(
|
|
...(rows.length
|
|
? rows.map((r) => rowOf(r))
|
|
: [el("p", { className: "m02-cfip__muted", text: t("NoRow") })]),
|
|
);
|
|
} catch (e) {
|
|
if (mine === seq)
|
|
list.replaceChildren(el("p", { className: "m02-cfip__muted", text: plain(e) }));
|
|
}
|
|
};
|
|
const rowOf = (r: M01Row): HTMLElement => {
|
|
const fields = group === "기계" ? machineFields(r) : [];
|
|
const field = createSelectField({
|
|
options: fields.map((f) => ({ value: f, text: f })),
|
|
value: fields.includes("가격") ? "가격" : (fields[0] ?? ""),
|
|
compact: true,
|
|
ariaLabel: t("Field"),
|
|
});
|
|
const pick = (): CfLogic =>
|
|
addElement(lg, box.id, elementOf(group, r, field.select.value || "가격"));
|
|
const v = valueOf(group, r, field.select.value || "가격");
|
|
const unitText = group === "기계" ? "" : String(r["단위"] ?? "");
|
|
const b = el("button", {
|
|
className: "m02-cfip__row",
|
|
attrs: { type: "button" },
|
|
children: [
|
|
el("span", {
|
|
className: "m02-cfip__name",
|
|
children: [
|
|
el("strong", { text: String(r["이름"] ?? "") }),
|
|
el("span", { className: "m02-cfip__muted", text: ` ${r["규격"] ?? ""}` }),
|
|
...(r["__mine"] ? [el("span", { className: "m02-cfip__tag", text: t("Mine") })] : []),
|
|
],
|
|
}),
|
|
el("span", {
|
|
className: "m02-cfip__muted",
|
|
text: `${v === null ? "" : fmt(v)} ${unitText}`.trim(),
|
|
}),
|
|
],
|
|
});
|
|
b.addEventListener("click", () => on.change(pick()));
|
|
return el("div", {
|
|
className: "m02-cfip__rowbox",
|
|
children: [b, ...(fields.length ? [field.root] : [])],
|
|
});
|
|
};
|
|
try {
|
|
file = await fileOf(group);
|
|
subs = await fetchSubs({ file }).catch(() => []);
|
|
subBox.setOptions([all, ...subs.map((s) => ({ value: s.name, text: s.name }))], "");
|
|
subBox.root.hidden = !subs.length;
|
|
bar.push(subBox.root, detailBox.root);
|
|
} catch (e) {
|
|
host.replaceChildren(el("p", { className: "m02-cfip__muted", text: plain(e) }));
|
|
return;
|
|
}
|
|
let timer = 0;
|
|
q.input.addEventListener(
|
|
"input",
|
|
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
|
|
);
|
|
host.replaceChildren(
|
|
el("div", { className: "m02-cfip__bar", children: [...bar, q.root] }),
|
|
count,
|
|
list,
|
|
);
|
|
void load();
|
|
void USER_PREFIX;
|
|
}
|
|
|
|
/** 새 요소 — M01 표 「추가」 화면 꼴(이름 · 규격 · 단위 · 값) · 마스터에 안 쓰고 사용자 요소로 */
|
|
function newForm(
|
|
lg: CfLogic,
|
|
box: CfBox,
|
|
group: CfGroup,
|
|
on: { change(lg: CfLogic): void },
|
|
): HTMLElement {
|
|
const name = createInputField({ type: "text", label: t("NewName"), compact: true });
|
|
const spec = createInputField({ type: "text", label: t("NewSpec"), compact: true });
|
|
const unit = createInputField({ type: "text", label: t("NewUnit"), compact: true });
|
|
const value = createInputField({ type: "text", label: t("NewValue"), compact: true });
|
|
const go = createButton({
|
|
label: t("Make"),
|
|
onClick: () => {
|
|
const n =
|
|
value.input.value.trim() === "" ? null : Number(value.input.value.replace(/,/g, ""));
|
|
if (!name.input.value.trim()) return name.setError(t("NeedName"));
|
|
if (n !== null && !Number.isFinite(n)) return value.setError(t("NeedValue"));
|
|
name.setError(undefined);
|
|
value.setError(undefined);
|
|
cfUserElementAdd({
|
|
group,
|
|
이름: name.input.value.trim(),
|
|
규격: spec.input.value.trim(),
|
|
단위: unit.input.value.trim(),
|
|
값: n,
|
|
}).then(
|
|
(made) => {
|
|
const el1 = elementOf(group, {
|
|
키: made.key,
|
|
이름: made.이름,
|
|
규격: made.규격,
|
|
단위: made.단위,
|
|
값: made.값,
|
|
});
|
|
const withUser: CfLogic = {
|
|
...lg,
|
|
userElements: [
|
|
...lg.userElements,
|
|
{
|
|
id: made.key,
|
|
group,
|
|
name: made.이름,
|
|
spec: made.규격,
|
|
unit: made.단위,
|
|
value: made.값,
|
|
},
|
|
],
|
|
};
|
|
on.change(addElement(withUser, box.id, el1));
|
|
},
|
|
(e) => showToast(plain(e), "error"),
|
|
);
|
|
},
|
|
});
|
|
const d = el("details", {
|
|
className: "m02-cfip__new",
|
|
children: [
|
|
el("summary", { text: t("NewHead") }),
|
|
name.root,
|
|
spec.root,
|
|
unit.root,
|
|
value.root,
|
|
go,
|
|
],
|
|
});
|
|
return d;
|
|
}
|