Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_1
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user