- [로직 추가] 창 탭 단가산출 · 단가승격 · 사전조합 — 서고 탭은 층 단추(위 층만 · 기본 = 가장 가까운 층) + 그 층 목록 · 고르면 참조 줄 더함(같은 참조 두 번 막음) - 담은 로직 표 참조 줄 — 줄 가름 lineId · 키 · 이름 · 단위 = 가리킨 문서 것 · 구분 = 참조 키 머리 · [옵션] 없음(넣을 값은 승격 때 고정) - 줄 상세 — 단가승격 = 원 로직 키 + 원 로직 표 + 기준(1 단위) · 사전조합 = 안 줄 목록 한 층(구분 · 이름 · 수량 · 금액 넷) + 기준 · 아래 반영 합 - 문서 정리 · 보기 패널 · 다이어그램이 참조 줄을 받음(다이어그램은 단가산출 줄만) - 시험: 승격 줄 원 로직 · 표 · 사전조합 안 줄 · ui_checks 50-3-4 시나리오(목록 · calc 는 탭 안 가짜 · 공유 마스터 안 씀) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
602 lines
23 KiB
TypeScript
602 lines
23 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_Labor_Detail.ts
|
|
* 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임
|
|
* (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램.
|
|
* · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) + 표 아래 그 줄 상세(50-4 · M01 시험계산 표) ·
|
|
* 같은 줄 다시 누르면 닫힘 · [옵션] = 입력 모달(`Labor_Input`) ·
|
|
* [확인] = 화면 캐시만 · 저장은 [저장]
|
|
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
|
|
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
|
|
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
|
|
* ========================================================================== */
|
|
|
|
import { createButton, createInputField, 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 { answerView } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc";
|
|
import type { CalcAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
|
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
|
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
|
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
|
|
import {
|
|
getActiveLayer,
|
|
getActiveOwner,
|
|
readTemplate,
|
|
saveTemplate,
|
|
type ItemLogic,
|
|
type Layer,
|
|
} from "./M02_MasterTemplete_Api_Fetch";
|
|
import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick";
|
|
import { lineId, MONEY_KEYS, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
|
import {
|
|
areaLinks,
|
|
calcLabor,
|
|
fetchLogicInputs,
|
|
normalizeLabor,
|
|
type LaborCalc,
|
|
type LaborCalcLine,
|
|
type LaborCost,
|
|
type LaborDoc,
|
|
type LaborQty,
|
|
type LaborRow,
|
|
} from "./M02_MasterTemplete_Labor_Api";
|
|
import {
|
|
buildLaborDiagram,
|
|
diagramTitle,
|
|
renderComboDiagram,
|
|
} from "./M02_MasterTemplete_Labor_Diagram";
|
|
import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input";
|
|
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
|
|
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
|
import { kindOf, logicTable, matTable, qtyShort } 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 { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
|
|
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;
|
|
/** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(옮겨 가면 참 · 취소 = 거짓 · 없으면 단추 없음) */
|
|
goArea: GoArea | null;
|
|
/** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 위로 · 아래로 · 로직 추가 ·
|
|
* 자재 추가 · 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 줄 · 수량 모달은 상세와 같음 */
|
|
card?: boolean;
|
|
}
|
|
|
|
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 logics = new Map<string, ItemLogic>();
|
|
let calc: 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.값]])));
|
|
/** 그 열 면적산출 문서 · 판 — [엑셀에서 고르기]로 이으면 바뀜(일위대가 [저장] 때 함께 저장) */
|
|
let area: { doc: Workbook; 판: string } | 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 · 카드 갈래엔 없음) — 열려 있을 때만 찾음 · 고른 줄 없으면 안내 한 줄 */
|
|
const source = createSourceView();
|
|
const sourceHost = el("div", { attrs: { "data-labor-source": "" } });
|
|
const side = opt.card
|
|
? null
|
|
: createSidePanels({
|
|
prefKey: "m02-labor-side",
|
|
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);
|
|
|
|
/** 고른 줄 상세(50-4) — 단가산출 = M01 시험계산 표(재료 · 노무 · 경비 줄 + 기준 합) ·
|
|
* 단가승격 = 원 로직 표 + 원 로직 키 + 기준(1 단위) · 사전조합 = 안 줄 목록 한 층 + 기준 ·
|
|
* 셋 다 아래 반영 합 · 고른 줄 없으면 비움 */
|
|
const lineDet = el("div", {
|
|
className: "m02-labor__linedet",
|
|
attrs: { "data-labor-linedet": "" },
|
|
});
|
|
const sums = (title: string, money: Partial<LaborCost> | null | undefined): HTMLElement[] => [
|
|
el("h4", { text: title }),
|
|
el("dl", {
|
|
className: "m01-logic__sums",
|
|
children: MONEY_KEYS.flatMap((k) => [
|
|
el("dt", { text: k === "계" ? tc("Prev_Sum") : k }),
|
|
el("dd", {
|
|
className: "m01-logic__money",
|
|
text: money ? formatMoney(money[k] ?? 0) : "—",
|
|
}),
|
|
]),
|
|
}),
|
|
];
|
|
/** 사전조합 안 줄 — 구분 · 이름 · 수량 · 금액 넷(= 그 줄 1 단위 기준 × 수량) */
|
|
const innerTable = (inner: NonNullable<LaborCalcLine["안"]>): HTMLElement =>
|
|
el("table", {
|
|
className: "m01-logic__grid",
|
|
attrs: { "data-labor-inner": "" },
|
|
children: [
|
|
el("thead", {
|
|
children: [
|
|
el("tr", {
|
|
children: [tc("Sub"), tc("Col_Name"), tc("Labor_Qty"), ...MONEY_KEYS].map((h) =>
|
|
el("th", { text: h === "계" ? tc("Prev_Sum") : h }),
|
|
),
|
|
}),
|
|
],
|
|
}),
|
|
el("tbody", {
|
|
children: inner.map((x) =>
|
|
el("tr", {
|
|
children: [
|
|
el("td", { text: kindOf({ 로직: "", 참조: x.참조 }) }),
|
|
el("td", {
|
|
text: `${x.참조.키} ${x.이름 ?? ""}${x.까닭 ? ` · ${x.까닭}` : ""}`.trim(),
|
|
}),
|
|
el("td", { text: `${x.수량} ${x.단위 ?? ""}`.trim() }),
|
|
...MONEY_KEYS.map((k) =>
|
|
el("td", {
|
|
className: "m01-logic__money",
|
|
text: x.기준 ? formatMoney(x.기준[k] * x.수량) : "—",
|
|
}),
|
|
),
|
|
],
|
|
}),
|
|
),
|
|
}),
|
|
],
|
|
});
|
|
function showLine(): void {
|
|
if (!picked) return void lineDet.replaceChildren();
|
|
const row = cur.담은로직.find((r) => lineId(r) === picked);
|
|
const line = lineOf(picked);
|
|
const logic = logics.get(picked);
|
|
const name = row?.참조 ? (line?.이름 ?? "") : (logic?.이름 ?? "");
|
|
const head = el("h4", {
|
|
text: tc("Labor_LineDetail").replace("{value}", `${row?.참조?.키 ?? picked} ${name}`.trim()),
|
|
});
|
|
if (!line)
|
|
return void lineDet.replaceChildren(
|
|
head,
|
|
el("p", { className: "m01-logic__muted", text: fail ?? "…" }),
|
|
);
|
|
const stop: CalcAnswer | null = line.까닭 ? { ok: false, reason: line.까닭 } : null;
|
|
const unit = `${tc("Labor_LineBase")} (1 ${line.분모 ?? ""})`.trim();
|
|
const body: HTMLElement[] = !row?.참조
|
|
? answerView(
|
|
stop ?? {
|
|
ok: true,
|
|
lines: line.줄,
|
|
sums: line.기준 ?? undefined,
|
|
result: line.결과,
|
|
middle: {},
|
|
},
|
|
false,
|
|
(logic?.결과단위 ?? "").startsWith("원"),
|
|
)
|
|
: line.안
|
|
? [
|
|
innerTable(line.안),
|
|
...(stop ? answerView(stop, false, true) : []),
|
|
...sums(unit, line.기준),
|
|
]
|
|
: [
|
|
el("p", {
|
|
className: "m01-logic__muted",
|
|
text: `${tc("Labor_FromLogic")} ${line.원로직 ?? "—"}`,
|
|
}),
|
|
...answerView(stop ?? { ok: true, lines: line.줄, middle: {} }, false, true),
|
|
...sums(unit, line.기준),
|
|
];
|
|
// 반영 = 기준 × 수량 ÷ 배(표 아래 줄과 같음) · 수량 없으면 「—」 · 돈 아닌 로직은 없음
|
|
const applied = line.기준 ? sums(tc("Labor_LineApplied"), line.반영) : [];
|
|
lineDet.replaceChildren(head, ...body, ...applied);
|
|
}
|
|
|
|
const drawDg = (): void => {
|
|
if (opt.card || !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);
|
|
|
|
const draw = (): void => {
|
|
logicHost.replaceChildren(
|
|
logicTable(
|
|
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
|
|
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
|
|
edit
|
|
? {
|
|
open: pick,
|
|
option: openRow,
|
|
qty: openQty,
|
|
...(opt.card ? {} : { move }),
|
|
remove: removeRow,
|
|
alias: setAlias,
|
|
}
|
|
: { open: pick, option: openRow },
|
|
),
|
|
lineDet,
|
|
);
|
|
showLine();
|
|
matHost.replaceChildren(
|
|
matTable(
|
|
{ mats: cur.자재, calc, fail, cells: cells() },
|
|
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 got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined);
|
|
if (!alive || mine !== seq) return;
|
|
calc = 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.계산값 = recalcWorkbook(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 },
|
|
goArea: opt.goArea,
|
|
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");
|
|
}
|
|
};
|
|
|
|
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();
|
|
}
|
|
/** 줄 고름(47-5) — 하이라이트(표만 · 다이어그램 안 다시 그림) + 줄 상세(50-4) + 원문 패널(47-4) ·
|
|
* 고른 줄 다시 누름 = 닫음 · 화면 캐시 안 바뀜 */
|
|
function pick(i: number): void {
|
|
const key = lineId(cur.담은로직[i]) ?? "";
|
|
picked = picked === key ? null : key;
|
|
for (const tr of logicHost.querySelectorAll<HTMLElement>("[data-labor-logic],[data-labor-opt]"))
|
|
tr.classList.toggle("is-selected", (tr.dataset.laborLogic ?? tr.dataset.laborOpt) === picked);
|
|
showLine();
|
|
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());
|
|
}
|
|
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;
|
|
openLaborInput({
|
|
title: `${row.로직} ${logic?.이름 ?? ""}`.trim(),
|
|
logic,
|
|
values: row.입력값,
|
|
memo: row.메모,
|
|
readOnly: !edit,
|
|
done: (next) => {
|
|
const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
|
|
const same = JSON.stringify(values) === JSON.stringify(row.입력값);
|
|
row.입력값 = values;
|
|
row.메모 = next.메모;
|
|
changed(same); // 입력값이 그대로면(메모만) 서버 안 부름
|
|
},
|
|
});
|
|
}
|
|
|
|
/** 수량산출 칸 누름 — 로직 줄 */
|
|
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.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");
|
|
cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: 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");
|
|
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null });
|
|
void loadLogics().then(() => changed());
|
|
}, library),
|
|
});
|
|
const addMat = createButton({
|
|
label: tc("Labor_AddMat"),
|
|
variant: "filled",
|
|
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 = {
|
|
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 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: [unit.root, origin.root] }),
|
|
],
|
|
}),
|
|
]),
|
|
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
|
|
section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),
|
|
...(opt.card ? [] : [dgBox]),
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
draw();
|
|
// 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산
|
|
void Promise.all([
|
|
loadLogics(),
|
|
readTemplate(opt.layer, "area", opt.column, null).then(
|
|
(got) => {
|
|
area = { doc: got.문서 as Workbook, 판: got.판 ?? "" };
|
|
links = areaLinks(got.문서);
|
|
},
|
|
() => undefined,
|
|
),
|
|
]).then(() => {
|
|
if (alive) recalc();
|
|
});
|
|
|
|
return {
|
|
getDoc: () => cur,
|
|
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();
|
|
},
|
|
};
|
|
}
|