Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

# Conflicts:
#	B09_Estimation/B09_Estimation_UI_Page.css
#	B09_Estimation/B09_Estimation_UI_Shell.ts
#	B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts
This commit is contained in:
2026-10-10 12:04:39 +09:00
15 changed files with 413 additions and 58 deletions
+15 -8
View File
@@ -13,8 +13,11 @@
· 견적 = 사용자 손입력(quote · quote_page · 견적서 파일은 `B09_Estimation_Router_Quotes`)
· 관급 · 별도계상 = 사용자 표시(없으면 입력 줄 기본 · B08 파형강관 = 관급)
→ 재료비목록표 단가 = 적용 단가
재료비목록표 = 수량(B08 총괄자재수량집계표 합계 열) × 적용 단가 = 총액 ROUND(·, 0)
(엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸)
재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0)
(엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) · 수량 =
입력 `material_qty`(183-1 · 서버 1차 셈 역전개 총수량 = 재료비수량금액집계표 수량 ·
없는 키 빈칸) ·
`material_qty` 가 없으면 입력 줄 수량(B08 총괄 합계 열)
중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임)
+ 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100)
줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표
@@ -178,14 +181,18 @@ def compare_sheet(
return rows
def material_sheet(compare: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""재료비목록표 — 단가 = 대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0)."""
keep = ("no", "key", "name", "spec", "unit", "supply", "origin", "qty")
def material_sheet(
compare: list[dict[str, Any]], qty_of: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
"""재료비목록표 — 단가 = 대비표 적용 단가 · 수량 = `qty_of`(183-1) ?? 줄 수량 ·
총액 = ROUND(수량 × 단가, 0)."""
keep = ("no", "key", "name", "spec", "unit", "supply", "origin")
rows = []
for row in compare:
price, q = row["applied"], row["qty"]
price = row["applied"]
q = row["qty"] if qty_of is None else num(qty_of.get(row["key"]))
amount = None if price is None or q is None else round_half(q * price, 0)
rows.append({**{k: row[k] for k in keep}, "price": price, "amount": amount})
rows.append({**{k: row[k] for k in keep}, "qty": q, "price": price, "amount": amount})
return rows
@@ -328,7 +335,7 @@ def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]
"expense": expense,
"basis": basis,
"compare": compare,
"material": material_sheet(compare),
"material": material_sheet(compare, inputs.get("material_qty")),
"machine_blocks": blocks,
"machine": machine,
"gaps": gaps,
+7 -5
View File
@@ -9,7 +9,8 @@
* 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0)
* 자재단가대비표 = 출처 중 최저 자동(견적 포함) · 적용 = 고른 출처 › 사용자 단가 › 최저 ·
* 관급 · 별도 표시 → 재료비목록표
* 재료비목록표 = 수량(B08 총괄 합계 열) × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸
* 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 · 수량 = 입력
* material_qty(183-1 · 서버 역전개 총수량 · 없는 키 빈칸) ?? 입력 줄 수량(B08 총괄 합계 열)
* 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표
* 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김
* ========================================================================== */
@@ -157,13 +158,14 @@ function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] {
});
}
function materialSheet(compare: Row[]): Row[] {
const keep = ["no", "key", "name", "spec", "unit", "supply", "origin", "qty"];
function materialSheet(compare: Row[], qtyOf?: Row | null): Row[] {
const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"];
return compare.map((row) => {
const price: Num = row.applied;
const q: Num = row.qty;
const q: Num = qtyOf == null ? row.qty : num(qtyOf[row.key]);
return {
...Object.fromEntries(keep.map((k) => [k, row[k]])),
qty: q,
price,
amount: price === null || q === null ? null : roundHalf(q * price, 0),
};
@@ -301,7 +303,7 @@ export function computeBase(inputs: Row, user: Row): Row {
expense,
basis,
compare,
material: materialSheet(compare),
material: materialSheet(compare, inputs.material_qty),
machine_blocks: blocks,
machine,
gaps,
+7 -1
View File
@@ -3,7 +3,9 @@
연결된 사본 = `M02_Item_Snapshot.linked_snapshots`(지정 ∩ 같은 항목 사본 · B08 · B09 공용).
사본마다 `일위대가.json` 담은 로직을 그 사본 단가 묶음(`단가.json`) · 입력값 · 고른자재로 돌리며
실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째
(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음).
(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음) ·
사본 일위대가 자재 · 굳힌 사전조합 자재 코드도(183-1 · 로직을 안 돌아 `get` 에 안 걸림).
고르기가 훑은 후보 줄도 걸림 → 원가 길이 두 번 셈해 쓰는 줄만 남김(`Summary.used_materials`).
구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음.
"""
@@ -57,6 +59,10 @@ def used_keys(folder: Path) -> list[str]:
labor_doc.run_line(
snapshot, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
)
for mat in [*(doc.get("자재") or []), *(m for d in inner for m in d.get("자재") or [])]:
code = str(mat.get("코드") or "") if isinstance(mat, dict) else ""
if code[:2] in RESOURCE_TABLES and code not in seen:
seen.append(code)
return seen
+16 -3
View File
@@ -37,7 +37,7 @@ from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table
from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
@@ -81,6 +81,19 @@ def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) ->
return sheets
def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> tuple[dict, dict]:
"""183-1 두 번 셈 — 1차 셈으로 쓰는 자재만 남기고(`used_materials`) 재료비목록표 수량 =
1차 역전개 총수량(`material_qty` = 재료비수량금액집계표 수량) → 2차 셈 · 집계표.
`inputs` 를 고쳐 둠(화면 짝이 거른 입력 · 수량을 그대로 받음)."""
first = compute_sheets(root, inputs, user)
summary = compute_summary(first, hopyo_lines(first))
keep = used_materials(first, inputs, user)
inputs["materials"] = [m for m in inputs.get("materials") or [] if m["key"] in keep]
inputs["material_qty"] = {row["key"]: row["qty"] for row in summary["sum_material"]}
sheets = compute_sheets(root, inputs, user)
return sheets, compute_summary(sheets, hopyo_lines(sheets))
async def _demand(root: Path) -> list[str]:
"""단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2)."""
keys = await asyncio.to_thread(linked_resource_keys, root)
@@ -103,7 +116,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
inputs["hopyo"] = hopyo_prices(root)
inputs["daega"] = await asyncio.to_thread(daega_inputs, root)
user = await asyncio.to_thread(store.read_user, root)
sheets = await asyncio.to_thread(compute_sheets, root, inputs, user)
sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, user)
return {
"inputs": inputs,
"user": user,
@@ -113,7 +126,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
"rate_table": rate_table(),
"notes": notes,
# 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜
"summary": compute_summary(sheets, hopyo_lines(sheets)),
"summary": summary,
}
+19
View File
@@ -155,6 +155,25 @@ def block_lines(blocks: list[dict[str, Any]] | None) -> dict[str, Any]:
return out
def used_materials(sheets: dict[str, Any], inputs: dict[str, Any], user: dict[str, Any]) -> set:
"""재료비목록표에 남길 자재 키(183-1) — 대가 · 산근 · 중기 블록 재료 줄(수량 빈칸 · 0 도 =
집계표 역전개와 같은 줄) · B08 총괄 줄(수량 있는 입력) · 관급 · 별도 표시 줄.
그 밖(고르기 후보 줄) = 뺌."""
blocks = [*(sheets.get("sg") or []), *(sheets.get("daega_blocks") or [])]
blocks += sheets.get("machine_blocks") or []
keep = {
str((line.get("ref") or {}).get("key"))
for block in blocks
for line in block.get("lines") or []
if line.get("kind") == "material" and (line.get("ref") or {}).get("key")
}
marks = user.get("materials") or {}
for m in inputs.get("materials") or []:
if "qty" in m or m.get("supply") or (marks.get(m["key"]) or {}).get("supply"):
keep.add(m["key"])
return keep
def hopyo_lines(sheets: dict[str, Any]) -> dict[str, Any]:
"""호표 구성(1 단위당 자원 · 겹 호표) — 산근(145-1) + 대가(145-2 · 들어오면)."""
return {**block_lines(sheets.get("sg")), **block_lines(sheets.get("daega_blocks"))}
@@ -106,3 +106,9 @@
.b09-estimation .ui-data-table__table tbody tr.is-pick td:last-child {
--pick-r: inset -2px 0 0 var(--color-danger);
}
/* 183-3 자재단가대비표 — 적용 단가를 낸 출처 단가 칸(고른 출처 · 기본 최저) */
.b09-estimation .ui-data-table__table tbody td.is-picked {
background: color-mix(in srgb, var(--color-accent) 22%, var(--color-canvas));
font-weight: var(--font-weight-bold);
}
+13 -1
View File
@@ -49,7 +49,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 { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
import {
buildSumExpense,
buildSumLabor,
@@ -206,6 +206,7 @@ export function renderB09Estimation(root: HTMLElement): void {
rowClass: extra
? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null
: spec.rowClass,
cellClass: spec.cellClass,
onCellClick: (i, key) => {
if (spec.onCell) spec.onCell(i, key);
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));
@@ -215,6 +216,17 @@ export function renderB09Estimation(root: HTMLElement): void {
// 166-2 줄 우클릭 메뉴(설계내역서) — 공용 표는 그대로 · 표 root 에 붙임
const menu = (spec as BillTable).menu;
if (menu) attachRowMenu(made.root, menu);
// 183-3 칸 고르기 메뉴 — 누른 칸 아래에 B09 공용 메뉴
if (spec.choices)
made.root.addEventListener("click", (event) => {
const cell = (event.target as Element).closest<HTMLTableCellElement>("tr[data-i] td");
const column = cell ? spec.columns[cell.cellIndex] : undefined;
const items =
cell && column && spec.choices?.(Number(cell.parentElement?.dataset.i), column.key);
if (!cell || !items?.length) return;
const box = cell.getBoundingClientRect();
openMenuAt(box.left, box.bottom, items);
});
// 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title
if (spec.tip)
made.root.addEventListener("mouseover", (event) => {
+5
View File
@@ -7,6 +7,7 @@
* 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만
* ========================================================================== */
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { DataColumn, DataRow } from "@ui/ui_template_data_table";
import type { Row } from "./B09_Estimation_Calc_Base";
@@ -107,6 +108,10 @@ export interface TabTable {
rowClass?: (index: number) => string | null | undefined;
/** 칸 풍선 글(157-3 · 예 「이름 규격」 칸의 원문번호) */
tip?: (index: number, key: string) => string | null | undefined;
/** 칸 꼴 class(183-3 공용 표 `cellClass` · 예 고른 출처 칸 강조) */
cellClass?: (index: number, key: string) => string | null | undefined;
/** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */
choices?: (index: number, key: string) => MapContextMenuItem[] | null;
}
export type TabBuild = (ctx: TabContext) => TabTable | string;
+68 -32
View File
@@ -2,14 +2,18 @@
* B09_Estimation_UI_Tabs_Base.ts
* B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 ·
* 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`).
* 재료비목록표 = 엑셀 꼴 + 수량(B08 합계) · 총액 열(166-3)
* 재료비목록표 = 엑셀 꼴 + 수량 · 총액 열(166-3) · 줄 = 대가 · 산근 · 중기가 쓰는 자재 + B08 줄 ·
* 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`)
*
* 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈
* · 견적 단가 · 면 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도)
* · 「견적서」 칸 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4)
* 자재단가대비표 적용 단가 칸 누름 = 고르기 메뉴(183-3 · 값 있는 출처 · 견적 · 「최저(자동)」 →
* `pick` · 옛 수기 `applied` 지움) · 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처) ·
* 관급 / 별도 칸 누름 = 표시 바꿈 · 견적 단가 손입력
* · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4)
* · 출처 묶음 둘째 칸 = 「출처」(M01 면수 · 유가 기준일 값 그대로 · 183-2 · 견적엔 없음)
* 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표
* ========================================================================== */
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { DataRow } from "@ui/ui_template_data_table";
import { SOURCES, type Row } from "./B09_Estimation_Calc_Base";
import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote";
@@ -107,10 +111,18 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => {
const SUPPLY_NEXT: Record<string, string | null> = { "": "관급", 관급: "별도", 별도: null };
/** 고를 출처 돌림 — 자동 → 값 있는 출처 차례 → 자동 */
function nextPick(r: Row): string | null {
/** 적용 단가 고르기 항목(183-3) — 값 있는 출처(견적 포함 · 0 뺌) 「출처 단가 · 최저」 + 「최저(자동)」 ·
* `pick(s)` = 그 출처 · null = 자동 */
function appliedChoices(r: Row, pick: (s: string | null) => void): MapContextMenuItem[] {
const have = SOURCES.filter((s) => (r.prices[s] ?? 0) > 0);
return have[have.indexOf(r.pick) + 1] ?? null;
const label = (s: string): string =>
`${s === r.applied_from ? "✓ " : ""}${s} ${Number(r.prices[s]).toLocaleString("ko-KR")}` +
(s === r.lowest_from ? " · 최저" : "");
return [
...have.map((s): MapContextMenuItem => [label(s), () => pick(s)]),
"-",
[`${r.pick || r.applied_from === "사용자" ? "" : "✓ "}최저(자동)`, () => pick(null)],
];
}
export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
@@ -148,35 +160,42 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
text("name", "품명", 180),
text("spec", "규격", 140),
text("unit", "단위", 56),
...SOURCES.flatMap((s) => [money(`p:${s}`, "단가", [s]), text(`g:${s}`, "면", 64, [s])]),
// 183-2 — 견적 = 단가 · 견적서 · 그 밖 = 단가 · 출처(면수 값)
...SOURCES.flatMap((s) => [
money(`p:${s}`, "단가", [s]),
s === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s]),
]),
money("lowest", "최저"),
money("applied", "적용 단가", ["적용"]),
text("applied_from", "출처", 80, ["적용"]),
text("supply", "관급 · 별도", 80),
text("files", "견적서", 72),
],
rows: out,
frozen: 2,
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 · 면 = 수기 입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기",
editable: (_i, key) => key === "applied" || key === "p:견적" || key === "g:견적",
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기",
editable: (_i, key) => key === "p:견적",
onEdit: (i, key, value) => {
const row = out[i];
if (!row) return;
const cell = mine(String(row.key));
if (key === "g:견적") {
if (value.trim()) cell.quote_page = value.trim();
else delete cell.quote_page;
return put(String(row.key), cell);
}
const v = parseNumber(value);
if (v === undefined) return;
const field = key === "applied" ? "applied" : key === "p:견적" ? "quote" : null;
if (!field) return;
if (v === null) delete cell[field];
else cell[field] = v;
if (field === "applied") delete cell.pick; // 손 수 = 고른 출처를 풂
if (!row || key !== "p:견적" || v === undefined) return;
const cell = mine(String(row.key));
if (v === null) delete cell.quote;
else cell.quote = v;
put(String(row.key), cell);
},
// 183-3 — 고른 출처(없으면 최저) 단가 칸 강조 · 적용 단가 칸 누름 = 고르기 메뉴
cellClass: (i, key) => (key === `p:${rows[i]?.applied_from}` ? "is-picked" : null),
choices: (i, key) => {
const r = rows[i];
if (key !== "applied" || !r) return null;
return appliedChoices(r, (s) => {
const cell = mine(r.key);
delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂
if (s) cell.pick = s;
else delete cell.pick;
put(r.key, cell);
});
},
onCell: (i, key) => {
const row = out[i];
const r = rows[i];
@@ -192,12 +211,6 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
put(r.key, now);
});
}
if (key === "applied_from") {
const pick = nextPick(r);
if (pick) cell.pick = pick;
else delete cell.pick;
return put(String(row.key), cell);
}
if (key !== "supply") return;
const next = SUPPLY_NEXT[String(row.supply ?? "")] ?? null;
if (next) cell.supply = next;
@@ -207,10 +220,33 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
};
};
/** 쓰는 곳(183-1) — 자재 키 → 「대가 n호표」 · 「산근 n호표」 · 「중기 n호표」 · 「내역 자재대」
* (블록 재료 줄 · 설계내역서 자재 항목 · 표시 잇기만 · 셈 아님) */
function usesOf(sheets: Row): Map<string, string[]> {
const out = new Map<string, string[]>();
const add = (key: unknown, label: string): void => {
if (!key) return;
const list = out.get(String(key)) ?? [];
if (!list.includes(label)) out.set(String(key), [...list, label]);
};
const scan = (blocks: Row[] | undefined, label: (b: Row, n: number) => string): void =>
(blocks || []).forEach((b, n) =>
(b.lines || []).forEach((ln: Row) => ln.kind === "material" && add(ln.ref?.key, label(b, n))),
);
scan(sheets.daega_blocks, (b) => `대가 ${b.no}호표`);
scan(sheets.sg, (b) => `산근 ${b.no}호표`);
scan(sheets.machine_blocks, (_b, n) => `중기 ${n + 1}호표`);
for (const r of sheets.bill?.rows || [])
if (r.kind === "item" && r.ref?.tab === "material") add(r.ref.key, "내역 자재대");
return out;
}
export const buildMaterial: TabBuild = ({ sheets }): TabTable => {
const uses = usesOf(sheets);
const rows: DataRow[] = (sheets.material || []).map((r: Row) => ({
...r,
supply: r.supply ?? "",
uses: (uses.get(r.key) ?? []).join(" · ") || (r.origin === "B08" ? "B08 총괄" : ""),
}));
return {
columns: [
@@ -222,14 +258,14 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => {
money("price", "단가"),
money("amount", "총액"),
text("supply", "관급 · 별도", 80),
text("origin", "비고", 90),
text("uses", "비고(쓰는 곳)", 240),
],
rows,
total: rows.length
? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) }
: null,
frozen: 2,
hint: `수량 = B08 총괄자재수량집계표 합계 열(할증 포함 · 사본 자원 줄은 빈칸) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · ${LINK_HINT}`,
hint: `줄 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재 + B08 총괄 줄 · 관급 · 별도 줄 · 수량 = 설계내역서 × 호표 역전개 총수량(재료비수량금액집계표와 같음 · B08 할증 값과 다를 수 있음) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · 비고 = 쓰는 곳 · ${LINK_HINT}`,
link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null),
};
};
@@ -348,17 +348,27 @@ export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void
shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks);
}
/** B09 메뉴 한 벌(우클릭 · 183-3 칸 고르기 같이 씀) — 화면 기준 자리 */
function sharedMenu(): MapContextMenu {
shared ??= createMapContextMenu("b09-estimation");
if (!shared.element.isConnected) {
shared.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y)
document.body.append(shared.element);
}
return shared;
}
/** 화면 자리(x · y)에 B09 메뉴를 엶(183-3 칸 고르기) */
export function openMenuAt(x: number, y: number, items: MapContextMenuItem[]): void {
if (items.length) sharedMenu().open(x, y, items);
}
/** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */
export function attachRowMenu(
root: HTMLElement,
menu: (index: number) => MapContextMenuItem[],
): void {
shared ??= createMapContextMenu("b09-estimation");
const ctxMenu = shared;
if (!ctxMenu.element.isConnected) {
ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y)
document.body.append(ctxMenu.element);
}
const ctxMenu = sharedMenu();
// 칸 누름보다 먼저(잡기 단계) — 「연결 ▾」 메뉴를 그 칸 자리에 엶
root.addEventListener(
"click",