From 2d9a469fc74fdcaa8a56c86d268a749a00c72d26 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 16:31:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(806-1):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=3D=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=ED=95=9C=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=20=C2=B7=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=EC=A4=84=20=3D=20=EA=B7=B8=20SK=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC(=EC=88=98=EC=8B=9D=20=ED=83=AD=20=C2=B7=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=3D=20=EA=B7=B8=20SK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조합 상세 · 카드 · 서고 모두 Labor_Table 한 부품 — 조합 전용 칸(면적 잇기 · 수량산출 모달 · 규격 열 · 다이어그램)은 새 Labor_Combo - 단가산출(SK) 참조 줄 누름 = 새 Labor_SkEdit — 그 SK 문서를 단가산출 상세 부품(수식 탭 첫 화면)으로 열고 [저장] = 수식 저장 → SK 문서 저장 → 일위대가 금액 다시 셈 · 위 층 SK = 읽기만 · 두 번 눌러도 모달 하나 - 옛 조합 전용 표 · 단가산출 보기 모달(Labor_SangeunModal) 지움 · 전국 자재 줄은 Labor_Table 이 그림 - 새 시험 test_806_1_labor_view · 소스 지킴 시험 12 개를 새 자리(Labor_Table · Labor_RowModal · Labor_SkEdit)로 고침 — 옛 Detail 자리 문구가 없어져 낡았음 Co-Authored-By: Claude Sonnet 5.5 --- .../M02_MasterTemplete_Labor_Combo.ts | 202 ++++++ .../M02_MasterTemplete_Labor_Detail.ts | 623 +----------------- .../M02_MasterTemplete_Labor_SangeunModal.ts | 133 ---- .../M02_MasterTemplete_Labor_SkEdit.ts | 118 ++++ .../M02_MasterTemplete_Labor_Table.ts | 190 ++++-- resources/tester/test_175_precombo_mats.py | 2 +- resources/tester/test_222_1_m02_apart.py | 7 +- resources/tester/test_704_3_hopyo_grid.py | 4 +- resources/tester/test_717_3_sangeun_modal.py | 30 +- resources/tester/test_721_find_pick.py | 3 +- resources/tester/test_723_labor_table.py | 4 +- resources/tester/test_742_1_sc_qty_saved.py | 3 +- resources/tester/test_793_3b_import_screen.py | 2 +- resources/tester/test_806_1_labor_view.py | 121 ++++ resources/tester/test_m01_logic_source.py | 2 +- resources/tester/test_m02_labor_logic_mats.py | 9 +- resources/tester/test_m02_labor_mat_marks.py | 6 +- resources/tester/test_m02_labor_national.py | 2 +- resources/tester/test_m02_labor_rowdrag.py | 3 +- resources/tester/test_m02_precombo_scope.py | 2 +- resources/tester/test_ui_side_panels.py | 2 +- 21 files changed, 624 insertions(+), 844 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Combo.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_SkEdit.ts create mode 100644 resources/tester/test_806_1_labor_view.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Combo.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Combo.ts new file mode 100644 index 000000000..3aeb99b34 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Combo.ts @@ -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; + given: () => Record; + /** 저장 안 한 칸 연결이 있음 — 서버 셈이 그 칸 값을 써야 함 */ + dirty: () => boolean; + setPills: (pills: { 키: string; 이름?: string; 단위?: string }[]) => void; + /** 면적산출 + 엔진을 받음(없으면 빈 것 — 새 구조물) */ + load: () => Promise; + /** 수량산출 모달 — 줄 · 자재 같음 · 바뀌면 `changed` */ + openQty: ( + title: string, + target: { 수량: LaborQty }, + line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, + unit: string, + changed: () => void, + ) => void; + /** 면적 문서 저장 · 저장했으면 참 */ + save: () => Promise; +} + +export function createCombo(opt: ComboOptions): Combo { + let links = new Map(); + // 엔진(recalc)은 면적 문서와 함께 늦게 받음 — 정적 import 금지(계약 `spreadsheet_types.ts` 머리) + let area: { doc: Workbook; 판: string; recalc: Recalc } | null = null; + let areaDirty = false; + let pills = new Map(); + const cells = (): Map => 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, + 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(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index cc698a99f..8cbab866d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -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(); - 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(); - const cells = (): Map => new Map([...links].map(([k, v]) => [k, v.칸])); - const given = (): Record => - 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(); - 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 => { - 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); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts deleted file mode 100644 index 7b3f2da21..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts +++ /dev/null @@ -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; -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), - })); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_SkEdit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_SkEdit.ts new file mode 100644 index 000000000..d7a23a544 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_SkEdit.ts @@ -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(); + +export async function openSkEdit(opt: SkEditOptions): Promise { + 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 | 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); // [저장] = 머리 [닫기] 왼쪽 + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts index 84100eba3..4622bd325 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts @@ -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 = { 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 = { ...cur, 종류: "사전조합" }; delete out.열; return out as unknown as LaborDoc; }, - saveArea: async () => false, + saveArea: async () => (await combo?.save()) ?? false, destroy: () => { alive = false; window.clearTimeout(timer); diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py index d12d156f8..f42133eaf 100644 --- a/resources/tester/test_175_precombo_mats.py +++ b/resources/tester/test_175_precombo_mats.py @@ -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 diff --git a/resources/tester/test_222_1_m02_apart.py b/resources/tester/test_222_1_m02_apart.py index d83937571..de67ef303 100644 --- a/resources/tester/test_222_1_m02_apart.py +++ b/resources/tester/test_222_1_m02_apart.py @@ -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 # ── ③ 원문 여럿 ────────────────────────────────────────────────────── diff --git a/resources/tester/test_704_3_hopyo_grid.py b/resources/tester/test_704_3_hopyo_grid.py index 93ce81009..dcc1a1567 100644 --- a/resources/tester/test_704_3_hopyo_grid.py +++ b/resources/tester/test_704_3_hopyo_grid.py @@ -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 diff --git a/resources/tester/test_717_3_sangeun_modal.py b/resources/tester/test_717_3_sangeun_modal.py index eefa8201d..286f5a6f2 100644 --- a/resources/tester/test_717_3_sangeun_modal.py +++ b/resources/tester/test_717_3_sangeun_modal.py @@ -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", ): diff --git a/resources/tester/test_721_find_pick.py b/resources/tester/test_721_find_pick.py index 0d9a2cca3..07142422e 100644 --- a/resources/tester/test_721_find_pick.py +++ b/resources/tester/test_721_find_pick.py @@ -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 # 중복 검사 그대로 diff --git a/resources/tester/test_723_labor_table.py b/resources/tester/test_723_labor_table.py index 814903e4a..c7b25e8f0 100644 --- a/resources/tester/test_723_labor_table.py +++ b/resources/tester/test_723_labor_table.py @@ -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 = { ...cur, 종류: "사전조합" };' in table for label in ('tc("Up")', 'tc("Down")', 't("Alias")', 'tc("Remove")'): assert label in table # 우클릭 메뉴 diff --git a/resources/tester/test_742_1_sc_qty_saved.py b/resources/tester/test_742_1_sc_qty_saved.py index 48350ace1..36ed18948 100644 --- a/resources/tester/test_742_1_sc_qty_saved.py +++ b/resources/tester/test_742_1_sc_qty_saved.py @@ -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 diff --git a/resources/tester/test_793_3b_import_screen.py b/resources/tester/test_793_3b_import_screen.py index f69f94197..1be3fbd12 100644 --- a/resources/tester/test_793_3b_import_screen.py +++ b/resources/tester/test_793_3b_import_screen.py @@ -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 # 공용 쪽은 새 모달을 모름 diff --git a/resources/tester/test_806_1_labor_view.py b/resources/tester/test_806_1_labor_view.py new file mode 100644 index 000000000..f6aec72a9 --- /dev/null +++ b/resources/tester/test_806_1_labor_view.py @@ -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 diff --git a/resources/tester/test_m01_logic_source.py b/resources/tester/test_m01_logic_source.py index 6d23437af..2d7da25d8 100644 --- a/resources/tester/test_m01_logic_source.py +++ b/resources/tester/test_m01_logic_source.py @@ -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 diff --git a/resources/tester/test_m02_labor_logic_mats.py b/resources/tester/test_m02_labor_logic_mats.py index 34fff0dea..3609be9b3 100644 --- a/resources/tester/test_m02_labor_logic_mats.py +++ b/resources/tester/test_m02_labor_logic_mats.py @@ -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 diff --git a/resources/tester/test_m02_labor_mat_marks.py b/resources/tester/test_m02_labor_mat_marks.py index 556c47686..4e2bf0a5f 100644 --- a/resources/tester/test_m02_labor_mat_marks.py +++ b/resources/tester/test_m02_labor_mat_marks.py @@ -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(): diff --git a/resources/tester/test_m02_labor_national.py b/resources/tester/test_m02_labor_national.py index 0b0873133..66b0e8d64 100644 --- a/resources/tester/test_m02_labor_national.py +++ b/resources/tester/test_m02_labor_national.py @@ -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 diff --git a/resources/tester/test_m02_labor_rowdrag.py b/resources/tester/test_m02_labor_rowdrag.py index 684f97ed1..e0d469d6a 100644 --- a/resources/tester/test_m02_labor_rowdrag.py +++ b/resources/tester/test_m02_labor_rowdrag.py @@ -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_구분열_품셈_글(): diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index bd96219af..642ca5268 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -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 diff --git a/resources/tester/test_ui_side_panels.py b/resources/tester/test_ui_side_panels.py index 353093a1c..04a33bc04 100644 --- a/resources/tester/test_ui_side_panels.py +++ b/resources/tester/test_ui_side_panels.py @@ -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