feat(821-2): M01 기계 화면 — 목록 열 줄이기 · 상세 페이지 · 새 기계(비슷한 기계에서 시작) · 저장 때 계수 서버 재계산

- 목록 = 이름 · 규격 · 구분 · 가격 · 손료계수 · 주연료 · 조종원 · 시간당 경비(서버 값) · 별칭 · 비고 (단위 · 출처 열 없음 · 가로 넘침 없음) · 다른 요소 표 그대로
- 상세 = 기계 양식 + 원문번호 · 원문 · 구분 · 상세구분 · 별칭 · 비고 · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인) · 주소 ?machine= 로 바로 열림 · 뒤로 가기 = 목록 · 저장 안 한 고침 나가기 경고
- 고침 = 초안 → 페이지 [저장](자동저장 없음) · 서버 M01_MasterData_Machine.fix = 품셈 입력이 바뀌면 계수 짝으로 다시 셈 · 단위 · 출처 칸 채움(새 칸 = 마스터 단위 · 「자체」)
- API /api/m01/machine/lists · hourly · uses · find · row (시스템 관리자만) · 시험 test_821_2_machine_save.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 14:02:04 +09:00
co-authored by Claude Opus 5.5
parent 6e0d86c961
commit 086f6d214a
10 changed files with 1078 additions and 4 deletions
@@ -0,0 +1,304 @@
/* =============================================================================
* M01_MasterData_UI_MachineDetail.ts
* M01 기계 상세 페이지(PLAN 821-2 · 요구 790-2 · 791) — 기계 양식 부품 + 원문번호 · 원문 · 구분 ·
* 상세구분 · 별칭 · 비고 · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인).
* 고침은 초안(sessionStorage)에 바로 쌓임 → 페이지 [저장] 한 번에 서버로(자동저장 없음) ·
* 서버가 계수를 계수 짝으로 다시 셈 · 단위 · 출처 칸을 채움(`M01_MasterData_Machine.fix`).
* 대상 = 키(EQ…) · 저장 전 새 줄 `add:<차례>` · 새 기계 `new`(비슷한 기계에서 시작이 펼쳐짐).
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createMachineForm, type MachineSimilar } from "@ui/ui_template_machine_form";
import { createText } from "@ui/ui_template_text";
import { setLeaveGuard } from "../A00_Common/router";
import type { Row } from "./M01_MasterData_Api_Fetch";
import { addRow, editRow, peek, removeAdd, setAdd, toggleDelete } from "./M01_MasterData_Draft";
import {
fetchMachineLists,
fetchMachineRow,
fetchMachineUses,
findMachines,
type MachineLists,
} from "./M01_MasterData_Machine_Api";
import type { Pick } from "./M01_MasterData_UI_Side";
export interface MachineDetailOptions {
target: string;
pick: Pick;
back: () => void;
/** 새 기계가 초안 새 줄이 되면 — 주소를 `add:<차례>` 로 */
onAdded?: (target: string) => void;
}
const META = ["원문번호", "원문", "구분", "상세구분", "별칭", "비고"] as const;
type Meta = Record<(typeof META)[number], string>;
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
/** 돌려받은 함수 = 걷기 */
export function openMachineDetail(host: HTMLElement, opts: MachineDetailOptions): () => void {
let alive = true;
const stop = (): void => {
alive = false;
setLeaveGuard(null);
};
void build(host, opts, () => alive).catch((error) => {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
opts.back();
});
return stop;
}
async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => boolean) {
const file = opts.pick.file.file;
const lists = await fetchMachineLists();
let version = opts.pick.file.version;
let key = "";
let addAt: number | null = null;
let orig: Row | null = null;
let cur: Row;
if (opts.target.startsWith("add:")) {
addAt = Number(opts.target.slice(4));
const row = peek(file)?.adds[addAt];
if (!row) throw new Error(L("M01_MachineNotFound"));
cur = row;
} else if (opts.target === "new") {
cur = blankRow(lists);
} else {
key = opts.target;
const got = await fetchMachineRow(key);
version = peek(file)?.version ?? got.version;
orig = got.row;
cur = peek(file)?.edits[key] ?? got.row;
}
if (!alive()) return;
let base: Row = orig ?? cur; // 줄 칸 차례 — 고침은 원래 줄 · 새 줄은 지금 줄
const meta: Meta = Object.fromEntries(META.map((m) => [m, text(cur[m])])) as Meta;
const metaOrig: Meta = Object.fromEntries(META.map((m) => [m, text((orig ?? {})[m])])) as Meta;
const isNew = !key;
/* --- 비슷한 기계 찾기(양식이 고르면 값 · 이름 · 규격 복사 · 여기서 원문 · 구분 복사) --- */
const found = new Map<string, Row>();
const findSimilar = async (q: string): Promise<MachineSimilar[]> => {
const rows = await findMachines(q);
return rows.map((r) => {
found.set(String(r["키"]), r);
return {
key: String(r["키"]),
name: text(r["이름"]),
spec: text(r["규격"]),
tag: text(r["상세구분"] || r["구분"]),
values: (r["값"] ?? {}) as Row,
};
});
};
const notice = el("div");
const form = createMachineForm({
name: text(cur["이름"]),
spec: text(cur["규격"]),
values: (cur["값"] ?? {}) as Row,
coefMode: "pumsem",
fuels: lists.fuels,
jobs: lists.jobs,
power: lists.power,
hour: lists.hour,
findSimilar,
onSimilar: (item) => {
const r = found.get(item.key);
if (!r || !isNew) return;
base = { ...blankRow(lists), ...pickMeta(r), 값: r["값"] ?? {}, 비고: r["비고"] ?? null };
for (const m of ["원문", "구분", "상세구분"] as const) meta[m] = text(r[m]);
paintMeta();
},
onChange: () => commit(),
});
/** 양식 · 머리 칸 → 초안(이름 빈 칸 · 숫자 아닌 칸이 있으면 안 넣음) */
const commit = (): void => {
const s = form.state();
const errors = [
...s.errors,
...(s.name ? [] : [`${L("MF_Name")} · ${L("M01_MachineErrors")}`]),
];
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
if (errors.length) return;
const row: Row = { ...base, ...cur, 이름: s.name, 규격: s.spec, 값: s.values };
for (const m of META) {
if (orig && meta[m] === metaOrig[m]) {
if (m in orig) row[m] = orig[m];
else delete row[m]; // 안 고친 칸 = 원래 그대로
} else if (meta[m]) row[m] = meta[m];
else if (m === "별칭")
delete row[m]; // 빈 별칭 = 칸 없음
else row[m] = m === "비고" ? null : "";
}
const ordered: Row = Object.fromEntries(
[...Object.keys(base), ...Object.keys(row)]
.filter((k, i, all) => all.indexOf(k) === i && k in row)
.map((k) => [k, row[k]]),
);
if (key && orig) editRow(file, version, key, orig, ordered);
else if (addAt !== null) setAdd(file, addAt, ordered);
else {
addRow(file, version, { 키: "", ...ordered });
addAt = (peek(file)?.adds.length ?? 1) - 1;
opts.onAdded?.(`add:${addAt}`);
}
cur = ordered;
checkSame(s.name, s.spec);
};
/* --- 같은 이름 · 규격 알림 --- */
let sameTimer: number | undefined;
const checkSame = (name: string, spec: string): void => {
window.clearTimeout(sameTimer);
sameTimer = window.setTimeout(async () => {
const rows = name ? await findMachines(name).catch(() => [] as Row[]) : [];
const hit = rows.find(
(r) => r["키"] !== key && text(r["이름"]) === name && text(r["규격"]) === spec,
);
form.setNotice(hit ? L("M01_MachineSame").replace("{value}", text(hit["키"])) : "");
}, 300);
};
/* --- 머리 칸 --- */
const metaBox = el("div", { className: "m01-machine-detail__meta" });
const paintMeta = (): void => {
const field = (m: (typeof META)[number], label: string): HTMLElement => {
const options = m === "원문" ? lists.books : m === "구분" ? Object.keys(lists.groups) : null;
const mark = (root: HTMLElement): void => {
root.classList.toggle("is-changed", !isNew && meta[m] !== metaOrig[m]);
};
if (options) {
const list = options.includes(meta[m]) || !meta[m] ? options : [meta[m], ...options];
const f = createSelectField({
label,
options: list.map((v) => ({ value: v, text: v })),
value: meta[m],
size: 28,
});
f.select.addEventListener("change", () => {
meta[m] = f.select.value;
if (m === "구분" && lists.groups[meta[m]]) meta["원문"] = lists.groups[meta[m]];
mark(f.root);
commit();
if (m === "구분") paintMeta();
});
mark(f.root);
return f.root;
}
const f = createInputField({ label, value: meta[m], size: 28 });
f.input.addEventListener("input", () => {
meta[m] = f.input.value;
mark(f.root);
commit();
});
mark(f.root);
return f.root;
};
metaBox.replaceChildren(
field("원문번호", L("M01_MachineSourceNo")),
field("원문", "원문"),
field("구분", "구분"),
field("상세구분", L("M01_MachineDetailGroup")),
field("별칭", "별칭"),
field("비고", "비고"),
);
};
paintMeta();
/* --- 쓰는 곳 --- */
const usesBox = el("section", { className: "m01-machine-detail__uses" });
let uses: string[] = [];
const paintUses = (): void => {
usesBox.replaceChildren(
el("strong", { text: `${L("M01_MachineUses")} ${uses.length}` }),
uses.length
? el("ul", { children: uses.map((u) => el("li", { text: u })) })
: createText(L("M01_MachineNoUses")),
);
};
if (key)
void fetchMachineUses(key)
.then((got) => {
uses = got;
paintUses();
})
.catch(() => paintUses());
else paintUses();
/* --- 단추 · 떠남 --- */
const dirty = (): boolean => addAt !== null || Boolean(key && peek(file)?.edits[key]);
const leave = async (): Promise<boolean> =>
!dirty() || (await showConfirmDialog(L("M01_MachineLeave")));
setLeaveGuard(leave);
const backBtn = createButton({
label: L("M01_MachineBack"),
variant: "ghost",
iconStart: "chevronLeft",
});
backBtn.addEventListener("click", async () => {
if (await leave()) opts.back();
});
const del = createButton({ label: L("M01_MachineDelete"), variant: "danger-ghost" });
del.addEventListener("click", async () => {
const ask = L("M01_MachineDeleteAsk").replace("{value}", String(uses.length));
if (!(await showConfirmDialog(uses.length ? `${ask}\n${uses.join("\n")}` : ask))) return;
if (key) toggleDelete(file, version, key);
else if (addAt !== null) removeAdd(file, addAt);
setLeaveGuard(null);
opts.back();
});
if (!key && addAt === null) del.hidden = true;
const title = isNew ? L("M01_MachineNew") : `${text(cur["이름"])} ${text(cur["규격"])}`.trim();
host.replaceChildren(
el("div", {
className: "m01-machine-detail",
children: [
el("div", {
className: "ui-toolbar m01-master__bar",
children: [
backBtn,
el("strong", { text: title }),
el("span", { className: "ui-text ui-text--muted", text: key }),
del,
],
}),
notice,
metaBox,
form.root,
usesBox,
],
}),
);
if (opts.target === "new")
host.querySelector<HTMLDetailsElement>("details")?.setAttribute("open", "");
}
/** 새 기계 바탕 — 칸 차례는 기계 줄과 같음 · 단위 · 출처는 서버가 채움 */
function blankRow(lists: MachineLists): Row {
const group = Object.keys(lists.groups)[0] ?? "";
return {
원문번호: "",
원문: lists.groups[group] ?? lists.books[0] ?? "",
구분: group,
상세구분: "",
이름: "",
규격: "",
단위: {},
값: {},
출처: {},
비고: null,
};
}
/** 고른 기계의 원문 · 구분 · 상세구분(새 기계 바탕) */
const pickMeta = (r: Row): Row => ({ 원문: r["원문"], 구분: r["구분"], 상세구분: r["상세구분"] });