Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts
T

681 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M02_MasterTemplete_Labor_Detail.ts
* 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임
* (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램.
* · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) + 그 줄 바로 아래 하위 행(51-2 · `Labor_Rows`) ·
* 같은 줄 다시 누르면 닫힘 · [옵션] = 입력 모달(`Labor_Input`) · 참조 줄 [옵션] · [수정] = 서고 원본 모달(51-14 · `Labor_RefEdit`) ·
* [확인] = 화면 캐시만 · 저장은 [저장]
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
* · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) +
* 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
* · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
* 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1)
* · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음)
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { ROUTES } from "@config/config_frontend";
import { navigateTo } from "../A00_Common/router";
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
type Recalc = (typeof import("../A00_Common/spreadsheet/spreadsheet_recalc"))["recalcWorkbook"];
import {
getActiveLayer,
getActiveOwner,
readTemplate,
saveTemplate,
type ItemLogic,
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick";
import { lineId, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import {
areaLinks,
calcLabor,
fetchLogicInputs,
normalizeLabor,
type LaborCalc,
type LaborDoc,
type LaborQty,
type LaborRow,
} from "./M02_MasterTemplete_Labor_Api";
import {
buildLaborDiagram,
diagramTitle,
renderComboDiagram,
} from "./M02_MasterTemplete_Labor_Diagram";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model";
import { keepKnown } from "./M02_Labor_Values_Model";
import { pickMaterial } from "./M02_Material_Pick";
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo";
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 interface LaborDetailOptions {
layer: Layer;
column: string;
readOnly: boolean;
/** 저장 안 한 고침이 생기거나 사라짐 */
onChanged: (dirty: boolean) => void;
/** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 위로 · 아래로 · 로직 추가 ·
* 자재 추가 · 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 줄 · 수량 모달은 상세와 같음 */
card?: boolean;
/** 일위대가 사전조합 서고(50-3) — 사전조합 문서(`column` = 키) */
mode?: "precombo";
/** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) — 없으면 페이지 층 것 */
owner?: string | null;
/** 셈 · 면적 자리 주입(142-1 · 고르기 모달 = 프로젝트 복사본) — 없으면 층 길 */
calc?: (doc: LaborDoc, 수량?: Record<string, number>) => Promise<LaborCalc & { 알약: unknown[] }>;
readArea?: () => Promise<{ 문서: unknown; 판?: string | null }>;
}
export interface LaborDetailHandle {
getDoc: () => LaborDoc;
/** [엑셀에서 고르기]로 바뀐 면적산출 문서를 저장(일위대가 [저장] 앞) · 저장했으면 참(없으면 거짓) —
* 서버가 면적 저장 때 그 열 일위대가 계산값을 다시 적어 일위대가 판이 바뀜 → 부르는 쪽이 판을 다시 읽음 */
saveArea: () => Promise<boolean>;
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 lib = opt.mode ?? null;
const logics = new Map<string, ItemLogic>();
let calc: LaborCalc | null = null;
/** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */
let last: LaborCalc | null = null;
let fail: string | null = null;
let seq = 0;
let timer = 0;
let alive = true;
/** 고른 줄 로직 키(47-5) — 키로 둬서 순서가 바뀌어도 따라감 */
let picked: string | null = null;
/** 그 열 면적산출의 알약 → 칸 · 값(저장본 · 서버 calc 와 같은 것) */
let links = new Map<string, { 칸: string; 값: number | null }>();
const cells = (): Map<string, string> => new Map([...links].map(([k, v]) => [k, v.칸]));
const given = (): Record<string, number> =>
Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]])));
/** 그 열 면적산출 문서 · 판 — [엑셀에서 고르기]로 이으면 바뀜(일위대가 [저장] 때 함께 저장) */
// 엔진(recalc)은 면적 문서와 함께 늦게 받음 — 정적 import 금지(계약 `spreadsheet_types.ts` 머리)
let area: { doc: Workbook; 판: string; recalc: Recalc } | null = null;
let areaDirty = false;
/** 알약 키 → 이름 · 단위(calc 답 `알약`) — 칸에 이을 때 연결 정보 */
let pills = new Map<string, { 이름?: string; 단위?: string }>();
const dirty = (): boolean => areaDirty || JSON.stringify(cur) !== saved;
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],
});
/** 원문 패널(47-4 · 카드 갈래엔 없음) — 열려 있을 때만 찾음 · 고른 줄 없으면 안내 한 줄 */
// 85-4 — M02 길(로그인 사용자 모두) · 제목 행 [원문 다운로드]
const source = createSourceView({
path: "/m02/logic-lib/source",
pdf: "/m02/logic-lib/source-pdf",
});
const sourceHost = el("div", { attrs: { "data-labor-source": "" } });
const side = opt.card
? null
: createSidePanels({
prefKey: "m02-labor-side",
follow: false, // 85-5 — 상세 고정 높이 칸을 채움 · 메인 스크롤과 따로
panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }],
onChange: () => showSource(),
});
function showSource(): void {
if (side?.current() !== "source") return;
if (!picked)
return void sourceHost.replaceChildren(
el("p", { className: "m01-logic__muted", text: tc("Labor_SourceHint") }),
);
source.show(logics.get(picked)?.출처 ?? "");
if (sourceHost.firstChild !== source.root) sourceHost.replaceChildren(source.root);
}
const lineOf = (key: string) => calc?.줄.find((l) => lineId(l) === key);
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
const drawDg = (): void => {
if (opt.card || lib || !dgBox.open) return; // 카드 갈래 · 서고 모드엔 다이어그램 없음
const data = buildLaborDiagram({
rows: cur.담은로직.filter((r) => !r.참조), // ponytail: 참조 줄은 다이어그램에 안 그림 — 그릴 때 카드 꼴 더함
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);
// 줄 끄는 동안(102-2)엔 다시 그리지 않음 — 끝난 뒤 한 번
const drawLogic = (): void =>
afterRowDrag(logicHost, () =>
logicHost.replaceChildren(
logicTable(
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
edit
? {
open: pick,
option: openRow,
ref: openRef,
qty: openQty,
move: reorder,
remove: removeRow,
alias: setAlias,
// 134-1 — 구조물 일위대가 로직 줄만(사전조합 안 로직 줄엔 없음)
...(lib ? {} : { promote: setPromote }),
}
: { open: pick, option: openRow, ref: openRef },
),
),
);
const draw = (): void => {
drawLogic();
matHost.replaceChildren(
matTable(
{
mats: cur.자재,
calc,
fail,
cells: cells(),
lead: logicMatRows(
cur.담은로직,
calc ?? last,
!calc && !fail,
edit ? { swap: swapMat, reset: resetMat } : undefined,
),
},
edit ? { open: openMat, remove: removeMat } : { open: openMat },
),
);
drawDg();
showSource();
};
/** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */
const recalc = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(async () => {
const mine = ++seq;
calc = null;
fail = null;
draw();
try {
// 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음)
const qty = areaDirty ? given() : undefined;
const got = opt.calc
? await opt.calc(cur, qty)
: await calcLabor(opt.layer, opt.column, cur, qty, opt.owner);
if (!alive || mine !== seq) return;
calc = last = got;
pills = new Map(
(got.알약 as { 키: string; 이름?: string; 단위?: string }[]).map((p) => [p.키, p]),
);
} catch (error) {
if (!alive || mine !== seq) return;
fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail");
}
draw();
}, 150);
};
/** `qtyOnly` = 수량만 바뀜 — 기준은 그대로라 짝 셈(`Labor_Calc.settle`)으로 바로 곱함(서버 안 부름) */
const changed = (qtyOnly = false): void => {
opt.onChanged(dirty());
if (qtyOnly && calc) {
calc = settle(calc, cur, given());
draw();
} else recalc();
};
/** 칸 잇기 · 풀기(`cell` null) — 화면 문서만(저장은 [저장]) · 됐으면 참 */
const linkCell = (pill: string, cell: string | null, replace = false): boolean => {
if (!area) {
showToast(tc("Labor_NoArea"), "error");
return false;
}
const info = pills.get(pill);
const next = linkAreaCell(
area.doc,
pill,
cell,
{
...(info?.이름 ? { 이름: info.이름 } : {}),
...(info?.단위 ? { 단위: info.단위 } : {}),
},
replace, // 그 칸 다른 알약 — 기본 함께 · [바꾸기]면 뺌(물음은 시트 안)
);
if (!next) {
showToast(tc("Labor_BadCell"), "error");
return false;
}
next.계산값 = area.recalc(next); // 식 칸 값도 바로 — 저장 때 서버가 다시 셈
area.doc = next;
links = areaLinks(next);
areaDirty = true;
return true;
};
/** 수량산출 모달(43-1) — 로직 줄 · 자재 줄 같음 · 손 · 칸 잇기 · 풀기로 `target.수량` 을 바꿈 */
const openQtyFor = (
title: string,
target: { 수량: LaborQty },
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
): void => {
const pill = line?.알약 ?? null;
openQtyModal({
title,
now: qtyShort(line, unit, cells()),
linked: !!pill && links.has(pill),
hand: target.수량?.손 ?? null,
unit,
readOnly: !edit,
sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill },
...(lib ? { handOnly: true } : {}),
done: (r) => {
if (r.kind === "keep") return;
if (r.kind === "hand") target.수량 = { 손: r.손 };
else if (r.kind === "follow") target.수량 = null;
else {
const cell = r.kind === "cell" ? r.pick.칸 : null;
if (!pill || !linkCell(pill, cell, r.kind === "cell" && r.pick.바꾸기 === true)) return;
target.수량 = null; // 칸을 따름
}
changed(true);
},
});
};
/** 아직 안 받은 로직 입력 목록만 받음 */
const loadLogics = async (): Promise<void> => {
const want = cur.담은로직.map((r) => r.로직).filter((k) => 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");
}
};
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 끌기 = 놓은 줄 자리) */
function reorder(from: number, to: number): void {
if (from === to || to < 0 || to >= cur.담은로직.length) return;
cur.담은로직.splice(to, 0, ...cur.담은로직.splice(from, 1));
changed();
}
/** 줄 고름(47-5) — 하이라이트 + 하위 행(51-2 · 표만 다시 그림 · 다이어그램 안 다시 그림) + 원문 패널(47-4) ·
* 고른 줄 다시 누름 = 닫음 · 화면 캐시 안 바뀜 */
function pick(i: number): void {
const key = lineId(cur.담은로직[i]) ?? "";
picked = picked === key ? null : key;
drawLogic();
showSource();
}
function removeRow(i: number): void {
if (lineId(cur.담은로직[i]) === picked) picked = null;
cur.담은로직.splice(i, 1);
changed();
}
/** 별칭(44-6) — 화면 캐시만(저장은 [저장]) · 금액과 무관이라 다시 셈 · 다시 그림 없음 */
function setAlias(i: number, value: string | null): void {
cur.담은로직[i].별칭 = value;
opt.onChanged(dirty());
}
/** 구분 드롭다운(134-1) — 화면 캐시만 · 금액 무관이라 다시 셈 없음 */
function setPromote(i: number, on: boolean): void {
if (on) cur.담은로직[i].승격 = true;
else delete cur.담은로직[i].승격;
opt.onChanged(dirty());
}
function removeMat(i: number): void {
cur.자재.splice(i, 1);
changed();
}
/** 로직 자재 [바꾸기](103-1) — 사용자 자재와 같은 품목은 막음 */
function swapMat(i: number, at: number, key: string): void {
const own = cur.자재.find((m) => m.코드 === key);
if (own)
return showToast(
tc("Labor_AlreadyMat").replace("{value}", `${key} ${own.이름 ?? ""}`),
"error",
);
const row = cur.담은로직[i];
row.고름 = { ...row.고름, [String(at)]: key };
changed();
}
function resetMat(i: number, at: number): void {
const row = cur.담은로직[i];
if (!row.고름) return;
delete row.고름[String(at)];
if (!Object.keys(row.고름).length) delete row.고름;
changed();
}
function openRow(i: number): void {
const row: LaborRow = cur.담은로직[i];
const logic = logics.get(row.로직) ?? null;
openLaborInput({
title: `${row.로직} ${logic?.이름 ?? ""}`.trim(),
logic,
values: row.입력값,
memo: row.메모,
rate: row.켜쌓기율 ?? null,
readOnly: !edit,
done: (next) => {
const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
const same =
JSON.stringify(values) === JSON.stringify(row.입력값) &&
next.켜쌓기율 === (row.켜쌓기율 ?? null);
const before = row.입력값;
row.입력값 = values;
releaseTied(row, before, lineOf(row.로직)?.재료 ?? []); // 바뀐 입력에 걸린 고름 = 기본(103-1)
row.메모 = next.메모;
if (next.켜쌓기율 === null) delete row.켜쌓기율;
else row.켜쌓기율 = next.켜쌓기율;
changed(same); // 입력값 · 켜쌓기율이 그대로면(메모만) 서버 안 부름
},
});
}
/** 참조 줄 [옵션] · [수정](51-14) — 서고 원본 모달 · 자기 층 서고만 고침(위 층 = 읽기만 · 서버도 막음) ·
* 닫으면 다시 셈(원본이 바뀌었을 수 있음) */
function openRef(i: number): void {
const ref = cur.담은로직[i].참조;
if (!ref) return;
void openRefEdit({
ref,
title: `${ref.키} ${lineOf(lineId({ 참조: ref }) ?? "")?.이름 ?? ""}`.trim(),
readOnly: !edit || ref.층 !== opt.layer,
closed: () => alive && recalc(),
});
}
/** 수량산출 칸 누름 — 로직 줄 */
function openQty(i: number): void {
const row = cur.담은로직[i];
const line = lineOf(lineId(row) ?? "");
openQtyFor(
`${row.참조?.키 ?? row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(),
row,
line,
line?.분모 ?? "",
);
}
/** 자재 줄 누름 — 수량산출 모달 */
function openMat(i: number): void {
const mat = cur.자재[i];
openQtyFor(`${mat.코드} ${mat.이름 ?? ""}`.trim(), mat, matOf(mat.코드), mat.단위 ?? "");
}
/** 서고 탭(50-3 ④) — 참조 줄 더함(같은 참조 두 번 막음 · 위 층만은 서버도 막음) */
const library = {
layer: opt.layer,
owner:
opt.owner !== undefined
? opt.owner
: opt.layer === getActiveLayer()
? getActiveOwner()
: null,
pick: (ref: LaborRef, name: string) => {
const id = lineId({ 참조: ref });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error");
const 수량 = lib ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6)
cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량 });
changed();
},
};
const addLogic = createButton({
label: tc("AddLogic"),
variant: "filled",
onClick: () =>
openLogicPicker(
(x) => {
if (cur.담은로직.some((r) => r.로직 === x.키))
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
// 사전조합 = 면적산출 없음 → 손 1(51-11)
const 수량 = lib ? { 손: 1 } : null;
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량 });
void loadLogics().then(() => changed());
},
// 사전조합도 같은 창 · 같은 목록(51-11 · 자기 자신 · 순환은 [저장] 때 서버 422)
library,
),
});
const addMat = createButton({
label: tc("Labor_AddMat"),
variant: "filled",
onClick: () =>
pickMaterial((m) => {
if (insideKeys((calc ?? last)?.줄).has(m.코드))
return showToast(
tc("Labor_InLogicMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
"error",
);
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 = {
root: el("div", { className: "ui-field", attrs: { "data-labor-field": "단위" } }),
};
const unit0 = cur.단위;
const drawUnit = (): void =>
unit.root.replaceChildren(
el("label", { className: "ui-field__label", text: tc("Labor_Unit") }),
createUnitPick({
value: cur.단위 || NO_UNIT,
extra: [unit0, cur.단위].filter((u): u is string => !!u),
readOnly: !edit,
ariaLabel: tc("Labor_Unit"),
onPick: (u) => {
cur.단위 = u === NO_UNIT ? null : u;
opt.onChanged(dirty());
drawUnit();
},
}),
);
drawUnit();
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,
],
});
// 서고 모드 이름 칸(문서 `이름` · 목록에 보이는 이름)
const nameField = createInputField({ type: "text", label: tc("Lib_Name") });
nameField.root.dataset.laborField = "이름";
nameField.input.value = String((cur as { 이름?: unknown }).이름 ?? "");
nameField.input.readOnly = !edit;
nameField.input.addEventListener("input", () => {
(cur as { 이름?: string }).이름 = nameField.input.value.trim();
opt.onChanged(dirty());
});
/** 서고 구분 · 상세구분(51-12 · 134-5) — 구조물군 › 종류 + 공통(`precomboClasses`)에서 고름 ·
* 구분 바꾸면 상세구분 = 그 첫 줄(공통) · 빈 값 = 미분류 */
const meta = cur as { 구분?: string | null; 상세구분?: string | null };
const libMeta = (): HTMLElement[] => {
const sub = createSelectField({ options: [], label: tc("Sub"), disabled: !edit });
const detail = createSelectField({ options: [], label: tc("Detail"), disabled: !edit });
sub.root.dataset.laborField = "구분";
detail.root.dataset.laborField = "상세구분";
let subs: { name: string; details: string[] }[] = [];
// 목록에 없는 지금 값(빈 것 = 「—」 · 옛 문서)도 선택지로 둠
const opts = (names: string[], now: string | null | undefined) =>
[...new Set([now ?? "", ...names])]
.filter((v, i) => v || i === 0)
.map((v) => ({ value: v, text: v || "—" }));
const fill = (): void => {
sub.setOptions(
opts(
subs.map((s) => s.name),
meta.구분,
),
meta.구분 ?? "",
);
const details = subs.find((s) => s.name === meta.구분)?.details ?? [];
detail.setOptions(opts(details, meta.상세구분), meta.상세구분 ?? "");
};
sub.select.addEventListener("change", () => {
meta.구분 = sub.select.value;
meta.상세구분 = subs.find((s) => s.name === meta.구분)?.details[0] ?? null;
fill();
opt.onChanged(dirty());
});
detail.select.addEventListener("change", () => {
meta.상세구분 = detail.select.value;
opt.onChanged(dirty());
});
fill();
void fetchStructureDef().then(
(def) => {
subs = precomboClasses(def);
fill();
},
() => undefined,
);
return [sub.root, detail.root];
};
const editor = (body: HTMLElement): HTMLElement =>
side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body;
host.replaceChildren(
editor(
el("div", {
className: "m01-logic__editor m01-combo m02-labor__edit",
attrs: { "data-labor": opt.column },
children: [
...(opt.card
? []
: [
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", {
className: "m01lab__info",
children: [
...(lib ? [nameField.root, ...libMeta()] : []),
unit.root,
origin.root,
],
}),
],
}),
]),
// 사전조합 = 같은 [로직 추가](51-11)
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]),
...(opt.card || lib ? [] : [dgBox]),
],
}),
),
);
draw();
// 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산
void Promise.all([
loadLogics(),
lib // 서고 모드엔 면적산출 없음
? undefined
: Promise.all([
opt.readArea ? opt.readArea() : readTemplate(opt.layer, "area", opt.column, null),
import("../A00_Common/spreadsheet/spreadsheet_recalc"),
]).then(
([got, { recalcWorkbook }]) => {
area = { doc: got.문서 as Workbook, 판: got.판 ?? "", recalc: recalcWorkbook };
links = areaLinks(got.문서);
},
() => undefined,
),
]).then(() => {
if (alive) recalc();
});
return {
// 서고 모드 = 문서 종류 그대로 · 열 · 자재 없음
getDoc: () => {
if (!lib) return cur;
const out: Record<string, unknown> = { ...cur, 종류: "사전조합" };
delete out.열;
delete out.자재;
return out as unknown as LaborDoc;
},
saveArea: async () => {
if (!area || !areaDirty) return false;
const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc);
area.판 = info.판;
areaDirty = false;
return true;
},
destroy: () => {
alive = false;
window.clearTimeout(timer);
host.replaceChildren();
},
};
}