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:
@@ -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["상세구분"] });
|
||||
Reference in New Issue
Block a user