Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_1

This commit is contained in:
2026-10-10 11:57:07 +09:00
8 changed files with 658 additions and 36 deletions
+70 -3
View File
@@ -1,7 +1,7 @@
"""B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_145_3_b09_bill.py`.
거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨).
실무 엑셀 설계내역서 꼴 · 공종 나무 = 엑셀 꼴 고정(`SECTIONS` · 공구 안 나눔):
1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액)
@@ -18,6 +18,9 @@
단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 ·
logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) ·
그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴).
레벨(182) = 손 줄 `kind: "group"`(묶음 줄 · 수량 · 연결 없음) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로
아래) · 줄마다 `level`(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1) ·
묶음 금액 = 자손 항목 합 · 공종 합 · `sums` = 항목만(묶음 줄 안 셈 · 원가계산서 그대로).
원가계산서로 넘길 합 = `sums`(직접재료비 · 직접노무비 · 산출경비 · 관급 순자재대 — 145-4 이음 꼴 ·
빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
"""
@@ -153,6 +156,62 @@ def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list
return out
def _group_row(item: dict[str, Any]) -> dict[str, Any]:
"""묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`_nest`)."""
return {
"kind": "group",
"key": item["key"],
"section": item["section"],
"name": item.get("name") or "",
"spec": item.get("spec") or "",
"unit": item.get("unit") or "",
"qty": None,
"note": "",
"ref": None,
"hand": True,
"sum": 0.0,
"labor": 0.0,
"material": 0.0,
"expense": 0.0,
"blank": 0,
}
def _nest(rows: list[dict[str, Any]]) -> None:
"""182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액."""
groups = {row["key"]: row for row in rows if row["kind"] == "group"}
for row in rows:
if not row.get("hand"):
continue
up = groups.get(row.get("parent") or "")
if up is None or up["section"] != row["section"]:
row["parent"] = None
continue
at, n = up, 0
while at is not None and n <= len(groups):
if at["key"] == row["key"]:
row["parent"] = None
break
at, n = groups.get(at.get("parent") or ""), n + 1
for row in rows:
chain = []
at = groups.get(row.get("parent") or "")
while at is not None:
chain.append(at)
at = groups.get(at.get("parent") or "")
depth = 2 if next(p for c, _n, p in SECTIONS if c == row["section"]) else 1
row["level"] = depth if row["kind"] == "title" else depth + 1 + len(chain)
if row["kind"] != "item":
continue
for g in chain:
for p in ("sum", *PARTS):
g[p] += row[p] or 0
if row["sum"] is None:
g["blank"] += 1
for g in groups.values():
g["note"] = f"빈 줄 {g['blank']}" if g["blank"] else ""
def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> None:
"""손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝."""
at = next((n + 1 for n, row in enumerate(items) if row["key"] == after), None)
@@ -213,8 +272,13 @@ 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), item.get("after") or "")
owner = [row for row in items if row["section"] == "G"]
if item.get("kind") == "group":
row = _group_row(item)
else:
row = _item_row({**item, "why": why}, prices, gaps)
row["parent"] = item.get("parent")
_place(items, row, item.get("after") or "")
owner = [row for row in items if row["section"] == "G" and row["kind"] == "item"]
net = 0.0
for row in owner:
net += row["material"] or 0
@@ -248,6 +312,8 @@ def compute_bill(
for code, name, parent in SECTIONS:
sums[code] = {"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0}
for row in items:
if row["kind"] != "item":
continue
code = row["section"]
chain = [code]
parent = next(p for c, _n, p in SECTIONS if c == code)
@@ -276,6 +342,7 @@ def compute_bill(
}
)
rows.extend(row for row in items if row["section"] == code)
_nest(rows)
direct = {p: 0.0 for p in ("sum", *PARTS)}
blank = 0
for code in DIRECT:
+68 -4
View File
@@ -3,7 +3,7 @@
* B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임).
*
* ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
* 거울 시험 `resources/tester/test_145_3_b09_bill.py`.
* 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨).
*
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
@@ -11,6 +11,9 @@
* 제목 줄 = 아래 항목 금액 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null)
* 손질(166-2) = `user.bill` — qty{줄 키: 수} 자동 줄 수량 덮어쓰기 · rows[] 손 줄(after 뒤에 끼움) ·
* 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo)
* 레벨(182) = 손 줄 `kind: "group"`(묶음 줄 · 수량 · 연결 없음) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로 아래) ·
* 줄마다 `level`(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1) · 묶음 금액 = 자손 항목 합 ·
* 공종 합 · sums = 항목만(묶음 줄 안 셈 · 원가계산서 그대로)
* ========================================================================== */
import {
@@ -143,6 +146,62 @@ export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row |
return out;
}
/** 묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`nest`) */
function groupRow(item: Row): Row {
return {
kind: "group",
key: item.key,
section: item.section,
name: item.name || "",
spec: item.spec || "",
unit: item.unit || "",
qty: null,
note: "",
ref: null,
hand: true,
sum: 0,
labor: 0,
material: 0,
expense: 0,
blank: 0,
};
}
/** 182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액 */
function nest(rows: Row[]): void {
const groups = new Map<string, Row>();
for (const row of rows) if (row.kind === "group") groups.set(row.key, row);
for (const row of rows) {
if (!row.hand) continue;
const up = groups.get(row.parent ?? "");
if (!up || up.section !== row.section) {
row.parent = null;
continue;
}
let at: Row | undefined = up;
for (let n = 0; at && n <= groups.size; n += 1) {
if (at.key === row.key) {
row.parent = null;
break;
}
at = groups.get(at.parent ?? "");
}
}
for (const row of rows) {
const chain: Row[] = [];
for (let at = groups.get(row.parent ?? ""); at; at = groups.get(at.parent ?? ""))
chain.push(at);
const depth = SECTIONS.find(([c]) => c === row.section)![2] ? 2 : 1;
row.level = row.kind === "title" ? depth : depth + 1 + chain.length;
if (row.kind !== "item") continue;
for (const g of chain) {
for (const p of ["sum", ...PARTS]) g[p] += row[p] || 0;
if (row.sum === null) g.blank += 1;
}
}
for (const g of groups.values()) g.note = g.blank ? `빈 줄 ${g.blank}` : "";
}
/** 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */
function place(items: Row[], hand: Row, after: string): void {
let at = items.findIndex((row) => row.key === after);
@@ -204,9 +263,12 @@ export function computeBill(
const prices = price === null ? null : { labor: 0, material: price, expense: 0 };
items.push(itemRow(item, prices, gaps, qtyOf));
}
for (const [item, prices, why] of handItems(user, book, hopyo))
place(items, itemRow({ ...item, why }, prices, gaps), item.after || "");
const owner = items.filter((row) => row.section === "G");
for (const [item, prices, why] of handItems(user, book, hopyo)) {
const row = item.kind === "group" ? groupRow(item) : itemRow({ ...item, why }, prices, gaps);
row.parent = item.parent ?? null;
place(items, row, item.after || "");
}
const owner = items.filter((row) => row.section === "G" && row.kind === "item");
let net = 0;
for (const row of owner) net += row.material || 0;
const pct = num((user.estimate || {}).procurement_fee_pct);
@@ -238,6 +300,7 @@ export function computeBill(
for (const [code] of SECTIONS)
sums[code] = { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 };
for (const row of items) {
if (row.kind !== "item") continue;
const code = row.section;
const chain = [code];
const parent = SECTIONS.find(([c]) => c === code)![2];
@@ -264,6 +327,7 @@ export function computeBill(
});
rows.push(...items.filter((row) => row.section === code));
}
nest(rows);
const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 };
let blank = 0;
for (const code of DIRECT) {
+8 -3
View File
@@ -109,7 +109,8 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None:
def clean_bill(raw: Any) -> dict[str, Any]:
"""설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만)."""
"""설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만) ·
182 레벨 = `kind`(group) · `parent`(`hand:` 키)."""
raw = raw if isinstance(raw, dict) else {}
qty = raw.get("qty") if isinstance(raw.get("qty"), dict) else {}
codes = {code for code, _name, _parent in SECTIONS}
@@ -119,14 +120,18 @@ def clean_bill(raw: Any) -> dict[str, Any]:
continue
if row.get("section") not in codes:
continue
group = row.get("kind") == "group" # 182 묶음 줄 = 이름만
parent = str(row.get("parent") or "")
rows.append(
{
"key": str(row["key"]),
"section": row["section"],
"after": _text(row.get("after")),
**({"kind": "group"} if group else {}),
**({"parent": parent} if parent.startswith("hand:") else {}),
**{k: _text(row.get(k)) for k in ("name", "spec", "unit")},
"qty": _num(row.get("qty")),
"ref": _clean_ref(row.get("ref")),
"qty": None if group else _num(row.get("qty")),
"ref": None if group else _clean_ref(row.get("ref")),
}
)
out: dict[str, Any] = {}
+33
View File
@@ -44,6 +44,39 @@
gap: var(--spacing-8);
}
/* 182 설계내역서 레벨 — 글자 크기(줄 높이 28px 고정 → 16px 까지) · 첫 칸 들여쓰기 1em × (레벨 − 1) */
.b09-estimation .ui-data-table__table tbody tr.is-lv1 td {
font-size: var(--text-body);
}
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv2, .is-lv3) td {
font-size: var(--text-body-sm);
}
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv4, .is-lv5, .is-lv6) td {
font-size: 13px;
}
.b09-estimation .ui-data-table__table tbody tr.is-group td {
font-weight: var(--font-weight-bold);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv2 td:first-child {
padding-left: calc(var(--spacing-8) + 1em);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td:first-child {
padding-left: calc(var(--spacing-8) + 2em);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv4 td:first-child {
padding-left: calc(var(--spacing-8) + 3em);
}
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv5, .is-lv6) td:first-child {
padding-left: calc(var(--spacing-8) + 4em);
}
/* 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);
+21 -5
View File
@@ -6,7 +6,9 @@
* 고칠 칸 = 관급자재대 조달수수료 율(%) · 항목 수량(166-2 · 덮어쓰기 · 빈칸 = 자동) ·
* 손 줄 명칭 · 규격 · 단위 · 수량 · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음)
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
* 우클릭 = 줄 추가 · 지우기 · 연결(`…_UI_Tabs_Bill_Edit`)
* 우클릭 = 줄 추가 · 지우기 · 연결 · 레벨(`…_UI_Tabs_Bill_Edit`)
* 레벨(182) = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS) · 묶음 줄 = 이름만 고침
* 「연결 ▾」 칸(182) = 손 항목 줄만 · 누름 = 우클릭 [연결] 과 같은 메뉴(연결됨 = 호표 번호)
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -17,6 +19,7 @@ import {
editQty,
editText,
handEditable,
openLinkMenu,
type BillTable,
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
@@ -25,7 +28,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
const bill: Row = sheets.bill || { rows: [], totals: null };
const rows: DataRow[] = bill.rows.map((r: Row) => ({
...r,
name: r.kind === "title" ? r.name : ` ${r.name}`,
connect: r.hand && r.kind === "item" ? `${r.note || "연결"} ▾` : "",
}));
const direct: Row = bill.totals?.direct || {};
const pair = (key: string, label: string) => [
@@ -42,6 +45,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
...pair("labor", "노무비"),
...pair("material", "재료비"),
...pair("expense", "경비"),
text("connect", "연결", 96),
text("note", "비고", 200),
],
rows,
@@ -49,10 +53,12 @@ export const buildBill: TabBuild = (ctx): BillTable => {
frozen: 1,
hint:
"공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " +
"수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " +
"수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 · 「연결 ▾」 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " +
"레벨 = 우클릭 묶음 줄로 · 하위 줄 추가 · 들여쓰기 · 내어쓰기(묶음 금액 = 아래 항목 합 · 공종 합은 항목만) · " +
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
editable: (i, key) => {
const r = rows[i];
if (r?.kind === "group") return key === "name";
if (!r || r.kind !== "item") return false;
return r.hand ? handEditable(key) : key === "qty";
},
@@ -71,9 +77,19 @@ export const buildBill: TabBuild = (ctx): BillTable => {
if (key !== "qty" || !r || !("qty_auto" in r)) return null;
return `자동 값 ${r.qty_auto ?? "빈칸"} — 우클릭 [수량 자동으로 되돌리기]`;
},
rowClass: (i) => (rows[i]?.hand ? "is-hand" : null),
// 182 레벨 — 글자 크기 · 들여쓰기(is-lvN) · 제목 · 묶음 줄 굵게
rowClass: (i) => {
const r = rows[i];
if (!r) return null;
const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : "";
return [`is-lv${r.level ?? 1}`, title, r.hand ? "is-hand" : ""].filter((c) => c).join(" ");
},
menu: (i) => billMenu(ctx, rows, i),
link: (i) => {
onCell: (i, key) => {
if (key === "connect") openLinkMenu(ctx, rows, i);
},
link: (i, key) => {
if (key === "connect") return null; // 「연결 ▾」 = 메뉴(이동 안 함)
const ref = rows[i]?.ref as Row | null | undefined;
return ref?.key ? { tab: ref.tab as TabId, key: String(ref.key) } : null;
},
@@ -7,6 +7,8 @@
* rows[] = 손 줄 {key hand:… · section · after(앞 줄 키 · 제목 = sec:공종) · 명칭 · 규격 · 단위 · 수량 · ref}
* 연결 = 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가)
* 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) · 로직 고르기 · 입력값 = M02 창 재사용
* 레벨(182) = 손 줄 kind(group = 묶음 줄 · 이름만) · parent(묶음 손 줄 키) — 하위 줄 추가 · 묶음 줄로 ·
* 항목으로 · 들여쓰기 · 내어쓰기 · 손 묶음 깊이 2 · 「연결 ▾」 칸 = 우클릭 [연결] 과 같은 메뉴
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -29,6 +31,8 @@ export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuIte
const NEW_NAME = "새 줄";
const TEXT_KEYS = ["name", "spec", "unit"];
/** 182 손 묶음 깊이 — 묶음 안 묶음까지 */
const MAX_GROUP = 2;
interface Bill {
qty: Record<string, number>;
@@ -69,35 +73,138 @@ export function editText(ctx: TabContext, key: string, field: string, value: str
put(ctx, bill);
}
/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종) */
function slot(rows: DataRow[], i: number, below: boolean): { section: string; after: string } {
const lv = (row: DataRow | undefined): number => Number(row?.level ?? 0);
/** 줄 i 자손 끝(다음 줄 번호) — 더 깊은 레벨 줄이 이어지는 데까지 */
function subtreeEnd(rows: DataRow[], i: number): number {
let k = i + 1;
while (k < rows.length && lv(rows[k]) > lv(rows[i])) k += 1;
return k;
}
/** 부모 바꿈 — 없음 = 공종 바로 아래(키 지움) */
function reparent(h: Row, parent: unknown): void {
if (parent) h.parent = parent;
else delete h.parent;
}
/** 손 묶음 깊이 지킴 — 묶음 줄 위 묶음 ≤ 1 · 항목 위 묶음 ≤ 2(도는 부모는 셈이 끊음 · 여기선 멈춤) */
function fits(bill: Bill): boolean {
const byKey = new Map(bill.rows.map((h) => [String(h.key), h]));
return bill.rows.every((h) => {
let n = 0;
const seen = new Set([h.key]);
let at = byKey.get(String(h.parent ?? ""));
while (at && at.kind === "group" && !seen.has(at.key)) {
seen.add(at.key);
n += 1;
at = byKey.get(String(at.parent ?? ""));
}
return n <= (h.kind === "group" ? MAX_GROUP - 1 : MAX_GROUP);
});
}
/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종 · 아래 = 자손 끝) · 부모 = 그 줄 부모 */
function slot(rows: DataRow[], i: number, below: boolean): Row {
const row = rows[i];
const section = String(row.section);
if (row.kind === "title") return { section, after: `sec:${section}` };
if (below) return { section, after: String(row.key) };
const parent = row.hand && row.parent ? { parent: String(row.parent) } : {};
if (below) return { section, after: String(rows[subtreeEnd(rows, i) - 1].key), ...parent };
const prev = rows[i - 1];
const same = prev && prev.kind !== "title" && prev.section === section;
return { section, after: same ? String(prev.key) : `sec:${section}` };
return { section, after: same ? String(prev.key) : `sec:${section}`, ...parent };
}
function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void {
function newRow(ctx: TabContext, at: Row): void {
const bill = billOf(ctx.user);
const key = `hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const at = slot(rows, i, below);
bill.rows.push({ key, ...at, name: NEW_NAME, spec: "", unit: "", qty: null, ref: null });
put(ctx, bill);
}
/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 옮김 */
function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void {
newRow(ctx, slot(rows, i, below));
}
/** 묶음 줄 아래 하위 줄 — 자리 = 마지막 자손 뒤 */
function addChild(ctx: TabContext, rows: DataRow[], i: number): void {
const after = String(rows[subtreeEnd(rows, i) - 1].key);
newRow(ctx, { section: String(rows[i].section), after, parent: String(rows[i].key) });
}
/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 · 자식은 지운 줄 부모로 옮김 */
function removeRow(ctx: TabContext, key: string): void {
const bill = billOf(ctx.user);
const gone = bill.rows.find((h) => h.key === key);
if (!gone) return;
bill.rows = bill.rows.filter((h) => h.key !== key);
for (const h of bill.rows) if (h.after === key) h.after = gone.after;
for (const h of bill.rows) if (h.parent === key) reparent(h, gone.parent);
put(ctx, bill);
}
/** 레벨 바꿈 — 깊이 한도 안이면 [동작] · 넘으면 null(메뉴에 안 보임) */
function levelAction(
ctx: TabContext,
key: string,
change: (own: Row, bill: Bill) => void,
): (() => void) | null {
const bill = billOf(ctx.user);
const own = bill.rows.find((h) => h.key === key);
if (!own) return null;
change(own, bill);
return fits(bill) ? () => put(ctx, bill) : null;
}
/** 들여쓰기 대상 — 바로 위 같은 레벨 형제가 같은 공종 묶음 줄이면 그 줄 */
function indentTarget(rows: DataRow[], i: number): DataRow | null {
const row = rows[i];
for (let j = i - 1; j >= 0; j -= 1) {
const r = rows[j];
if (lv(r) > lv(row)) continue;
return lv(r) === lv(row) && r.kind === "group" && r.section === row.section ? r : null;
}
return null;
}
/** 레벨 메뉴 — 묶음 줄로 · 항목으로 · 들여쓰기 → · 내어쓰기 ←(막내만 · 자리 안 어긋남) */
function levelMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
const row = rows[i];
const key = String(row.key);
const out: MapContextMenuItem[] = [];
const add = (label: string, action: (() => void) | null): void => {
if (action) out.push([label, action]);
};
if (row.kind === "group")
add(
"항목으로 바꾸기",
levelAction(ctx, key, (own, bill) => {
delete own.kind;
for (const h of bill.rows) if (h.parent === key) reparent(h, own.parent);
}),
);
else
add(
"묶음 줄로 바꾸기",
levelAction(ctx, key, (own) => Object.assign(own, { kind: "group", qty: null, ref: null })),
);
const target = indentTarget(rows, i);
if (target)
add(
"들여쓰기 →",
levelAction(ctx, key, (own) => reparent(own, String(target.key))),
);
if (row.parent && lv(rows[subtreeEnd(rows, i)]) < lv(row))
add(
"내어쓰기 ←",
levelAction(ctx, key, (own, bill) =>
reparent(own, bill.rows.find((h) => h.key === row.parent)?.parent),
),
);
return out;
}
/** 연결 — 명칭이 비었거나 「새 줄」이면 고른 호표 명칭 · 규격 · 단위를 채움 */
function setRef(ctx: TabContext, key: string, ref: Row | null, from?: Row): void {
const bill = billOf(ctx.user);
@@ -182,7 +289,24 @@ function pickLogic(ctx: TabContext, key: string): void {
openLogicPicker((logic) => askInputs(ctx, key, logic.키, logic.이름));
}
/** 줄 우클릭 메뉴 — 위 · 아래 줄 추가 · (손 줄) 연결 · 연결 풀기 · 지우기 · (자동 줄) 수량 자동으로 */
type Leaf = [string, () => void];
/** 손 항목 줄 연결 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] */
function linkItems(ctx: TabContext, key: string): [Leaf[], Leaf[]] {
const ref: Row | null = billOf(ctx.user).rows.find((h) => h.key === key)?.ref ?? null;
const more: Leaf[] = [];
if (ref?.tab === "logic")
more.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]);
if (ref) more.push(["연결 풀기", () => setRef(ctx, key, null)]);
const picks: Leaf[] = [
["일위대가 호표", () => pickHopyo(ctx, key, "dg")],
["단가산출 호표", () => pickHopyo(ctx, key, "sg")],
["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)],
];
return [picks, more];
}
/** 줄 우클릭 메뉴 — 위 · 아래(· 하위) 줄 추가 · (손 줄) 연결 · 연결 풀기 · 레벨 · 지우기 · (자동 줄) 수량 자동으로 */
export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
const row = rows[i];
if (!row) return [];
@@ -191,19 +315,13 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
const items: MapContextMenuItem[] = [];
if (row.kind !== "title") items.push(["위에 줄 추가", () => addRow(ctx, rows, i, false)]);
items.push(["아래에 줄 추가", () => addRow(ctx, rows, i, true)]);
if (row.kind === "group") items.push(["아래에 하위 줄 추가", () => addChild(ctx, rows, i)]);
if (row.hand) {
const ref: Row | null = bill.rows.find((h) => h.key === key)?.ref ?? null;
items.push("-", {
label: "연결",
children: [
["일위대가 호표", () => pickHopyo(ctx, key, "dg")],
["단가산출 호표", () => pickHopyo(ctx, key, "sg")],
["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)],
],
});
if (ref?.tab === "logic")
items.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]);
if (ref) items.push(["연결 풀기", () => setRef(ctx, key, null)]);
if (row.kind !== "group") {
const [picks, more] = linkItems(ctx, key);
items.push("-", { label: "연결", children: picks }, ...more);
}
items.push("-", ...levelMenu(ctx, rows, i));
items.push("-", ["줄 지우기", () => removeRow(ctx, key)]);
} else if (row.kind === "item" && key in bill.qty) {
items.push("-", [
@@ -218,6 +336,17 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
}
let shared: MapContextMenu | null = null;
/** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */
let lastCell: Element | null = null;
/** 「연결 ▾」 칸 누름(182) — 우클릭 [연결] 과 같은 메뉴를 그 칸 아래에 엶 */
export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void {
const row = rows[i];
const box = lastCell?.getBoundingClientRect();
if (!shared || !box || !row?.hand || row.kind !== "item") return;
const [picks, more] = linkItems(ctx, String(row.key));
shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks);
}
/** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */
export function attachRowMenu(
@@ -230,6 +359,12 @@ export function attachRowMenu(
ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y)
document.body.append(ctxMenu.element);
}
// 칸 누름보다 먼저(잡기 단계) — 「연결 ▾」 메뉴를 그 칸 자리에 엶
root.addEventListener(
"click",
(event) => (lastCell = (event.target as Element).closest("td")),
true,
);
root.addEventListener("contextmenu", (event) => {
const target = event.target as Element;
if (target.closest("input")) return;