Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_Labor_Detail.ts
|
|
* 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임
|
|
* (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램.
|
|
* · 담은 로직 줄 누름 = 입력 모달(`Labor_Input`) · [확인] = 화면 캐시만 · 저장은 [저장]
|
|
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
|
|
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
|
|
* ========================================================================== */
|
|
|
|
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
|
import { ROUTES } from "@config/config_frontend";
|
|
import { navigateTo } from "../A00_Common/router";
|
|
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
|
|
import type { ItemLogic, Layer } from "./M02_MasterTemplete_Api_Fetch";
|
|
import {
|
|
calcLabor,
|
|
fetchLogicInputs,
|
|
type LaborCalc,
|
|
type LaborDoc,
|
|
type LaborRow,
|
|
} from "./M02_MasterTemplete_Labor_Api";
|
|
import {
|
|
buildLaborDiagram,
|
|
diagramTitle,
|
|
renderComboDiagram,
|
|
} from "./M02_MasterTemplete_Labor_Diagram";
|
|
import { openLaborInput, openQtyModal } from "./M02_MasterTemplete_Labor_Input";
|
|
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
|
import { logicTable, matTable, qtyText } from "./M02_MasterTemplete_Labor_Rows";
|
|
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
|
import { keepKnown } from "./M02_Labor_Values_Model";
|
|
import { pickMaterial } from "./M02_Material_Pick";
|
|
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
|
|
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
|
|
import "@ui/ui_template_general_layout.css";
|
|
import "./M02_MasterTemplete_Labor.css";
|
|
|
|
/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */
|
|
export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
|
const d = (doc && typeof doc === "object" ? doc : {}) as Partial<LaborDoc>;
|
|
return {
|
|
...d,
|
|
종류: "일위대가",
|
|
열: d.열 ?? column,
|
|
단위: d.단위 ?? null,
|
|
담은로직: (d.담은로직 ?? []).map((r) => ({
|
|
로직: r.로직,
|
|
메모: r.메모 ?? null,
|
|
입력값: r.입력값 ?? {},
|
|
수량: r.수량 ?? null,
|
|
})),
|
|
자재: (d.자재 ?? []).map((m) => ({
|
|
코드: m.코드,
|
|
이름: m.이름 ?? null,
|
|
규격: m.규격 ?? null,
|
|
단위: m.단위 ?? null,
|
|
수량: m.수량 ?? null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export interface LaborDetailOptions {
|
|
layer: Layer;
|
|
column: string;
|
|
readOnly: boolean;
|
|
/** 저장 안 한 고침이 생기거나 사라짐 */
|
|
onChanged: (dirty: boolean) => void;
|
|
/** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(없으면 단추 없음) */
|
|
goArea: (() => void) | null;
|
|
}
|
|
|
|
export interface LaborDetailHandle {
|
|
getDoc: () => LaborDoc;
|
|
destroy: () => void;
|
|
}
|
|
|
|
export function mountLaborDetail(
|
|
host: HTMLElement,
|
|
doc: unknown,
|
|
opt: LaborDetailOptions,
|
|
): LaborDetailHandle {
|
|
const cur = normalizeLabor(doc, opt.column);
|
|
const saved = JSON.stringify(cur);
|
|
const edit = !opt.readOnly;
|
|
const logics = new Map<string, ItemLogic>();
|
|
let calc: LaborCalc | null = null;
|
|
let fail: string | null = null;
|
|
let seq = 0;
|
|
let timer = 0;
|
|
let alive = true;
|
|
|
|
const logicHost = el("div");
|
|
const matHost = el("div");
|
|
const dgHost = el("div");
|
|
const dgBox = el("details", {
|
|
className: "m01lab__box m01-logic__section m02-labor__dg",
|
|
attrs: { open: "" },
|
|
children: [el("summary", { text: diagramTitle() }), dgHost],
|
|
});
|
|
|
|
const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key);
|
|
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
|
|
|
|
const drawDg = (): void => {
|
|
if (!dgBox.open) return;
|
|
const data = buildLaborDiagram({
|
|
rows: cur.담은로직,
|
|
logics: [...logics.values()],
|
|
calc,
|
|
mats: cur.자재.length,
|
|
});
|
|
renderComboDiagram(dgHost, data, {
|
|
focusInput: (key) => {
|
|
const i = cur.담은로직.findIndex((r) => r.로직 === key);
|
|
if (i >= 0) openRow(i);
|
|
},
|
|
// 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard)
|
|
openLogic: (key) => {
|
|
setPendingLogicKey(key);
|
|
navigateTo(ROUTES.M01_MASTER_DATA);
|
|
},
|
|
});
|
|
};
|
|
dgBox.addEventListener("toggle", drawDg);
|
|
|
|
const draw = (): void => {
|
|
logicHost.replaceChildren(
|
|
logicTable(
|
|
{ rows: cur.담은로직, logics, calc, fail },
|
|
// 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠)
|
|
edit ? { open: openRow, move, remove: removeRow } : { open: openRow },
|
|
),
|
|
);
|
|
matHost.replaceChildren(
|
|
matTable(
|
|
{ mats: cur.자재, calc, fail },
|
|
edit ? { open: openMat, remove: removeMat } : { open: openMat },
|
|
),
|
|
);
|
|
drawDg();
|
|
};
|
|
|
|
/** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */
|
|
const recalc = (): void => {
|
|
window.clearTimeout(timer);
|
|
timer = window.setTimeout(async () => {
|
|
const mine = ++seq;
|
|
calc = null;
|
|
fail = null;
|
|
draw();
|
|
try {
|
|
const got = await calcLabor(opt.layer, opt.column, cur);
|
|
if (!alive || mine !== seq) return;
|
|
calc = got;
|
|
} catch (error) {
|
|
if (!alive || mine !== seq) return;
|
|
fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail");
|
|
}
|
|
draw();
|
|
}, 150);
|
|
};
|
|
|
|
const changed = (): void => {
|
|
opt.onChanged(JSON.stringify(cur) !== saved);
|
|
recalc();
|
|
};
|
|
|
|
/** 아직 안 받은 로직 입력 목록만 받음 */
|
|
const loadLogics = async (): Promise<void> => {
|
|
const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k));
|
|
if (!want.length) return;
|
|
try {
|
|
const got = await fetchLogicInputs(want);
|
|
for (const l of got.로직) logics.set(l.로직, l);
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
|
|
}
|
|
};
|
|
|
|
function move(i: number, d: number): void {
|
|
const j = i + d;
|
|
if (j < 0 || j >= cur.담은로직.length) return;
|
|
[cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]];
|
|
changed();
|
|
}
|
|
function removeRow(i: number): void {
|
|
cur.담은로직.splice(i, 1);
|
|
changed();
|
|
}
|
|
function removeMat(i: number): void {
|
|
cur.자재.splice(i, 1);
|
|
changed();
|
|
}
|
|
|
|
function openRow(i: number): void {
|
|
const row: LaborRow = cur.담은로직[i];
|
|
const logic = logics.get(row.로직) ?? null;
|
|
const line = lineOf(row.로직);
|
|
openLaborInput({
|
|
title: `${row.로직} ${logic?.이름 ?? ""}`.trim(),
|
|
logic,
|
|
values: row.입력값,
|
|
memo: row.메모,
|
|
seat: {
|
|
qty: row.수량,
|
|
linked: line && line.수량출처 !== "손" ? qtyText(line, line.분모 ?? "") || null : null,
|
|
unit: line?.분모 ?? "",
|
|
},
|
|
readOnly: !edit,
|
|
goArea: opt.goArea,
|
|
done: (next) => {
|
|
row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
|
|
row.메모 = next.메모;
|
|
row.수량 = next.수량;
|
|
changed();
|
|
},
|
|
});
|
|
}
|
|
|
|
function openMat(i: number): void {
|
|
const mat = cur.자재[i];
|
|
const line = matOf(mat.코드);
|
|
openQtyModal(
|
|
`${mat.코드} ${mat.이름 ?? ""}`.trim(),
|
|
{
|
|
qty: mat.수량,
|
|
linked:
|
|
line && line.수량출처 && line.수량출처 !== "손" && line.수량 !== null
|
|
? `${line.수량출처} = ${line.수량}`
|
|
: null,
|
|
unit: mat.단위 ?? "",
|
|
},
|
|
!edit,
|
|
opt.goArea,
|
|
(qty) => {
|
|
mat.수량 = qty;
|
|
changed();
|
|
},
|
|
);
|
|
}
|
|
|
|
const addLogic = createButton({
|
|
label: tc("AddLogic"),
|
|
variant: "filled",
|
|
size: "sm",
|
|
onClick: () =>
|
|
openLogicPicker((x) => {
|
|
if (cur.담은로직.some((r) => r.로직 === x.키))
|
|
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
|
|
cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null });
|
|
void loadLogics().then(changed);
|
|
}),
|
|
});
|
|
const addMat = createButton({
|
|
label: tc("Labor_AddMat"),
|
|
variant: "filled",
|
|
size: "sm",
|
|
onClick: () =>
|
|
pickMaterial((m) => {
|
|
if (cur.자재.some((x) => x.코드 === m.코드))
|
|
return showToast(
|
|
tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
|
|
"error",
|
|
);
|
|
cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null });
|
|
changed();
|
|
}),
|
|
});
|
|
|
|
const unit = createInputField({ type: "text", label: tc("Labor_Unit") });
|
|
unit.input.value = cur.단위 ?? "";
|
|
unit.input.readOnly = !edit;
|
|
unit.input.maxLength = 20;
|
|
unit.input.setAttribute("data-labor-field", "단위");
|
|
unit.input.addEventListener("input", () => {
|
|
cur.단위 = unit.input.value.trim() || null;
|
|
opt.onChanged(JSON.stringify(cur) !== saved);
|
|
});
|
|
const origin = createInputField({ type: "text", label: tc("Labor_Origin") });
|
|
origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self");
|
|
origin.input.readOnly = true;
|
|
|
|
const section = (title: string, button: HTMLElement | null, body: HTMLElement): HTMLElement =>
|
|
el("section", {
|
|
className: "m01lab__box m01-logic__section",
|
|
children: [
|
|
el("div", {
|
|
className: "m01-logic__section-head",
|
|
children: [el("h3", { text: title }), ...(edit && button ? [button] : [])],
|
|
}),
|
|
body,
|
|
],
|
|
});
|
|
|
|
host.replaceChildren(
|
|
el("div", {
|
|
className: "m01-logic__editor m01-combo m02-labor__edit",
|
|
attrs: { "data-labor": opt.column },
|
|
children: [
|
|
el("section", {
|
|
className: "m01lab__box m01-logic__section",
|
|
children: [el("div", { className: "m01lab__info", children: [unit.root, origin.root] })],
|
|
}),
|
|
section(tc("Logics_Title"), addLogic, logicHost),
|
|
section(tc("Labor_Mats"), addMat, matHost),
|
|
dgBox,
|
|
],
|
|
}),
|
|
);
|
|
draw();
|
|
void loadLogics().then(() => {
|
|
if (alive) recalc();
|
|
});
|
|
|
|
return {
|
|
getDoc: () => cur,
|
|
destroy: () => {
|
|
alive = false;
|
|
window.clearTimeout(timer);
|
|
host.replaceChildren();
|
|
},
|
|
};
|
|
}
|