feat(b09): 설계내역서 수량 칸 · 손 줄 칸 고침 · 우클릭 메뉴 연결 · 시험(166-2)

- UI_Tabs_Bill — 항목 수량 덮어쓰기(빈칸 = 자동 · 풍선 자동 값) · 손 줄 명칭 · 규격 · 단위 · 수량
- UI_Shell — 내역서 표 우클릭 메뉴 붙임 · 고친 뒤 보던 자리 지킴
- Calc_Bill 짝 — 손 줄 자리(after) 놓침 고침
- 손 줄 띠 표시(CSS) · 새 시험 test_166_2(셈 · 거울 · 다듬기 · 새 산근 · 집계표 · 메뉴)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-10 00:14:23 +09:00
co-authored by Claude Opus 5.5
parent b584d52e25
commit cb09ab96b2
6 changed files with 278 additions and 18 deletions
+2 -3
View File
@@ -153,9 +153,8 @@ def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list
return out
def _place(items: list[dict[str, Any]], hand: dict[str, Any]) -> None:
def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> None:
"""손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝."""
after = hand.get("after") or ""
at = next((n + 1 for n, row in enumerate(items) if row["key"] == after), None)
if at is None and after == f"sec:{hand['section']}":
at = next((n for n, row in enumerate(items) if row["section"] == hand["section"]), None)
@@ -214,7 +213,7 @@ def compute_bill(
prices = None if price is None else {"labor": 0, "material": price, "expense": 0}
items.append(_item_row(item, prices, gaps, qty_of))
for item, prices, why in hand_items(user, books, hopyo):
_place(items, _item_row({**item, "why": why}, prices, gaps))
_place(items, _item_row({**item, "why": why}, prices, gaps), item.get("after") or "")
owner = [row for row in items if row["section"] == "G"]
net = 0.0
for row in owner:
+2 -3
View File
@@ -144,8 +144,7 @@ export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row |
}
/** 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */
function place(items: Row[], hand: Row): void {
const after = hand.after || "";
function place(items: Row[], hand: Row, after: string): void {
let at = items.findIndex((row) => row.key === after);
if (at >= 0) at += 1;
else if (after === `sec:${hand.section}`) at = items.findIndex((r) => r.section === hand.section);
@@ -206,7 +205,7 @@ export function computeBill(
items.push(itemRow(item, prices, gaps, qtyOf));
}
for (const [item, prices, why] of handItems(user, book, hopyo))
place(items, itemRow({ ...item, why }, prices, gaps));
place(items, itemRow({ ...item, why }, prices, gaps), item.after || "");
const owner = items.filter((row) => row.section === "G");
let net = 0;
for (const row of owner) net += row.material || 0;
@@ -110,3 +110,8 @@
.b09-sg-card__cols .b09-sg-card__num {
font-variant-numeric: normal;
}
/* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */
.b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child {
background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px);
}
@@ -48,6 +48,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 {
buildSumExpense,
buildSumLabor,
@@ -170,7 +171,11 @@ export function renderB09Estimation(root: HTMLElement): void {
user = next;
if (projectId) writeState("b09-estimation", user, projectId);
recompute();
// 고친 뒤 다시 그려도 보던 자리 지킴(166-2 내역서 수량 · 손 줄)
const top = panel.querySelector<HTMLElement>(".ui-data-table")?.scrollTop ?? 0;
show(current);
const box = panel.querySelector<HTMLElement>(".ui-data-table");
if (box && top) box.scrollTop = top;
};
/** 탭 하나 그림 · focus = 이동해 온 줄 키 */
@@ -217,6 +222,9 @@ export function renderB09Estimation(root: HTMLElement): void {
},
});
made.root.dataset.b09Table = tab;
// 166-2 줄 우클릭 메뉴(설계내역서) — 공용 표는 그대로 · 표 root 에 붙임
const menu = (spec as BillTable).menu;
if (menu) attachRowMenu(made.root, menu);
// 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title
if (spec.tip)
made.root.addEventListener("mouseover", (event) => {
+30 -12
View File
@@ -3,23 +3,25 @@
* B09 설계내역서 탭(PLAN 145-3) — 실무 엑셀 꼴(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고).
*
* 값 = `B09_Estimation_Calc_Bill`(조작 중 브라우저 · [저장] 때 서버 정본)
* 고칠 칸 = 관급자재대 조달수수료 율(%) · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음)
* 고칠 칸 = 관급자재대 조달수수료 율(%) · 항목 수량(166-2 · 덮어쓰기 · 빈칸 = 자동) ·
* 손 줄 명칭 · 규격 · 단위 · 수량 · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음)
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
* 우클릭 = 줄 추가 · 지우기 · 연결(`…_UI_Tabs_Bill_Edit`)
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
import type { Row } from "./B09_Estimation_Calc_Base";
import { money, parseNumber, qty, text, type TabBuild, type TabId } from "./B09_Estimation_UI_Tabs";
import {
money,
parseNumber,
qty,
text,
type TabBuild,
type TabId,
type TabTable,
} from "./B09_Estimation_UI_Tabs";
billMenu,
editQty,
editText,
handEditable,
type BillTable,
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
export const buildBill: TabBuild = ({ sheets, user, setUser }): TabTable => {
export const buildBill: TabBuild = (ctx): BillTable => {
const { sheets, user, setUser } = ctx;
const bill: Row = sheets.bill || { rows: [], totals: null };
const rows: DataRow[] = bill.rows.map((r: Row) => ({
...r,
@@ -47,14 +49,30 @@ export const buildBill: TabBuild = ({ sheets, user, setUser }): TabTable => {
frozen: 1,
hint:
"공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " +
"수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " +
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
editable: (i, key) => key === "qty" && rows[i]?.key === "fee",
editable: (i, key) => {
const r = rows[i];
if (!r || r.kind !== "item") return false;
return r.hand ? handEditable(key) : key === "qty";
},
onEdit: (i, key, value) => {
if (key !== "qty" || rows[i]?.key !== "fee") return;
const r = rows[i];
if (!r) return;
if (r.hand && key !== "qty") return editText(ctx, String(r.key), key, value);
if (key !== "qty") return;
const v = parseNumber(value);
if (v === undefined) return;
if (r.key !== "fee") return editQty(ctx, r, v);
setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } });
},
tip: (i, key) => {
const r = rows[i];
if (key !== "qty" || !r || !("qty_auto" in r)) return null;
return `자동 값 ${r.qty_auto ?? "빈칸"} — 우클릭 [수량 자동으로 되돌리기]`;
},
rowClass: (i) => (rows[i]?.hand ? "is-hand" : null),
menu: (i) => billMenu(ctx, rows, i),
link: (i) => {
const ref = rows[i]?.ref as Row | null | undefined;
return ref?.key ? { tab: ref.tab as TabId, key: String(ref.key) } : null;