diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 8a81b93ae..67219f9e2 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -106,3 +106,9 @@ .b09-estimation .ui-data-table__table tbody tr.is-pick td:last-child { --pick-r: inset -2px 0 0 var(--color-danger); } + +/* 183-3 자재단가대비표 — 적용 단가를 낸 출처 단가 칸(고른 출처 · 기본 최저) */ +.b09-estimation .ui-data-table__table tbody td.is-picked { + background: color-mix(in srgb, var(--color-accent) 22%, var(--color-canvas)); + font-weight: var(--font-weight-bold); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 39109d305..7147fe004 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -49,7 +49,7 @@ import { buildMaterial, } from "./B09_Estimation_UI_Tabs_Base"; import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; -import { attachRowMenu, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { buildSumExpense, buildSumLabor, @@ -206,6 +206,7 @@ export function renderB09Estimation(root: HTMLElement): void { rowClass: extra ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null : spec.rowClass, + cellClass: spec.cellClass, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); @@ -215,6 +216,17 @@ export function renderB09Estimation(root: HTMLElement): void { // 166-2 줄 우클릭 메뉴(설계내역서) — 공용 표는 그대로 · 표 root 에 붙임 const menu = (spec as BillTable).menu; if (menu) attachRowMenu(made.root, menu); + // 183-3 칸 고르기 메뉴 — 누른 칸 아래에 B09 공용 메뉴 + if (spec.choices) + made.root.addEventListener("click", (event) => { + const cell = (event.target as Element).closest("tr[data-i] td"); + const column = cell ? spec.columns[cell.cellIndex] : undefined; + const items = + cell && column && spec.choices?.(Number(cell.parentElement?.dataset.i), column.key); + if (!cell || !items?.length) return; + const box = cell.getBoundingClientRect(); + openMenuAt(box.left, box.bottom, items); + }); // 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title if (spec.tip) made.root.addEventListener("mouseover", (event) => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 1b07aa109..8f68f8ead 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -7,6 +7,7 @@ * 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만 * ========================================================================== */ +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DataColumn, DataRow } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; @@ -107,6 +108,10 @@ export interface TabTable { rowClass?: (index: number) => string | null | undefined; /** 칸 풍선 글(157-3 · 예 「이름 규격」 칸의 원문번호) */ tip?: (index: number, key: string) => string | null | undefined; + /** 칸 꼴 class(183-3 공용 표 `cellClass` · 예 고른 출처 칸 강조) */ + cellClass?: (index: number, key: string) => string | null | undefined; + /** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */ + choices?: (index: number, key: string) => MapContextMenuItem[] | null; } export type TabBuild = (ctx: TabContext) => TabTable | string; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index d81d42d73..76d071d45 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -5,13 +5,15 @@ * 재료비목록표 = 엑셀 꼴 + 수량 · 총액 열(166-3) · 줄 = 대가 · 산근 · 중기가 쓰는 자재 + B08 줄 · * 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`) * - * 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈 - * · 견적 단가 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도) + * 자재단가대비표 적용 단가 칸 누름 = 고르기 메뉴(183-3 · 값 있는 출처 · 견적 · 「최저(자동)」 → + * `pick` · 옛 수기 `applied` 지움) · 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처) · + * 관급 / 별도 칸 누름 = 표시 바꿈 · 견적 단가 손입력 * · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) * · 출처 묶음 둘째 칸 = 「출처」(M01 면수 · 유가 기준일 값 그대로 · 183-2 · 견적엔 없음) * 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표 * ========================================================================== */ +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DataRow } from "@ui/ui_template_data_table"; import { SOURCES, type Row } from "./B09_Estimation_Calc_Base"; import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote"; @@ -109,10 +111,18 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => { const SUPPLY_NEXT: Record = { "": "관급", 관급: "별도", 별도: null }; -/** 고를 출처 돌림 — 자동 → 값 있는 출처 차례 → 자동 */ -function nextPick(r: Row): string | null { +/** 적용 단가 고르기 항목(183-3) — 값 있는 출처(견적 포함 · 0 뺌) 「출처 단가 · 최저」 + 「최저(자동)」 · + * `pick(s)` = 그 출처 · null = 자동 */ +function appliedChoices(r: Row, pick: (s: string | null) => void): MapContextMenuItem[] { const have = SOURCES.filter((s) => (r.prices[s] ?? 0) > 0); - return have[have.indexOf(r.pick) + 1] ?? null; + const label = (s: string): string => + `${s === r.applied_from ? "✓ " : ""}${s} ${Number(r.prices[s]).toLocaleString("ko-KR")}` + + (s === r.lowest_from ? " · 최저" : ""); + return [ + ...have.map((s): MapContextMenuItem => [label(s), () => pick(s)]), + "-", + [`${r.pick || r.applied_from === "사용자" ? "" : "✓ "}최저(자동)`, () => pick(null)], + ]; } export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { @@ -162,21 +172,30 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { ], rows: out, frozen: 2, - hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 = 수기 입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", - editable: (_i, key) => key === "applied" || key === "p:견적", + hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", + editable: (_i, key) => key === "p:견적", onEdit: (i, key, value) => { const row = out[i]; - if (!row) return; - const cell = mine(String(row.key)); const v = parseNumber(value); - if (v === undefined) return; - const field = key === "applied" ? "applied" : key === "p:견적" ? "quote" : null; - if (!field) return; - if (v === null) delete cell[field]; - else cell[field] = v; - if (field === "applied") delete cell.pick; // 손 수 = 고른 출처를 풂 + if (!row || key !== "p:견적" || v === undefined) return; + const cell = mine(String(row.key)); + if (v === null) delete cell.quote; + else cell.quote = v; put(String(row.key), cell); }, + // 183-3 — 고른 출처(없으면 최저) 단가 칸 강조 · 적용 단가 칸 누름 = 고르기 메뉴 + cellClass: (i, key) => (key === `p:${rows[i]?.applied_from}` ? "is-picked" : null), + choices: (i, key) => { + const r = rows[i]; + if (key !== "applied" || !r) return null; + return appliedChoices(r, (s) => { + const cell = mine(r.key); + delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂 + if (s) cell.pick = s; + else delete cell.pick; + put(r.key, cell); + }); + }, onCell: (i, key) => { const row = out[i]; const r = rows[i]; @@ -192,12 +211,6 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { put(r.key, now); }); } - if (key === "applied_from") { - const pick = nextPick(r); - if (pick) cell.pick = pick; - else delete cell.pick; - return put(String(row.key), cell); - } if (key !== "supply") return; const next = SUPPLY_NEXT[String(row.supply ?? "")] ?? null; if (next) cell.supply = next; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 5f69f8ab6..5d9d186e1 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -348,17 +348,27 @@ export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks); } +/** B09 메뉴 한 벌(우클릭 · 183-3 칸 고르기 같이 씀) — 화면 기준 자리 */ +function sharedMenu(): MapContextMenu { + shared ??= createMapContextMenu("b09-estimation"); + if (!shared.element.isConnected) { + shared.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) + document.body.append(shared.element); + } + return shared; +} + +/** 화면 자리(x · y)에 B09 메뉴를 엶(183-3 칸 고르기) */ +export function openMenuAt(x: number, y: number, items: MapContextMenuItem[]): void { + if (items.length) sharedMenu().open(x, y, items); +} + /** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */ export function attachRowMenu( root: HTMLElement, menu: (index: number) => MapContextMenuItem[], ): void { - shared ??= createMapContextMenu("b09-estimation"); - const ctxMenu = shared; - if (!ctxMenu.element.isConnected) { - ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) - document.body.append(ctxMenu.element); - } + const ctxMenu = sharedMenu(); // 칸 누름보다 먼저(잡기 단계) — 「연결 ▾」 메뉴를 그 칸 자리에 엶 root.addEventListener( "click", diff --git a/resources/tester/test_183_3_b09_applied_pick.py b/resources/tester/test_183_3_b09_applied_pick.py new file mode 100644 index 000000000..dcc5b1c91 --- /dev/null +++ b/resources/tester/test_183_3_b09_applied_pick.py @@ -0,0 +1,113 @@ +"""183-3 자재단가대비표 적용 단가 — 칸 누름 고르기 메뉴 · 고른 출처 칸 강조 · 기본 최저. + +- 공용 표 `cellClass` 옵션(칸 class · 안 주면 같은 글) — Node 로 `renderDataTableHtml` +- 고르기 항목 `appliedChoices`(값 있는 출처 · 견적 · 최저(자동) · ✓ 표시) — Node 로 함수만 떼어 돌림 +- 배선: 수기 적용 칸 · 출처 칸 돌림 없앰 · Shell 이 cellClass · choices 를 넘김 · B09 메뉴 한 벌 +셈 · 저장(pick › applied › 최저)은 그대로(test_144 · test_166_4). +""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS = ROOT / "config" / "node_modules" / "typescript" + + +def _src(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(script: str): + out = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +PRELUDE = f""" +const fs = require("fs"); +const ts = require({json.dumps(str(TS))}); +const js = (code) => ts.transpileModule(code, {{ + compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }}, +}}).outputText; +""" + + +def test_공용_표_cellClass_칸_class_안_주면_같은_글(): + table = json.dumps(str(ROOT / "ui_template" / "ui_template_data_table.ts")) + got = _node( + PRELUDE + + f""" +const box = {{ exports: {{}} }}; +new Function("exports", "module", "require", js(fs.readFileSync({table}, "utf8")))( + box.exports, box, (p) => (p.endsWith(".css") ? {{}} : require(p))); +const m = box.exports; +const o = {{ columns: [{{ key: "a", label: "A" }}, {{ key: "b", label: "B" }}], + rows: [{{ a: 1, b: 2 }}, {{ a: 3, b: 4 }}] }}; +const marked = m.renderDataTableHtml({{ ...o, + cellClass: (i, k) => (i === 0 && k === "b" ? "is-picked" : null) }}); +console.log(JSON.stringify({{ + plain: m.renderDataTableHtml(o), + none: m.renderDataTableHtml({{ ...o, cellClass: () => null }}), + count: marked.split("is-picked").length - 1, + row0: marked.split("")[1].split("")[0], +}})); +""" + ) + assert got["plain"] == got["none"] + assert got["count"] == 1 and '2' in got["row0"] + + +def test_고르기_항목_값_있는_출처_견적_최저_자동(): + base = _src("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts") + start = base.index("function appliedChoices") + body = base[start : base.index("\n}\n", start) + 3] + sources = _src("B09_Estimation/B09_Estimation_Calc_Base.ts") + at = sources.index("export const SOURCES") + line = sources[at : sources.index(";", at) + 1] + "\n" + got = _node( + PRELUDE + + f""" +const appliedChoices = new Function(js({json.dumps(line.replace("export ", "") + body)}) + "; return appliedChoices;")(); +const r = {{ prices: {{ 관급: null, 물가정보: 1200, 물가자료: 0, 유통물가: 1000, 견적: 1500 }}, + lowest_from: "유통물가", applied_from: "유통물가", pick: null }}; +const seen = []; +const items = appliedChoices(r, (s) => seen.push(s)); +items.forEach((x) => Array.isArray(x) && x[1]()); +const pick = {{ ...r, applied_from: "견적", pick: "견적" }}; +console.log(JSON.stringify({{ + labels: items.map((x) => (Array.isArray(x) ? x[0] : x)), + seen, + picked: appliedChoices(pick, () => 0).map((x) => (Array.isArray(x) ? x[0] : x)), +}})); +""" + ) + assert got["labels"] == [ + "물가정보 1,200", + "✓ 유통물가 1,000 · 최저", + "견적 1,500", + "-", + "✓ 최저(자동)", + ] + assert got["seen"] == ["물가정보", "유통물가", "견적", None] + assert got["picked"][2] == "✓ 견적 1,500" and got["picked"][-1] == "최저(자동)" + + +def test_배선_수기_적용_칸_출처_돌림_없앰(): + base = _src("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts") + compare = base[base.index("export const buildCompare") : base.index("function usesOf")] + assert 'editable: (_i, key) => key === "p:견적",' in compare + assert "nextPick" not in base and 'key === "applied_from"' not in compare + assert 'key === `p:${rows[i]?.applied_from}` ? "is-picked"' in compare + assert 'if (key !== "applied" || !r) return null;' in compare + assert "delete cell.applied" in compare + shell = _src("B09_Estimation/B09_Estimation_UI_Shell.ts") + assert ( + "cellClass: spec.cellClass," in shell and "openMenuAt(box.left, box.bottom, items)" in shell + ) + edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts") + assert "export function openMenuAt(" in edit and "const ctxMenu = sharedMenu();" in edit + css = _src("B09_Estimation/B09_Estimation_UI_Page.css") + assert "td.is-picked" in css and "var(--color-accent)" in css diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index f16bc7ecf..13e7a3b54 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -60,6 +60,8 @@ export interface DataTableOptions { onEdit?: (index: number, key: string, text: string) => void; /** 줄 꼴 class(본문 줄만 · 예 `is-title` = 굵게) — 빈 값이면 안 붙임 */ rowClass?: (index: number) => string | null | undefined; + /** 칸 꼴 class(본문 줄 · 글 상자 아닌 칸만 · 예 `is-picked` = 고른 칸 강조) — 빈 값이면 안 붙임 */ + cellClass?: (index: number, key: string) => string | null | undefined; } export interface DataTable { @@ -139,6 +141,7 @@ function rowHtml( editable?: (index: number, key: string) => boolean, focus = false, extra: string | null | undefined = null, + cellClass?: (key: string) => string | null | undefined, ): string { let tr = ""; for (let j = 0; j < cols.length; j++) { @@ -152,7 +155,12 @@ function rowHtml( } const [txt, empty] = formatCell(r[key], fmts[j], blank); const frz = left[j] !== null; - const cls = [frz && "is-frozen", frz && left[j + 1] == null && "is-edge", empty && "is-empty"] + const cls = [ + frz && "is-frozen", + frz && left[j + 1] == null && "is-edge", + empty && "is-empty", + cellClass?.(key), + ] .filter(Boolean) .join(" "); const attrs = @@ -218,6 +226,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin o.editable, o.focus === i, o.rowClass?.(i), + o.cellClass ? (key) => o.cellClass?.(i, key) : undefined, ); if (win) body += pad((n - to) * win.rowH, cols.length); const foot = o.total ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : "";