Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts
T
eomsangdonandClaude Opus 5.5 a2da6cb069 Merge remote-tracking branch 'origin/dev' into sub_laptop_8
- 충돌 시험 셋(717-4 · 719-2 · hopyo build) = 740-1 펼침 단언 + 742-1 저장 수량 단언 합침
- 740-2 일위대가표 풍선 줄의 body(742-1 이 걷은 휘발 셈 몸) → cur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:47:51 +09:00

482 lines
20 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` — 이음만 · 저장은 [저장])
* 구조물 조합 일위대가(카드 · 열 상세)는 그대로 `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,
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 { unlinkedBar } from "./M02_MasterTemplete_Labor_Unlinked";
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 { 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;
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 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 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 label: Record<string, string> = { sum_price: t("BasePrice"), sum: t("QtyAmount") };
const grid = createHopyoGrid(
[block],
{
columns: g.columns.map((c) => (label[c.key] ? { ...c, label: label[c.key] } : c)),
onRowClick: (i) => {
const n = at(i);
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) => {
const n = at(i);
if (!edit || 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) setQty(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))));
});
tipBorn(grid.root, at, (n) => (cur.담은로직[n] ?? cur.자재[n - cur.담은로직.length])?.본뜬); // 740-2
const bar = unlinkedBar(cur, edit && ((qtyOnly) => changed(qtyOnly))); // 729-3 안 이은 줄 띠
gridHost.replaceChildren(...(bar ? [bar] : []), 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 got = opt.calc
? await opt.calc(cur)
: await calcLabor(opt.layer, opt.column, cur, 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, cur, {});
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.로직;
/** 줄 누름 — 줄 갈래마다 */
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) cur.담은로직.splice(n, 1);
else 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, 입력값: {}, 수량: { ...ONE } });
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,
입력값: {},
수량: { ...ONE },
});
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.단위, 수량: { ...ONE } });
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, 입력값: {}, 수량: { ...ONE } });
void loadLogics().then(() => changed());
}, withHopyo(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;
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] })]
: []),
],
}),
createText(t("QtyHint"), "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();
},
};
}