feat(B09 설계내역서 펼침): 우클릭 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나 (PLAN 703-2)

- 복사 = 덮개 K:… 의 @copy {from · name · spec} + 원본 덮개째(B09 사용자 값 · 브레인 정함) — 셈 짝 Calc_Labor py·ts 가 원본 블록을 본뜬 새 호표(번호 끝 · 기본값 = 원본) · 이 줄 단가연결을 새 호표로 바꿀지 물음 · 복사본 지우기(잇는 줄 있으면 막음)
- 원본 없음 = 짝 잃음 알림 · 저장 다듬기 @copy 글만 · 서고 사본 원본 = prune 에서 지킴
- 내 자료집 = POST …/estimation/daega/personal → 개인 층 사전조합(B09_Estimation_Daega_Personal) — 넣을 값 · 고른 자재 · 줄 수량 덮개(손) 담음 · 품 · 단가 덮개 = 안 담은 수 알림 · 같은 이름 409 → 덮어쓰기 · 새 이름
- 어디 쓰였나 = 누른 줄 노임 · 자재 · 중기(그 밖 = 호표)를 쓰는 내역 줄 목록 → 누르면 그 줄로 · 펼침
- 새 파일 B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts · 시험 test_703_b09_daega_copy(거울 · 임시 storage 길)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 13:35:44 +09:00
co-authored by Claude Opus 5.5
parent 595d6415eb
commit 1c1dc9c523
9 changed files with 693 additions and 14 deletions
@@ -341,12 +341,18 @@ def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]:
def used_folders(user: dict[str, Any]) -> set[str]:
"""설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더."""
"""설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더 +
일위대가 복사본(703-2 `@copy`)의 원본 사본."""
copies = [
str(lines["@copy"].get("from") or "")
for lines in (user.get("daega_cover") or {}).values()
if isinstance(lines, dict) and isinstance(lines.get("@copy"), dict)
]
return {
str(ref["lib"])
for row in ref_rows(user)
if isinstance(ref := row.get("ref"), dict) and ref.get("tab") == "dg" and ref.get("lib")
}
} | {src[2:] for src in copies if src.startswith("L:")}
def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]:
+20 -1
View File
@@ -19,6 +19,8 @@
고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} —
있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) ·
짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만.
복사본(703-2) = 덮개 `K:…` 의 `@copy` {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
(id `DG:K:…` · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}.
"""
from __future__ import annotations
@@ -30,6 +32,7 @@ from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown,
PARTS = ("labor", "material", "expense")
COVER_KEYS = ("qty", *PARTS)
TAB = "dg"
COPY = "@copy"
WHY = {
"labor": "노임 단가 없음",
"material": "자재 적용 단가 없음",
@@ -142,6 +145,21 @@ def _block(
}
def with_copies(blocks: list[dict[str, Any]], cover: dict[str, Any]) -> list[dict[str, Any]]:
"""복사본(703-2) — 덮개 차례대로 `@copy` 마다 원본 블록을 본떠 끝에 더함(복사본의 복사본 됨)."""
out = list(blocks)
for key, lines in cover.items():
meta = (lines or {}).get(COPY)
if not isinstance(meta, dict):
continue
src = next((b for b in out if b.get("cover") == meta.get("from")), None)
if src is not None:
spec = meta["spec"] if "spec" in meta else src["spec"]
name = meta.get("name") or src["name"]
out.append({**src, "id": f"DG:{key}", "cover": key, "name": name, "spec": spec})
return out
def compute_labor(
daega: dict[str, Any] | None,
sheets: dict[str, Any],
@@ -161,6 +179,7 @@ def compute_labor(
"sangeun": sangeun or {},
}
cover = cover or {}
blocks = with_copies(blocks, cover)
by_id = {b["id"]: b for b in blocks}
no = {b["id"]: n for n, b in enumerate(blocks, 1)}
done: dict[str, dict[str, Any]] = {}
@@ -211,7 +230,7 @@ def compute_labor(
{"cover": key, "cid": cid}
for key, lines in cover.items()
for cid in lines
if cid not in cids.get(key, ())
if cid not in cids.get(key, ()) and not (cid == COPY and f"DG:{key}" in by_id)
]
return {
"daega_blocks": out_blocks,
+20 -2
View File
@@ -15,6 +15,8 @@
* 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김
* 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} —
* 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만)
* 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
* (id DG:K:… · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}
* ========================================================================== */
import {
@@ -31,6 +33,7 @@ import {
const PARTS = ["labor", "material", "expense"] as const;
const COVER_KEYS = ["qty", ...PARTS] as const;
const TAB = "dg";
export const COPY = "@copy";
const WHY: Record<string, string> = {
labor: "노임 단가 없음",
material: "자재 적용 단가 없음",
@@ -146,6 +149,20 @@ function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[], own:
};
}
/** 복사본(703-2) — 덮개 차례대로 @copy 마다 원본 블록을 본떠 끝에 더함(복사본의 복사본 됨) */
function withCopies(blocks: Row[], covers: Row): Row[] {
const out = [...blocks];
for (const [key, lines] of Object.entries(covers)) {
const meta: Row | undefined = (lines || {})[COPY];
if (!meta || typeof meta !== "object") continue;
const src = out.find((b) => b.cover === meta.from);
if (!src) continue;
const spec = "spec" in meta ? meta.spec : src.spec;
out.push({ ...src, id: `DG:${key}`, cover: key, name: meta.name || src.name, spec });
}
return out;
}
/** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */
export function computeLabor(
daega: Row | null | undefined,
@@ -153,8 +170,8 @@ export function computeLabor(
sangeun: Row = {},
covers: Row = {},
): Row {
const blocks: Row[] = (daega || {}).blocks || [];
covers = covers || {};
const blocks: Row[] = withCopies((daega || {}).blocks || [], covers);
const index = (rows: Row[] | undefined): Row =>
Object.fromEntries((rows || []).map((r) => [r.key, r]));
const books: Row = {
@@ -207,7 +224,8 @@ export function computeLabor(
const orphans: Row[] = [];
for (const [key, lines] of Object.entries(covers))
for (const cid of Object.keys(lines || {}))
if (!cids.get(key)?.has(cid)) orphans.push({ cover: key, cid });
if (!cids.get(key)?.has(cid) && !(cid === COPY && `DG:${key}` in byId))
orphans.push({ cover: key, cid });
return {
daega_blocks: outBlocks,
daega: listing,
@@ -0,0 +1,165 @@
"""B09 「내 자료집에 저장」(PLAN 703-2) — 펼친 일위대가(대가 블록) → 개인 층 사전조합(SC).
담는 것 = 담은로직(넣을 값 · 고른 자재 얹음) · 자재 · 줄 수량(= 수량산출 수기 `손`) —
맨 위 줄 수량 덮개 = 손(원래 손 × 덮개 ÷ 블록 줄 수량 · 대가 줄 ÷ 배를 되돌림)
안 담는 것 = 품 덮개(부른 블록 안 줄 · 품셈 블록 줄 — 로직 통째 배수로 바꾸면 다른 줄까지 바뀜) ·
단가 덮개(사전조합 꼴에 단가 칸 없음) → 수만 알림(품은 PLAN 705 고정 줄이 생기면 담김)
원천 = 블록 cover — 조합 `S:`/`L:` = 사본 폴더(일위대가 · 넣을 값 · 고른 자재 · 셈 수량) ·
사전조합 `C:`/`L:` = 굳힌 참조사본 문서 · 품셈 `P:` = 로직 한 줄 ·
복사본 `K:`(703-2) = 원본까지 따라감(이름 · 덮개 = 복사본 것)
같은 이름 = 409 `{같은이름: 키, 이름}` → 부르는 쪽이 덮어쓰기(`overwrite` = 그 키) · 새 이름 ·
쓰기 = 지금 층(`store.at` · 층 권한 · 폴더는 부르는 쪽).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation.B09_Estimation_Calc_Labor import COPY, with_copies
from B09_Estimation.B09_Estimation_Labor_Source import AREA_FILE, daega_inputs
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Item_Snapshot import (
MANIFEST_NAME,
PICKS_FILE,
VALUES_FILE,
structures_dir,
)
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
KIND = "precombo"
PRICE_KEYS = ("labor", "material", "expense")
def _combo(folder: Path) -> tuple[list[dict], list[dict]]:
"""구조물조합 사본 → (담은로직 · 자재) — 넣을 값 · 고른 자재 얹고 수량 = 셈 수량(손)."""
labor = read_json(folder / LABOR_FILE) or {}
values = read_json(folder / VALUES_FILE) or {}
picks = read_json(folder / PICKS_FILE) or {}
got = item_prices.calc(
labor, read_json(folder / PRICE_FILE) or {}, values, picks, read_json(folder / AREA_FILE)
)
doc = item_prices.with_values(labor.get("줄") or {}, values, picks)
lines = [
{**one, "수량": {"손": done.get("수량")}}
for one, done in zip(doc.get("담은로직") or [], got.get("줄") or [])
]
mats = [
{**one, "수량": {"손": done.get("수량")}}
for one, done in zip(doc.get("자재") or [], got.get("자재") or [])
]
return lines, mats
def _frozen(root: Path, rid: str) -> dict[str, Any] | None:
"""굳힌 사전조합 문서 — 구조물 사본 · 서고 사본의 `참조사본` 에서 찾음."""
folders = [p for p in sorted(structures_dir(root).glob("*")) if p.is_dir()]
folders += [p for p, _m in library.copies(root)]
for folder in folders:
doc = ((read_json(folder / LABOR_FILE) or {}).get("참조사본") or {}).get(rid)
if isinstance(doc, dict):
return doc
return None
def _precombo(doc: dict[str, Any]) -> tuple[list[dict], list[dict]]:
lines = [
{**one, "수량": {"손": (one.get("수량") or {}).get("손", 1)}}
for one in doc.get("담은로직") or []
if isinstance(one, dict)
]
return lines, [m for m in doc.get("자재") or [] if isinstance(m, dict)]
def source(root: Path, block: dict[str, Any], base: str) -> tuple[list[dict], list[dict]]:
"""블록 원천(`base` = 복사본이면 원본 cover) → (담은로직 · 자재) · 못 찾으면 LookupError."""
head, _, rest = base.partition(":")
if block["kind"] == "품셈":
origin = block.get("origin") or {}
one = {"로직": origin.get("로직"), "입력값": origin.get("입력값") or {}}
one.update({k: origin[k] for k in ("켜쌓기율", "고름") if origin.get(k) is not None})
return [{**one, "수량": {"손": 1}}], []
if head == "S":
return _combo(structures_dir(root) / rest)
if head == "L":
folder = library.library_dir(root) / rest
manifest = read_json(folder / MANIFEST_NAME) or {}
if manifest.get("종류") != "precombo":
return _combo(folder)
ref = (manifest.get("원본") or {}).get("참조")
doc = ((read_json(folder / LABOR_FILE) or {}).get("참조사본") or {}).get(refs.ref_id(ref))
if isinstance(doc, dict):
return _precombo(doc)
if head == "C" and (doc := _frozen(root, rest)) is not None:
return _precombo(doc)
raise LookupError(f"일위대가 원천을 못 찾음 「{base}」")
def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None = None) -> dict:
"""블록 cover → `{문서, 담음, 안담음}` — 문서 = 사전조합 꼴(키 · 층 쓰기 전)."""
raw = daega_inputs(root).get("blocks") or []
blocks = {b["cover"]: b for b in with_copies(raw, cover)}
block = blocks.get(cover_key)
if block is None:
raise LookupError(f"없는 일위대가 「{cover_key}」")
base = cover_key
while base.startswith("K:"): # 복사본 → 원본 cover
base = str(((cover.get(base) or {}).get(COPY) or {}).get("from") or "")
lines, mats = source(root, blocks.get(base, block), base)
mine = cover.get(cover_key) or {}
kept = {"수량": 0, "넣을값": sum(bool(ln.get("입력값")) for ln in lines)}
kept["고른자재"] = sum(bool(ln.get("고름")) for ln in lines)
skip = {"품": 0, "단가": 0}
top = block["lines"] if block["kind"] != "품셈" else []
for line, one in zip(top, [*lines, *mats]):
cell = mine.get(line.get("cid")) or {}
skip["단가"] += sum(k in cell for k in PRICE_KEYS)
if "qty" not in cell:
continue
hand, each = (one.get("수량") or {}).get("손"), line.get("qty")
if not isinstance(hand, (int, float)) or not each:
skip["품"] += 1
continue
one["수량"] = {"손": round(hand * cell["qty"] / each, 9)}
kept["수량"] += 1
# 맨 위 밖 덮개 = 품셈 블록 줄 · 부른 블록 안 줄(품) · 단가
by_id = {b["id"]: b for b in blocks.values()}
todo = [] if top else [block["id"]]
todo += [ln.get("key") for ln in top if ln.get("kind") == "daega"]
seen: set[str] = set()
while todo:
sub = by_id.get(todo.pop())
if sub is None or sub["id"] in seen:
continue
seen.add(sub["id"])
for cell in (cover.get(sub["cover"]) or {}).values():
skip["품"] += "qty" in cell
skip["단가"] += sum(k in cell for k in PRICE_KEYS)
todo += [ln.get("key") for ln in sub["lines"] if ln.get("kind") == "daega"]
doc = {
"이름": (name or block.get("name") or "").strip(),
"단위": block.get("unit") or "",
"담은로직": lines,
"자재": mats,
}
return {"문서": doc, "담음": kept, "안담음": skip}
def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]:
"""지금 층(`store.at` 안)에 씀 — 같은 이름이면 409 · `overwrite` = 같은 이름 그 키를 덮음."""
same = [
path.stem
for path in sorted((store.root() / KIND).glob("SC-*.json"))
if (read_json(path) or {}).get("이름") == doc["이름"]
]
if overwrite:
if overwrite not in same:
raise store.StoreError(409, f"덮어쓸 「{doc['이름']}」 이 없음 — 다시 누름")
old = store.read(KIND, overwrite)
return store.write(KIND, overwrite, old["판"], {**doc, "키": overwrite})
if same:
raise store.StoreError(409, {"같은이름": same[0], "이름": doc["이름"]})
return refs.create(KIND, doc)
+37
View File
@@ -24,6 +24,9 @@
운반공 줄 = {given, own, dist} 같이 → 거리 값 사본(263-2 · 폴더 `~값 해시`)
POST /api/projects/{id}/estimation/library-inputs {kind, layer, owner, key, lib?}
「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음)
POST /api/projects/{id}/estimation/daega/personal {cover, name?, overwrite?, user?}
「내 자료집에 저장」(703-2) — 펼친 일위대가 → 개인 층 사전조합
(`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 · 같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름
운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`).
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
@@ -53,6 +56,7 @@ from B08_Quantity.B08_Quantity_Router_Material import material_sheets
from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone
from B08_Quantity.B08_Quantity_Router_Milestone import site_info
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Daega_Personal as personal
from B09_Estimation import B09_Estimation_Prices as prices
from B09_Estimation import B09_Estimation_Sangeun as sangeun
from B09_Estimation import B09_Estimation_Store as store
@@ -71,6 +75,7 @@ from common_util.common_util_project_settings import reset_section
from common_util.common_util_workflow_state import mark_stage
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete.M02_Item_Snapshot import importer
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _call, _root
try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이)
from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft
@@ -381,6 +386,38 @@ async def library_inputs(
return {"lines": lines}
class PersonalBody(BaseModel):
cover: str = Field(min_length=1, max_length=200)
name: str | None = Field(None, max_length=200)
overwrite: str | None = Field(None, max_length=20)
user: dict[str, Any] | None = None
@router.post("/{project_id}/estimation/daega/personal")
async def save_personal(
project_id: UUID, body: PersonalBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
root = await _project_root(project_id)
raw = (
body.user if isinstance(body.user, dict) else await asyncio.to_thread(store.read_user, root)
)
cover = store.clean_user(raw).get("daega_cover") or {}
try:
made = await asyncio.to_thread(personal.build, root, body.cover, cover, body.name)
except LookupError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
if not made["문서"]["이름"]:
raise HTTPException(status_code=400, detail="이름이 빔")
folder = await asyncio.to_thread(_root, session, "personal", None, True)
got = await asyncio.to_thread(_call, folder, personal.write, made["문서"], body.overwrite)
return {
"키": got["이름"],
"이름": made["문서"]["이름"],
"담음": made["담음"],
"안담음": made["안담음"],
}
# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한
router.include_router(quotes_router)
+7 -1
View File
@@ -12,6 +12,7 @@
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
+ 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만)
quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) ·
읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함
@@ -227,12 +228,17 @@ def clean_bill(raw: Any) -> dict[str, Any]:
def clean_cover(raw: Any) -> dict[str, Any]:
"""702-2 고친 칸 덮개 — 열쇠 글 · 칸 = qty · 노 · 재 · 경 수만 · 빈 줄 · 빈 블록 버림."""
"""702-2 고친 칸 덮개 — 열쇠 글 · 칸 = qty · 노 · 재 · 경 수만 · 빈 줄 · 빈 블록 버림 ·
복사본 표지 `@copy`(703-2) = from · name · spec 글만(from 없으면 버림)."""
out: dict[str, Any] = {}
for block, lines in (raw if isinstance(raw, dict) else {}).items():
kept = {}
for cid, cell in (lines if isinstance(lines, dict) else {}).items():
cell = cell if isinstance(cell, dict) else {}
if cid == "@copy":
if _text(cell.get("from")):
kept[cid] = {k: _text(cell[k]) for k in ("from", "name", "spec") if k in cell}
continue
got = {k: _num(cell[k]) for k in COVER_KEYS if _num(cell.get(k)) is not None}
if got:
kept[str(cid)[:200]] = got
@@ -9,6 +9,7 @@
* [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4)
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
* 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로)
* 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`)
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
@@ -16,9 +17,11 @@ import { createCard } from "@ui/ui_template_card";
import { createText } from "@ui/ui_template_text";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { cellColumn, createDataTable, type DataRow } from "@ui/ui_template_data_table";
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { Row } from "./B09_Estimation_Calc_Base";
import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit";
import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu";
/** 고칠 칸 → 덮개 칸 */
const FIELD: Record<string, string> = {
@@ -91,7 +94,14 @@ function clearCovers(ctx: TabContext, ats: FoldLine[]): void {
const covered = (at: FoldLine | undefined, field: string): boolean =>
!!at && (at.line.covered || []).includes(field);
function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTMLElement {
function foldCard(
ctx: TabContext,
raw: DataRow[],
row: Row,
block: Row,
close: () => void,
go: (key: string) => void,
): HTMLElement {
const blocks = new Map<string, Row>(
(ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.key), b]),
);
@@ -168,9 +178,11 @@ function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTM
);
attachRowMenu(table.root, (i) => {
const at = lines[i];
return at && (at.line.covered || []).length
? [["품셈 값으로", () => clearCovers(ctx, [at])]]
: [];
const undo: MapContextMenuItem[] =
at && (at.line.covered || []).length
? [["품셈 값으로", () => clearCovers(ctx, [at])], "-"]
: [];
return [...undo, ...foldMenu(ctx, raw, row, block, at?.line, go)];
});
const actions = [
...(edited.length
@@ -204,6 +216,13 @@ function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTM
/** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */
export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement {
const host = document.createElement("div");
/** 어디 쓰였나 → 그 줄로(보이게 · 펼침) */
const go = (key: string): void => {
const i = raw.findIndex((r) => String(r.key) === key);
table.querySelector(`tr[data-i="${i}"]`)?.scrollIntoView({ block: "center" });
open = key;
draw();
};
const draw = (): void => {
const row = raw.find((r) => r.key === open) as Row | undefined;
const ref: Row | undefined = row?.ref;
@@ -214,10 +233,17 @@ export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLEleme
host.replaceChildren(
...(row && block
? [
foldCard(ctx, row, block, () => {
open = null;
draw();
}),
foldCard(
ctx,
raw,
row,
block,
() => {
open = null;
draw();
},
go,
),
]
: []),
);
@@ -0,0 +1,208 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts
* B09 설계내역서 펼친 일위대가 우클릭(PLAN 703-2) — 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나.
*
* 복사 = 이름 · 규격 묻기 → 덮개 `K:…` 에 `@copy` {from · name · spec} + 원본 덮개째(캐시 · 셈 짝이 새 호표 ·
* 번호 끝) → 「이 줄 단가연결을 새 호표로」 물음 · 복사본 지우기 = 그 덮개 통째(쓰는 줄 있으면 안 지움)
* 내 자료집 = POST …/estimation/daega/personal(화면 캐시 덮개째 · 서버가 개인 층 사전조합) ·
* 같은 이름 409 → 「덮어쓰기 · 새 이름」 · 담음 · 안 담음(품 · 단가 덮개) 수 알림
* 어디 쓰였나 = 누른 줄 노임 · 자재 · 중기(그 밖 = 그 호표)를 쓰는 내역 줄 목록 → 누르면 그 줄로
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { askFields } from "@ui/ui_template_item_picker_ask";
import { createListRow } from "@ui/ui_template_list_row";
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import { askConfirm, askText, openModal } from "@ui/ui_template_modal";
import type { DataRow } from "@ui/ui_template_data_table";
import { createText } from "@ui/ui_template_text";
import { currentProjectId } from "../A00_Common/b_project_switch";
import type { Row } from "./B09_Estimation_Calc_Base";
import { COPY } from "./B09_Estimation_Calc_Labor";
import type { TabContext } from "./B09_Estimation_UI_Tabs";
import { editCell } from "./B09_Estimation_UI_Tabs_Bill_Edit";
/** 어디 쓰였나 — 같은 품목으로 찾는 줄 */
const GOODS = ["labor", "material", "machine"];
/** 부른 대가 따라가는 깊이 */
const DEPTH = 6;
const covers = (ctx: TabContext): Row => JSON.parse(JSON.stringify(ctx.user.daega_cover || {}));
const newKey = (): string => `K:${Math.random().toString(16).slice(2, 10).padEnd(8, "0")}`;
/** 복사해 새 일위대가 — 덮개째 · 번호 끝 · 이 줄 연결 바꿀지 물음 */
async function copyBlock(ctx: TabContext, raw: DataRow[], row: Row, block: Row): Promise<void> {
const got = await askFields({
title: "복사해 새 일위대가",
note: `「${block.name}」 을 이 공사 안 새 호표로 — 고친 칸까지 복사`,
fields: [
{ key: "name", label: "이름", value: `${block.name} 복사`, required: true },
{ key: "spec", label: "규격", value: block.spec || "" },
],
okLabel: "만들기",
closeLabel: "닫기",
emptyText: "{label} 이 빔",
});
if (!got) return;
const key = newKey();
const all = covers(ctx);
all[key] = { ...(all[block.cover] || {}), [COPY]: { from: block.cover, ...got } }; // 덮개째
const relink = await askConfirm({
title: "단가연결",
message: `「${row.name || ""}」 줄의 단가연결을 새 호표 「${got.name}」 로 바꿀지`,
okLabel: "바꿈",
cancelLabel: "그대로",
});
const next = { ...ctx, user: { ...ctx.user, daega_cover: all } };
const i = raw.findIndex((r) => r.key === row.key);
if (relink && i >= 0) editCell(next, raw, i, "ref", { tab: "dg", key: `DG:${key}` });
else ctx.setUser(next.user);
showToast(`새 호표 「${got.name}」 — 일위대가목록표 끝 · [저장] 때 서버가 다시 셈`, "success");
}
/** 복사본 지우기 — 그 호표를 잇는 내역 줄이 있으면 막음 */
function removeCopy(ctx: TabContext, raw: DataRow[], block: Row): void {
const used = raw.filter((r) => (r.ref as Row | undefined)?.key === block.id);
if (used.length) {
showToast(`잇는 내역 줄 ${used.length} — 단가연결을 먼저 바꿈`, "error");
return;
}
const all = covers(ctx);
delete all[block.cover];
ctx.setUser({ ...ctx.user, daega_cover: all });
}
async function postPersonal(body: Row): Promise<{ ok: boolean; status: number; got: Row }> {
const response = await fetch(
`${API_BASE_URL}/projects/${currentProjectId()}/estimation/daega/personal`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const got = ((await response.json().catch(() => null)) as Row | null) ?? {};
return { ok: response.ok, status: response.status, got };
}
/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 같은 이름 물음 */
async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
let name = await askText({
title: "내 자료집에 저장",
message: "개인 층 일위대가 사전조합으로 — 넣을 값 · 고른 자재 · 줄 수량 고친 칸까지",
value: block.name || "",
okLabel: "저장",
});
let overwrite: string | null = null;
while (name !== null && name.trim()) {
const body = { cover: block.cover, name: name.trim(), overwrite, user: ctx.user };
const { ok, status, got } = await postPersonal(body);
const same = got.detail?.같은이름;
if (ok) {
const skip: Row = got.안담음 || {};
const left = (skip.품 || 0) + (skip.단가 || 0);
const tail = left
? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가}) — 품은 고정 줄(PLAN 705) 뒤 담김`
: "";
showToast(`내 자료집에 저장 「${got.이름}」(${got.키})${tail}`, "success", 6000);
return;
}
if (status !== 409 || !same) {
const detail = got.detail;
showToast(`저장 못 함 — ${typeof detail === "string" ? detail : status}`, "error");
return;
}
const over = await askConfirm({
title: "같은 이름",
message: `내 자료집에 「${name.trim()}」(${same}) 이 있음 — 덮어쓰기 · 새 이름`,
okLabel: "덮어쓰기",
cancelLabel: "새 이름",
});
if (over) overwrite = same;
else {
overwrite = null;
name = await askText({ title: "새 이름", message: "내 자료집 이름", value: `${name} 2` });
}
}
}
/** 블록 나무에 그 품목(종류 · 키) · 그 호표(id)가 있나 */
function holds(blocks: Map<string, Row>, bid: string, hit: (ln: Row) => boolean): boolean {
const seen = new Set<string>();
const walk = (id: string, depth: number): boolean => {
const b = blocks.get(id);
if (!b || seen.has(id) || depth > DEPTH) return false;
seen.add(id);
return (b.lines || []).some(
(ln: Row) => hit(ln) || (ln.kind === "daega" && walk(String(ln.key), depth + 1)),
);
};
return walk(bid, 0);
}
/** 어디 쓰였나 — 그 품목 · 호표를 쓰는 내역 줄 → 누르면 그 줄로 */
function whereUsed(
ctx: TabContext,
raw: DataRow[],
block: Row,
line: Row,
go: (key: string) => void,
): void {
const blocks = new Map<string, Row>(
(ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.id), b]),
);
const goods = GOODS.includes(line.kind);
const id = line.kind === "daega" ? String(line.key) : String(block.id);
const label = goods ? `${line.name || line.key}` : `${blocks.get(id)?.name || ""}`;
const hit = goods
? (ln: Row) => ln.kind === line.kind && ln.key === line.key
: (ln: Row) => ln.kind === "daega" && ln.key === id;
const used = raw.filter((r) => {
const ref = r.ref as Row | undefined;
if (ref?.tab !== "dg") return false;
return (!goods && String(ref.key) === id) || holds(blocks, String(ref.key), hit);
});
openModal({
title: `어디 쓰였나 — 「${label}」`,
closeLabel: "닫기",
width: "40rem",
mount: (body, close) => {
body.append(
createText(`쓰는 내역 줄 ${used.length} · 누르면 그 줄로`, "muted"),
...used.map((r) =>
createListRow({
label: String(r.name || ""),
sub: [r.spec, r.qty != null ? `${r.qty} ${r.unit || ""}` : ""]
.filter(Boolean)
.join(" · "),
onClick: () => {
close();
go(String(r.key));
},
}),
),
);
},
});
}
/** 펼친 줄 우클릭 덧메뉴 — 복사 · 내 자료집 · 어디 쓰였나(+ 복사본 지우기) */
export function foldMenu(
ctx: TabContext,
raw: DataRow[],
row: Row,
block: Row,
line: Row | undefined,
go: (key: string) => void,
): MapContextMenuItem[] {
return [
["복사해 새 일위대가", () => void copyBlock(ctx, raw, row, block)],
["내 자료집에 저장", () => void savePersonal(ctx, block)],
["어디 쓰였나", () => whereUsed(ctx, raw, block, line || {}, go)],
...(String(block.cover).startsWith("K:")
? [["이 복사본 지우기", () => removeCopy(ctx, raw, block)] as MapContextMenuItem]
: []),
];
}