feat(B09): 첫 화면 = 설계내역서 · 빈 줄 수 하나(PLAN 701-2)

- 첫 탭 = 설계내역서(빈 원가계산서 아님) · 엑셀 탭 차례는 그대로
- 빈 줄 = 설계내역서 금액 모르는 직접공사 항목 줄(blankRows) — 설계내역서 안내 · 데이터 점검 · 집계표 넷이 같은 수 · 같은 낱말
- 데이터 점검 = 「빈 줄 n」(줄마다 하나 · 까닭 모음) + 「그 밖 확인 n곳」(호표 · 재료 · 탭 · 키마다 하나)
- test_203_1 낡은 값(첫 탭 · 「연결 없음 n곳」) 갱신 · helper 에 593-1 시각 글 stub(dateTimeText 없어 깨져 있던 것)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 12:51:05 +09:00
co-authored by Claude Opus 5.5
parent 80bae255bd
commit 21bc4f856a
6 changed files with 115 additions and 59 deletions
+58 -26
View File
@@ -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<string, string[]>();
const order: string[] = [];
const named = new Map<string, Row>();
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 };
}
+2 -9
View File
@@ -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));
+6
View File
@@ -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"]],
@@ -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),
};
};
@@ -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) => {
+39 -16
View File
@@ -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"]