Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts
T
eomsangdonandClaude Opus 5.5 8cbbe41d99 feat(723-3): 일위대가 줄 모달 = 단가산출 상세 줄 모드(보기 넷) — [적용] = 그 줄 덮개 + 옵션 · 단가산출로도 저장(기본 꺼짐)
- 새 `Labor_RowSk.ts` = 줄 → 이름 없는 SK 문서(lineDoc · 바탕 줄 정체) → 717-2b `mountSangeunDetail` 그대로 얹음 ·
  take = 패치 {덮개, 뺀줄, 더한줄}(M02 변경 항목 · M01 안 바뀜) + 옵션(손댄 값만 · 자동값 안 남김)
- `Labor_RowModal.ts` = 717-3 두 보기 갈음 · [적용] 뒤 닫힘 · [단가산출로도 저장] = 개인 층 새 SK(from-line)
- `Sangeun_Detail.ts` handle 에 getValues 한 줄(브레인 허락 · 724 공용 부품이 살림)
- `Labor_Table.ts` = 우클릭 메뉴 두 그림 뒤 엶(칸 초점 밀림에 닫히던 것) · 원문 패널 처음 접힘(표 넓게) · 머리 단추 묶음
- `Hopyo_Relink._total` = 엑셀 수량(본뜬.실무수량)으로 — 705-2 다시 잇기 전 · 후 금액 되살림(723-1 번짐)
- 새 지킴 시험 test_723_labor_table(저장 수량 걷음 · 이은 알약 · 안 줄 알약 · 화면 이음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:27:51 +09:00

485 lines
20 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Labor_Table.ts
* M02 일위대가(서고 · 사전조합) 상세 = 일위대가표만(PLAN 723-2 · 내역 16 · 17 · 요구서_내역16 2장 3~5).
* · 차례 = 정보(이름 · 구분 · 단위 · 본뜬) → 일위대가표(엑셀 꼴 격자 · `Labor_Grid.laborBlock`) · 오른쪽 원문 패널
* · 옵션 값 = 규격 칸 · 「빈 값 n」 = 비고 칸 붉게(입력 빈 로직 줄)
* · 수량 칸 = 확인용 임시 입력(723-1 · 휘발 · 문서 · 저장 · dirty 안 건드림) → 그 줄 금액 · 합계 미리 보기
* (짝 셈 `Labor_Calc.settle` · 서버 안 부름) · 문서 안 줄 수량은 늘 null(쓰는 쪽 알약으로 이음)
* · 머리 단추 = [로직 추가] [자재 추가] · 줄 우클릭 = 위로 · 아래로 · 별칭 · 삭제
* · 줄 누름 = 줄 모달(`Labor_RowModal` — 옵션 · 메모 · 켜쌓기율 · 로직 자재 바꾸기 · 세 칸 · 단가산출) ·
* 사전조합 참조 줄 = 서고 원본 모달(`Labor_RefEdit`) · 사용자 자재 줄 = 자재 모달(세 칸)
* · 금액 = 서버 calc(최신 응답만 · 저장 안 함) · [저장]은 UI_Main(서버가 수량 걷고 다시 셈)
* 구조물 조합 일위대가(카드 · 열 상세)는 그대로 `Labor_Detail`(면적 잇기 · 수량산출).
* ========================================================================== */
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,
} from "./M02_MasterTemplete_Labor_Api";
import type { LaborDetailHandle, LaborDetailOptions } from "./M02_MasterTemplete_Labor_Detail";
import { laborBlock } from "./M02_MasterTemplete_Labor_Grid";
import { pasteInto } from "./M02_MasterTemplete_Labor_Paste";
import { openLogicPicker, withHopyo } 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 { addMaterialButton } from "./M02_Material_Pick";
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 = {
TempHint: [
"수량 칸 = 확인용 임시 입력(저장 안 함) — 실제 수량은 구조물 조합 · 프로젝트에서 이음",
"Quantity = temporary check only (not saved) — real quantities link in structures · projects",
],
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.고정?.비율;
const matKey = (code: string): string => `자재|${code}`;
export function mountLaborTable(
host: HTMLElement,
doc: unknown,
opt: LaborDetailOptions,
): LaborDetailHandle {
const cur = normalizeLabor(doc, opt.column);
// 723-1 — 옛 문서 손 수량은 안 씀(서버도 저장 때 걷음) · 비율 줄 율만
for (const r of cur.담은로직) if (!isRate(r)) r.수량 = null;
for (const m of cur.자재) m.수량 = null;
const saved = JSON.stringify(cur);
const edit = !opt.readOnly;
const logics = new Map<string, ItemLogic>();
/** 임시 수량(휘발) — 줄 id · 「자재|코드」 → 수 */
const temp = new Map<string, number>();
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;
const dirty = (): boolean => 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 withTemp = (): LaborDoc => ({
...cur,
담은로직: cur.담은로직.map((r) => {
const v = temp.get(lineId(r) ?? "");
return v === undefined ? r : { ...r, 수량: { 손: v } };
}),
자재: cur.자재.map((m) => {
const v = temp.get(matKey(m.코드));
return v === undefined ? m : { ...m, 수량: { 손: v } };
}),
});
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 = 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 body = withTemp();
const block = laborBlock(body, calc ?? last, logics);
const empties = new Set<number>();
block.lines.forEach((ln, n) => {
const row = body.담은로직[n];
if (!row) return void (ln.note = ln.note === tc("Grid_NoQty") ? "" : ln.note); // 자재 줄
const empty = row.로직 ? valueLine(logics.get(row.로직)?.입력 ?? [], row.입력값).empty : 0;
if (empty) empties.add(n);
if (empty) ln.note = t("Empty").replace("{n}", String(empty));
else if (ln.note === tc("Grid_NoQty")) ln.note = ""; // 수량 없음 = 서고에선 보통(723-1)
});
const have = calc ?? last;
// 빈 줄 = 단가 모름(까닭) · 빈 값만 — 임시 수량 없는 줄은 안 셈
block.blank = have ? block.lines.filter((l) => l.prices.sum == null).length + empties.size : 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 grid = createHopyoGrid(
[block],
{
onRowClick: (i) => {
const n = at(i);
if (n !== null) openAt(n);
},
editable: (i, key) => {
const n = at(i);
if (key !== "qty" || n === null) return false;
const row = cur.담은로직[n];
return !row || !isRate(row); // 임시 입력이라 읽기만 층도 됨
},
onEdit: (i, _key, text) => {
const n = at(i);
if (n !== null) setTemp(n, text);
},
cellClass: (i, key) => {
const n = at(i);
if (key === "note" && n !== null && empties.has(n)) return "ui-hopyo-grid__blank";
return 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))));
});
gridHost.replaceChildren(grid.root);
showSource();
for (const fn of watchers) fn(calc ?? last);
};
/** 서버 다시 셈 — 늦게 온 답은 버림 */
const recalc = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(async () => {
const mine = ++seq;
calc = null;
fail = null;
draw();
try {
const body = withTemp();
const got = opt.calc
? await opt.calc(body)
: await calcLabor(opt.layer, opt.column, body, undefined, opt.owner);
if (!alive || mine !== seq) return;
calc = last = got;
} 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, withTemp(), {});
draw();
} else recalc();
};
/** 임시 수량(휘발) — 빈 글 = 지움 · 수 아님 = 알림 */
function setTemp(n: number, text: string): void {
const row = cur.담은로직[n];
const key = row ? (lineId(row) ?? "") : matKey(cur.자재[n - cur.담은로직.length]?.코드 ?? "");
const raw = text.replace(/,/g, "").trim();
if (!raw) temp.delete(key);
else if (Number.isFinite(Number(raw))) temp.set(key, Number(raw));
else return showToast(t("NotNumber"), "error");
calc = calc && settle(calc, withTemp(), {});
draw();
}
/** 붙여넣기(705-2) 뒤 — 엑셀 수량은 임시 칸으로(문서엔 안 남김) */
function afterPaste(): void {
for (const r of cur.담은로직) {
const v = r.수량?.손;
if (isRate(r) || v == null) continue;
temp.set(lineId(r) ?? "", v);
r.수량 = null;
}
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.로직;
/** 줄 누름 — 줄 갈래마다 */
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; // 고정 줄 = 숫자 그대로(이름 · 별칭은 우클릭)
openRowModal({
row,
logic: logics.get(row.로직) ?? null,
title: nameOf(row),
head: [String((cur as { 이름?: string }).이름 ?? ""), cur.단위 ? `1 ${cur.단위} 당` : ""]
.filter(Boolean)
.join(" · "),
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) {
temp.delete(lineId(cur.담은로직[n]) ?? "");
cur.담은로직.splice(n, 1);
} else {
temp.delete(matKey(cur.자재[n - rows]?.코드 ?? ""));
cur.자재.splice(n - rows, 1);
}
changed();
}
/** 서고 고르기 — 참조 줄 · 고정 줄(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) => {
const id = lineId({ 참조: ref });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error");
cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: null });
changed();
},
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, 입력값: {}, 수량: 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());
}, withHopyo(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();
},
opt.layer === "personal" ? { owner: library.owner } : undefined,
);
// 정보 칸 — 이름 · 구분 · 상세구분 · 단위 · 본뜬
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 = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self");
origin.input.readOnly = true;
host.replaceChildren(
el("div", {
className: "ui-side-panels-row",
children: [
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: [
nameField.root,
...libMeta(cur, edit, () => opt.onChanged(dirty())),
unit,
origin.root,
],
}),
],
}),
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h3", { text: tc("Grid_Title") }),
...(edit
? [el("div", { className: "ui-btn-group", children: [addLogic, addMat] })]
: []),
],
}),
createText(t("TempHint"), "hint"),
gridHost,
],
}),
],
}),
side.root,
],
}),
);
draw();
void loadLogics().then(() => alive && recalc());
return {
getDoc: () => {
const out: Record<string, unknown> = { ...cur, 종류: "사전조합" };
delete out.열;
return out as unknown as LaborDoc;
},
saveArea: async () => false,
destroy: () => {
alive = false;
window.clearTimeout(timer);
host.replaceChildren();
},
};
}