From 56e2c0b5154f2d365b6abb01e0cc7cb24295cc9b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 14:23:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EA=B7=9C=EA=B2=A9=20=ED=91=9C=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4):=20=EA=B2=A9=EC=9E=90=20=EA=B7=9C=EA=B2=A9=20?= =?UTF-8?q?=EC=97=B4=20=C2=B7=20M02=20=EA=B7=9C=EA=B2=A9=20=ED=91=9C=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=C2=B7=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=B0=BD=20=EC=97=B4=20=EB=8B=A8=EC=B6=94=20(PLAN=20704-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 격자 — 첫 호표 specs 면 수량 칸을 열마다(머리 「H 열」 · 기본 표시) · 합계 줄 그 칸 = 열마다 1 단위 합계 - M02 일위대가 카드 · 상세 — 격자 위 규격 줄(이름 · 열 이름 · 기본 · 열 지움 · [+ 열]) · 격자 수량 칸 열마다 고침(짝 셈 바로) - 새 파일 M02_MasterTemplete_Labor_Spec.ts — 상세(700 줄)는 한 줄만 바꿈 - 고르기 창 — 규격 열 둘 이상이면 열 단추(열마다 합계) · [적용] = spec 붙은 참조 · 이름 뒤 「(H=열)」 · 열 하나 = 단추 없음 - 시험 704-3 격자 시험 두 줄 고침(laborGrid 넷째 인자 · Labor_Spec 이어 붙임) · 704-4 화면 자료 시험 둘 Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Labor_Api.ts | 4 +- .../M02_MasterTemplete_Labor_Detail.ts | 4 +- .../M02_MasterTemplete_Labor_Grid.ts | 17 +- .../M02_MasterTemplete_Labor_Spec.ts | 189 ++++++++++++++++++ resources/tester/test_704_3_hopyo_grid.py | 3 +- resources/tester/test_704_4_spec.py | 56 ++++++ ui_template/ui_template_hopyo_grid.ts | 39 +++- ui_template/ui_template_item_picker.ts | 44 +++- ui_template/ui_template_item_picker_data.ts | 33 ++- 9 files changed, 377 insertions(+), 12 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 82d64ec84..6005b80f2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -11,7 +11,7 @@ import { type LogicSummary, type SubBrief, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { LaborCalc, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import type { LaborCalc, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc"; import { call, getActiveLayer, @@ -238,6 +238,8 @@ export interface LaborDoc { 담은로직: LaborRow[]; 자재: LaborMat[]; 계산값?: unknown; + /** 규격 열(704-4 · `Labor_Spec`) — 없으면 옛 문서(열 하나) */ + 규격?: LaborSpec | null; } /** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */ export function normalizeLabor(doc: unknown, column: string): LaborDoc { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index dfdcf42fc..5c7b65d42 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -13,7 +13,7 @@ * · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) · * 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1) * · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음) - * · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 + * · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 · 규격 표(704-4 · `Labor_Spec`) * ========================================================================== */ import { @@ -218,7 +218,7 @@ export function mountLaborDetail( ), ); const draw = (): void => { - gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics)); + gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && !lib && changed)); drawLogic(); matHost.replaceChildren( matTable( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts index c2a4b7b9e..b2addf083 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts @@ -6,6 +6,7 @@ * 로직 줄 = 이름(별칭 앞섬) · 규격(옵션 값 · 켜쌓기) · 수량 · 분모 · 단가 = 기준 ÷ 배 · 금액 = 반영 · 꼬리표 품셈 * 자재 줄 = 이름 · 규격 · 수량 · 단위 · 재료비 단가 = 기준단가 · 금액 = 반영 · 별도계상은 비고 「합계 밖」 * 빈 줄(금액 모름 · 수량 없음) = 합계 줄 「빈 줄 n」 붉게 — 701-4 경고와 같은 줄 + * 규격 열(704-4 · `Labor_Spec`) = 수량 칸 열마다 · 고칠 때 격자 위 규격 줄 + 수량 칸 고침 * ========================================================================== */ import { @@ -20,6 +21,7 @@ import type { LaborCalc, LaborCost, LaborDoc } from "./M02_MasterTemplete_Labor_ import { lineId } from "./M02_MasterTemplete_Labor_Calc"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { valueLine } from "./M02_Labor_Values_Model"; +import { specBar, specCells, withSpecs, type SpecEdit } from "./M02_MasterTemplete_Labor_Spec"; const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney => m @@ -77,7 +79,7 @@ export function laborBlock( }; }); const lines = [...logicLines, ...matLines]; - return { + const block: HopyoBlock = { key: doc.열, name: doc.일위대가번 || doc.열, unit: doc.단위 ?? "", @@ -85,11 +87,20 @@ export function laborBlock( lines, ...(calc ? {} : { blank: 0 }), }; + return withSpecs(block, doc, calc); } -/** 격자 하나(읽기 전용) */ +/** 격자 하나 — `edit` 있으면 위에 규격 줄 · 규격 수량 칸 고침(704-4) */ export const laborGrid = ( doc: LaborDoc, calc: LaborCalc | null, logics: ReadonlyMap, -): HTMLElement => createHopyoGrid([laborBlock(doc, calc, logics)]).root; + edit?: SpecEdit | false, +): HTMLElement => { + const block = laborBlock(doc, calc, logics); + if (!edit) return createHopyoGrid([block]).root; + const grid = createHopyoGrid([block], specCells(block, doc, edit)).root; + const box = document.createElement("div"); + box.append(specBar(doc, calc, edit), grid); + return box; +}; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts new file mode 100644 index 000000000..fd31eb32a --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts @@ -0,0 +1,189 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Spec.ts + * 일위대가 규격 표(PLAN 704-4) — 704-3 격자에 규격 열(H=1.5 | 2.0 …)을 폄 · 고칠 때 격자 위 규격 줄. + * 문서 `규격` = {이름, 열, 기본, 수량: {줄 id: {열: 수}}}(짝 `M02_Labor_Spec.py`) · 화면 캐시만(저장은 [저장]) + * 규격 줄 = 규격 이름 · 열마다 이름 칸 · [기본] · [×] · [+ 열](새 열 수량 = 지금 기본 열 값 그대로) + * 격자 수량 칸(열마다) 고침 = 그 줄 · 그 열 수량(빈 글 = 공통 수량을 따름) + * 열마다 수량 · 1 단위 합계 = 짝 셈 `settle(…, 열)`(서버 안 부름) · 금액 칸 = 기본 열 + * 쓰는 사본이 있는 열 지움 · 이름 바꿈은 [저장] 때 서버가 막음(409 까닭 그대로) + * ========================================================================== */ + +import { createButton, createInputField, el } from "@ui/ui_template_elements"; +import type { DataTableOptions } from "@ui/ui_template_data_table"; +import { hopyoGrid, hopyoSpecKey, type HopyoBlock } from "@ui/ui_template_hopyo_grid"; +import { createToolbar } from "@ui/ui_template_toolbar"; +import { createText } from "@ui/ui_template_text"; +import type { LaborCalc, LaborDoc } from "./M02_MasterTemplete_Labor_Api"; +import { givenOf, lineId, settle, specColumn } from "./M02_MasterTemplete_Labor_Calc"; + +/** 고칠 때 — 규격이 바뀌면 `true`(수량만 · 기준 그대로 · 짝 셈으로 바로) */ +export type SpecEdit = (qtyOnly: boolean) => void; + +const TEXT = { + none: "규격 열 없음 — [+ 열] = H 같은 규격마다 줄 수량만 다른 열", + add: "+ 열", + name: "규격 이름", + base: "기본", + remove: "열 지움", + first: "1", + next: "새 열", +}; + +/** 줄 id 차례 — 격자 줄 차례(로직 줄 → 자재 줄)와 같음 */ +const lineIds = (doc: LaborDoc): string[] => [ + ...doc.담은로직.map((r) => lineId(r) ?? ""), + ...doc.자재.map((m) => m.코드), +]; + +/** 열마다 셈 → 블록 `specs` · 줄 `qtys`(규격 없음 = 그대로) */ +export function withSpecs(block: HopyoBlock, doc: LaborDoc, calc: LaborCalc | null): HopyoBlock { + const spec = doc.규격; + if (!spec?.열.length) return block; + const per = calc ? spec.열.map((c) => settle(calc, doc, givenOf(calc), c)) : []; + const ids = lineIds(doc); + const logics = doc.담은로직.length; + const qtyOf = (p: LaborCalc, n: number): number | null => + (n < logics + ? p.줄.find((l) => lineId(l) === ids[n])?.수량 + : p.자재.find((m) => m.코드 === ids[n])?.수량) ?? null; + return { + ...block, + specs: { + name: spec.이름, + cols: spec.열, + pick: specColumn(doc), + totals: per.map((p) => p.반영합?.계 ?? null), + }, + lines: block.lines.map((ln, n) => ({ ...ln, qtys: per.map((p) => qtyOf(p, n)) })), + }; +} + +/** 격자 수량 칸 고침(열마다) — 공용 표 옵션 덧 */ +export function specCells( + block: HopyoBlock, + doc: LaborDoc, + edit: SpecEdit, +): Partial { + const spec = doc.규격; + if (!spec?.열.length) return {}; + const g = hopyoGrid([block]); + const ids = lineIds(doc); + const col = (key: string): string | undefined => spec.열.find((_, n) => hopyoSpecKey(n) === key); + return { + editable: (i, key) => g.at.has(i) && col(key) !== undefined, + onEdit: (i, key, text) => { + const id = ids[g.at.get(i)?.[1] ?? -1]; + const name = col(key); + if (id === undefined || name === undefined) return; + const v = Number(text.replace(/,/g, "").trim()); + const row = { ...(spec.수량[id] ?? {}) }; + if (text.trim() === "" || !Number.isFinite(v)) delete row[name]; + else row[name] = v; + if (Object.keys(row).length) spec.수량[id] = row; + else delete spec.수량[id]; + edit(true); + }, + }; +} + +/** 새 열 이름 — 겹치면 뒤에 번호 */ +const fresh = (cols: string[], want: string): string => { + let out = want; + for (let n = 2; cols.includes(out); n++) out = `${want} ${n}`; + return out; +}; + +/** [+ 열] — 규격이 없으면 지금 수량 열(1) + 새 열 · 새 열 수량 = 기본 열 손 값 그대로 */ +function addColumn(doc: LaborDoc, calc: LaborCalc | null): void { + const spec = (doc.규격 ??= { 이름: "H", 열: [TEXT.first], 기본: TEXT.first, 수량: {} }); + const col = fresh(spec.열, TEXT.next); + const base = specColumn(doc) ?? spec.열[0]; + spec.열.push(col); + const ids = lineIds(doc); + const logics = doc.담은로직.length; + ids.forEach((id, n) => { + const own = spec.수량[id]?.[base]; + const line = + n < logics ? calc?.줄.find((l) => lineId(l) === id) : calc?.자재.find((m) => m.코드 === id); + const v = own ?? (line?.수량출처 === "손" ? line.수량 : null); + if (v != null) spec.수량[id] = { ...(spec.수량[id] ?? {}), [col]: v }; + }); +} + +function renameColumn(doc: LaborDoc, from: string, to: string): boolean { + const spec = doc.규격; + const name = to.trim(); + if (!spec || !name || name.length > 20 || name === from || spec.열.includes(name)) return false; + spec.열 = spec.열.map((c) => (c === from ? name : c)); + if (spec.기본 === from) spec.기본 = name; + for (const row of Object.values(spec.수량)) + if (from in row) { + row[name] = row[from]; + delete row[from]; + } + return true; +} + +function removeColumn(doc: LaborDoc, col: string): void { + const spec = doc.규격; + if (!spec) return; + spec.열 = spec.열.filter((c) => c !== col); + for (const [id, row] of Object.entries(spec.수량)) { + delete row[col]; + if (!Object.keys(row).length) delete spec.수량[id]; + } + if (!spec.열.length) + doc.규격 = null; // 마지막 열 = 규격 없음(옛 문서 꼴) + else if (spec.기본 === col) spec.기본 = spec.열[0]; +} + +/** 격자 위 규격 줄(고칠 때만) */ +export function specBar(doc: LaborDoc, calc: LaborCalc | null, edit: SpecEdit): HTMLElement { + const add = createButton({ + label: TEXT.add, + variant: "ghost", + onClick: () => { + addColumn(doc, calc); + edit(true); + }, + }); + const spec = doc.규격; + if (!spec?.열.length) + return createToolbar({ items: [createText(TEXT.none, "muted"), add], label: TEXT.name }); + const name = createInputField({ value: spec.이름, compact: true, placeholder: TEXT.name }); + name.input.title = TEXT.name; + name.input.addEventListener("change", () => { + const v = name.input.value.trim(); + if (v && v.length <= 20) spec.이름 = v; + edit(true); + }); + const cols = spec.열.flatMap((col) => { + const box = createInputField({ value: col, compact: true }); + box.input.setAttribute("data-spec-col", col); + box.input.addEventListener("change", () => { + renameColumn(doc, col, box.input.value); + edit(true); + }); + const base = createButton({ + label: TEXT.base, + variant: col === spec.기본 ? "filled" : "ghost", + size: "sm", + onClick: () => { + spec.기본 = col; + edit(true); + }, + }); + const drop = createButton({ + label: "×", + variant: "ghost", + size: "sm", + onClick: () => { + removeColumn(doc, col); + edit(true); + }, + }); + drop.title = TEXT.remove; + return [el("span", { children: [box.root] }), base, drop, "divider" as const]; + }); + return createToolbar({ items: [name.root, "divider", ...cols, add], label: TEXT.name }); +} diff --git a/resources/tester/test_704_3_hopyo_grid.py b/resources/tester/test_704_3_hopyo_grid.py index bb9b3e521..935a63bcd 100644 --- a/resources/tester/test_704_3_hopyo_grid.py +++ b/resources/tester/test_704_3_hopyo_grid.py @@ -95,6 +95,7 @@ CALC = {"줄": [{"로직": "GF1", "분모": "㎡", "배": 10, "수량": 2.09, def test_M02_문서_calc_를_격자_줄로(): files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts", + "M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts", "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip logics = [ ["GF1", {"이름": "돌쌓기 찰쌓기", "입력": [], "결과단위": "㎡"}], @@ -134,7 +135,7 @@ 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 + assert "laborGrid(cur, calc ?? last, logics, " in detail # 카드 · 상세 = 같은 Labor_Detail 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_704_4_spec.py b/resources/tester/test_704_4_spec.py index 1743d615c..805e17604 100644 --- a/resources/tester/test_704_4_spec.py +++ b/resources/tester/test_704_4_spec.py @@ -168,3 +168,59 @@ def test_쓰는_열_지움_409_안_쓰는_열_됨(world): assert got.value.status == 409 and "proj/st0" in str(got.value.detail) spec.guard(path, {"규격": {**SPEC, "열": ["2.5", "3.0"]}}, "ms") # 1.5 = 안 씀 spec.guard(path, {"규격": SPEC}, "ms") + + +# ── 화면 자료(TS · Node) — 격자 규격 열 · 고르기 창 spec ───────────────────── +def test_격자_규격_열_수량_칸_열마다(): + from resources.tester import test_704_3_hopyo_grid as g + + files = [g.GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts", + "M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts", + "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip + doc = {**g.DOC, "규격": {"이름": "H", "열": ["1.5", "2.0"], "기본": "2.0", + "수량": {"GF1": {"1.5": 1.5, "2.0": 2.09}, "MT1": {"1.5": 4}}}} # fmt: skip + doc["자재"] = [{**doc["자재"][0], "수량": {"손": 5}}, {**doc["자재"][1], "수량": {"손": 2}}] + out = g._node(files, ( + f"\nconst b = laborBlock({json.dumps(doc, ensure_ascii=False)}, {json.dumps(g.CALC, ensure_ascii=False)}, new Map());" + "\nconst grid = hopyoGrid([b]);" + "\nconsole.log(JSON.stringify({ cols: grid.columns.map((c) => [c.key, c.label, c.group])," + " rows: grid.rows, specs: b.specs }));" + ), g.STUBS) # fmt: skip + cols = [c for c in out["cols"] if c[0].startswith("qty")] + assert cols == [["qty_0", "1.5", ["H 열"]], ["qty_1", "2.0 (기본)", ["H 열"]]] + line = out["rows"][1] # 첫 구성 줄 + assert line["qty_0"] == 1.5 and line["qty_1"] == 2.09 + mt1 = next(r for r in out["rows"] if r["name"] == "ㄴ 큰돌") + assert mt1["qty_0"] == 4 and mt1["qty_1"] == 5 # 2.0 빈 열 = 공통 수량 + t15, t20 = out["specs"]["totals"] + assert t20 == pytest.approx(112559.55) # 기본 열 = 금액 칸 합 + assert t15 == pytest.approx(529950 * 1.5 / 10 + 1800) # 열 수량 · 별도계상 MT1 = 합 밖 + + +def test_고르기_창_spec_돌려줌(): + import subprocess + + data = Path(__file__).resolve().parents[2] / "ui_template/ui_template_item_picker_data.ts" + body = ( + data.read_text("utf-8") + + """ +const row = { key: "SS000022", 열: "ms", 경로: ["석축", "메쌓기"], + labor: { 규격: { 이름: "H", 기본: "2.0", 열: [{ 이름: "1.5", 합계: 1 }, { 이름: "2.0", 합계: 2 }] } } }; +const one = { ...row, labor: { 규격: { 이름: "H", 기본: "1.5", 열: [{ 이름: "1.5", 합계: 1 }] } } }; +const plain = { key: "SS000001", 열: "rv" }; +const cur = { layer: "system", owner: null, key: "SS000022", name: "x", spec: "1.5" }; +console.log(JSON.stringify([toSummary(row, "1.5"), toSummary(one, "1.5"), toSummary(plain, "1.5"), + startSpec(row, cur), startSpec(row, null), startSpec(row, { ...cur, spec: "지운" }), startSpec(plain, cur)])); +""" + ) + got = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=body, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert got.returncode == 0, got.stderr + two, single, plain, *starts = json.loads(got.stdout) + assert two == {"layer": "system", "owner": None, "key": "SS000022", + "name": "석축 메쌓기 (H=1.5)", "spec": "1.5"} # fmt: skip + assert single["name"] == "석축 메쌓기" and single["spec"] == "1.5" # 열 하나 = 이름 그대로 + assert "spec" not in plain # 규격 없는 옛 항목 + assert starts == ["1.5", "2.0", "2.0", None] diff --git a/ui_template/ui_template_hopyo_grid.ts b/ui_template/ui_template_hopyo_grid.ts index af7320113..a3a7658da 100644 --- a/ui_template/ui_template_hopyo_grid.ts +++ b/ui_template/ui_template_hopyo_grid.ts @@ -8,6 +8,8 @@ * 구성 줄 = 「ㄴ 품명」 · 규격 · 품/수량 · 단위 · 합계 · 노무비 · 재료비 · 경비(단가 / 금액 쌍) · 비고 · 구분(줄 꼬리표) * 합계 줄 = 비목 금액 합 · 빈 줄(금액 모름 · 수량 없음) 있으면 비고 「빈 줄 n」 붉게 — 합계는 아는 줄만 * 줄 꼬리표 = 「품셈」(우리 로직이 품을 셈) · 「고정」 갈래는 705 몫(자리만 · 같은 칸) + * 규격 열(704-4 · 첫 호표 `specs`) = 수량 칸이 열 수만큼(`qty_n` · 머리 「H 열」) · 합계 줄 그 칸 = 열마다 1 단위 합계 · + * 금액 칸 = 기본 열 값 * 값은 부르는 쪽이 준 것 그대로(서버 계산 값) — 여기서 셈하지 않음 * * const g = createHopyoGrid([block]); host.append(g.root); g.set([block2]); @@ -44,6 +46,16 @@ export interface HopyoLine { note?: string | null; /** 줄 꼬리표 — 「품셈」 · 「고정」(705) · 빈 값 = 없음 */ tag?: string | null; + /** 규격 열마다 품 · 수량(704-4 · 블록 `specs.cols` 차례) — 없으면 `qty` */ + qtys?: (number | null)[]; +} + +/** 규격 열(704-4) — 이름(H) · 열 이름 · 기본 열 · 열마다 1 단위 합계 */ +export interface HopyoSpecs { + name: string; + cols: string[]; + pick?: string | null; + totals: (number | null)[]; } export interface HopyoBlock { @@ -63,6 +75,8 @@ export interface HopyoBlock { /** 빈 줄 수 · 이름 — 없으면 금액 모르는 줄에서 셈 */ blank?: number; blank_names?: string[]; + /** 규격 열(704-4) — 첫 호표 것만 열로 폄 */ + specs?: HopyoSpecs | null; } /** 줄 꼬리표 낱말 */ @@ -106,6 +120,23 @@ export const HOPYO_COLUMNS: DataColumn[] = [ hopyoText("tag", "구분", 56), ]; +/** 규격 열 수량 칸 열쇠(704-4) */ +export const hopyoSpecKey = (n: number): string => `qty_${n}`; + +/** 열 — 첫 호표에 규격 열이 있으면 수량 칸을 열마다 폄(기본 열 = 「(기본)」) */ +export function hopyoColumns(blocks: HopyoBlock[]): DataColumn[] { + const specs = blocks[0]?.specs; + if (!specs?.cols.length) return HOPYO_COLUMNS; + const qty = HOPYO_COLUMNS.find((c) => c.key === "qty") as DataColumn; + const cols = specs.cols.map((col, n): DataColumn => ({ + ...qty, + key: hopyoSpecKey(n), + label: col === specs.pick ? `${col} (기본)` : col, + group: [`${specs.name} 열`], + })); + return HOPYO_COLUMNS.flatMap((c) => (c.key === "qty" ? cols : [c])); +} + export interface HopyoGrid { columns: DataColumn[]; rows: DataRow[]; @@ -126,6 +157,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid { const rows: DataRow[] = []; const titles = new Set(); const at = new Map(); + const specCount = blocks[0]?.specs?.cols.length ?? 0; blocks.forEach((b, bi) => { titles.add(rows.length); rows.push({ @@ -153,6 +185,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid { note: ln.note ?? "", tag: ln.tag ?? "", }; + for (let k = 0; k < specCount; k++) row[hopyoSpecKey(k)] = ln.qtys?.[k] ?? ln.qty; for (const [key] of HOPYO_COSTS) { row[`${key}_price`] = ln.prices[key] ?? null; row[key] = ln.amounts[key] ?? null; @@ -161,7 +194,11 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid { }); const blank = hopyoBlank(b); titles.add(rows.length); + const sums = Object.fromEntries( + Array.from({ length: specCount }, (_, k) => [hopyoSpecKey(k), b.specs?.totals[k] ?? null]), + ); rows.push({ + ...sums, key: `${b.key}|합계`, name: "합 계", sum: b.total.sum ?? null, @@ -173,7 +210,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid { blank_names: blank.names, }); }); - return { columns: HOPYO_COLUMNS, rows, titles, at }; + return { columns: hopyoColumns(blocks), rows, titles, at }; } /** 칸 꼴 — 꼬리표 · 빈 줄 알림 */ diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index 3d6461087..d80b39ab3 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -11,6 +11,8 @@ * 224-1 — 맨 위 = M02 구조물조합 상단 표(구조물집계표 · 보기 전용 · 고른 항목의 열이 골라짐). * 173-2 — 나무 = M02 나무 꼴: 층 단추 셋(하나만 보임 · 뿌리 줄 없음) · 개인 맨 위 「새 항목」 = 빈 자리 오른쪽 클릭. * 280-2 — 위 표 · 나무 · 오른쪽 카드 = 한 상태: 위 표 = 보이는 층 표 · 표에서 열을 고르면 나무(펼쳐 굴림) · 카드도 그 항목. + * 704-4 — 고른 항목에 규격 열이 둘 이상이면 표 아래 열 단추(열마다 1 단위 합계) · [적용] = `spec` 붙은 참조 · + * 처음 = 지금 지정 열(같은 항목) · 아니면 기본 · 열 하나 = 단추 없음(그 열로). * ========================================================================== */ import "./ui_template_item_picker.css"; @@ -25,6 +27,8 @@ import { ownerOf, pathIds, rowId, + specOf, + startSpec, tableKey, toSummary, type PickNode, @@ -47,6 +51,8 @@ import { projectItems, type CreatedItem } from "./ui_template_item_picker_projec import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail"; import { createEditView, type EditView } from "./ui_template_item_picker_edit"; import { createComboTable } from "./ui_template_item_picker_table"; +import { createToolbar } from "./ui_template_toolbar"; +import { createText } from "./ui_template_text"; export type { SummaryItem } from "./ui_template_item_picker_data"; @@ -166,6 +172,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { + if (chosen && rowId(chosen) !== specFor) { + specFor = rowId(chosen); + specPick = startSpec(chosen, opts.current); + } + const spec = chosen ? specOf(chosen) : null; + specHost.hidden = !spec || spec.열.length < 2; + if (!spec || specHost.hidden) return void specHost.replaceChildren(); + const sum = (n: number | null): string => + n === null ? "" : ` · ${n.toLocaleString("ko-KR", { maximumFractionDigits: 0 })}`; + const buttons = spec.열.map((col) => { + const button = createButton({ + label: `${spec.이름}=${col.이름}${sum(col.합계)}`, + variant: col.이름 === specPick ? "filled" : "ghost", + size: "sm", + onClick: () => { + specPick = col.이름; + paintSpec(); + }, + }); + button.setAttribute("aria-pressed", String(col.이름 === specPick)); + return button; + }); + const head = createText(`${spec.이름} 규격 열`, "muted"); + specHost.replaceChildren(createToolbar({ items: [head, ...buttons], label: spec.이름 })); + }; + const paintDetail = (): void => { detailView.show(chosen); paintTable(); + paintSpec(); const layer = chosen ? layerOf(chosen) : null; applyButton.disabled = !chosen; clearButton.disabled = !opts.current; @@ -337,7 +375,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise chosen && finish({ action: "apply", item: toSummary(chosen) }), + () => chosen && finish({ action: "apply", item: toSummary(chosen, specPick) }), ); clearButton.addEventListener("click", () => finish({ action: "clear" })); manageButton.addEventListener("click", () => { @@ -407,6 +445,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { - body.append(tableWrap, cols, editHost, ...(opts.projectId ? [actions] : [])); + body.append(tableWrap, specHost, cols, editHost, ...(opts.projectId ? [actions] : [])); paintDetail(); void load(null); }, diff --git a/ui_template/ui_template_item_picker_data.ts b/ui_template/ui_template_item_picker_data.ts index 16aaf25e4..14f39e468 100644 --- a/ui_template/ui_template_item_picker_data.ts +++ b/ui_template/ui_template_item_picker_data.ts @@ -2,6 +2,7 @@ * ui_template_item_picker_data.ts * 항목 고르기의 자료 쪽(화면 없음) — 항목 목록 → 나무 · 찾기 · 폴더 줄 · 돌려줄 `summary_item`. * 모양은 PLAN.md 21-1 약속 그대로: 항목 목록 API 한 줄 = `PickerRow`, 참조 = `SummaryItem`. + * 규격 열(704-4) = 줄 `labor.규격` · 돌려줄 값에 `spec`(열 둘 이상이면 이름 뒤 「(H=2.0)」). * ========================================================================== */ export type PickerLayer = "system" | "company" | "personal"; @@ -12,6 +13,15 @@ export interface SummaryItem { owner: string | null; key: string; name: string; + /** 규격 열 이름(704-4) — 없으면 기본 열 */ + spec?: string; +} + +/** 규격 열(704-4 · 서버 목록 `labor.규격`) — 열마다 1 단위 합계 */ +export interface PickerSpec { + 이름: string; + 열: { 이름: string; 합계: number | null }[]; + 기본: string; } /** 항목 줄이 가리키는 키 칸 — 서버가 `names` 로 이름을 함께 주면 이름을 보임 */ @@ -36,6 +46,8 @@ export interface PickerRow { names?: Partial>; layer?: PickerLayer; owner?: string | null; + /** 일위대가 문서 요약(39-5) — 규격 열만 씀 */ + labor?: { 규격?: PickerSpec } | null; } /** 그룹 차례 = 마스터 · 회사 · 개인 */ @@ -88,8 +100,25 @@ export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) /** 상단 표(224-1)가 보이는 집계표 = 고른 항목이 속한 층(+ 주인) 것 — 같은 키면 표는 그대로 두고 열만 옮김 */ export const tableKey = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}`; -export function toSummary(row: PickerRow): SummaryItem { - return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) }; +/** 그 항목 규격 열 — 없으면 null */ +export const specOf = (row: PickerRow): PickerSpec | null => + row.labor?.규격?.열?.length ? row.labor.규격 : null; + +/** 처음 고른 열 — 지금 지정이 같은 항목이고 그 열이 있으면 그것 · 아니면 기본 */ +export function startSpec(row: PickerRow, current: SummaryItem | null): string | null { + const spec = specOf(row); + if (!spec) return null; + const want = isCurrent(current, row) ? current?.spec : undefined; + return spec.열.some((c) => c.이름 === want) ? (want as string) : spec.기본; +} + +/** 돌려줄 참조 — 규격 열이 있으면 `spec`(열 둘 이상 = 이름 뒤 「(H=열)」) */ +export function toSummary(row: PickerRow, col: string | null = null): SummaryItem { + const out = { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) }; + const spec = specOf(row); + if (!spec || col === null) return out; + const name = spec.열.length > 1 ? `${out.name} (${spec.이름}=${col})` : out.name; + return { ...out, name, spec: col }; } export function isCurrent(current: SummaryItem | null, row: PickerRow): boolean {