Merge branch 'sub_laptop_1' of C:/Aislo_wt/sub_laptop_1 into main_laptop_1

This commit is contained in:
2026-11-08 16:32:28 +09:00
21 changed files with 624 additions and 844 deletions
@@ -0,0 +1,202 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Combo.ts
* 구조물 조합 일위대가의 덧칸(PLAN 806-1) — 일위대가 컨테이너 부품(`Labor_Table`)에 조합만 얹는 것:
* · 면적산출 잇기 = 그 열 면적산출의 알약 → 칸 · 값(`links`) · 수량산출 모달(43-1 · 손 · 칸 따름 · 잇기 · 풀기) ·
* [저장] 앞 면적 문서 저장(`save`) — 잇기 · 풀기는 화면 문서만
* · 다이어그램 상자(열 상세만) — 담은 로직 · 자재 → 흐름도
* 표 · 줄 · 합계 · 단추는 컨테이너와 한 벌 — 여기엔 조합 전용 칸만 둠.
* ========================================================================== */
import { el, showToast } from "@ui/ui_template_elements";
import { ROUTES } from "@config/config_frontend";
import { navigateTo } from "../A00_Common/router";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
import {
readTemplate,
saveTemplate,
type ItemLogic,
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick";
import {
areaLinks,
type LaborCalc,
type LaborDoc,
type LaborQty,
} from "./M02_MasterTemplete_Labor_Api";
import {
buildLaborDiagram,
diagramTitle,
renderComboDiagram,
} from "./M02_MasterTemplete_Labor_Diagram";
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
import { qtyShort } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
type Recalc = (typeof import("../A00_Common/spreadsheet/spreadsheet_recalc"))["recalcWorkbook"];
export interface ComboOptions {
layer: Layer;
column: string;
edit: boolean;
/** 셈 · 면적 자리 주입(142-1) — 없으면 층 길 */
readArea?: () => Promise<{ 문서: unknown; 판?: string | null }>;
}
export interface Combo {
/** 칸 이음 · 값(서버 calc 와 같은 것) */
cells: () => Map<string, string>;
given: () => Record<string, number>;
/** 저장 안 한 칸 연결이 있음 — 서버 셈이 그 칸 값을 써야 함 */
dirty: () => boolean;
setPills: (pills: { 키: string; 이름?: string; 단위?: string }[]) => void;
/** 면적산출 + 엔진을 받음(없으면 빈 것 — 새 구조물) */
load: () => Promise<void>;
/** 수량산출 모달 — 줄 · 자재 같음 · 바뀌면 `changed` */
openQty: (
title: string,
target: { 수량: LaborQty },
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
changed: () => void,
) => void;
/** 면적 문서 저장 · 저장했으면 참 */
save: () => Promise<boolean>;
}
export function createCombo(opt: ComboOptions): Combo {
let links = new Map<string, { 칸: string; 값: number | null }>();
// 엔진(recalc)은 면적 문서와 함께 늦게 받음 — 정적 import 금지(계약 `spreadsheet_types.ts` 머리)
let area: { doc: Workbook; 판: string; recalc: Recalc } | null = null;
let areaDirty = false;
let pills = new Map<string, { 이름?: string; 단위?: string }>();
const cells = (): Map<string, string> => new Map([...links].map(([k, v]) => [k, v.칸]));
/** 칸 잇기 · 풀기(`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.계산값 = area.recalc(next); // 식 칸 값도 바로 — 저장 때 서버가 다시 셈
area.doc = next;
links = areaLinks(next);
areaDirty = true;
return true;
};
return {
cells,
given: () =>
Object.fromEntries(
[...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값] as [string, number]])),
),
dirty: () => areaDirty,
setPills: (list) => {
pills = new Map(list.map((p) => [p.키, p]));
},
load: () =>
Promise.all([
opt.readArea ? opt.readArea() : readTemplate(opt.layer, "area", opt.column, null),
import("../A00_Common/spreadsheet/spreadsheet_recalc"),
]).then(
([got, { recalcWorkbook }]) => {
area = { doc: got.문서 as Workbook, 판: got.판 ?? "", recalc: recalcWorkbook };
links = areaLinks(got.문서);
},
() => undefined,
),
openQty: (title, target, line, unit, changed) => {
const pill = line?.알약 ?? null;
openQtyModal({
title,
now: qtyShort(line, unit, cells()),
linked: !!pill && links.has(pill),
hand: target.수량?.손 ?? null,
unit,
readOnly: !opt.edit,
sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill },
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();
},
});
},
save: 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;
},
};
}
/** 다이어그램 상자(열 상세) — `draw` 를 표가 다시 그릴 때마다 부름 */
export function diagramBox(): {
root: HTMLElement;
draw: (
doc: LaborDoc,
logics: ReadonlyMap<string, ItemLogic>,
calc: LaborCalc | null,
focus: (index: number) => void,
) => void;
} {
const host = el("div");
const root = el("details", {
className: "m01lab__box m01-logic__section m02-labor__dg",
attrs: { open: "" },
children: [el("summary", { text: diagramTitle() }), host],
});
let again: () => void = () => undefined;
root.addEventListener("toggle", () => again());
return {
root,
draw: (doc, logics, calc, focus) => {
again = () => {
if (!root.open) return;
const data = buildLaborDiagram({
rows: doc.담은로직.filter((r) => !r.참조), // ponytail: 참조 줄은 다이어그램에 안 그림 — 그릴 때 카드 꼴 더함
logics: [...logics.values()],
calc,
mats: doc.자재.length,
});
renderComboDiagram(host, data, {
focusInput: (key) => {
const i = doc.담은로직.findIndex((r) => r.로직 === key);
if (i >= 0) focus(i);
},
// 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard)
openLogic: (key) => {
setPendingLogicKey(key);
navigateTo(ROUTES.M01_MASTER_DATA);
},
});
};
again();
},
};
}
@@ -1,77 +1,17 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Detail.ts
* 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임
* (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램.
* · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) + 그 줄 바로 아래 하위 행(51-2 · `Labor_Rows`) ·
* 같은 줄 다시 누르면 닫힘 · [옵션] = 입력 모달(`Labor_Input`) · 참조 줄 [옵션] · [수정] = 서고 원본 모달(51-14 · `Labor_RefEdit`) ·
* [확인] = 화면 캐시만 · 저장은 [저장]
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
* · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) +
* 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
* · 서고 모드(50-3 · `mode`) = 일위대가표만 → `Labor_Table`(723-2 · 수량 = 휘발 임시 입력)
* · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음)
* · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 · 규격 표(704-4 · `Labor_Spec`)
* 일위대가 상세 입구(PLAN 39-3 · 806-1) — 구조물 열마다 하나(`labor/<열>.json`) · 일위대가 서고(사전조합) ·
* 구조물 조합 카드 · 일위조합 참조 모달이 모두 부르는 자리. 화면은 **한 부품**(`Labor_Table` — 일위대가 컨테이너) —
* · `mode: "precombo"` = 컨테이너(서고 · 정보 칸 · 수량 칸 손 입력)
* · `mode` 없음 = 구조물 조합(면적산출 잇기 · 수량산출 모달 · 규격 열 · 다이어그램 = `Labor_Combo` 덧칸)
* · `card` = 조합 카드 갈래(정보 칸 · 원문 패널 · 다이어그램 · [항목 추가] 없음)
* 줄 누름 = 줄 갈래마다 모달(단가산출 SK 줄 = `Labor_SkEdit` 수식 탭) — 옛 조합 전용 표 · 단가산출 보기 모달은 걷음.
* 양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main.
* ========================================================================== */
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 type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
type Recalc = (typeof import("../A00_Common/spreadsheet/spreadsheet_recalc"))["recalcWorkbook"];
import {
getActiveLayer,
getActiveOwner,
readTemplate,
saveTemplate,
type ItemLogic,
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick";
import { nationalRows } from "./M02_MasterTemplete_Labor_National";
import { lineId, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import {
areaLinks,
calcLabor,
fetchLogicInputs,
normalizeLabor,
type LaborCalc,
type LaborDoc,
type LaborQty,
type LaborRow,
type MatMarkKey,
originText,
} from "./M02_MasterTemplete_Labor_Api";
import {
buildLaborDiagram,
diagramTitle,
renderComboDiagram,
} from "./M02_MasterTemplete_Labor_Diagram";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
import { openRowSangeun } from "./M02_MasterTemplete_Labor_SangeunModal";
import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
import { openLogicPicker, withSangeun } from "./M02_MasterTemplete_Labor_Pick";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks";
import { logicTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
import { matTable } from "./M02_MasterTemplete_Labor_MatTable";
import { laborGrid } from "./M02_MasterTemplete_Labor_Grid";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model";
import { keepKnown } from "./M02_Labor_Values_Model";
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborCalc, LaborDoc } from "./M02_MasterTemplete_Labor_Api";
import { mountLaborTable } from "./M02_MasterTemplete_Labor_Table";
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";
import { createText } from "@ui/ui_template_text";
export interface LaborDetailOptions {
layer: Layer;
@@ -104,548 +44,5 @@ export function mountLaborDetail(
doc: unknown,
opt: LaborDetailOptions,
): LaborDetailHandle {
if (opt.mode === "precombo") return mountLaborTable(host, doc, opt); // 723-2 — 서고 = 일위대가표만
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;
/** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */
let last: 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.값]])));
/** 그 열 면적산출 문서 · 판 — [엑셀에서 고르기]로 이으면 바뀜(일위대가 [저장] 때 함께 저장) */
// 엔진(recalc)은 면적 문서와 함께 늦게 받음 — 정적 import 금지(계약 `spreadsheet_types.ts` 머리)
let area: { doc: Workbook; 판: string; recalc: Recalc } | null = null;
let areaDirty = false;
/** 알약 키 → 이름 · 단위(calc 답 `알약`) — 칸에 이을 때 연결 정보 */
let pills = new Map<string, { 이름?: string; 단위?: string }>();
const dirty = (): boolean => areaDirty || JSON.stringify(cur) !== saved;
const gridHost = el("div", { attrs: { "data-labor-grid": "" } }); // 704-3 엑셀 꼴 격자(맨 위)
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 · 카드 갈래엔 없음) — 열려 있을 때만 찾음 · 고른 줄 없으면 안내 한 줄 */
// 85-4 — M02 길(로그인 사용자 모두) · 제목 행 [원문 다운로드]
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-side",
follow: false, // 85-5 — 상세 고정 높이 칸을 채움 · 메인 스크롤과 따로
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 lineOf = (key: string) => calc?.줄.find((l) => lineId(l) === key);
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
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);
// 줄 끄는 동안(102-2)엔 다시 그리지 않음 — 끝난 뒤 한 번
const drawLogic = (): void =>
afterRowDrag(logicHost, () =>
logicHost.replaceChildren(
logicTable(
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
edit
? {
open: pick,
option: openRow,
ref: openRef,
qty: openQty,
move: reorder,
remove: removeRow,
alias: setAlias,
promote: setPromote, // 134-1 — 구조물 일위대가 로직 줄
...sk,
}
: { open: pick, option: openRow, ref: openRef, ...sk },
),
),
);
const draw = (): void => {
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && changed));
drawLogic();
matHost.replaceChildren(
matTable(
{
mats: cur.자재,
calc,
fail,
cells: cells(),
lead: logicMatRows(
cur.담은로직,
calc ?? last,
!calc && !fail,
// 세 칸(148-4) — 사전조합도 같음(175-1 · `_combo_lines` 가 남김)
edit
? {
open: openRow,
swap: swapMat,
reset: resetMat,
mark: markLogicMat,
}
: { open: openRow },
),
},
edit ? { open: openMat, remove: removeMat, mark: markMat } : { open: openMat },
),
...nationalRows(cur.본뜬), // 361-2 — 전국 자재(보기만)
);
drawDg();
showSource();
};
/** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */
const recalc = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(async () => {
const mine = ++seq;
calc = null;
fail = null;
draw();
try {
// 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음)
const qty = areaDirty ? 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;
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.계산값 = area.recalc(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 },
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");
}
};
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 끌기 = 놓은 줄 자리) */
function reorder(from: number, to: number): void {
if (from === to || to < 0 || to >= cur.담은로직.length) return;
cur.담은로직.splice(to, 0, ...cur.담은로직.splice(from, 1));
changed();
}
/** 줄 고름(47-5) — 하이라이트 + 하위 행(51-2 · 표만 다시 그림 · 다이어그램 안 다시 그림) + 원문 패널(47-4) ·
* 고른 줄 다시 누름 = 닫음 · 화면 캐시 안 바뀜 */
function pick(i: number): void {
const key = lineId(cur.담은로직[i]) ?? "";
picked = picked === key ? null : key;
drawLogic();
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());
}
/** 구분 드롭다운(134-1) — 화면 캐시만 · 금액 무관이라 다시 셈 없음 */
function setPromote(i: number, on: boolean): void {
if (on) cur.담은로직[i].승격 = true;
else delete cur.담은로직[i].승격;
opt.onChanged(dirty());
}
// 자재 세 칸(148-4) — 화면 캐시만 · 별도계상만 금액에 닿음(222-1 · 사용자 자재 = 짝 셈 · 로직 자재 = 서버 기준)
function markMat(i: number, key: MatMarkKey, v: string): void {
setMark(cur.자재[i], key, v);
if (key === "별도계상") changed(true);
else opt.onChanged(dirty());
}
function markLogicMat(i: number, at: number, key: MatMarkKey, v: string): void {
setLogicMark(cur.담은로직[i], at, key, v);
if (key === "별도계상") changed();
else opt.onChanged(dirty());
}
function removeMat(i: number): void {
cur.자재.splice(i, 1);
changed();
}
/** 로직 자재 [바꾸기](103-1) — 사용자 자재와 같은 품목은 막음 */
function swapMat(i: number, at: number, key: string): void {
const own = cur.자재.find((m) => m.코드 === key);
if (own)
return showToast(
tc("Labor_AlreadyMat").replace("{value}", `${key} ${own.이름 ?? ""}`),
"error",
);
const row = cur.담은로직[i];
row.고름 = { ...row.고름, [String(at)]: key };
changed();
}
function resetMat(i: number, at: number): void {
const row = cur.담은로직[i];
if (!row.고름) return;
delete row.고름[String(at)];
if (!Object.keys(row.고름).length) delete row.고름;
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.메모,
rate: row.켜쌓기율 ?? null,
readOnly: !edit,
done: (next) => {
const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
const same =
JSON.stringify(values) === JSON.stringify(row.입력값) &&
next.켜쌓기율 === (row.켜쌓기율 ?? null);
const before = row.입력값;
row.입력값 = values;
releaseTied(row, before, lineOf(row.로직)?.재료 ?? []); // 바뀐 입력에 걸린 고름 = 기본(103-1)
row.메모 = next.메모;
if (next.켜쌓기율 === null) delete row.켜쌓기율;
else row.켜쌓기율 = next.켜쌓기율;
changed(same); // 입력값 · 켜쌓기율이 그대로면(메모만) 서버 안 부름
},
});
}
/** 참조 줄 [옵션] · [수정](51-14) — 서고 원본 모달 · 자기 층 서고만 고침(위 층 = 읽기만 · 서버도 막음) ·
* 닫으면 다시 셈(원본이 바뀌었을 수 있음) */
function openRef(i: number): void {
const ref = cur.담은로직[i].참조;
if (!ref) return;
void openRefEdit({
ref,
title: lineOf(lineId({ 참조: ref }) ?? "")?.이름 ?? "",
readOnly: !edit || ref.층 !== opt.layer,
closed: () => alive && recalc(),
});
}
/** [단가산출 보기](717-3) — 고침 = 그 줄 패치(화면 캐시) · 프로젝트 복사본(셈 주입)엔 없음 */
const sk = opt.calc ? {} : { sangeun: openSk };
function openSk(i: number): void {
const row = cur.담은로직[i];
const line = lineOf(lineId(row) ?? "");
const name = (cur as { 이름?: string }).이름 || opt.column;
openRowSangeun({
scope: { layer: opt.layer, owner: library.owner },
row,
title:
`${row.참조?.키 ?? row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(),
head: [name, cur.단위 ? `1 ${cur.단위} 당` : ""].filter(Boolean).join(" · "),
qty: qtyShort(line, line?.분모 ?? "", cells()),
unit: line?.분모 ?? "",
readOnly: !edit,
apply: (p) => {
if (p) row.패치 = p;
else delete row.패치;
changed();
},
// 새 SK 참조 줄로 = 자기 개인 층 일위대가만(위 층 일위대가는 개인 SK 를 못 가리킴)
...(edit && opt.layer === "personal" && !library.owner
? { replace: (next: LaborRow) => ((cur.담은로직[i] = next), changed()) }
: {}),
});
}
/** 수량산출 칸 누름 — 로직 줄 */
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.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();
},
/** 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.단위, 수량: 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());
},
// 사전조합도 같은 창 · 같은 목록(51-11 · 자기 자신 · 순환은 [저장] 때 서버 422)
withSangeun(library),
),
});
// 단위 = 표준 단위 목록에서 고르기(지금 값이 목록에 없어도 선택지로 둠) · 저장은 [저장]
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 = originText(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],
}),
],
}),
]),
// 사전조합 = 같은 [항목 추가](51-11)
section(tc("Grid_Title"), null, gridHost),
...[
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
section(tc("Labor_Mats"), null, matHost),
].filter(() => edit || !opt.card), // 읽기만 카드 = 격자만
...(opt.card ? [] : [dgBox]),
],
}),
),
);
draw();
// 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산
void Promise.all([
loadLogics(),
Promise.all([
opt.readArea ? opt.readArea() : readTemplate(opt.layer, "area", opt.column, null),
import("../A00_Common/spreadsheet/spreadsheet_recalc"),
]).then(
([got, { recalcWorkbook }]) => {
area = { doc: got.문서 as Workbook, 판: got.판 ?? "", recalc: recalcWorkbook };
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();
},
};
return mountLaborTable(host, doc, opt);
}
@@ -1,133 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Labor_SangeunModal.ts
* 일위대가 줄 [단가산출 보기] 모달(PLAN 717-3 · 724-1 · 요구서 내역12 4장 4 · 16 · 24 · 내역 16 · 17-2) —
* 구조물 조합 일위대가 상세(`Labor_Detail`)의 로직 줄 · 단가산출(SK) 참조 줄 하나의 단가산출을 보고 그 일위대가에서만 고침.
* · 위 = 일위대가 머리(「1 ㎡ 당」) · 이 줄 수량
* · 몸 = 단가산출 상세 줄 모드(`Labor_RowSk` — 717-2b 보기 넷 · 공용 상세 줄 모달 그 자리 고침)
* · [적용] = 그 일위대가 줄 칸 `패치 {덮개, 뺀줄, 더한줄}`(M02 변경 항목 · M01 안 바뀜) · 화면 캐시(저장은 페이지 [저장]) ·
* 적용 안 하고 닫으면 물음
* · [단가산출로 저장] = 개인 층 새 SK(이름 받음 · `from-line`) · 일위대가가 자기 개인 층이면 그 줄을 새 SK 참조 줄로
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { askConfirm, askText, openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import type { LaborRow } from "./M02_MasterTemplete_Labor_Api";
import { mountRowSk, saveAsSk } from "./M02_MasterTemplete_Labor_RowSk";
import type { SkScope } from "./M02_MasterTemplete_Sangeun_Cover";
import type { LinePatch } from "./logic/M02_Sangeun_Cover_Model";
const TEXT = {
Title: ["단가산출", "Unit-price build-up"],
Qty: ["이 줄 수량", "Line quantity"],
Apply: ["적용", "Apply"],
ToSk: ["단가산출로 저장", "Save as unit-price build-up"],
AskName: ["새 단가산출 이름(내 개인 층)", "New build-up name (my layer)"],
Saved: ["{k} 저장함(내 개인 층)", "{k} saved (my layer)"],
Replaced: ["이 줄을 {k} 참조로 바꿈 — [저장]으로 남김", "Line now refers to {k} — press [Save]"],
Discard: ["적용 안 한 고침이 있음 — 버리고 닫을까?", "Discard unapplied edits?"],
Close: ["닫기", "Close"],
ReadOnly: ["읽기만 — 고친 값은 [단가산출로 저장]으로만 남김", "Read only — use [Save as]"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
export interface RowSangeunOptions {
/** 그 일위대가 층(셈 길 · 참조 위 층 검사) */
scope: SkScope;
row: LaborRow;
/** 줄 이름(로직 키 · 이름) */
title: string;
/** 일위대가 머리(이름 · 「1 ㎡ 당」) */
head: string;
/** 이 줄 지금 수량 글(「수기 5 ㎥」) */
qty: string;
/** 줄 결과 단위(분모 · SK 단위 기본) */
unit: string;
readOnly: boolean;
/** [적용] — 그 줄 패치(null = 고침 없음) */
apply: (patch: LinePatch | null) => void;
/** [단가산출로 저장] 뒤 그 줄을 새 SK 참조 줄로(없으면 안 바꿈) */
replace?: (row: LaborRow) => void;
}
export function openRowSangeun(o: RowSangeunOptions): void {
const info = el("div", {
className: "m02-std-sheet__body",
attrs: { "data-sk-head": "" },
children: [
createText(o.head, "muted", 14),
createText(`${o.title} · ${t("Qty")} ${o.qty}`, "muted", 14),
...(o.readOnly ? [createText(t("ReadOnly"), "hint")] : []),
],
});
const host = el("div", { attrs: { "data-sk-body": "" } });
// 읽기만이어도 고쳐 볼 수 있음([단가산출로 저장]으로만 남김) — 717-3 그대로
const sk = mountRowSk(host, {
row: o.row,
scope: o.scope,
title: o.title,
unit: o.unit,
readOnly: false,
});
/** 지금 패치(고침 없으면 그 줄 원래 패치) */
const now = (): LinePatch | null => {
const got = sk.take();
return got ? got.patch : (o.row.패치 ?? null);
};
let close = (): void => {};
let done = false;
const applyBtn = createButton({
label: t("Apply"),
variant: "filled",
onClick: () => {
if (sk.take()) o.apply(now());
done = true;
close();
},
});
applyBtn.dataset.skApply = "";
applyBtn.hidden = o.readOnly;
const saveBtn = createButton({
label: t("ToSk"),
variant: "ghost",
onClick: async () => {
const name = (await askText({ message: t("AskName"), value: o.title }))?.trim();
if (!name) return;
try {
const key = await saveAsSk(o.row, now(), name, o.unit);
if (!key) return; // 같은 내용 물음 취소(733-3)
showToast(t("Saved").replace("{k}", key), "success");
if (!o.replace) return;
o.replace({
로직: "",
입력값: {},
메모: null,
별칭: null,
수량: o.row.수량,
참조: { 층: "personal", 주인: null, 키: key },
});
showToast(t("Replaced").replace("{k}", key), "info");
done = true;
close();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
},
});
saveBtn.dataset.skSave = "";
({ close } = openModal({
title: `${t("Title")} — ${o.title}`,
closeLabel: t("Close"),
width: "1100px",
wide: true,
actions: [saveBtn, applyBtn],
beforeClose: async () => {
if (!done && sk.take() && !(await askConfirm({ message: t("Discard"), danger: true })))
return false;
sk.destroy();
return true;
},
mount: (body) => body.append(info, host),
}));
}
@@ -0,0 +1,118 @@
/* =============================================================================
* M02_MasterTemplete_Labor_SkEdit.ts
* 일위대가 단가산출(SK) 참조 줄 모달(PLAN 806-1 · 요구 698-2 · 689) — 일위대가 표(컨테이너 · 구조물 조합 한 부품)에서
* 단가산출 줄을 고르면 그 단가산출 문서를 그대로 엶 = M02 단가산출 상세(`Sangeun_Detail` · 첫 탭 「수식」 줄 쌓기) 부품 그대로.
* · [저장] = 수식 저장(`flush`) → 그 SK 문서 저장(`saveTemplate` · 그 층 · 그 주인) → 닫음 → 부르는 쪽이 일위대가 금액을 다시 셈
* · 그 층을 못 고치면(위 층 · 남의 주인) 읽기만 · [저장] 없음 · 고친 것 있이 닫으면 물음(버리면 수식 캐시도 비움)
* 짝 모달 = `Labor_RefEdit`(일위조합 SC 줄).
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { readTemplate, saveTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail";
export interface SkEditOptions {
ref: LaborRef;
/** 「뒷채움돌 … 」 */
title: string;
/** 일위대가 머리(이름 · 「1 ㎡ 당」) */
head: string;
readOnly: boolean;
/** 닫힘 — `saved` = 그 SK 문서를 저장함(부르는 쪽이 금액을 다시 셈) */
closed: (saved: boolean) => void;
}
/** 단가산출 줄인가 — SK 참조 줄만 이 모달(로직 줄 · SC 줄 · 고정 줄은 다른 길) */
export const isSkRef = (ref: LaborRef | undefined): ref is LaborRef =>
!!ref && ref.키.startsWith("SK-");
/** 열린 단가산출 모달 — 같은 줄을 빨리 두 번 눌러도 하나만(읽는 동안 늦게 열림) */
const opened = new Set<string>();
export async function openSkEdit(opt: SkEditOptions): Promise<void> {
const layer = opt.ref.층 as Layer;
const at = `${layer}/${opt.ref.주인 ?? ""}/${opt.ref.키}`;
if (opened.has(at)) return;
opened.add(at);
let doc: unknown;
let version: string;
try {
const got = await readTemplate(layer, "sangeun", opt.ref.키, null, opt.ref.주인);
doc = got.문서;
version = got.판 ?? "";
} catch (error) {
opened.delete(at);
return void showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
}
let dirty = false;
let saved = false;
let handle: ReturnType<typeof mountSangeunDetail> | null = null;
const finish = (): void => {
handle?.destroy(); // 고친 채 버리면 수식 캐시도 비움
opened.delete(at);
opt.closed(saved);
};
openModal({
title: opt.title,
closeLabel: tc("In_Close"),
dialogClass: "m02-modal m02-labsk",
width: "1100px",
wide: true,
beforeClose: async () => {
if (dirty && !saved && !(await showConfirmDialog(tc("Ref_Discard")))) return false;
finish();
return true;
},
mount: (body, close, head) => {
const host = el("div", { attrs: { "data-labor-sk": opt.ref.키 } });
handle = mountSangeunDetail(host, doc, {
layer,
owner: opt.ref.주인,
readOnly: opt.readOnly,
onChanged: (d) => (dirty = d && !opt.readOnly),
onVersion: (v) => (version = v), // 수식 저장이 파일 판을 바꿈 — 문서 저장이 이어 받음
source: true, // 페이지와 같은 길 — 수식 저장 단추는 이 [저장] 한 번에 모임
});
const ok = createButton({
label: tc("Save"),
variant: "filled",
onClick: () =>
void (async () => {
if (!handle) return;
try {
await handle.flush();
await saveTemplate(
layer,
"sangeun",
opt.ref.키,
null,
version,
handle.getDoc(),
opt.ref.주인,
);
saved = true;
showToast(tc("Saved"), "success");
close();
finish();
} catch (error) {
showToast(error instanceof Error ? error.message : tc("Lib_SaveFailed"), "error");
}
})(),
});
ok.dataset.skSave = "";
body.append(
createText(opt.head, "muted", 14),
createText(
opt.readOnly ? `${tc("Ref_Shared")} · ${tc("Ref_ReadOnly")}` : tc("Ref_Shared"),
"hint",
),
host,
);
if (!opt.readOnly) head.append(ok); // [저장] = 머리 [닫기] 왼쪽
},
});
}
@@ -11,7 +11,9 @@
* 사전조합 참조 줄 = 서고 원본 모달(`Labor_RefEdit`) · 사용자 자재 줄 = 자재 모달(세 칸)
* · 금액 = 서버 calc(최신 응답만 · 저장 안 함) · [저장]은 UI_Main(서버가 다시 셈)
* · 안 이은 고정 줄(729-3) = 격자 위 띠 · [연결](`Labor_Unlinked` — 이음만 · 저장은 [저장])
* 구조물 조합 일위대가(카드 · 열 상세)는 그대로 `Labor_Detail`(면적 잇기 · 수량산출).
* 구조물 조합 일위대가(카드 · 열 상세 · PLAN 806-1)도 이 한 부품 — `mode` 없음 = 조합 갈래: 정보 칸(이름 · 구분)
* 없음 · 수량 칸 = 수량산출 모달(`Labor_Combo` — 면적산출 잇기) · 규격 열 줄(`Labor_Spec`) · 다이어그램(열 상세) ·
* 새 줄 수량 = 면적을 따름(빈 값). 단가산출(SK) 참조 줄 누름 = 그 단가산출 문서 모달(`Labor_SkEdit` — 수식 탭 · 저장 = 그 SK).
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
@@ -36,6 +38,10 @@ import {
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 { 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";
@@ -88,7 +94,11 @@ export function mountLaborTable(
let timer = 0;
let alive = true;
let picked: string | null = null;
const dirty = (): boolean => JSON.stringify(cur) !== saved;
/** 조합 갈래(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) => {
@@ -103,15 +113,17 @@ export function mountLaborTable(
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(),
});
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 (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);
@@ -136,12 +148,24 @@ export function mountLaborTable(
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],
{
columns: g.columns.map((c) => (label[c.key] ? { ...c, label: label[c.key] } : c)),
// 조합 갈래 — 수량 칸 누름 = 수량산출 모달(줄 모달은 안 엶)
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) openAt(n);
},
@@ -153,12 +177,14 @@ export function mountLaborTable(
}
: 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) => {
onEdit: (i, key, text) => {
if (combo) return spec.onEdit?.(i, key, text);
const n = at(i);
if (n !== null) setQty(n, text);
},
@@ -180,7 +206,9 @@ export function mountLaborTable(
});
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);
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);
};
@@ -194,11 +222,14 @@ export function mountLaborTable(
fail = null;
draw();
try {
// 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음)
const qty = combo?.dirty() ? combo.given() : undefined;
const got = opt.calc
? await opt.calc(cur)
: await calcLabor(opt.layer, opt.column, cur, undefined, opt.owner);
? 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");
@@ -210,7 +241,7 @@ export function mountLaborTable(
const changed = (qtyOnly = false): void => {
opt.onChanged(dirty());
if (qtyOnly && calc) {
calc = settle(calc, cur, {});
calc = settle(calc, cur, combo?.given() ?? {});
draw();
} else recalc();
};
@@ -249,6 +280,29 @@ export function mountLaborTable(
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];
@@ -263,13 +317,20 @@ export function mountLaborTable(
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: [String((cur as { 이름?: string }).이름 ?? ""), cur.단위 ? `1 ${cur.단위} 당` : ""]
.filter(Boolean)
.join(" · "),
head: head(),
unit: lineOf(row)?.분모 ?? "",
watch,
scope: { layer: opt.layer, owner: library.owner },
@@ -342,7 +403,7 @@ export function mountLaborTable(
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 } });
cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: fresh() });
changed();
},
fixed: (fx: LaborFixed, name: string) => {
@@ -355,7 +416,7 @@ export function mountLaborTable(
별칭: null,
메모: null,
입력값: {},
수량: { ...ONE },
수량: fresh(),
});
changed();
},
@@ -370,7 +431,7 @@ export function mountLaborTable(
? "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 } });
cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: fresh() });
changed();
},
};
@@ -381,7 +442,7 @@ export function mountLaborTable(
openLogicPicker((x) => {
if (cur.담은로직.some((r) => r.로직 === x.키))
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: { ...ONE } });
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: fresh() });
void loadLogics().then(() => changed());
}, withSangeun(library)),
});
@@ -418,60 +479,63 @@ export function mountLaborTable(
origin.input.value = originText(cur.본뜬) ?? tc("Origin_Self");
origin.input.readOnly = true;
host.replaceChildren(
el("div", {
className: "ui-side-panels-row",
children: [
const info = combo
? opt.card
? [] // 카드 갈래 = 정보 칸 없음
: [el("div", { className: "m01lab__info", children: [unit, origin.root] })]
: [
el("div", {
className: "m01-logic__editor m01-combo m02-labor__edit",
attrs: { "data-labor": opt.column },
className: "m01lab__info",
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,
],
}),
nameField.root,
...libMeta(cur, edit, () => opt.onChanged(dirty())),
unit,
origin.root,
],
}),
side.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 loadLogics().then(() => alive && recalc());
// 로직 입력 목록 · 면적산출 이은 칸(조합 — 없으면 빈 것)을 받은 뒤 첫 계산
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 () => false,
saveArea: async () => (await combo?.save()) ?? false,
destroy: () => {
alive = false;
window.clearTimeout(timer);
+1 -1
View File
@@ -42,7 +42,7 @@ def test_화면_서고_모드도_자재목록_세_칸_자재_저장(world): # n
read("M02_MasterTemplete_Labor_Table.ts"),
)
modal = read("M02_MasterTemplete_Labor_RowModal.ts")
assert 'if (opt.mode === "precombo") return mountLaborTable(host, doc, opt);' in detail
assert "return mountLaborTable(host, doc, opt);" in detail # 806-1 — 조합도 같은 부품
assert 'className: "ui-btn-group", children: [addLogic]' in lib and "addMat" not in lib
assert "setLogicMark(row, at, key, v);" in modal and "setMark(o.mat, key, v);" in modal
assert "delete out.자재" not in lib and "delete out.열" in lib
+4 -3
View File
@@ -131,9 +131,10 @@ def test_사전조합_안_줄도_별도계상_뺌():
def test_화면_별도계상_바꾸면_다시_셈():
detail = open(os.path.join(SRC, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read()
assert 'if (key === "별도계상") changed(true);' in detail # 사용자 자재 = 짝 셈
assert 'if (key === "별도계상") changed();' in detail # 로직 자재 = 서버 기준
# 806-1 — 조합 · 서고 모두 일위대가표 부품 · 세 칸은 줄 모달(`Labor_RowModal`)
modal = open(os.path.join(SRC, "M02_MasterTemplete_Labor_RowModal.ts"), encoding="utf-8").read()
assert 'o.done(key === "별도계상");' in modal # 사용자 자재 · 로직 자재 모두 별도계상만 다시 셈
assert modal.count('o.done(key === "별도계상");') == 2
# ── ③ 원문 여럿 ──────────────────────────────────────────────────────
+2 -2
View File
@@ -137,8 +137,8 @@ def test_M02_문서_calc_를_격자_줄로():
def test_세_곳_같은_부품():
detail = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8")
assert "laborGrid(cur, calc ?? last, logics, " in detail # 카드 · 상세 = 같은 Labor_Detail
table = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts").read_text("utf-8")
assert "laborBlock(cur, calc ?? last, logics)" in table # 카드 · 상세 · 서고 = 같은 Labor_Table(806-1)
m02 = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts").read_text("utf-8")
b09 = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8")
assert '"@ui/ui_template_hopyo_grid"' in m02 and '"@ui/ui_template_hopyo_grid"' in b09
+19 -11
View File
@@ -9,6 +9,7 @@
"""
import json
import re
import shutil
import subprocess
from pathlib import Path
@@ -153,16 +154,22 @@ def test_normalizeLabor_줄_패치_지킴():
def test_화면_이음():
"""줄 단추 = 로직 줄 · SK 참조 줄만 · 셈 주입(프로젝트 복사본)엔 없음 · 적용 안 한 고침 물음 · 700 줄."""
rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text("utf-8")
assert 'sangeun && !fx && (!row.참조 || row.참조.키.startsWith("SK-"))' in rows
"""806-1 — 조합 일위대가도 일위대가표 한 부품 · 단가산출(SK) 줄 = 그 SK 문서 모달(`Labor_SkEdit`) · 옛 조합 전용
단가산출 보기 모달 걷음 · 셈 주입(프로젝트 복사본)엔 옛 줄 모달 · 고친 것 있이 닫으면 물음 · 700 줄."""
detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8")
assert "const sk = opt.calc ? {} : { sangeun: openSk };" in detail
assert 'edit && opt.layer === "personal" && !library.owner' in detail
modal = (M02 / "M02_MasterTemplete_Labor_SangeunModal.ts").read_text("utf-8")
assert "mountRowSk(host," in modal # 724-1 — 줄 모드 SK 상세(공용 상세)
assert "!done && sk.take() && !(await askConfirm(" in modal # 적용 안 한 고침 물음
assert "saveAsSk(o.row, now(), name, o.unit)" in modal # 새 SK = 내 개인 층(saveAsSk)
assert "return mountLaborTable(host, doc, opt);" in detail
assert not (M02 / "M02_MasterTemplete_Labor_SangeunModal.ts").exists()
table = (M02 / "M02_MasterTemplete_Labor_Table.ts").read_text("utf-8")
assert "if (isSkRef(row.참조) && !opt.calc)" in table and "openSkEdit({" in table
assert "readOnly: !edit || row.참조.층 !== opt.layer," in table # 위 층 SK = 읽기만
assert "closed: (done) => alive && done && recalc()," in table # 저장했으면 금액 다시 셈
modal = (M02 / "M02_MasterTemplete_Labor_SkEdit.ts").read_text("utf-8")
assert "mountSangeunDetail(host, doc," in modal and "source: true" in modal
assert re.search(
r'saveTemplate\(\s*layer,\s*"sangeun",\s*opt\.ref\.키', modal
) # 저장 = 그 SK 문서(그 층 · 그 주인)
assert "await handle.flush();" in modal # 수식 저장 먼저
assert "dirty && !saved && !(await showConfirmDialog(" in modal # 고친 것 물음
sk = (M02 / "M02_MasterTemplete_Labor_RowSk.ts").read_text("utf-8")
assert '{ layer: "personal", owner: null }' in sk
cover = (M02 / "M02_MasterTemplete_Sangeun_Cover.ts").read_text("utf-8")
@@ -170,8 +177,9 @@ def test_화면_이음():
assert "coverTable" not in cover and not (M02 / "M02_Sangeun_Cover_Model.ts").exists()
for name in (
"M02_MasterTemplete_Labor_Rows.ts",
"M02_MasterTemplete_Labor_Detail.ts",
"M02_MasterTemplete_Labor_SangeunModal.ts",
"M02_MasterTemplete_Labor_Table.ts",
"M02_MasterTemplete_Labor_Combo.ts",
"M02_MasterTemplete_Labor_SkEdit.ts",
"M02_MasterTemplete_Sangeun_Cover.ts",
"logic/M02_Sangeun_Cover_Model.ts",
):
+1 -2
View File
@@ -116,8 +116,7 @@ def test_736_1_항목_추가_찾기_창_하나():
assert "[로직 추가]" not in locale and "기초는 [항목 추가]로" in locale
assert 'AddLogic: ["항목 추가", "Add item"]' in _text(M02 / "M02_MasterTemplete_Labor_Text.ts")
table = _text(M02 / "M02_MasterTemplete_Labor_Table.ts")
detail = _text(M02 / "M02_MasterTemplete_Labor_Detail.ts")
for text in (table, detail):
for text in (table,): # 806-1 — 조합 · 서고 = 같은 Labor_Table
assert "addMaterialButton" not in text and "addMat" not in text
assert "material: (m: {" in text # 재료 고름 → 「자재 목록」
assert '"Labor_InLogicMat"' in text and '"Labor_AlreadyMat"' in text # 중복 검사 그대로
+2 -2
View File
@@ -39,8 +39,8 @@ def test_화면_서고_상세는_일위대가표만():
_src("M02_MasterTemplete_Labor_Table.ts"),
_src("M02_MasterTemplete_Labor_Detail.ts"),
)
assert 'if (opt.mode === "precombo") return mountLaborTable(host, doc, opt);' in detail
assert "calc = settle(calc, cur, {});" in table # 짝 셈 바로(742-1 수량 = 문서)
assert "return mountLaborTable(host, doc, opt);" in detail # 806-1 — 조합도 같은 부품
assert "calc = settle(calc, cur, combo?.given() ?? {});" in table # 짝 셈 바로(742-1 수량 = 문서)
assert 'const out: Record<string, unknown> = { ...cur, 종류: "사전조합" };' in table
for label in ('tc("Up")', 'tc("Down")', 't("Alias")', 'tc("Remove")'):
assert label in table # 우클릭 메뉴
+2 -1
View File
@@ -133,7 +133,8 @@ def test_화면_수량_칸_저장_새_줄_1_두_칸():
table = _src("M02_MasterTemplete_Labor_Table.ts")
assert "const temp = new Map" not in table and "withTemp" not in table # 휘발 칸 걷음
assert "row.수량 = raw ? { 손: v } : null;" in table and "changed(true);" in table
assert table.count("수량: { ...ONE }") == 4 # 기초 · 참조 · 고정 · 자재 새 줄 = 1
assert table.count("수량: fresh()") == 4 # 기초 · 참조 · 고정 · 자재 새 줄 = 서고 1 · 조합 면적 따름(806-1)
assert "const fresh = (): LaborRow" in table and "{ ...ONE }" in table
assert 'BasePrice: ["기준단위 단가"' in table and 'QtyAmount: ["수량 반영 금액"' in table
assert 'sum_price: t("BasePrice"), sum: t("QtyAmount")' in table
assert 'NeedQty: ["수량 입력"' in table
@@ -148,7 +148,7 @@ def test_부품상자만_700줄_공용_안_고침():
assert not re.search(r"""\bel\(\s*["'](button|input|select)["']""", text), name
assert not re.search(r"""createElement\(\s*["'](button|input|select)["']""", text), name
assert "#" not in "".join(re.findall(r"color:\s*[^;]+", text)), name
for name in ("Labor_Rows", "Labor_SangeunModal", "Sangeun_Detail", "Sangeun_Tabs"):
for name in ("Labor_Rows", "Labor_SkEdit", "Sangeun_Detail", "Sangeun_Tabs"):
assert "Hopyo_Imported" not in _src(name), name # 공용 쪽은 새 모달을 모름
+121
View File
@@ -0,0 +1,121 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""PLAN 806-1 — 구조물 조합 일위대가 보기 = 일위대가 컨테이너 부품 한 벌 · 단가산출 줄 = 그 SK 문서 모달(요구 698).
- 화면(소스 지킴) = 조합 상세 · 카드 · 서고 모두 `Labor_Table` 한 부품 · 조합 전용 칸은 `Labor_Combo` · 옛 조합 전용 표 ·
단가산출 보기 모달 걷음 · 단가산출 줄 누름 = `Labor_SkEdit`(수식 탭 부품 · 저장 = 그 SK 문서) · 부품 상자 · 700 줄
- 금액(서버) = 같은 문서 · 같은 SK → 같은 금액(전후 같음) · 모달 [저장] 길(`store.write` sangeun) 뒤엔 그 SK 를 가리킨 줄 금액이
따라 바뀜 · 되돌리면 원래 금액
"""
import re
from decimal import Decimal
from pathlib import Path
import pytest
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」
COVER = {"덮개": {"labor:LB000036#0": {"qty": 0.5}}}
def _src(name: str) -> str:
return (M02 / name).read_text("utf-8")
def test_조합_상세_카드_서고_한_부품():
detail = _src("M02_MasterTemplete_Labor_Detail.ts")
table = _src("M02_MasterTemplete_Labor_Table.ts")
assert "return mountLaborTable(host, doc, opt);" in detail # 입구 하나
assert "logicTable" not in detail and "afterRowDrag" not in detail # 옛 조합 전용 표 걷음
assert not (
M02 / "M02_MasterTemplete_Labor_SangeunModal.ts"
).exists() # 옛 단가산출 보기 모달 걷음
assert 'opt.mode === "precombo" ? null : createCombo(' in table # 조합 = 컨테이너 + 덧칸
for text in ("openSkEdit({", "specBar(cur,", "specCells(block, cur,", "dg?.draw("):
assert text in table, text
assert "수량: fresh()" in table # 새 줄 수량 — 조합 = 면적을 따름
combo = _src("M02_MasterTemplete_Labor_Combo.ts")
assert "openQtyModal({" in combo and "linkAreaCell(" in combo and "saveTemplate(" in combo
def test_단가산출_줄은_SK_문서_모달_저장은_그_SK():
table = _src("M02_MasterTemplete_Labor_Table.ts")
modal = _src("M02_MasterTemplete_Labor_SkEdit.ts")
assert 'ref.키.startsWith("SK-")' in modal # SK 참조 줄만
assert "if (isSkRef(row.참조) && !opt.calc)" in table # 셈 주입(프로젝트 복사본)엔 옛 줄 모달
assert "readOnly: !edit || row.참조.층 !== opt.layer," in table # 위 층 SK = 읽기만
assert re.search(r'readTemplate\(layer, "sangeun", opt\.ref\.키, null, opt\.ref\.주인\)', modal)
assert re.search(
r'saveTemplate\(\s*layer,\s*"sangeun",\s*opt\.ref\.키', modal
) # 저장 = 그 SK 문서
assert (
"mountSangeunDetail(host, doc," in modal and "source: true" in modal
) # 수식 탭 부품 그대로
assert "await handle.flush();" in modal # 수식 저장 먼저 → 문서 저장
assert "if (!opt.readOnly) head.append(ok);" in modal # 읽기만 = [저장] 없음
assert "if (opened.has(at)) return;" in modal # 빨리 두 번 눌러도 하나
assert "closed: (done) => alive && done && recalc()," in table # 저장했으면 금액 다시 셈
def test_부품상자만_700줄_명사형():
for name in (
"M02_MasterTemplete_Labor_Table.ts",
"M02_MasterTemplete_Labor_Combo.ts",
"M02_MasterTemplete_Labor_SkEdit.ts",
"M02_MasterTemplete_Labor_Detail.ts",
):
text = _src(name)
assert len(text.splitlines()) <= 700, name
assert not re.search(r"""\bel\(\s*["'](button|input|select)["']""", text), name
assert not re.search(r"""createElement\(\s*["'](button|input|select)["']""", text), name
assert "#" not in "".join(re.findall(r"color:\s*[^;]+", text)), name
def _total(layer: Path, sk: str, qty: float) -> Decimal:
"""그 층 일위대가 한 벌(SK 참조 줄 하나 · 수량 qty)의 1 단위 합계."""
with store.at(layer):
ref = {"층": "personal", "주인": refs.here()[1], "키": sk}
doc = labor.clean({"담은로직": [{"로직": "", "참조": ref, "수량": {"손": qty}}]})
return Decimal(str(labor.calc(doc, {})["반영합"]["계"]))
def test_모달_저장_뒤_금액_따라감_되돌리면_같음(world):
"""모달 [저장] = SK 문서 쓰기(`store.write`) — 그 SK 를 가리킨 일위대가 줄 금액이 바뀌고 · 되돌리면 전후 같음."""
personal = world["personal"]
with store.at(personal):
key = refs.create(
"sangeun", {"이름": "sub1 시험", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []}
)["이름"]
got = store.read("sangeun", key)
ver0, doc0 = got["판"], got["문서"]
before = _total(personal, key, 2)
assert before == _total(personal, key, 2) and before > 0 # 같은 문서 · 같은 SK = 같은 금액
with store.at(personal):
cut = {**doc0, **COVER}
ver1 = store.write("sangeun", key, ver0, calc.clean_doc(cut, refs.here()))["판"]
changed = _total(personal, key, 2)
assert changed != before # 품 0.5 로 바뀜 — 일위대가 금액이 그 SK 를 따라감
with store.at(personal):
store.write("sangeun", key, ver1, doc0)
assert _total(personal, key, 2) == before # 되돌림 = 전후 같음
def test_옛_판_저장_409(world):
"""모달 [저장]이 낡은 판으로 쓰면 서버가 막음(같은 SK 를 다른 창이 먼저 고침)."""
personal = world["personal"]
with store.at(personal):
key = refs.create(
"sangeun", {"이름": "sub1 시험2", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []}
)["이름"]
got = store.read("sangeun", key)
store.write("sangeun", key, got["판"], {**got["문서"], "이름": "다른 창이 고침"})
with pytest.raises(store.StoreError) as why:
store.write("sangeun", key, got["판"], got["문서"])
assert why.value.status == 409
+1 -1
View File
@@ -150,7 +150,7 @@ def test_원문_패널_길_주입() -> None:
assert 'opts.path ?? "/m01/logic/source"' in src
side = (root / "M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts").read_text(encoding="utf-8")
assert "createSourceView()" in side
labor = (root / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text(
labor = (root / "M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts").read_text(
encoding="utf-8"
)
assert 'path: "/m02/logic-lib/source"' in labor and 'pdf: "/m02/logic-lib/source-pdf"' in labor
@@ -170,10 +170,11 @@ def _src(name):
def test_로직_자재_줄_누름은_그_로직_옵션_모달_사용자_줄은_그대로():
"""148-1 — 고칠 때 · 읽기만 둘 다 `open: openRow` · 바꾸기 단추 칸은 swap 있을 때만 · 사용자 줄 = openMat."""
detail = _src("M02_MasterTemplete_Labor_Detail.ts")
assert "open: openRow,\n swap: swapMat," in detail
assert ": { open: openRow }," in detail
assert "{ open: openMat, remove: removeMat, mark: markMat } : { open: openMat }" in detail
# 806-1 — 조합 · 서고 모두 일위대가표 부품 — 로직 자재 바꾸기 · 세 칸은 줄 모달(`Labor_RowModal`) 안
modal = _src("M02_MasterTemplete_Labor_RowModal.ts")
assert (
"open: () => undefined," in modal and "swap: (_r: number, at: number, key: string)" in modal
)
mats = _src("M02_MasterTemplete_Labor_LogicMats.ts")
assert "const tools = swap" in mats
assert 'tr.addEventListener("click", () => on.open(r))' in mats
+3 -3
View File
@@ -124,9 +124,9 @@ def test_화면_칸_부품_사용자_줄_로직_줄_같은_칸():
rows = _src("M02_MasterTemplete_Labor_MatTable.ts") # 717-3 자재목록 표 나눔
assert "...markCells(mat, on?.mark" in rows and "...markHeads()," in rows
assert "...markCells(" in _src("M02_MasterTemplete_Labor_LogicMats.ts")
detail = _src("M02_MasterTemplete_Labor_Detail.ts")
assert "mark: markLogicMat," in detail and "lib ? undefined" not in detail # 사전조합도(175-1)
assert "mark: markMat" in detail
modal = _src("M02_MasterTemplete_Labor_RowModal.ts") # 806-1 — 조합 · 서고 모두 줄 모달 안
assert "mark: (_r: number, at: number, key: MatMarkKey, v: string)" in modal
assert "mark: (_i, key, v) =>" in modal
# 서버 허용 값 = 화면 허용 값
api = _src("M02_MasterTemplete_Labor_Api.ts")
for key, allowed in labor.MARKS.items():
+1 -1
View File
@@ -83,7 +83,7 @@ def test_소스_보기만_명사형_자리():
words = re.findall(r'"([^"]*[가-힣][^"]*)"', code)
assert words and not [w for w in words if re.search(r"(합니다|해요|한다|하세요)", w)]
assert "createButton" not in code and "fetch" not in code and "sessionStorage" not in code
detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text(encoding="utf-8")
detail = (M02 / "M02_MasterTemplete_Labor_Table.ts").read_text(encoding="utf-8") # 806-1
assert "...nationalRows(cur.본뜬)" in detail and len(detail.splitlines()) <= 700
api = (M02 / "M02_MasterTemplete_Labor_Api.ts").read_text(encoding="utf-8")
assert "전국자재?: NationalMat[]" in api
+2 -1
View File
@@ -74,8 +74,9 @@ def test_배치_손잡이_차례칸_앞_단추칸():
order = css[css.index(".m02-labrow__order {") :]
assert "flex-direction: column;" in order[: order.index("}")]
assert ".m02-labrow__tools > .ui-btn-group {\n align-items: stretch;" in css
# 806-1 — 조합 상세는 일위대가표(격자 · 우클릭 위로 · 아래로)로 일원화 — 끌기 칸 표(`Labor_Rows`)는 면적산출 패널만
detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text(encoding="utf-8")
assert "afterRowDrag(logicHost, () =>" in detail
assert "afterRowDrag" not in detail and "logicTable" not in detail
def test_구분열_품셈_글():
+1 -1
View File
@@ -102,7 +102,7 @@ def test_고름에_owner_권한_서고_길에_명시_owner():
assert "owner: next.owner ?? null," in main and "mode, owner: at.owner ?? null" in main
lib = _text("M02_MasterTemplete_Library_List.ts")
assert "getActiveOwner" not in lib and lib.count("opt.owner") >= 6
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
detail = _text("M02_MasterTemplete_Labor_Table.ts") # 806-1
assert "opt.owner !== undefined" in detail
+1 -1
View File
@@ -53,7 +53,7 @@ def test_따라붙기_끔_옵션():
css = PART.with_suffix(".css").read_text(encoding="utf-8")
assert ".ui-side-panels.is-fill {" in css and "overscroll-behavior: contain" in css
m02 = ROOT / "M02_MasterTemplete"
for name in ("M02_MasterTemplete_AreaPanel.ts", "M02_MasterTemplete_Labor_Detail.ts"):
for name in ("M02_MasterTemplete_AreaPanel.ts", "M02_MasterTemplete_Labor_Table.ts"):
assert "follow: false," in (m02 / name).read_text(encoding="utf-8"), name
m01 = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts").read_text(encoding="utf-8")
assert "follow" not in m01