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
+177
View File
@@ -0,0 +1,177 @@
/* =============================================================================
* M01_MasterData_UI_Machine.ts
* M01 기계 목록(PLAN 821-2 · 요구 790-2) — 표는 목록용 열만(이름 · 규격 · 구분 · 가격 · 손료계수 ·
* 주연료 · 조종원 · 시간당 경비(서버 값) · 별칭 · 비고) · 줄을 누르면 상세 페이지 · [새 기계] = 양식.
* 단위 · 출처 열 없음 · 고친 것 · 새 줄 · 지울 줄은 초안(노랑 · 새 · 지움 표시) — 저장은 페이지 [저장].
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createEmpty } from "@ui/ui_template_empty";
import { guessRows, pageKeepFirst, watchFit } from "@ui/ui_template_pager";
import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch";
import { onDraftChange, peek, same } from "./M01_MasterData_Draft";
import { fetchMachineHourly } from "./M01_MasterData_Machine_Api";
import { buildPager } from "./M01_MasterData_UI_Cells";
import { saveView, type Pick } from "./M01_MasterData_UI_Side";
const BAR_H = 52;
const num = (v: unknown): string =>
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "";
/** 목록 열 — 이름 · 줄 → 글 */
const COLS: [string, (r: Row) => string][] = [
["이름", (r) => String(r["이름"] ?? "")],
["규격", (r) => String(r["규격"] ?? "")],
["구분", (r) => [r["구분"], r["상세구분"]].filter(Boolean).join(" · ")],
["가격(천원)", (r) => num(val(r)["가격"])],
["손료계수", (r) => num(val(r)["손료계수"])],
[
"주연료",
(r) =>
val(r)["주연료"] ? `${val(r)["주연료종류"] ?? ""} ${num(val(r)["주연료"])}ℓ`.trim() : "",
],
[
"조종원",
(r) =>
val(r)["조종원"] ? `${num(val(r)["조종원"])}인 ${val(r)["조종원직종"] ?? ""}`.trim() : "",
],
];
const val = (r: Row): Record<string, unknown> => (r["값"] ?? {}) as Record<string, unknown>;
/** 돌려받은 함수 = 걷기. `open(target)` = 상세(키 · 새 줄 `add:<차례>` · 새 기계 `new`). */
export function renderMachines(
host: HTMLElement,
pick: Pick,
q: string,
open: (target: string) => void,
): () => void {
const file = pick.file.file;
let size = guessRows(host, BAR_H);
let page = pick.view?.page ?? 1;
let data: RowsPage | null = null;
const hourly = new Map<string, string>();
const hourText = (key: string): string => hourly.get(key) ?? "…";
const line = (row: Row, cur: Row, cls: string, target: string, key = ""): HTMLElement => {
const tr = el("tr", {
className: `m01-machine__row ${cls}`.trim(),
attrs: { "data-key": key || target },
children: [
...COLS.map(([, get]) => {
const text = get(cur);
const td = el("td", { text, attrs: { title: text } });
td.classList.toggle("is-changed", cls !== "is-new" && text !== get(row));
return td;
}),
el("td", {
className: "m01-machine__money",
text: key ? hourText(key) : "",
attrs: key ? { "data-hourly": key } : {},
}),
el("td", { text: String(cur["별칭"] ?? ""), attrs: { title: String(cur["별칭"] ?? "") } }),
el("td", { text: String(cur["비고"] ?? ""), attrs: { title: String(cur["비고"] ?? "") } }),
],
});
tr.addEventListener("click", () => open(target));
return tr;
};
const paint = (): void => {
if (!data) return;
const d = data;
const draft = peek(file);
const body = el("tbody");
(draft?.adds ?? []).forEach((r, i) => body.append(line(r, r, "is-new", `add:${i}`)));
for (const row of d.rows) {
const key = String(row["키"]);
const cur = draft?.edits[key] ?? row;
const cls = draft?.deletes.includes(key) ? "is-deleted" : same(cur, row) ? "" : "is-changed";
body.append(line(row, cur, cls, key, key));
}
const add = createButton({ label: L("M01_MachineNew"), variant: "ghost", iconStart: "plus" });
add.addEventListener("click", () => open("new"));
const empty =
d.rows.length || draft?.adds.length
? []
: [createEmpty({ text: L("M01_NoRows"), small: true })];
host.replaceChildren(
el("div", { className: "ui-toolbar m01-master__bar", children: [add] }),
el("div", {
className: "ui-table-wrap m01-master__grid-wrap m01-machine__wrap",
children: [
el("table", {
className: "ui-table m01-master__grid m01-machine__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [
...COLS.map(([h]) => el("th", { text: h })),
el("th", { text: `${L("M01_MachineHourly")}(원)` }),
el("th", { text: "별칭" }),
el("th", { text: "비고" }),
],
}),
],
}),
body,
],
}),
],
}),
buildPager(d.total, size, d.page, go),
...empty,
);
fit.ask();
};
/** 시간당 경비 — 표를 그린 뒤 서버 값으로 채움(마스터 판마다 서버가 한 번 셈) */
const fillHourly = async (keys: string[]): Promise<void> => {
const want = keys.filter((k) => !hourly.has(k));
if (!want.length) return;
try {
const got = await fetchMachineHourly(want);
for (const [k, h] of Object.entries(got)) hourly.set(k, h.계 === undefined ? "—" : num(h.계));
} catch {
for (const k of want) hourly.set(k, "—");
}
host.querySelectorAll<HTMLElement>("td[data-hourly]").forEach((td) => {
const text = hourly.get(td.dataset.hourly!);
if (text) td.textContent = text;
});
};
const go = (next: number): void => {
page = next;
void load();
};
const load = async (): Promise<void> => {
try {
data = await fetchRows(file, page, size, q, false, pick.sub, pick.detail, pick.state);
saveView(pick.group, { page: data.page });
paint();
void fillHourly(data.rows.map((r) => String(r["키"])));
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
}
};
const fit = watchFit(
host,
() => host.querySelector<HTMLElement>(".m01-master__grid-wrap"),
(rows) => {
if (rows === size) return;
page = pageKeepFirst(data?.page ?? page, size, rows);
size = rows;
void load();
},
);
const off = onDraftChange(paint);
void load();
return () => {
fit.stop();
off();
};
}