From 21bc4f856ae530ccb9a9a793dea1a950cc227e8e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 12:51:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=B2=AB=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=3D=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD=EC=84=9C=20=C2=B7=20?= =?UTF-8?q?=EB=B9=88=20=EC=A4=84=20=EC=88=98=20=ED=95=98=EB=82=98(PLAN=207?= =?UTF-8?q?01-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첫 탭 = 설계내역서(빈 원가계산서 아님) · 엑셀 탭 차례는 그대로 - 빈 줄 = 설계내역서 금액 모르는 직접공사 항목 줄(blankRows) — 설계내역서 안내 · 데이터 점검 · 집계표 넷이 같은 수 · 같은 낱말 - 데이터 점검 = 「빈 줄 n」(줄마다 하나 · 까닭 모음) + 「그 밖 확인 n곳」(호표 · 재료 · 탭 · 키마다 하나) - test_203_1 낡은 값(첫 탭 · 「연결 없음 n곳」) 갱신 · helper 에 593-1 시각 글 stub(dateTimeText 없어 깨져 있던 것) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_UI_Check.ts | 84 +++++++++++++------ B09_Estimation/B09_Estimation_UI_Shell.ts | 11 +-- B09_Estimation/B09_Estimation_UI_Tabs.ts | 6 ++ .../B09_Estimation_UI_Tabs_Summary.ts | 17 ++-- resources/tester/helper_b09_group_cards.py | 1 + resources/tester/test_203_1_b09_data_check.py | 55 ++++++++---- 6 files changed, 115 insertions(+), 59 deletions(-) diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts index 285941732..07ce77bb7 100644 --- a/B09_Estimation/B09_Estimation_UI_Check.ts +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -4,7 +4,8 @@ * * [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) * 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버 - * 연결 점검 = 「연결 없음」 줄 목록(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키 + * 연결 점검 = 「빈 줄 n」(설계내역서 금액 모르는 줄 · 안내 · 집계표와 같은 수 · 701-2) 목록 + + * 그 밖 확인 목록(일위대가 · 단가산출 · 재료 등 · 탭 · 키마다 하나)(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키 * ========================================================================== */ import { attachHoverInfo } from "@ui/ui_template_hover_info"; @@ -12,7 +13,7 @@ import { createBadge } from "@ui/ui_template_badge"; import { createText } from "@ui/ui_template_text"; import type { Row } from "./B09_Estimation_Calc_Base"; import { dateTimeText } from "../common_util/common_util_time"; -import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs"; +import { blankRows, tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs"; export interface CheckItem { link: Link; @@ -21,19 +22,36 @@ export interface CheckItem { why: string; } -/** 빠진 연결 줄 · 알림 글(서버 알림 + M01 에 없는 키) */ -export function checkItems(sheets: Row, payload: Row): { items: CheckItem[]; notes: string[] } { - const gaps: Row[] = sheets.gaps || []; - const items = gaps.map((g) => ({ - link: { tab: g.tab as TabId, key: g.key }, - tab: tabName(g.tab), - name: String(g.name ?? ""), - why: String(g.why ?? ""), - })); +const BLANK_WHY = "금액 모름 — 단가연결 칸 램프 · 이은 호표의 빈 줄을 확인"; + +/** 빈 줄(설계내역서 줄마다 하나 · 까닭 모음) · 그 밖 확인 줄(탭 · 키마다 하나) · 알림 글 */ +export function checkItems( + sheets: Row, + payload: Row, +): { items: CheckItem[]; others: CheckItem[]; notes: string[] } { + const why = new Map(); + const order: string[] = []; + const named = new Map(); + for (const g of (sheets.gaps || []) as Row[]) { + const id = `${g.tab}|${g.key}`; + if (!why.has(id)) (why.set(id, []), order.push(id), named.set(id, g)); + const list = why.get(id)!; + if (g.why && !list.includes(String(g.why))) list.push(String(g.why)); + } + const item = (tab: string, key: string, name: unknown, reasons?: string[]): CheckItem => ({ + link: { tab: tab as TabId, key }, + tab: tabName(tab), + name: String(name ?? ""), + why: reasons?.length ? reasons.join(" · ") : BLANK_WHY, + }); + const items = blankRows(sheets).map((r) => item("bill", r.key, r.name, why.get(`bill|${r.key}`))); + const others = order + .filter((id) => !id.startsWith("bill|")) + .map((id) => item(named.get(id)!.tab, named.get(id)!.key, named.get(id)!.name, why.get(id))); const notes: string[] = [...(payload.notes || [])]; const missing: string[] = payload.inputs?.missing || []; if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`); - return { items, notes }; + return { items, others, notes }; } /** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */ @@ -54,25 +72,24 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi let payload: Row | null = null; let items: CheckItem[] = []; attachHoverInfo(refresh, () => refreshTip(payload)); - const list = document.createElement("div"); - list.className = "b09-check"; - attachHoverInfo(list, (row) => items[Number(row.dataset.i)]?.why, "[data-i]"); + const box = document.createElement("div"); + box.className = "b09-check"; + attachHoverInfo(box, (row) => items[Number(row.dataset.i)]?.why, "[data-i]"); - const draw = (next: Row, sheets: Row): void => { - payload = next; - refresh.classList.toggle("is-pending", Boolean(next.stale)); - const made = checkItems(sheets, next); - items = made.items; - const head = createText(`연결 없음 ${items.length}곳`, "muted"); - head.classList.add("b09-check__head"); + const head = (text: string): HTMLElement => { + const p = createText(text, "muted"); + p.classList.add("b09-check__head"); + return p; + }; + const list = (group: CheckItem[], offset: number): HTMLElement => { const rows = document.createElement("div"); rows.className = "ui-sidebar-list"; rows.append( - ...items.map((item, i) => { + ...group.map((item, n) => { const row = document.createElement("button"); row.type = "button"; row.className = "ui-sidebar-row"; - row.dataset.i = String(i); + row.dataset.i = String(offset + n); const name = document.createElement("span"); name.textContent = item.name; const tab = createBadge(item.tab, "neutral", { size: 18 }); @@ -82,12 +99,27 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi return row; }), ); + return rows; + }; + + const draw = (next: Row, sheets: Row): void => { + payload = next; + refresh.classList.toggle("is-pending", Boolean(next.stale)); + const made = checkItems(sheets, next); + items = [...made.items, ...made.others]; + const blank = [ + head(`빈 줄 ${made.items.length}`), + ...(made.items.length ? [list(made.items, 0)] : []), + ]; + const others = made.others.length + ? [head(`그 밖 확인 ${made.others.length}곳`), list(made.others, made.items.length)] + : []; const notes = made.notes.map((text) => { const p = createText(text, "muted"); p.classList.add("b09-check__note"); return p; }); - list.replaceChildren(head, ...(items.length ? [rows] : []), ...notes); + box.replaceChildren(...blank, ...others, ...notes); }; - return { list, draw }; + return { list: box, draw }; } diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 4eeb511d8..032a028ae 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -38,14 +38,7 @@ import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; import { createDock, linkMode } from "./B09_Estimation_UI_Dock"; import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; -import { - DRAFT, - rowIndex, - TABS, - type Link, - type TabContext, - type TabId, -} from "./B09_Estimation_UI_Tabs"; +import { DRAFT, rowIndex, type Link, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; import { @@ -126,7 +119,7 @@ export function renderB09Estimation(root: HTMLElement): void { let payload: Row | null = null; let user: Row = {}; let sheets: Row = {}; - let current: TabId = TABS[0][0]; + let current: TabId = "bill"; // 701-2 첫 화면 = 설계내역서(빈 원가계산서 아님) // 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar` const bars = createTabBars((tab) => show(tab)); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 1adb10aad..d37e2ff84 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -56,6 +56,12 @@ export const TABS: Array<[TabId, string]> = [ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] ?? id; +/** 빈 줄(701-2) = 설계내역서 금액 모르는 직접공사 항목 줄 — 안내 · 데이터 점검 · 집계표 · 원가계산서가 같은 수 */ +export const blankRows = (sheets: Row): Row[] => + ((sheets?.bill?.rows || []) as Row[]).filter( + (r) => r.kind === "item" && r.cost === "direct" && r.sum === null, + ); + /** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ export const TAB_GROUPS: Array<[string, TabId[]]> = [ ["공사원가계산서", ["cost"]], diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts index f3cef1dd2..6bbc83213 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -10,6 +10,7 @@ import type { DataRow } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; import { + blankRows, money, qty, text, @@ -20,17 +21,17 @@ import { const HINT = "줄을 누르면 목록표로 이동"; -/** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */ -const hint = (payload: Row, extra = ""): string => { - const n = (payload.summary || {}).unlinked || 0; - return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(좌측 데이터 점검)` : ""}`; +/** 안내 + 「빈 줄 n」(설계내역서와 같은 수 · 701-2 — 금액 모르는 줄은 집계에서 빠짐) */ +const hint = (sheets: Row, extra = ""): string => { + const n = blankRows(sheets).length; + return `${HINT}${extra}${n ? ` · 빈 줄 ${n} — 집계에서 빠짐(좌측 데이터 점검)` : ""}`; }; /** 줄이 없을 때 표 위 맨 앞 안내 */ const EXPENSE_NONE = "경비 자원 없음"; function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): TabBuild { - return ({ payload }): TabTable => { + return ({ payload, sheets }): TabTable => { const rows: DataRow[] = ((payload.summary || {})[id] || []).map((r: Row) => ({ ...r })); const sum = rows.reduce((acc, r) => acc + (typeof r.amount === "number" ? r.amount : 0), 0); return { @@ -47,7 +48,7 @@ function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): Tab rows, total: rows.length ? { name: "계", amount: sum } : null, frozen: 2, - hint: !rows.length && none ? `${none} · ${hint(payload)}` : hint(payload), + hint: !rows.length && none ? `${none} · ${hint(sheets)}` : hint(sheets), link: (i) => (rows[i] ? { tab: list, key: String(rows[i].key) } : null), }; }; @@ -57,7 +58,7 @@ export const buildSumMaterial = resourceTab("sum_material", "material", "단가" export const buildSumLabor = resourceTab("sum_labor", "labor", "단가"); export const buildSumExpense = resourceTab("sum_expense", "expense", "단가", EXPENSE_NONE); -export const buildSumMachine: TabBuild = ({ payload }): TabTable => { +export const buildSumMachine: TabBuild = ({ payload, sheets }): TabTable => { const rows: DataRow[] = ((payload.summary || {}).sum_machine || []).map((r: Row) => ({ ...r })); const add = (key: string) => rows.reduce((acc, r) => acc + (typeof r[key] === "number" ? (r[key] as number) : 0), 0); @@ -85,7 +86,7 @@ export const buildSumMachine: TabBuild = ({ payload }): TabTable => { } : null, frozen: 2, - hint: hint(payload), + hint: hint(sheets), link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null), }; }; diff --git a/resources/tester/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py index 532b80751..2e74ef513 100644 --- a/resources/tester/helper_b09_group_cards.py +++ b/resources/tester/helper_b09_group_cards.py @@ -18,6 +18,7 @@ GROUPS = "B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts" # 기억(b_page_state) 대신 Map · 탭 줄(fillTabs) 대신 가짜 button 줄 · 단추(createButton) 대신 가짜 button · 호버(attachHoverInfo) = 부른 자리 기록 · 157-3 원문번호 풍선(raw) 짝 STUBS = r""" const kept = new Map(); +const dateTimeText = (iso) => String(iso).replace("T", " "); // 593-1 PC 시간대 글 — 견본은 시간대 없음 const readState = (name) => (kept.has(name) ? JSON.parse(kept.get(name)) : null); const writeState = (name, value) => kept.set(name, JSON.stringify(value)); const createButton = (o) => { diff --git a/resources/tester/test_203_1_b09_data_check.py b/resources/tester/test_203_1_b09_data_check.py index 5e63d693e..10103fa8a 100644 --- a/resources/tester/test_203_1_b09_data_check.py +++ b/resources/tester/test_203_1_b09_data_check.py @@ -2,7 +2,9 @@ - 묶음 이름 = 데이터 점검 · 안 설명 글 없음(굳힌 시각 · 까닭 = [업데이트] 공용 호버) - [단가 갱신] → [업데이트] · 낡으면(서버 `book_stale` = 지금 M01 로 굳힌 책과 다름) 점 배지(is-pending) -- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌 · 첫 탭 = 공사원가계산서 +- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌 +- 701-2 첫 화면 = 설계내역서 · 목록 머리 「빈 줄 n」 = 설계내역서 금액 모르는 줄(줄마다 하나 · 까닭 모음) · + 그 밖(호표 · 재료) = 「그 밖 확인 n곳」(탭 · 키마다 하나) """ import json @@ -31,15 +33,15 @@ def test_묶음_이름_단추_글_설명_없음(): assert "check.draw(payload, sheets);" in SHELL -def test_연결_점검_탭_없음_첫_탭_공사원가계산서(): +def test_연결_점검_탭_없음_첫_화면_설계내역서(): out = run( [TABS], "\nconsole.log(JSON.stringify({ ids: TABS.map(([t]) => t), names: TABS.map(([, n]) => n)," " groups: TAB_GROUPS.map(([n]) => n), first: TABS[0][0] }));", ) assert "check" not in out["ids"] and "연결 점검" not in out["names"] + out["groups"] - assert out["first"] == "cost" and out["groups"][0] == "공사원가계산서" - assert "let current: TabId = TABS[0][0];" in SHELL + assert out["first"] == "cost" and out["groups"][0] == "공사원가계산서" # 엑셀 탭 차례 그대로 + assert 'let current: TabId = "bill";' in SHELL assert "buildCheck" not in SHELL and "check:" not in SHELL @@ -50,43 +52,64 @@ def test_좌측_목록_이동_호버_배지(): const moves = []; const refresh = document.createElement("button"); const panel = createCheckPanel(refresh, (link) => moves.push(link)); -const sheets = { gaps: [ - { tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" }, - { tab: "material", key: "MT9", name: "모래", why: "단가 없음" }, -] }; +const sheets = { + bill: { rows: [ + { kind: "title", cost: "direct", key: "sec:1", name: "토공사", sum: null }, + { kind: "item", cost: "direct", key: "earth:3", name: "제근", sum: null }, + { kind: "item", cost: "direct", key: "earth:4", name: "절토", sum: 100 }, + { kind: "item", cost: "direct", key: "st:w", name: "옹벽", sum: null }, + { kind: "item", cost: "owner", key: "mat:1", name: "레미콘", sum: null }, + ] }, + gaps: [ + { tab: "bill", key: "earth:3", name: "제근", why: "수량 연결 없음" }, + { tab: "bill", key: "earth:3", name: "제근", why: "준비공 대가 — 연결 없음" }, + { tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" }, + { tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" }, + { tab: "material", key: "MT9", name: "모래", why: "단가 없음" }, + ], +}; const payload = { book: { made: "2026-10-01T09:00:00" }, stale: "M01 에서 바뀐 단가 2", notes: ["B08 자재 집계표 못 읽음"], inputs: { missing: ["EQ1", "LB2"] } }; panel.draw(payload, sheets); const view = () => ({ pending: refresh.classList.contains("is-pending"), texts: panel.list.children.map((c) => c.text), - rows: (panel.list.children[1]?.children ?? []).map((r) => [r.dataset.i, r.className, r.text]), + rows: [1, 3].flatMap((n) => + (panel.list.children[n]?.children ?? []).map((r) => [r.dataset.i, r.className, r.text])), }); const a = view(); -const rowsBox = panel.list.children[1]; +const blankBox = panel.list.children[1]; +const rowsBox = panel.list.children[3]; rowsBox.children[1].click(); const tipRefresh = hoverCalls.find((h) => h.root === refresh).text(); const rowHover = hoverCalls.find((h) => h.root === panel.list); const tipRow = rowHover.text(rowsBox.children[0]); +const tipBlank = [0, 1].map((n) => rowHover.text(blankBox.children[n])); panel.draw({ book: null, stale: null }, { gaps: [] }); const b = view(); const tipEmpty = refreshTip({ book: null, stale: "단가 아직 안 굳힘" }); -console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, selector: rowHover.selector, tipEmpty })); +console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, tipBlank, selector: rowHover.selector, + tipEmpty })); """, ) a, b = out["a"], out["b"] assert a["pending"] and not b["pending"] - assert a["texts"][0] == "연결 없음 2곳" + # 빈 줄 = 직접공사 항목 중 금액 모름(관급 · 제목 · 아는 줄 빼고) · 같은 줄 까닭은 하나로 + assert a["texts"][0] == "빈 줄 2" and a["texts"][2] == "그 밖 확인 2곳" assert a["rows"] == [ - ["0", "ui-sidebar-row", "찰쌓기일위대가표"], - ["1", "ui-sidebar-row", "모래재료비목록표"], + ["0", "ui-sidebar-row", "제근설계내역서"], + ["1", "ui-sidebar-row", "옹벽설계내역서"], + ["2", "ui-sidebar-row", "찰쌓기일위대가표"], + ["3", "ui-sidebar-row", "모래재료비목록표"], ] - assert a["texts"][2:] == ["B08 자재 집계표 못 읽음", "M01 에 없는 키 2 — EQ1 · LB2"] + assert out["tipBlank"][0] == "수량 연결 없음 · 준비공 대가 — 연결 없음" + assert out["tipBlank"][1].startswith("금액 모름") # 까닭 없는 빈 줄(이은 대가에 빈 줄) + assert a["texts"][4:] == ["B08 자재 집계표 못 읽음", "M01 에 없는 키 2 — EQ1 · LB2"] assert out["moves"] == [{"tab": "material", "key": "MT9"}] assert out["tipRow"] == "대가 원천 없음" and out["selector"] == "[data-i]" assert "단가 굳힘 2026-10-01 09:00:00" in out["tipRefresh"] assert "업데이트 필요 — M01 에서 바뀐 단가 2" in out["tipRefresh"] - assert b["texts"] == ["연결 없음 0곳"] and b["rows"] == [] + assert b["texts"] == ["빈 줄 0"] and b["rows"] == [] assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"]