Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts
T
eomsangdonandClaude Opus 5.5 4673fd89ab feat(811-1): 일위대가 단가산출 줄 = SK 참조 한 행 · M01 기초 = 그 층 SK 사본 · 옮기기 도구
- 저장 · 읽기 · 복사 때 SC 안 SK 참조 줄 안 펼침(Library_Flat flat_doc sk=False) · 셈 = SK 1 단위 × 수량
- [로직 추가] M01 기초 = 그 층 SK 사본 참조(POST sangeun/from-logic · 같은 내용 물음) · 다른 층 SK = 이 층 사본 뒤 참조(Labor_SkPick)
- 프로젝트 · 셈 주입 = 옛 로직 줄 그대로
- 옮기기 M02_Labor_SkRow — 층 SC · 구조물 조합 로직 줄 → SK + 참조 한 행 · 면적 칸 잇기 옮김 · 줄마다 금액 같을 때만 · 보기 = 임시 사본
- 낡은 시험 고침: test_740_1 넷(SC 안 SK 줄 펼침 단언 → 참조 한 행) · test_723 AlsoSk(808-1 에서 걷힌 단추 단언)
- 새 시험 test_811_1_labor_ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 20:48:27 +09:00

578 lines
25 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_Table.ts
* M02 일위대가(서고 · 사전조합) 상세 = 일위대가표만(PLAN 723-2 · 내역 16 · 17 · 요구서_내역16 2장 3~5).
* · 차례 = 정보(이름 · 구분 · 단위 · 본뜬) → 일위대가표(엑셀 꼴 격자 · `Labor_Grid.laborBlock`) · 오른쪽 원문 패널
* · 옵션 값 = 규격 칸 · 「빈 값 n」 = 비고 칸 붉게(입력 빈 로직 줄)
* · 수량 칸 = 일위대가 1 단위에 드는 양(742-1 · 요구 584-1 · 문서에 저장 · 새 줄 1 · 빈 칸 = 「수량 입력」 ·
* 금액 빠짐) → 그 줄 금액 · 합계 바로(짝 셈 `Labor_Calc.settle`) · 구조물 조합 · 공사 알약이 이어지면 그 값이 앞섬
* · 합계 열 = 「기준단위 단가」(줄 1 단위) · 「수량 반영 금액」(× 수량)
* · 머리 단추 = [항목 추가](736-1 · 자재는 찾기 창에서 고름 → 자재 목록) · 줄 우클릭 · 줄 끝 ⋯ = 위로 · 아래로 · 별칭 · 삭제
* · 줄 누름 = 줄 모달(`Labor_RowModal` — 옵션 · 메모 · 켜쌓기율 · 로직 자재 바꾸기 · 세 칸 · 단가산출) ·
* 사전조합 참조 줄 = 서고 원본 모달(`Labor_RefEdit`) · 사용자 자재 줄 = 자재 모달(세 칸)
* · 금액 = 서버 calc(최신 응답만 · 저장 안 함) · [저장]은 UI_Main(서버가 다시 셈)
* · 안 이은 고정 줄(729-3) = 격자 위 띠 · [연결](`Labor_Unlinked` — 이음만 · 저장은 [저장])
* 구조물 조합 일위대가(카드 · 열 상세 · PLAN 806-1)도 이 한 부품 — `mode` 없음 = 조합 갈래: 정보 칸(이름 · 구분)
* 없음 · 수량 칸 = 수량산출 모달(`Labor_Combo` — 면적산출 잇기) · 규격 열 줄(`Labor_Spec`) · 다이어그램(열 상세) ·
* 새 줄 수량 = 면적을 따름(빈 값). 단가산출(SK) 참조 줄 누름 = 그 단가산출 문서 모달(`Labor_SkEdit` — 수식 탭 · 저장 = 그 SK).
* 811-1(요구 707-1) — 단가산출 줄 = SK 참조 한 행(저장 때 안 펼침) · [로직 추가] M01 기초 = 이 층 SK 사본 · 다른 층 SK =
* 이 층 사본 뒤 참조(`Labor_SkPick`) · 프로젝트 · 셈 주입 = 옛 로직 줄.
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { createHopyoGrid, hopyoCellClass, hopyoGrid } from "@ui/ui_template_hopyo_grid";
import { openTreeMenu, type TreeMenuItem } from "@ui/ui_template_tree";
import { askText, showConfirmDialog } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import { getActiveLayer, getActiveOwner, type ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import { lineId, settle, type LaborFixed, type LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import {
calcLabor,
fetchLogicInputs,
normalizeLabor,
type LaborCalc,
type LaborDoc,
type LaborRow,
originText,
} from "./M02_MasterTemplete_Labor_Api";
import type { LaborDetailHandle, LaborDetailOptions } from "./M02_MasterTemplete_Labor_Detail";
import { laborBlock } from "./M02_MasterTemplete_Labor_Grid";
import { tipBorn } from "./M02_MasterTemplete_Labor_Born";
import { nationalRows } from "./M02_MasterTemplete_Labor_National";
import { createCombo, diagramBox } from "./M02_MasterTemplete_Labor_Combo";
import { isSkRef, openSkEdit } from "./M02_MasterTemplete_Labor_SkEdit";
import { ownSk, skFromLogic, type SkPickScope } from "./M02_MasterTemplete_Labor_SkPick";
import { specBar, specCells } from "./M02_MasterTemplete_Labor_Spec";
import { unlinkedBar } from "./M02_MasterTemplete_Labor_Unlinked";
import { pasteInto } from "./M02_MasterTemplete_Labor_Paste";
import { openLogicPicker, withSangeun } from "./M02_MasterTemplete_Labor_Pick";
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
import { openMatModal, openRowModal } from "./M02_MasterTemplete_Labor_RowModal";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { insideKeys } from "./M02_Labor_LogicMats_Model";
import { valueLine } from "./M02_Labor_Values_Model";
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
import { libMeta } from "./M02_MasterTemplete_Labor_LibMeta";
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";
const TEXT = {
QtyHint: [
"수량 = 이 일위대가 1 단위에 드는 양(저장) — 구조물 조합 · 공사에서 알약 · 계산 값이 이어지면 그 값이 앞섬",
"Quantity = amount per 1 unit of this item (saved) — linked pills · project values take precedence",
],
NeedQty: ["수량 입력", "Enter quantity"],
BasePrice: ["기준단위 단가", "Unit price"],
QtyAmount: ["수량 반영 금액", "Amount × qty"],
Alias: ["별칭", "Alias"],
AliasAsk: ["별칭(비우면 지움)", "Alias (empty = clear)"],
RemoveAsk: ["「{value}」 줄을 지울까?", "Delete line “{value}”?"],
Empty: ["빈 값 {n}", "{n} empty"],
NotNumber: ["수가 아님", "Not a number"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
/** 비율 고정 줄(705-3) — 수량 = 율 */
const isRate = (row: LaborRow): boolean => !!row.고정?.비율;
/** 새 줄 수량(742-1) — 1 · 사용자가 고침 */
const ONE = { 손: 1 } as const;
export function mountLaborTable(
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 last: LaborCalc | null = null;
let fail: string | null = null;
let seq = 0;
let timer = 0;
let alive = true;
let picked: string | null = null;
/** 조합 갈래(806-1) — 면적 잇기 · 수량산출 덧칸 · 컨테이너(`precombo`)는 없음 */
const combo = opt.mode === "precombo" ? null : createCombo({ ...opt, edit });
const dg = combo && !opt.card ? diagramBox() : null;
const fresh = (): LaborRow["수량"] => (combo ? null : { ...ONE }); // 새 줄 수량 — 조합 = 면적을 따름
const dirty = (): boolean => !!combo?.dirty() || JSON.stringify(cur) !== saved;
/** 줄 모달이 받는 셈 답(다시 셀 때마다) */
const watchers = new Set<(c: LaborCalc | null) => void>();
const watch = (fn: (c: LaborCalc | null) => void): (() => void) => {
watchers.add(fn);
fn(calc ?? last);
return () => watchers.delete(fn);
};
const gridHost = el("div", { attrs: { "data-labor-grid": "", "data-labor-table": "" } });
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-table-side", // 일위대가표는 넓게 — 원문은 처음 접힘(누르면 엶)
defaultOpen: null,
follow: false,
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(createText(tc("Labor_SourceHint")));
source.show(logics.get(picked)?.출처 ?? "");
if (sourceHost.firstChild !== source.root) sourceHost.replaceChildren(source.root);
}
/** 일위대가표 — 줄 누름 · 우클릭 · 수량 칸(저장) · 빈 값 · 빈 수량 붉게 */
const draw = (): void => {
const block = laborBlock(cur, calc ?? last, logics);
const empties = new Set<number>();
block.lines.forEach((ln, n) => {
const row = cur.담은로직[n];
if (ln.note === tc("Grid_NoQty")) ln.note = t("NeedQty"); // 742-1 빈 수량 = 사용자가 넣음
const empty = row?.로직 ? valueLine(logics.get(row.로직)?.입력 ?? [], row.입력값).empty : 0;
if (empty) ln.note = t("Empty").replace("{n}", String(empty));
if (empty || ln.note === t("NeedQty")) empties.add(n);
});
const have = calc ?? last;
// 빈 줄 = 금액 모름(단가 까닭 · 빈 수량) · 빈 값
block.blank = have
? block.lines.filter((l, n) => l.amounts.sum == null || empties.has(n)).length
: 0;
if (fail && block.lines[0]) block.lines[0].note = fail;
const g = hopyoGrid([block]);
const at = (i: number): number | null => g.at.get(i)?.[1] ?? null;
const spec = combo && edit ? specCells(block, cur, (qtyOnly) => changed(qtyOnly)) : {};
let qtyHit = false;
const label: Record<string, string> = { sum_price: t("BasePrice"), sum: t("QtyAmount") };
const grid = createHopyoGrid(
[block],
{
// 809-1 덧칸 = 명칭 바로 뒤(넓은 표에서도 보임)
columns: g.columns
.map((c) => (label[c.key] ? { ...c, label: label[c.key] } : c))
.flatMap((c) => (c.key === "name" ? [c, ...(opt.view?.cols ?? [])] : [c])),
...(opt.view ? { rows: viewRows(g) } : {}), // 809-1 덧칸 값
// 조합 갈래 — 수량 칸 누름 = 수량산출 모달(줄 모달은 안 엶)
onCellClick: combo
? (i, key) => {
const n = at(i);
if (key !== "qty" || !edit || n === null) return;
qtyHit = true;
openQty(n);
}
: undefined,
onRowClick: (i) => {
if (qtyHit) return void (qtyHit = false);
const n = at(i);
if (n !== null && opt.view?.onRow?.(n)) return;
if (n !== null) openAt(n);
},
// 줄 끝 ⋯(731-1) — 우클릭 · 길게 누름이 없어도 같은 메뉴(위로 · 아래로 · 별칭 · 삭제)
rowMenu: edit
? {
show: (i) => at(i) !== null,
open: (i, ev) => openTreeMenu(ev, menuAt(at(i) ?? 0)),
}
: undefined,
editable: (i, key) => {
if (combo) return !!spec.editable?.(i, key); // 조합 = 규격 열 수량 칸만 손으로
const n = at(i);
if (!edit || key !== "qty" || n === null) return false;
const row = cur.담은로직[n];
return !row || !isRate(row);
},
onEdit: (i, key, text) => {
if (combo) return spec.onEdit?.(i, key, text);
const n = at(i);
if (n !== null) setQty(n, text);
},
cellClass: (i, key) => {
const n = at(i);
if (key === "note" && n !== null && empties.has(n)) return "ui-hopyo-grid__blank";
return (n !== null && opt.view?.tone?.(n, key)) || hopyoCellClass(g.rows, i, key);
},
},
edit ? (text) => void pasteInto(cur, text, afterPaste) : undefined,
);
grid.root.addEventListener("contextmenu", (ev) => {
const tr = (ev.target as Element).closest<HTMLElement>("tr[data-i]");
const n = tr ? at(Number(tr.dataset.i)) : null;
if (n === null || !edit) return;
ev.preventDefault();
// 칸 초점이 표를 밀면(scroll) 메뉴가 바로 닫힘 — 밀림이 끝난 다음 그림에 엶
requestAnimationFrame(() => requestAnimationFrame(() => openTreeMenu(ev, menuAt(n))));
});
tipBorn(grid.root, at, (n) => (cur.담은로직[n] ?? cur.자재[n - cur.담은로직.length])?.본뜬); // 740-2
const bar = opt.view ? null : unlinkedBar(cur, edit && ((qtyOnly) => changed(qtyOnly))); // 729-3 안 이은 줄 띠 · 덧칸 = 표지 열이 대신
const specs = combo && edit ? [specBar(cur, calc ?? last, (qtyOnly) => changed(qtyOnly))] : [];
gridHost.replaceChildren(...(bar ? [bar] : []), ...specs, grid.root);
dg?.draw(cur, logics, calc ?? last, openAt);
showSource();
for (const fn of watchers) fn(calc ?? last);
};
/** 덧칸 값(809-1) — 구성 줄만 */
const viewRows = (g: ReturnType<typeof hopyoGrid>) =>
g.rows.map((r, i) => {
const n = g.at.get(i)?.[1];
const v = opt.view;
return n === undefined || !v ? r : { ...r, ...Object.fromEntries(v.cols.map((c) => [c.key, v.cell(n, c.key)])) }; // prettier-ignore
});
/** 서버 다시 셈 — 늦게 온 답은 버림 */
const recalc = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(async () => {
const mine = ++seq;
calc = null;
fail = null;
draw();
try {
// 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음)
const qty = combo?.dirty() ? combo.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;
combo?.setPills(got.알약 as { 키: string; 이름?: string; 단위?: string }[]);
} catch (error) {
if (!alive || mine !== seq) return;
fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail");
}
draw();
}, 150);
};
/** `qtyOnly` = 수량만 — 짝 셈으로 바로 */
const changed = (qtyOnly = false): void => {
opt.onChanged(dirty());
if (qtyOnly && calc) {
calc = settle(calc, cur, combo?.given() ?? {});
draw();
} else recalc();
};
/** 수량 칸(742-1 · 저장) — 빈 글 = 빈 수량(「수량 입력」) · 수 아님 · 음수 = 알림 */
function setQty(n: number, text: string): void {
const row = cur.담은로직[n] ?? cur.자재[n - cur.담은로직.length];
const raw = text.replace(/,/g, "").trim();
const v = Number(raw);
if (!row) return;
if (raw && !(Number.isFinite(v) && v >= 0)) return showToast(t("NotNumber"), "error");
row.수량 = raw ? { 손: v } : null;
changed(true);
}
/** 붙여넣기(705-2) 뒤 — 엑셀 수량 = 저장 수량 */
function afterPaste(): void {
void loadLogics().then(() => changed());
}
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");
}
};
const lineOf = (row: LaborRow) => (calc ?? last)?.줄.find((l) => lineId(l) === lineId(row));
const nameOf = (row: LaborRow): string =>
row.별칭 ||
row.고정?.이름 ||
(row.참조 ? lineOf(row)?.이름 : logics.get(row.로직)?.이름) ||
row.참조?.키 ||
row.로직;
/** 모달 머리 글(이름 · 「1 ㎡ 당」) */
const head = (): string =>
[String((cur as { 이름?: string }).이름 || opt.column), cur.단위 ? `1 ${cur.단위} 당` : ""]
.filter(Boolean)
.join(" · ");
/** 수량산출 모달(조합) — 로직 줄 · 자재 줄 같음 */
function openQty(n: number): void {
const have = calc ?? last;
const mat = cur.자재[n - cur.담은로직.length];
const row = cur.담은로직[n];
if (row)
combo?.openQty(nameOf(row), row, lineOf(row), lineOf(row)?.분모 ?? "", () => changed(true));
else if (mat)
combo?.openQty(
`${mat.코드} ${mat.이름 ?? ""}`.trim(),
mat,
have?.자재.find((m) => m.코드 === mat.코드),
mat.단위 ?? "",
() => changed(true),
);
}
/** 줄 누름 — 줄 갈래마다 */
function openAt(n: number): void {
const row = cur.담은로직[n];
if (!row) return openMat(n - cur.담은로직.length);
picked = row.로직 || null;
showSource();
if (row.참조 && row.참조.키.startsWith("SC-"))
return void openRefEdit({
ref: row.참조,
title: nameOf(row),
readOnly: !edit || row.참조.층 !== opt.layer,
closed: () => alive && recalc(),
});
if (row.고정) return; // 고정 줄 = 숫자 그대로(이름 · 별칭은 우클릭)
// 단가산출(SK) 줄 = 그 단가산출 문서 모달(수식 탭 · 806-1) — 셈 주입(프로젝트 복사본)엔 옛 줄 모달
if (isSkRef(row.참조) && !opt.calc)
return void openSkEdit({
ref: row.참조,
title: nameOf(row),
head: head(),
readOnly: !edit || row.참조.층 !== opt.layer,
closed: (done) => alive && done && recalc(), // 저장했으면 금액을 다시 셈
});
openRowModal({
row,
logic: logics.get(row.로직) ?? null,
title: nameOf(row),
head: head(),
unit: lineOf(row)?.분모 ?? "",
watch,
scope: { layer: opt.layer, owner: library.owner },
readOnly: !edit,
inject: !!opt.calc,
userMats: cur.자재.map((m) => m.코드),
done: (again) => changed(!again),
});
}
function openMat(i: number): void {
const mat = cur.자재[i];
if (!mat) return;
openMatModal({
mat,
watch,
readOnly: !edit,
done: (money) => changed(!money),
});
}
/** 우클릭 — 위로 · 아래로 · 별칭 · 삭제 */
function menuAt(n: number): TreeMenuItem[] {
const rows = cur.담은로직.length;
const isMat = n >= rows;
const name = isMat ? (cur.자재[n - rows]?.이름 ?? "") : nameOf(cur.담은로직[n]);
const items: TreeMenuItem[] = [];
if (!isMat) {
if (n > 0) items.push({ label: tc("Up"), run: () => move(n, n - 1) });
if (n < rows - 1) items.push({ label: tc("Down"), run: () => move(n, n + 1) });
items.push({ label: t("Alias"), run: () => void alias(n) });
}
items.push({
label: tc("Remove"),
run: () =>
void showConfirmDialog(t("RemoveAsk").replace("{value}", name), tc("Remove")).then(
(ok) => ok && remove(n),
),
});
return items;
}
function move(from: number, to: number): void {
cur.담은로직.splice(to, 0, ...cur.담은로직.splice(from, 1));
changed();
}
async function alias(n: number): Promise<void> {
const row = cur.담은로직[n];
const got = await askText({ message: t("AliasAsk"), value: row.별칭 ?? "" });
if (got === null || got === undefined) return;
row.별칭 = got.trim() || null;
opt.onChanged(dirty());
draw();
}
function remove(n: number): void {
const rows = cur.담은로직.length;
if (n < rows) cur.담은로직.splice(n, 1);
else cur.자재.splice(n - rows, 1);
changed();
}
function addRef(ref: LaborRef, name: string, 별칭: string | null): void {
const id = lineId({ 참조: ref });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error");
cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: fresh() });
changed();
}
const failed = (e: unknown): void =>
showToast(e instanceof Error ? e.message : tc("Load_Failed"), "error");
/** 서고 고르기 — 참조 줄 · 고정 줄(721-2 새 고르기 창 · 인력 · 재료) */
const library = {
layer: opt.layer,
owner:
opt.owner !== undefined
? opt.owner
: opt.layer === getActiveLayer()
? getActiveOwner()
: null,
pick: (ref: LaborRef, name: string, 별칭: string | null = null) =>
own && isSkRef(ref) // 811-1 — 다른 층 SK = 이 층 사본 뒤 참조
? void ownSk(own, ref).then((r) => alive && addRef(r, name, 별칭), failed)
: addRef(ref, name, 별칭),
fixed: (fx: LaborFixed, name: string) => {
const id = lineId({ 고정: fx });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error");
cur.담은로직.push({
로직: "",
고정: fx,
별칭: null,
메모: null,
입력값: {},
수량: fresh(),
});
changed();
},
/** 736-1 찾아 넣기 창에서 재료를 고름 — 「자재 목록」 줄 · 로직 안 자재 · 이미 있는 자재는 막음 */
material: (m: {
코드: string;
이름: string | null;
규격: string | null;
단위: string | null;
}) => {
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.단위, 수량: fresh() });
changed();
},
};
/** SK 사본 길(811-1) — M02 층 일위대가 · 조합만(프로젝트 · 셈 주입 = 옛 로직 줄) */
const own: SkPickScope | null =
opt.layer !== "project" && !opt.calc ? { layer: opt.layer, owner: library.owner } : null;
const addLogic = createButton({
label: tc("AddLogic"),
variant: "filled",
onClick: () =>
openLogicPicker((x) => {
if (own)
// 811-1 — M01 기초 = 이 층 SK 사본 참조 한 행(펼치지 않음)
return void skFromLogic(own, x.키).then(
(r) => r && alive && addRef(r, x.이름 || x.키, null),
failed,
);
if (cur.담은로직.some((r) => r.로직 === x.키))
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: fresh() });
void loadLogics().then(() => changed());
}, withSangeun(library)),
});
// 정보 칸 — 이름 · 구분 · 상세구분 · 단위 · 본뜬
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());
});
const unit = el("div", { className: "ui-field", attrs: { "data-labor-field": "단위" } });
const unit0 = cur.단위;
const drawUnit = (): void =>
unit.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();
draw();
},
}),
);
drawUnit();
const origin = createInputField({ type: "text", label: tc("Labor_Origin") });
origin.input.value = originText(cur.본뜬) ?? tc("Origin_Self");
origin.input.readOnly = true;
const info = combo
? opt.card
? [] // 카드 갈래 = 정보 칸 없음
: [el("div", { className: "m01lab__info", children: [unit, origin.root] })]
: [
el("div", {
className: "m01lab__info",
children: [
nameField.root,
...libMeta(cur, edit, () => opt.onChanged(dirty())),
unit,
origin.root,
],
}),
];
const editor = el("div", {
className: "m01-logic__editor m01-combo m02-labor__edit",
attrs: { "data-labor": opt.column },
children: [
...(info.length
? [el("section", { className: "m01lab__box m01-logic__section", children: info })]
: []),
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h3", { text: tc("Grid_Title") }),
...(edit && !(combo && opt.card)
? [el("div", { className: "ui-btn-group", children: [addLogic] })]
: []),
],
}),
...(combo ? [] : [createText(t("QtyHint"), "hint")]),
gridHost,
],
}),
...nationalRows(cur.본뜬), // 361-2 전국 자재(보기만 · 없으면 아무것도 안 그림)
...(dg ? [dg.root] : []),
],
});
host.replaceChildren(
side ? el("div", { className: "ui-side-panels-row", children: [editor, side.root] }) : editor,
);
draw();
// 로직 입력 목록 · 면적산출 이은 칸(조합 — 없으면 빈 것)을 받은 뒤 첫 계산
void Promise.all([loadLogics(), combo?.load()]).then(() => alive && recalc());
return {
getDoc: () => {
if (combo) return cur;
const out: Record<string, unknown> = { ...cur, 종류: "사전조합" };
delete out.열;
return out as unknown as LaborDoc;
},
saveArea: async () => (await combo?.save()) ?? false,
destroy: () => {
alive = false;
window.clearTimeout(timer);
host.replaceChildren();
},
};
}