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>
This commit is contained in:
2026-11-01 22:27:51 +09:00
co-authored by Claude Opus 5.5
parent 6490b999e3
commit 8cbbe41d99
7 changed files with 306 additions and 51 deletions
+5 -1
View File
@@ -111,8 +111,12 @@ def rows(keys: list[str] | None = None, doc: dict[str, Any] | None = None) -> di
def _total(key: str) -> float | None:
"""1 단위 계 — 수량 = 불러온 때 엑셀 수량(`본뜬.실무수량` · 723-1 · 저장 수량 없음)."""
from M02_MasterTemplete.M02_Hopyo_Build import excel_given
at = refs.here()
got = refs.Expand(m01.loaded()[1])({"층": at[0], "주인": at[1], "키": key})
ref = {"층": at[0], "주인": at[1], "키": key}
got = refs.Expand(m01.loaded()[1], given=excel_given(ref))(ref)
return _num((got.get("기준") or {}).get("계"))
@@ -1,14 +1,17 @@
/* =============================================================================
* M02_MasterTemplete_Labor_RowModal.ts
* 일위대가표 줄 누름 모달(PLAN 723-2 · 요구서_내역16 2장 4) — 일위대가표에서 빠진 줄 동작을 모달 안으로.
* · 로직 줄 · 단가산출(SK) 참조 줄 = 머리(이름 · 「1 ㎡ 당」) · [옵션 · 메모](`Labor_Input` · 켜쌓기율) ·
* 로직 자재(바꾸기 · 기본 · 세 칸 · `Labor_LogicMats`) · [단가산출 보기](717-3 `Labor_SangeunModal`)
* 일위대가표 줄 누름 모달(PLAN 723-2 · 723-3 · 요구서_내역16 2장 4 · 5) — 일위대가표에서 빠진 줄 동작을 모달 안으로.
* · 로직 줄 · 단가산출(SK) 참조 줄 = 머리(이름 · 「1 ㎡ 당」) · [옵션](메모 · 켜쌓기율 · `Labor_Input`) ·
* 단가산출 상세 줄 모드(717-2b 보기 넷 · `Labor_RowSk` · 717-3 두 보기 갈음) ·
* 로직 자재(바꾸기 · 기본 · 세 칸 · `Labor_LogicMats`)
* · [적용] = 그 줄 덮개(M02 변경 항목) + 옵션(시험 입력 → 그 줄 입력값 · 규격 칸) · [단가산출로도 저장](기본 꺼짐)
* · 사용자 자재 줄 = 자재 모달(세 칸 · `Labor_MatTable` 한 줄)
* · 고침 = 그 줄 화면 캐시(저장은 [저장]) · 금액은 표가 다시 셈 → `watch` 로 받아 다시 그림
* ========================================================================== */
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { askConfirm, openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborCalc, LaborMat, LaborRow, MatMarkKey } from "./M02_MasterTemplete_Labor_Api";
@@ -16,7 +19,7 @@ import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks";
import { matTable } from "./M02_MasterTemplete_Labor_MatTable";
import { openRowSangeun } from "./M02_MasterTemplete_Labor_SangeunModal";
import { mountRowSk, saveAsSk, type RowSkHandle } from "./M02_MasterTemplete_Labor_RowSk";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import type { SkScope } from "./M02_MasterTemplete_Sangeun_Cover";
import { releaseTied } from "./M02_Labor_LogicMats_Model";
@@ -35,17 +38,23 @@ export interface RowModalOptions {
unit: string;
scope: SkScope;
readOnly: boolean;
/** 셈 주입(프로젝트 복사본) — [단가산출 보기] 없음 */
/** 셈 주입(프로젝트 복사본) — 단가산출 상세 없음 */
inject: boolean;
/** 사용자 자재 코드 — 로직 자재 [바꾸기]로 같은 품목 막음 */
userMats: string[];
watch: CalcWatch;
/** 고침 뒤 — `again` = 서버 다시 셈(입력 · 고름 · 별도계상) · 아니면 화면만 */
done: (again: boolean) => void;
/** [단가산출로 저장] 뒤 그 줄을 새 SK 참조 줄로 */
replace?: (row: LaborRow) => void;
}
const TEXT = {
Apply: ["적용", "Apply"],
AlsoSk: ["단가산출로도 저장", "Also save as build-up"],
Saved: ["{k} 저장함(내 개인 층)", "{k} saved (my layer)"],
Discard: ["적용 안 한 고침이 있음 — 버리고 닫을까?", "Discard unapplied edits?"],
} as const;
const t = (k: keyof typeof TEXT): string => TEXT[k][0];
export function openRowModal(o: RowModalOptions): void {
const row = o.row;
const edit = !o.readOnly;
@@ -112,56 +121,89 @@ export function openRowModal(o: RowModalOptions): void {
rate: row.켜쌓기율 ?? null,
readOnly: o.readOnly,
done: (next) => {
const values = o.logic ? keepKnown(o.logic.입력, next.입력값).kept : next.입력값;
const same =
JSON.stringify(values) === JSON.stringify(row.입력값) &&
next.켜쌓기율 === (row.켜쌓기율 ?? null);
const before = row.입력값;
row.입력값 = values;
releaseTied(row, before, line()?.재료 ?? []);
const same = setValues(next.입력값) && next.켜쌓기율 === (row.켜쌓기율 ?? null);
row.메모 = next.메모;
if (next.켜쌓기율 === null) delete row.켜쌓기율;
else row.켜쌓기율 = next.켜쌓기율;
o.done(!same);
draw();
if (!same) skMount(); // 바뀐 옵션으로 상세 다시
},
}),
});
option.dataset.rowOption = "";
const sk = createButton({
label: tc("Labor_Sangeun"),
variant: "ghost",
onClick: () =>
openRowSangeun({
scope: o.scope,
row,
title: o.title,
head: o.head,
qty: "",
unit: o.unit,
readOnly: o.readOnly,
apply: (p) => {
if (p) row.패치 = p;
else delete row.패치;
o.done(true);
},
...(o.replace ? { replace: o.replace } : {}),
}),
/** 옵션 → 그 줄 입력값(로직 줄만 · 아는 입력만) · 같으면 참 */
function setValues(next: Record<string, string | number>): boolean {
if (row.참조) return true;
const values = o.logic ? keepKnown(o.logic.입력, next).kept : next;
if (JSON.stringify(values) === JSON.stringify(row.입력값)) return true;
const before = row.입력값;
row.입력값 = values;
releaseTied(row, before, line()?.재료 ?? []); // 바뀐 입력에 걸린 고름 = 기본(103-1)
return false;
}
// 단가산출 상세 줄 모드(723-3) — 셈 주입(프로젝트 복사본)엔 없음
const skHost = el("div", { attrs: { "data-row-sk-host": "" } });
let sk: RowSkHandle | null = null;
const skMount = (): void => {
if (o.inject) return;
sk?.destroy();
sk = mountRowSk(skHost, { row, scope: o.scope, title: o.title, unit: o.unit, readOnly: !edit });
};
let close = (): void => {};
const finish = (): void => {
stop();
sk?.destroy();
};
const also = createCheckbox(t("AlsoSk"), { checked: false });
also.input.dataset.rowAlsoSk = "";
const apply = createButton({
label: t("Apply"),
variant: "filled",
onClick: async () => {
const got = sk?.take() ?? null;
if (got) {
if (got.patch) row.패치 = got.patch;
else delete row.패치;
setValues(got.values);
o.done(true);
draw();
}
if (also.input.checked) {
try {
const name = await saveAsSk(row, got?.patch ?? row.패치 ?? null, o.title, o.unit);
showToast(t("Saved").replace("{k}", name), "success");
} catch (error) {
return showToast(error instanceof Error ? error.message : String(error), "error");
}
}
finish(); // [적용] = 그 줄에 남기고 닫음(저장은 페이지 [저장]) · 코드 닫기는 beforeClose 안 거침
close();
},
});
sk.dataset.rowSangeun = "";
apply.dataset.rowApply = "";
const stop = o.watch((c) => {
calc = c;
draw();
});
openModal({
const tools = edit && !o.inject ? [also.root, apply] : [];
({ close } = openModal({
title: o.title,
closeLabel: tc("In_Close"),
width: "960px",
actions: [...(row.참조 ? [] : [option]), ...(o.inject ? [] : [sk])],
beforeClose: () => (stop(), true),
mount: (body) => body.append(info, mats),
});
width: "1100px",
wide: true,
actions: [...(row.참조 || !edit ? [] : [option]), ...tools],
beforeClose: async () => {
if (sk?.take() && !(await askConfirm({ message: t("Discard"), danger: true }))) return false;
finish();
return true;
},
mount: (body) => body.append(info, skHost, mats),
}));
draw();
skMount();
}
export interface MatModalOptions {
@@ -0,0 +1,108 @@
/* =============================================================================
* M02_MasterTemplete_Labor_RowSk.ts
* 일위대가 줄 모달 안 단가산출 상세 = 717-2b 단가산출(SK) 상세 「줄 모드」(PLAN 723-3 · 정함 4 · 5).
* · 줄 → 이름 없는 SK 문서(`lineDoc` · 고름 · 자재표시 = 바탕 줄 정체 cid) → `mountSangeunDetail` 그대로 얹음
* (보기 넷 · 품 · 단가 덮개 · 줄 빼기 · 더하기 · 시험 입력 칩)
* · `take()` = [적용] 때 그 줄에 남길 것 — 패치 `{덮개, 뺀줄, 더한줄}`(M02 변경 항목 · M01 안 바뀜 · 내역 17-2) +
* 옵션(시험 입력 → 그 줄 `입력값` · 정함 5)
* · [단가산출로도 저장](기본 꺼짐) = `saveAsSk` — 개인 층 새 SK(`from-line`)
* 공용 부품 갈아 끼움은 724(sub2) — 지금은 717-2b 상세를 그대로 씀.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import { fetchAuto } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { LaborRow } from "./M02_MasterTemplete_Labor_Api";
import { calcSangeun, sangeunFromLine, type SkScope } from "./M02_MasterTemplete_Sangeun_Cover";
import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail";
import {
cleanPatch,
lineDoc,
needsIds,
type LinePatch,
type SkCalc,
} from "./M02_Sangeun_Cover_Model";
export interface RowSkHandle {
/** 고친 것(패치 · 옵션) — 없으면 null */
take: () => { patch: LinePatch | null; values: Record<string, string> } | null;
destroy: () => void;
}
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
/** 줄 모드 SK 상세를 `host` 에 — 바탕 줄 정체가 필요하면 먼저 셈 */
export function mountRowSk(
host: HTMLElement,
o: { row: LaborRow; scope: SkScope; title: string; unit: string; readOnly: boolean },
): RowSkHandle {
let handle: ReturnType<typeof mountSangeunDetail> | null = null;
let alive = true;
/** 자동값(상세가 빈 입력에 채움) — 손대지 않은 자동값은 줄에 안 남김(빈 값 = 자동값 그대로) */
let auto: Record<string, unknown> = {};
const before = cleanPatch(o.row.패치);
const values0 = Object.fromEntries(
Object.entries(o.row.입력값 ?? {}).map(([k, v]) => [k, String(v)]),
);
host.replaceChildren(createText("…", "muted"));
void (async () => {
let ids: string[] | null = null;
try {
if (needsIds(o.row))
ids = (await calcSangeun<SkCalc>(o.scope, lineDoc(o.row, null, null))).정체 ?? null;
} catch {
ids = null; // 정체 못 받음 = 고름 · 자재표시 없이(셈 까닭은 상세가 보임)
}
if (!alive) return;
const doc = { ...lineDoc(o.row, ids, before), 이름: o.title, 단위: o.unit || "식" };
const box = el("div", { attrs: { "data-row-sk": "" } });
host.replaceChildren(box);
handle = mountSangeunDetail(box, doc, {
layer: o.scope.layer,
owner: o.scope.owner,
readOnly: o.readOnly,
onChanged: () => undefined,
});
if (o.row.로직 && !o.row.참조) auto = await fetchAuto(o.row.로직).catch(() => ({}));
})();
return {
take: () => {
if (!handle) return null;
const d = handle.getDoc();
const patch = cleanPatch({
덮개: d.덮개 as LinePatch["덮개"],
뺀줄: d.뺀줄,
더한줄: d.더한줄,
});
const values = o.row.참조
? values0
: Object.fromEntries(
Object.entries(handle.getValues()).filter(
([k, v]) =>
String(v).trim() !== "" &&
(k in values0 || auto[k] == null || String(auto[k]) !== String(v)),
),
);
return same(patch, before) && same(values, values0) ? null : { patch, values };
},
destroy: () => {
alive = false;
handle?.destroy();
},
};
}
/** [단가산출로도 저장] — 그 줄(고친 패치 포함)을 개인 층 새 SK 로 · 새 이름 돌려줌 */
export async function saveAsSk(
row: LaborRow,
patch: LinePatch | null,
name: string,
unit: string,
): Promise<string> {
const line: Record<string, unknown> = { ...row };
delete line.패치;
delete line.수량;
if (patch) line.패치 = patch;
const got = await sangeunFromLine({ layer: "personal", owner: null }, line, name, unit || "식");
return got.이름;
}
@@ -114,7 +114,8 @@ export function mountLaborTable(
});
const sourceHost = el("div", { attrs: { "data-labor-source": "" } });
const side = createSidePanels({
prefKey: "m02-labor-side",
prefKey: "m02-labor-table-side", // 일위대가표는 넓게 — 원문은 처음 접힘(누르면 엶)
defaultOpen: null,
follow: false,
panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }],
onChange: () => showSource(),
@@ -175,7 +176,8 @@ export function mountLaborTable(
const n = tr ? at(Number(tr.dataset.i)) : null;
if (n === null || !edit) return;
ev.preventDefault();
openTreeMenu(ev, menuAt(n));
// 칸 초점이 표를 밀면(scroll) 메뉴가 바로 닫힘 — 밀림이 끝난 다음 그림에 엶
requestAnimationFrame(() => requestAnimationFrame(() => openTreeMenu(ev, menuAt(n))));
});
gridHost.replaceChildren(grid.root);
showSource();
@@ -282,10 +284,6 @@ export function mountLaborTable(
inject: !!opt.calc,
userMats: cur.자재.map((m) => m.코드),
done: (again) => changed(!again),
replace:
edit && opt.layer === "personal" && !library.owner
? (next: LaborRow) => ((cur.담은로직[n] = next), changed())
: undefined,
});
}
function openMat(i: number): void {
@@ -452,7 +450,9 @@ export function mountLaborTable(
className: "m01-logic__section-head",
children: [
el("h3", { text: tc("Grid_Title") }),
...(edit ? [addLogic, addMat] : []),
...(edit
? [el("div", { className: "ui-btn-group", children: [addLogic, addMat] })]
: []),
],
}),
createText(t("TempHint"), "hint"),
@@ -58,7 +58,7 @@ export function mountSangeunDetail(
host: HTMLElement,
doc0: unknown,
opt: SangeunDetailOptions,
): { getDoc: () => SkDoc; destroy: () => void } {
): { getDoc: () => SkDoc; getValues: () => Record<string, string>; destroy: () => void } {
setLogicApiPaths(LIB); // 상세 부품 길 = M02 로그인 길(717-1)
const doc = clone((doc0 ?? {}) as SkDoc);
let saved = JSON.stringify(doc);
@@ -318,6 +318,7 @@ export function mountSangeunDetail(
saved = JSON.stringify(doc);
return clone(doc);
},
getValues: () => ({ ...values }), // 723-3 — 일위대가 줄 모달 [적용] = 옵션을 그 줄에 남김
destroy: () => {
alive = false;
host.replaceChildren();