feat(m02-df): 단가산출 흐름도 0걸음 — CostFlow 를 DataFlow 로 옮겨 이름 바꿈
- 파일 M02_CF_* → M02_DF_* · css m02-cf/m01-cf → m02-df · 서버 길 /api/m02/cf → /api/m02/df · 키 CF- → DF- · 사용자 요소 UE- 그대로 - _sample · .gitignore(_data git 밖) 옮김 · DataFlow 는 CostFlow 를 import 안 함 - 시험 test_m02_df_model · router · sample · inputpanel_build(옛 cf 시험은 그대로) - 서버 등록은 sub1 몫 — M02_MasterTemplete.DataFlow.M02_DF_Router 의 router Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel.ts
|
||||
* 단가산출 다이어그램 — 입력 상자 판: 갈래(그룹 · 변수) 고르기 · M01 그룹 요소 고르기 · 새 요소(사용자 요소) ·
|
||||
* 소요량 · 계수 표 고르기(Table.ts) · 변수 정의(Vars.ts) · 상자 단위
|
||||
* M01 Api(`Api_Fetch`)만 가져다 씀 — 마스터에 쓰지 않음(새 요소는 서버 `/api/m02/df/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 } from "./M02_DF_Api";
|
||||
import type { CfBox, CfElementRef, CfGroup, CfLogic } from "./M02_DF_Model";
|
||||
import {
|
||||
GROUPS,
|
||||
M01_OF,
|
||||
MACHINE_SEP,
|
||||
USER_PREFIX,
|
||||
addElement,
|
||||
elementOf,
|
||||
machineFields,
|
||||
queryOf,
|
||||
reconcileUnit,
|
||||
removeElement,
|
||||
setKind,
|
||||
valueOf,
|
||||
type M01Row,
|
||||
} from "./M02_DF_InputPanel_Build";
|
||||
import { renderTablePick } from "./M02_DF_InputPanel_Table";
|
||||
import { renderVariables } from "./M02_DF_InputPanel_Vars";
|
||||
import "./M02_DF_InputPanel.css";
|
||||
|
||||
/** Api 모양 — 서버 Api 가 채워지면(사용자 요소 = Model 모양 · 값 물음 = ref) 그 모양을 그대로 씀 */
|
||||
interface Answer {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
value?: number | string | null;
|
||||
}
|
||||
interface UserEl {
|
||||
id: string;
|
||||
group: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
value: number | null;
|
||||
}
|
||||
const apiValues = cfValues as unknown as (items: object[], users?: object[]) => Promise<Answer[]>;
|
||||
const apiUsers = cfUserElements as unknown as () => Promise<UserEl[]>;
|
||||
const apiAdd = cfUserElementAdd as unknown as (e: Omit<UserEl, "id">) => Promise<UserEl>;
|
||||
|
||||
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-dfip";
|
||||
const box = lg.boxes.find((b) => b.id === boxId);
|
||||
if (!box || box.kind !== "input") {
|
||||
host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("NotInput") }));
|
||||
return;
|
||||
}
|
||||
// 상자 단위가 고른 요소 · 변수 단위와 다르면(견본 등) 맞춰 다시 그림 — 맞으면 바로 지나감
|
||||
const fixed = reconcileUnit(lg, boxId);
|
||||
if (fixed !== lg) return on.change(fixed);
|
||||
const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] };
|
||||
const kind: CfGroup | "변수" = input.mode === "variable" ? "변수" : (input.group ?? "인력");
|
||||
const body = el("div", { className: "m02-dfip__body" });
|
||||
|
||||
// ① 갈래 — 그룹 여덟 + 변수
|
||||
const kinds = el("div", {
|
||||
className: "m02-dfip__chips",
|
||||
attrs: { role: "tablist", "aria-label": t("Kind") },
|
||||
children: [...GROUPS, "변수" as const].map((k) => {
|
||||
const b = el("button", {
|
||||
className: `m02-dfip__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-dfip__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-dfip__chosen" });
|
||||
const find = el("div", { className: "m02-dfip__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-dfip__value", text: t("NoValue") });
|
||||
values.set(e.ref, val);
|
||||
return el("div", {
|
||||
className: "m02-dfip__item",
|
||||
children: [
|
||||
el("span", { className: "m02-dfip__name", text: e.name }),
|
||||
val,
|
||||
el("span", { className: "m02-dfip__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-dfip__muted", text: t("NoneChosen") })]),
|
||||
);
|
||||
if (!els.length) return;
|
||||
const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) }));
|
||||
void apiValues(items, lg.userElements).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);
|
||||
}),
|
||||
// Api 가 아직 값을 못 주면 M01 줄에서 바로 읽어 보임(값 칸만 — 요소는 그대로)
|
||||
() =>
|
||||
els.forEach(
|
||||
(e) =>
|
||||
void m01Value(group, e).then((v) => {
|
||||
const node = values.get(e.ref);
|
||||
if (node && v !== null) node.textContent = fmt(v);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 요소 한 개의 값을 M01 줄에서 읽음 — 이름으로 찾아 키가 같은 줄 · 못 찾으면 null */
|
||||
async function m01Value(group: CfGroup, e: CfElementRef): Promise<number | null> {
|
||||
if (e.ref.startsWith(USER_PREFIX)) return null;
|
||||
try {
|
||||
const [key, field] = e.ref.split(MACHINE_SEP);
|
||||
const page = await fetchRows(
|
||||
await fileOf(group),
|
||||
1,
|
||||
30,
|
||||
e.name.split(" · ")[0].split(" ")[0],
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
);
|
||||
const row = (page.rows as M01Row[]).find((r) => r["키"] === key);
|
||||
return row ? valueOf(group, row, field || "가격") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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-dfip__list" });
|
||||
const count = el("span", { className: "m02-dfip__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-dfip__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),
|
||||
apiUsers().catch((): UserEl[] => []),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
const known = new Map<string, UserEl>();
|
||||
for (const u of [...lg.userElements, ...users])
|
||||
if (u.group === group) known.set(u.id, u as UserEl);
|
||||
const mineRows: M01Row[] = [...known.values()]
|
||||
.filter((u) => !word || `${u.name} ${u.spec}`.includes(word))
|
||||
.map((u) => ({
|
||||
키: u.id,
|
||||
이름: u.name,
|
||||
규격: u.spec,
|
||||
단위: u.unit,
|
||||
값: u.value,
|
||||
__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-dfip__muted", text: t("NoRow") })]),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mine === seq)
|
||||
list.replaceChildren(el("p", { className: "m02-dfip__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-dfip__row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-dfip__name",
|
||||
children: [
|
||||
el("strong", { text: String(r["이름"] ?? "") }),
|
||||
el("span", { className: "m02-dfip__muted", text: ` ${r["규격"] ?? ""}` }),
|
||||
...(r["__mine"] ? [el("span", { className: "m02-dfip__tag", text: t("Mine") })] : []),
|
||||
],
|
||||
}),
|
||||
el("span", {
|
||||
className: "m02-dfip__muted",
|
||||
text: `${v === null ? "" : fmt(v)} ${unitText}`.trim(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => on.change(pick()));
|
||||
return el("div", {
|
||||
className: "m02-dfip__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-dfip__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-dfip__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);
|
||||
apiAdd({
|
||||
group,
|
||||
name: name.input.value.trim(),
|
||||
spec: spec.input.value.trim(),
|
||||
unit: unit.input.value.trim(),
|
||||
value: n,
|
||||
}).then(
|
||||
(made) => {
|
||||
const el1 = elementOf(group, {
|
||||
키: made.id,
|
||||
이름: made.name,
|
||||
규격: made.spec,
|
||||
단위: made.unit,
|
||||
값: made.value,
|
||||
});
|
||||
const withUser: CfLogic = {
|
||||
...lg,
|
||||
userElements: [...lg.userElements.filter((u) => u.id !== made.id), { ...made, group }],
|
||||
};
|
||||
on.change(addElement(withUser, box.id, el1));
|
||||
},
|
||||
(e) => showToast(plain(e), "error"),
|
||||
);
|
||||
},
|
||||
});
|
||||
const d = el("details", {
|
||||
className: "m02-dfip__new",
|
||||
children: [
|
||||
el("summary", { text: t("NewHead") }),
|
||||
name.root,
|
||||
spec.root,
|
||||
unit.root,
|
||||
value.root,
|
||||
go,
|
||||
],
|
||||
});
|
||||
return d;
|
||||
}
|
||||
Reference in New Issue
Block a user