689 lines
28 KiB
TypeScript
689 lines
28 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
type MatMarkKey,
|
||
} 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 { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks";
|
||
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 { addMaterialButton } 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,
|
||
// 세 칸(148-4) = 구조물 일위대가만(사전조합 줄은 `_combo_lines` 가 안 남김)
|
||
edit
|
||
? {
|
||
open: openRow,
|
||
swap: swapMat,
|
||
reset: resetMat,
|
||
mark: lib ? undefined : markLogicMat,
|
||
}
|
||
: { open: openRow },
|
||
),
|
||
},
|
||
edit ? { open: openMat, remove: removeMat, mark: markMat } : { 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());
|
||
}
|
||
// 자재 세 칸(148-4) — 화면 캐시만 · 금액 무관이라 다시 셈 없음
|
||
function markMat(i: number, key: MatMarkKey, v: string): void {
|
||
setMark(cur.자재[i], key, v);
|
||
opt.onChanged(dirty());
|
||
}
|
||
function markLogicMat(i: number, at: number, key: MatMarkKey, v: string): void {
|
||
setLogicMark(cur.담은로직[i], at, key, v);
|
||
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 = addMaterialButton(tc("Labor_AddMat"), (m) => {
|
||
const why = insideKeys((calc ?? last)?.줄).has(m.코드)
|
||
? "Labor_InLogicMat"
|
||
: cur.자재.some((x) => x.코드 === m.코드) && "Labor_AlreadyMat";
|
||
if (why) return showToast(tc(why).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();
|
||
},
|
||
};
|
||
}
|