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
+55 -3
View File
@@ -33,6 +33,9 @@ import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch";
import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft";
import { buildHeadBar, buildHeadFilters } from "./M01_MasterData_UI_Head";
import { renderRows } from "./M01_MasterData_UI_Rows";
import { renderMachines } from "./M01_MasterData_UI_Machine";
import { openMachineDetail } from "./M01_MasterData_UI_MachineDetail";
import "./M01_MasterData_UI_Machine.css";
import { renderRateRows } from "./M01_MasterData_UI_RateSheet";
import {
buildSide,
@@ -54,6 +57,11 @@ import type { LogicHandle } from "./M01_MasterData_UI_LogicLab";
import "./M01_MasterData_UI_Style.css";
const TABLE_GROUPS = ["소요량", "계수"];
// 기계 = 목록 + 상세 페이지(821-2) · 주소 `?machine=키|add:n|new` · 목록 거름은 되돌아올 때 다시 엶
const MACHINE_FILE = "기계.json";
const MACHINE_BACK = "m01.machine.back";
const machineUrl = (target?: string): string =>
`#/${ROUTES.M01_MASTER_DATA}${target ? `?machine=${encodeURIComponent(target)}` : ""}`;
export async function renderM01MasterData(root: HTMLElement): Promise<void> {
const user = await fetchSessionUser().catch(() => null);
@@ -171,15 +179,39 @@ function buildPage(): HTMLElement {
current = { ...pick, view: undefined };
const groupTitle = pick.group === pick.label ? [pick.group] : [pick.group, pick.label];
title.textContent = groupTitle.join(" · ");
const machine = pick.file.file === MACHINE_FILE;
if (machine) sessionStorage.setItem(MACHINE_BACK, JSON.stringify(pick));
else sessionStorage.removeItem(MACHINE_BACK);
if (currentQuery().has("machine")) history.replaceState(null, "", machineUrl());
dispose = TABLE_GROUPS.includes(pick.group)
? renderTables(body, pick, query, openLogicTab)
: pick.group === "요율"
? renderRateRows(body, pick, query)
: renderRows(body, pick, query);
: machine
? renderMachines(body, pick, query, (target) => openMachine(target, true))
: pick.group === "요율"
? renderRateRows(body, pick, query)
: renderRows(body, pick, query);
renderParts(partsHost, pick, (key) => openLogicTab(key));
showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []);
};
/** 기계 상세 — 목록에서 열면 주소를 쌓아 뒤로 가기 = 목록(페이지를 다시 그려 목록 거름을 되엶) */
const openMachine = (target: string, push: boolean): void => {
if (!current) return;
const pick = current;
if (push) history.pushState({ m01Machine: true }, "", machineUrl(target));
else history.replaceState(history.state, "", machineUrl(target));
dispose();
dispose = openMachineDetail(body, {
target,
pick,
back: () => {
if ((history.state as { m01Machine?: boolean } | null)?.m01Machine) return history.back();
openFile({ ...pick, view: loadView(pick.group) });
},
onAdded: (next) => history.replaceState(history.state, "", machineUrl(next)),
});
};
let searchTimer: number | undefined;
search.addEventListener("input", () => {
window.clearTimeout(searchTimer);
@@ -342,5 +374,25 @@ function buildPage(): HTMLElement {
// 주소 `?open=` — 대시보드 설계 기본값 줄 · 옛 M02 `open=옵션` 즐겨찾기가 그 칸을 고른 채 엶
const want = currentQuery().get("open");
if (want === "로직" || want === "옵션" || want === "별칭") side.reveal(want);
const machine = currentQuery().get("machine");
const backTo = sessionStorage.getItem(MACHINE_BACK);
if (machine || (backTo && !want)) {
void side.refresh("기계").then((files) => {
const saved = (backTo ? JSON.parse(backTo) : {}) as Partial<Pick>;
const file = files.find((f) => f.file === MACHINE_FILE);
if (!file) return;
const label = saved.label ?? "기계";
openFile({
group: "기계",
sub: "",
detail: "",
label,
...saved,
file,
view: loadView("기계"),
});
if (machine) openMachine(machine, false);
});
}
return layout;
}