merge(M02): origin/sub_laptop_6 받음 — UI_Main 충돌 풂(면적 일위대가 패널 살림 · 재료산출 · 조합 패널 길 지움)
- sub3 apanel · 고른 칸 · refresh 살림 · sub6 의 matPanel · matState 지움 · 면적만 남아 죽은 조합 패널(cpanel) · remount 지움 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
@@ -39,7 +39,6 @@ from M02_MasterTemplete.M02_Item_Snapshot import (
|
||||
)
|
||||
from M02_MasterTemplete.M02_Item_Source import (
|
||||
LABOR_FILE,
|
||||
LINK_FILE,
|
||||
PRICE_FILE,
|
||||
build_source,
|
||||
read_json,
|
||||
@@ -50,16 +49,12 @@ from M02_MasterTemplete.M02_Item_Source import (
|
||||
KINDS = {
|
||||
"도면": "도면.json",
|
||||
"면적": "면적.json",
|
||||
"재료": "재료.json",
|
||||
"자재조합": "자재조합.json",
|
||||
"입력연결": LINK_FILE,
|
||||
"입력값": VALUES_FILE,
|
||||
"고른자재": PICKS_FILE,
|
||||
"일위대가": LABOR_FILE, # 읽기만 — 일위대가는 원본 층에서 고침(39-1c)
|
||||
}
|
||||
#: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출 · 재료산출)
|
||||
SKELETON = {"도면": "structure", "면적": "area", "재료": "material"}
|
||||
SEATS = ("면적", "재료", "선택", None)
|
||||
#: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출)
|
||||
SKELETON = {"도면": "structure", "면적": "area"}
|
||||
_MT = re.compile(r"^MT\d{6}$")
|
||||
|
||||
|
||||
@@ -160,17 +155,6 @@ def _check_picks(content: Any) -> None:
|
||||
raise ValueError(f"고른자재 「{logic}」 {at} → 「{key}」 — 차례는 수 · 값은 MT 키")
|
||||
|
||||
|
||||
def _check_links(content: Any) -> None:
|
||||
rows = content.get("줄") if isinstance(content, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("입력연결은 {판, 줄: [...]} 이어야 합니다.")
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or not row.get("로직") or not row.get("입력"):
|
||||
raise ValueError("입력연결 줄은 {로직, 입력, 자리, 알약?} 이어야 합니다.")
|
||||
if row.get("자리") not in SEATS:
|
||||
raise ValueError(f"입력연결 자리 「{row.get('자리')}」 — 면적 · 재료 · 선택 · 빈 값")
|
||||
|
||||
|
||||
def check(kind: str, content: Any) -> None:
|
||||
if kind == "일위대가":
|
||||
raise ValueError("일위대가는 원본(마스터 · 회사 · 개인) 층에서 고침 — 프로젝트는 넣을 값만")
|
||||
@@ -178,8 +162,6 @@ def check(kind: str, content: Any) -> None:
|
||||
_check_values(content)
|
||||
elif kind == "고른자재":
|
||||
_check_picks(content)
|
||||
elif kind == "입력연결":
|
||||
_check_links(content)
|
||||
elif kind in SKELETON:
|
||||
layers.check_skeleton(SKELETON[kind], content)
|
||||
elif not isinstance(content, dict):
|
||||
@@ -194,13 +176,13 @@ def write_file(
|
||||
content: Any,
|
||||
recalc: Callable[[dict], dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""판이 같을 때만 씀 — 면적 · 재료는 `recalc`(서버 계산값)를 거친 것을 씀."""
|
||||
"""판이 같을 때만 씀 — 면적은 `recalc`(서버 계산값)를 거친 것을 씀."""
|
||||
target = folder_of(project_root, folder)
|
||||
check(kind, content)
|
||||
current = version_of(read_json(target / KINDS[kind]))
|
||||
if version != current:
|
||||
raise Stale(current)
|
||||
if recalc is not None and kind in ("면적", "재료"):
|
||||
if recalc is not None and kind == "면적":
|
||||
content = recalc(content)
|
||||
atomic_write_json(target / KINDS[kind], content)
|
||||
manifest = read_json(target / MANIFEST_NAME) or {}
|
||||
@@ -248,7 +230,7 @@ def _kept_picks(picks: Any, labor: dict[str, Any]) -> dict[str, Any]:
|
||||
def reapply(project_root: str | Path, folder: str) -> dict[str, Any]:
|
||||
"""재적용 — 원본(층 · 마스터)을 다시 가져옴 · 입력값 · 고른자재는 같은 키면 살림.
|
||||
|
||||
원본과 달라 덮인 파일(도면 · 면적 · 재료 · 자재조합 · 입력연결)은 `덮임` 에 알림."""
|
||||
원본과 달라 덮인 파일(도면 · 면적)은 `덮임` 에 알림."""
|
||||
target = folder_of(project_root, folder)
|
||||
manifest = read_json(target / MANIFEST_NAME) or {}
|
||||
values = read_json(target / VALUES_FILE)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) ·
|
||||
같은 이름은 「이름 (2)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더 (2)」).
|
||||
파일(도면 · 면적 · 재료 · 입력 연결 · 자재 조합)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫.
|
||||
파일(도면 · 면적 · 입력 연결)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""프로젝트에서 개인 층으로 — [개인에 저장](save-as) · 프로젝트에서 새 항목(new) (PLAN 26-2b).
|
||||
|
||||
개인 층 새 항목 = 구조물집계표 새 열 하나 + 빈 도면 · 면적산출 · 재료산출(번호 · SS 키는 저장소가
|
||||
줌). [개인에 저장]은 구조물 복사본의 열 정의 · 도면 · 면적 · 재료 · 자재조합 · 입력연결을 담음 —
|
||||
개인 층 새 항목 = 구조물집계표 새 열 하나 + 빈 도면 · 면적산출(번호 · SS 키는 저장소가
|
||||
줌). [개인에 저장]은 구조물 복사본의 열 정의 · 도면 · 면적을 담음 —
|
||||
단가 묶음 · 입력값 · 고른자재는 프로젝트 몫이라 안 가져감. 쓰기는 지금 층 저장소 함수(`store.at` ·
|
||||
`store.write` · `matset.create` · 항목 연결 `write_file`) — 나무 고침(`M02_MasterTemplete_Tree`)과
|
||||
`store.write`) — 나무 고침(`M02_MasterTemplete_Tree`)과
|
||||
같은 집계표 저장 한 줄기(경로 · 머리 줄 수 맞춤 · 번호 · SS 장부)를 탐(PLAN 26-2a).
|
||||
권한(본인 개인 층 · 회사 없음 409)은 부르는 쪽 몫.
|
||||
"""
|
||||
@@ -15,16 +15,14 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_Item_Source import LINK_FILE, UM_FILE, read_json
|
||||
from M02_MasterTemplete.M02_Item_Source import read_json
|
||||
|
||||
#: 복사본 파일 → 층 양식 종류
|
||||
PART_KINDS = (("도면.json", "structure"), ("면적.json", "area"), ("재료.json", "material"))
|
||||
PART_KINDS = (("도면.json", "structure"), ("면적.json", "area"))
|
||||
|
||||
|
||||
def heads_of(path: list[str], name: str | None) -> list[str]:
|
||||
@@ -45,17 +43,6 @@ def _free_path(doc: dict[str, Any], full: list[str]) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _new_matset(row: dict[str, Any], fallback: str) -> str:
|
||||
"""자재 조합 줄을 이 층에 새 키로 — 이름이 겹치면 「이름 (2)」."""
|
||||
taken = {str(r.get("이름") or "") for r in matset.listing()["줄"]}
|
||||
name = base = str(row.get("이름") or fallback)
|
||||
n = 2
|
||||
while name in taken:
|
||||
name, n = f"{base} ({n})", n + 1
|
||||
got = {k: row.get(k) for k in ("담은자재", "소유", "비고")}
|
||||
return matset.create({**got, "이름": name, "본뜬키": None})["키"]
|
||||
|
||||
|
||||
def create_item(
|
||||
folder: Path,
|
||||
path: list[str],
|
||||
@@ -77,8 +64,6 @@ def create_item(
|
||||
doc = table["문서"]
|
||||
used = {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)}
|
||||
new_id = items_store._fresh_id(str(base_col.get("id") or "item").split("@")[0], used)
|
||||
um_row = read_json(copy / UM_FILE) if copy else None
|
||||
um_key = _new_matset(um_row, heads[0]) if isinstance(um_row, dict) else None
|
||||
heads = _free_path(doc, heads)
|
||||
column = {
|
||||
**json.loads(json.dumps(base_col)),
|
||||
@@ -88,9 +73,9 @@ def create_item(
|
||||
"꼴": "수",
|
||||
"단위": unit or base_col.get("단위") or base_row.get("단위"),
|
||||
"일위대가": base_col.get("일위대가") or base_row.get("일위대가"),
|
||||
"자재조합": um_key,
|
||||
}
|
||||
column.pop("본뜬열", None)
|
||||
column.pop("자재조합", None) # 옛 자재 조합 칸은 안 옮김
|
||||
if origin:
|
||||
column["본뜬"] = origin
|
||||
tree._place(doc, column) # 같은 앞마디 형제 곁(제목행 병합이 이어지게)
|
||||
@@ -107,9 +92,6 @@ def create_item(
|
||||
body = store._server_values(body)
|
||||
with store._LOCK:
|
||||
atomic_write_json(store._path(kind, new_id), body)
|
||||
links = read_json(copy / LINK_FILE) if copy else None
|
||||
if isinstance(links, dict) and links.get("줄"):
|
||||
inputs_store.write_file(new_id, {"판": 1, "줄": links["줄"]})
|
||||
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
|
||||
return {"key": ss["키"], "열": new_id, "이름": tree.name_of(heads)}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""구조물 집계 항목 복사본 — 프로젝트 `templates/structures/{구조물 id}/` (PLAN 25-2b · 26-2b).
|
||||
|
||||
복사본은 **구조물마다 하나** — 같은 항목을 고른 구조물 둘도 각자 도면 · 면적 · 재료 · 자재조합 ·
|
||||
일위대가 · 입력연결 · 입력값 · 고른자재 · 단가를 가짐(길이 · 소재 · 높이 · 옵션이 구조물마다 다름).
|
||||
복사본은 **구조물마다 하나** — 같은 항목을 고른 구조물 둘도 각자 도면 · 면적 ·
|
||||
일위대가 · 입력값 · 고른자재 · 단가를 가짐(길이 · 소재 · 높이 · 옵션이 구조물마다 다름).
|
||||
폴더 이름 = 구조물 id(`structure_id`) · 관 지점은 `pipe@측점`.
|
||||
|
||||
[저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤:
|
||||
@@ -32,7 +32,6 @@ from common_util.common_util_summary_item import snapshot_folder_name
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_Item_Source import (
|
||||
LABOR_FILE,
|
||||
LINK_FILE,
|
||||
PRICE_FILE,
|
||||
build_source,
|
||||
item_path,
|
||||
@@ -236,28 +235,24 @@ def safe_sync(project_root: str | Path) -> None:
|
||||
|
||||
|
||||
def complete_old(folder: Path) -> bool:
|
||||
"""옛 복사본(21-3 · 새 파일 없음)을 채움 — 입력연결 · 단가는 지금 원본에서 · 값은 빈 것.
|
||||
"""옛 복사본(21-3 · 새 파일 없음)을 채움 — 단가는 지금 원본에서 · 값은 빈 것.
|
||||
|
||||
옛 내용 파일(집계 · 도면 …)은 그대로 둠. 채웠으면 True(manifest 에 `옮겨받음` 날)."""
|
||||
manifest = read_json(folder / MANIFEST_NAME)
|
||||
if not isinstance(manifest, dict):
|
||||
return False
|
||||
lacking = [
|
||||
name
|
||||
for name in (LINK_FILE, PRICE_FILE, VALUES_FILE, PICKS_FILE)
|
||||
if not (folder / name).is_file()
|
||||
name for name in (PRICE_FILE, VALUES_FILE, PICKS_FILE) if not (folder / name).is_file()
|
||||
]
|
||||
if not lacking and "원본판" in manifest:
|
||||
return False
|
||||
need_source = LINK_FILE in lacking or PRICE_FILE in lacking
|
||||
need_source = PRICE_FILE in lacking
|
||||
try:
|
||||
source = build_source(manifest["원본"]) if need_source else {}
|
||||
except LookupError:
|
||||
source = {} # 원본이 지워짐 — 빈 연결 · 빈 단가로(계산은 멈춤으로 알림)
|
||||
source = {} # 원본이 지워짐 — 빈 단가로(계산은 멈춤으로 알림)
|
||||
for name in lacking:
|
||||
atomic_write_json(
|
||||
folder / name, source.get(name, {"판": None, "줄": []} if name == LINK_FILE else {})
|
||||
)
|
||||
atomic_write_json(folder / name, source.get(name, {}))
|
||||
kept = {
|
||||
name: read_json(folder / name)
|
||||
for name in sorted(p.name for p in folder.glob("*.json"))
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""집계 항목 원본 — 층(시스템 · 회사 · 개인)에서 항목 내용을 모음 (PLAN 21-1 · 25-1b · 25-2b).
|
||||
|
||||
모으는 것 = `{파일 이름: 내용}`:
|
||||
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · `재료.json` SM ·
|
||||
`자재조합.json` UM 줄 ·
|
||||
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA ·
|
||||
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄)
|
||||
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) ·
|
||||
`입력연결.json` 층의 `inputs/<열id>.json`(없으면 빈 연결) ·
|
||||
`단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT)
|
||||
`원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎.
|
||||
프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음.
|
||||
@@ -22,11 +20,9 @@ from typing import Any
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
TABLE_NAME = "구조물집계표"
|
||||
UM_FILE = "자재조합.json"
|
||||
#: 열 id → 스냅샷 파일(구조물 도면 · 면적산출 · 재료산출)
|
||||
PART_FILES = (("structure", "도면.json"), ("area", "면적.json"), ("material", "재료.json"))
|
||||
#: 열 id → 스냅샷 파일(구조물 도면 · 면적산출)
|
||||
PART_FILES = (("structure", "도면.json"), ("area", "면적.json"))
|
||||
LABOR_KIND = "labor" # 열 id → 일위대가 문서(39-1c) — 스냅샷은 `LABOR_FILE` 에 감싸서
|
||||
LINK_FILE = "입력연결.json"
|
||||
PRICE_FILE = "단가.json"
|
||||
LABOR_FILE = "일위대가.json"
|
||||
|
||||
@@ -123,20 +119,6 @@ def logic_rows(row: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def input_links(root: Path, column_id: str) -> dict[str, Any]:
|
||||
"""층의 항목 연결 `inputs/<열id>.json`(sub4 `_Inputs.read_file`) · 없으면 빈 연결."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
if not column_id:
|
||||
return {"판": None, "줄": []}
|
||||
with store.at(root):
|
||||
found = inputs.read_file(column_id)
|
||||
if isinstance(found, dict) and isinstance(found.get("줄"), list):
|
||||
return found
|
||||
return {"판": None, "줄": []}
|
||||
|
||||
|
||||
def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""원본 층 · 마스터에서 항목 내용을 모음 — SS 줄이 없으면 LookupError."""
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
@@ -153,10 +135,6 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
part = read_json(root / kind / f"{column_id}.json") if column_id else None
|
||||
if part is not None:
|
||||
files[name] = part
|
||||
if row.get("자재조합"):
|
||||
um = row_of(read_json(root / UM_FILE), str(row["자재조합"]))
|
||||
if um is not None:
|
||||
files[UM_FILE] = um
|
||||
doc = read_json(root / LABOR_KIND / f"{column_id}.json") if column_id else None
|
||||
if isinstance(doc, dict):
|
||||
labor = labor_doc_rows(doc)
|
||||
@@ -164,7 +142,6 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
|
||||
if labor is not None:
|
||||
files[LABOR_FILE] = labor
|
||||
files[LINK_FILE] = input_links(root, column_id)
|
||||
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
|
||||
materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []]
|
||||
wanted = logic_keys + [code for code in materials if code]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""M02 일위대가 입력 목록(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs` 에서 옮김).
|
||||
"""M02 일위대가 입력 목록(39-3).
|
||||
|
||||
담은 단가산출 로직마다 입력 전부 · 로직마다 갈라 보이고 이름이 같아도 합치지 않음 · 입력 목록은
|
||||
그 로직 줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만) · 자동값 =
|
||||
`master_auto.values`(보기용 · 저장 안 함). 일위대가 입력 모달(`GET /api/m02/logic-lib/inputs`)과
|
||||
옛 선택 입력 길(`inputs`)이 같이 씀. M01 은 읽기만 · 권한은 부르는 쪽 몫.
|
||||
`master_auto.values`(보기용 · 저장 안 함). 일위대가 입력 모달(`GET /api/m02/logic-lib/inputs`)이
|
||||
씀. M01 은 읽기만 · 권한은 부르는 쪽 몫.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,7 +13,6 @@ from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
|
||||
_SPEC = ("이름", "설명", "단위", "고르기", "범위")
|
||||
|
||||
@@ -41,17 +40,6 @@ def _logics(codes: list[str]) -> tuple[list[dict], list[str]]:
|
||||
return found, missing
|
||||
|
||||
|
||||
def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]:
|
||||
"""(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], [])."""
|
||||
if not ua:
|
||||
return None, [], []
|
||||
row = combos.find(str(ua)) # 마스터 UA · 회사 UC · 개인 UP(27-2c)
|
||||
if row is None:
|
||||
return None, [], []
|
||||
found, missing = _logics(combo_store.mcb.logic_keys(row))
|
||||
return row, found, missing
|
||||
|
||||
|
||||
def _listed(logics: list[dict]) -> list[dict[str, Any]]:
|
||||
"""로직 줄마다 `{로직, 이름, 결과단위, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`."""
|
||||
whole = m01.loaded()[1]
|
||||
@@ -79,16 +67,3 @@ def logic_inputs(codes: list[str]) -> dict[str, Any]:
|
||||
"""일위대가 입력 모달 — 로직 키들의 입력 목록 `{로직: [...], 빠진로직}`(키 차례 · 겹침 한 번)"""
|
||||
found, missing = _logics(list(dict.fromkeys(c for c in codes if c)))
|
||||
return _num({"로직": _listed(found), "빠진로직": missing})
|
||||
|
||||
|
||||
def inputs_of(ua: str | None) -> dict[str, Any]:
|
||||
"""옛 선택 입력 목록 — `{일위대가, 이름, 로직: [...], 빠진로직}` · 일위대가 없으면 로직 없음."""
|
||||
row, logics, missing = combo_logics(ua)
|
||||
return _num(
|
||||
{
|
||||
"일위대가": ua or None,
|
||||
"이름": row.get("이름") if row else None,
|
||||
"로직": _listed(logics),
|
||||
"빠진로직": missing,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-inputval__places,
|
||||
.m02-inputval__missing {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
/* =============================================================================
|
||||
* M02_Labor_Values.ts
|
||||
* 넣을 값 칸(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs.ts` 에서 옮김) — 값 칸 규칙 한 벌:
|
||||
* 넣을 값 칸(39-3) — 값 칸 규칙 한 벌:
|
||||
* 고르기 = 목록 · 범위 = 숫자(밖이면 붉은 글) · 빈 칸 = 자동값(흐리게 · placeholder).
|
||||
* · `createValueControl` — 칸 하나(일위대가 입력 모달 · 프로젝트 값 카드가 같이 씀)
|
||||
* · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄)
|
||||
* · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(담은 로직 입력 전부)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { ItemInput, ItemInputs, ItemLogic, InputLinks } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { ItemInput, ItemInputs, ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell";
|
||||
import { inputKey, pickedGroups, seatsOf } from "./M02_MasterTemplete_Inputs_Model";
|
||||
import { choicesOf, isOutside, rangeOf, storedOf, type ValueDoc } from "./M02_Labor_Values_Model";
|
||||
import {
|
||||
choicesOf,
|
||||
inputKey,
|
||||
isOutside,
|
||||
rangeOf,
|
||||
storedOf,
|
||||
type ValueDoc,
|
||||
} from "./M02_Labor_Values_Model";
|
||||
import "./M02_Labor_Values.css";
|
||||
|
||||
const fill = (text: string, values: Record<string, string | number>): string =>
|
||||
@@ -82,20 +88,19 @@ export function createValueControl(
|
||||
}
|
||||
|
||||
/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ─────────────────────────────
|
||||
* 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만
|
||||
* 같은 카드 틀 · 같은 입력 목록에서 **값**을 받음: 담은 로직 입력 전부를
|
||||
* 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님
|
||||
* (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음.
|
||||
* mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */
|
||||
* mode "view" 보기만 + [넣을 값 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */
|
||||
|
||||
export interface InputsValueData {
|
||||
list: ItemInputs;
|
||||
link: InputLinks | null;
|
||||
values: ValueDoc;
|
||||
}
|
||||
|
||||
export interface InputsValueOptions {
|
||||
mode: "view" | "value";
|
||||
/** mode "view" — [선택 입력 수정] */
|
||||
/** mode "view" — [넣을 값 수정] */
|
||||
onEdit?: () => void;
|
||||
/** mode "value" — 값이 바뀔 때마다 */
|
||||
onChange?: (values: ValueDoc) => void;
|
||||
@@ -111,21 +116,20 @@ export interface InputsValueHandle {
|
||||
getValues: () => ValueDoc;
|
||||
/** 범위 밖 값이 없나 */
|
||||
isValid: () => boolean;
|
||||
/** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */
|
||||
/** [넣을 값 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */
|
||||
setEditLabel: (label: string) => void;
|
||||
}
|
||||
|
||||
const VALUE_TEXT = {
|
||||
title: "선택 입력",
|
||||
title: "넣을 값",
|
||||
missing: "빠진 로직 {value}",
|
||||
edit: "선택 입력 수정",
|
||||
none: "선택으로 이은 입력이 없음",
|
||||
edit: "넣을 값 수정",
|
||||
none: "넣을 값이 없음 — 담은 로직에 입력이 없음",
|
||||
auto: "자동값",
|
||||
unconfirmed: "확인 안 함",
|
||||
confirmed: "✓ 확인함",
|
||||
confirmBox: "확인",
|
||||
summary: "선택 {value}",
|
||||
places: "면적산출 알약 {area} · 재료산출 알약 {material}",
|
||||
summary: "넣을 값 {value}",
|
||||
noList: "입력 목록을 못 읽음 — {value}",
|
||||
};
|
||||
|
||||
@@ -140,20 +144,22 @@ export function createInputsValueCard(opts: InputsValueOptions): InputsValueHand
|
||||
const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" });
|
||||
edit.hidden = opts.mode !== "view";
|
||||
if (opts.onEdit) edit.addEventListener("click", opts.onEdit);
|
||||
const places = el("p", { className: "m02-inputval__places" });
|
||||
const body = el("div", { className: "m02-struct__sheet m02-inputval" });
|
||||
// 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에
|
||||
// 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [넣을 값 수정] · 상태 글은 목록 칸에
|
||||
const shell = cardShell({
|
||||
title: VALUE_TEXT.title,
|
||||
sub: [summary],
|
||||
body: el("div", { className: "m02-inputval__wrap", children: [places, body] }),
|
||||
body: el("div", { className: "m02-inputval__wrap", children: [body] }),
|
||||
stateHost: body,
|
||||
});
|
||||
shell.tools.append(edit);
|
||||
const root = shell.root;
|
||||
|
||||
const groups = (): ReturnType<typeof pickedGroups> =>
|
||||
data ? pickedGroups(data.list, data.link) : [];
|
||||
/** 로직마다 묶은 입력 — 입력이 없는 로직은 뺌 */
|
||||
const groups = (): { logic: ItemLogic; inputs: ItemInput[] }[] =>
|
||||
(data?.list.로직 ?? [])
|
||||
.map((logic) => ({ logic, inputs: logic.입력 }))
|
||||
.filter((group) => group.inputs.length > 0);
|
||||
const one = (logic: string, name: string): ValueEntry =>
|
||||
values[logic]?.[name] ?? { 값: null, 확인: false };
|
||||
const isEmpty = (value: string | number | null): boolean => value === null || value === "";
|
||||
@@ -239,14 +245,9 @@ export function createInputsValueCard(opts: InputsValueOptions): InputsValueHand
|
||||
invalid = 0;
|
||||
paintSummary();
|
||||
if (!data) {
|
||||
places.textContent = "";
|
||||
if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry);
|
||||
return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none);
|
||||
}
|
||||
const seats = [...seatsOf(data.link).values()];
|
||||
const area = seats.filter((seat) => seat === "면적").length;
|
||||
const material = seats.filter((seat) => seat === "재료").length;
|
||||
places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : "";
|
||||
const shown = groups();
|
||||
const missing = data.list.빠진로직.length
|
||||
? [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M02_Labor_Values_Model.ts
|
||||
* 넣을 값 셈(화면 없음 · 39-3) — 옛 선택 입력 `M02_MasterTemplete_Inputs_Model.ts` 의 값 함수를 옮김.
|
||||
* 넣을 값 셈(화면 없음 · 39-3).
|
||||
* 일위대가 입력 모달 · 프로젝트 고르기 모달 값 카드가 같은 값 칸 규칙(고르기 = 목록 · 범위 = 숫자 ·
|
||||
* 빈 값 = 자동값)을 씀. 일위대가 입력값 = `{입력 이름: 값}`(빈 값은 안 둠 = 자동값 · 모르는 이름 버림).
|
||||
* ========================================================================== */
|
||||
@@ -32,6 +32,9 @@ export function valueHint(input: ItemInput): string {
|
||||
export const autoText = (input: ItemInput): string => text(input.자동값);
|
||||
|
||||
/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */
|
||||
/** 입력 키 `{로직키}|{입력이름}` — 같은 이름의 입력도 로직마다 따로 */
|
||||
export const inputKey = (logic: string, name: string): string => `${logic}|${name}`;
|
||||
|
||||
export type ValueDoc = Record<
|
||||
string,
|
||||
Record<string, { 값: string | number | null; 확인: boolean }>
|
||||
|
||||
@@ -8,9 +8,9 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
/** 층 이름 — 서버와 같은 글 */
|
||||
export type Layer = "system" | "company" | "personal" | "project";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure ·
|
||||
* 「면적산출」 = area · 「재료산출」 = material(둘 다 구조물집계표 열마다 한 통합문서) ·
|
||||
* 「면적산출」 = area(구조물집계표 열마다 한 통합문서) ·
|
||||
* 「일위대가」 = labor(열마다 문서 · 39장) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "material" | "labor";
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "labor";
|
||||
|
||||
export interface TemplateInfo {
|
||||
종류: Kind;
|
||||
@@ -130,7 +130,7 @@ export const deleteLayerTemplate = (
|
||||
/* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) ---
|
||||
* 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */
|
||||
|
||||
/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · 면적산출 · 재료산출은 따로(같은 열 id) */
|
||||
/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · 면적산출은 따로(같은 열 id) */
|
||||
export interface StructureDoc {
|
||||
양식: "구조물도면";
|
||||
종류: "structure";
|
||||
@@ -144,21 +144,20 @@ export interface StructureDoc {
|
||||
export const listStructures = async (): Promise<TemplateInfo[]> =>
|
||||
(await listTemplates("system", null)).filter((t) => t.종류 === "structure");
|
||||
|
||||
/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 면적산출 · 재료산출 통합문서) · 이미 있으면 StaleError */
|
||||
/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 면적산출 통합문서) · 이미 있으면 StaleError */
|
||||
export const createStructure = (column: string): Promise<TemplateDoc> =>
|
||||
scoped(`/structures/${enc(column)}`, { method: "POST" });
|
||||
|
||||
/** 한 열의 번호 — 도번 `SD` · 면적번 `SA` · 재료번 `SM` + 같은 8자리
|
||||
/** 한 열의 번호 — 도번 `SD` · 면적번 `SA` + 같은 8자리
|
||||
* [종류][분류1][분류2][분류3](없는 쪽은 빈 글) */
|
||||
export interface StructureNumbers {
|
||||
도번: string;
|
||||
면적번: string;
|
||||
재료번: string;
|
||||
/** 일위대가 `SU-######`(39장) */
|
||||
일위대가번: string;
|
||||
}
|
||||
|
||||
/** `{열 id: {도번, 면적번, 재료번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
|
||||
/** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
|
||||
export const fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>> =>
|
||||
scoped("/structures/numbers");
|
||||
|
||||
@@ -172,8 +171,6 @@ export interface StructureCombo extends StructureNumbers {
|
||||
분류3: string | null;
|
||||
단위: string | null;
|
||||
일위대가: string | null;
|
||||
/** 자재 조합 키 `UM` */
|
||||
자재조합: string | null;
|
||||
바인딩: unknown;
|
||||
펼침: unknown;
|
||||
}
|
||||
@@ -210,72 +207,13 @@ export const branchCombo = (
|
||||
): Promise<BranchedCombo> =>
|
||||
scoped(`/structures/${enc(column)}/combo`, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
/** 있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 · 자재조합 키만 바꿈(조합은 그대로 · `새로만듦` false) */
|
||||
/** 있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 키만 바꿈(조합은 그대로 · `새로만듦` false) */
|
||||
export const setColumnCombo = (
|
||||
column: string,
|
||||
body: { 종류: "일위대가" | "자재조합"; 키: string; 판?: string },
|
||||
body: { 종류: "일위대가"; 키: string; 판?: string },
|
||||
): Promise<BranchedCombo> =>
|
||||
scoped(`/structures/${enc(column)}/use-combo`, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
/** 자재 조합(키 `UM`) 담은 자재 한 줄 */
|
||||
export interface MaterialItem {
|
||||
코드: string;
|
||||
이름: string | null;
|
||||
규격: string | null;
|
||||
단위: string | null;
|
||||
}
|
||||
|
||||
/** 자재 조합 한 줄 — 본뜬키 = 갈라 낸 원래 키(없으면 null) */
|
||||
export interface MaterialCombo {
|
||||
키: string;
|
||||
이름: string;
|
||||
담은자재: MaterialItem[];
|
||||
본뜬키: string | null;
|
||||
소유: string;
|
||||
비고: string | null;
|
||||
}
|
||||
|
||||
export const fetchMaterialCombo = (key: string): Promise<MaterialCombo> =>
|
||||
scoped(`/material-combos/${enc(key)}`);
|
||||
|
||||
/** 이 열의 자재 조합을 고친 담은 자재로 갈라 냄 — 규칙은 `branchCombo` 와 같음 ·
|
||||
* `본뜬키` 빈 글 = 이 열의 첫 자재 조합 */
|
||||
export const branchMaterialCombo = (
|
||||
column: string,
|
||||
body: { 본뜬키?: string; 담은자재: MaterialItem[]; 이름: string; 판?: string },
|
||||
): Promise<BranchedCombo> =>
|
||||
scoped(`/structures/${enc(column)}/material-combo`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/* --- 자재 조합 표(마스터 템플릿 `자재조합.json`) — 좌측 「자재 조합」 컨테이너 --- */
|
||||
/** 표 통째 — 판(파일 판본) · 줄(담은 자재 포함) · 쓰는곳(키 → 쓰는 구조물 열 이름들) */
|
||||
export const listMaterialCombos = (): Promise<{
|
||||
판: string;
|
||||
줄: MaterialCombo[];
|
||||
쓰는곳: Record<string, string[]>;
|
||||
}> => scoped("/material-combos");
|
||||
|
||||
/** 새로 · 본떠 만들기(`본뜬키`) — 키는 서버가 표 「다음」 번호로 줌 */
|
||||
export const createMaterialCombo = (
|
||||
판: string,
|
||||
줄: Omit<MaterialCombo, "키">,
|
||||
): Promise<{ 키: string; 판: string; 줄: MaterialCombo }> =>
|
||||
scoped("/material-combos", { method: "POST", body: JSON.stringify({ 판, 줄 }) });
|
||||
|
||||
/** 고치기 — 이름 · 비고 · 담은자재(본뜬키 · 소유는 서버가 있던 것으로) */
|
||||
export const editMaterialCombo = (
|
||||
키: string,
|
||||
판: string,
|
||||
줄: MaterialCombo,
|
||||
): Promise<{ 키: string; 판: string; 줄: MaterialCombo }> =>
|
||||
scoped(`/material-combos/${enc(키)}`, { method: "PUT", body: JSON.stringify({ 판, 줄 }) });
|
||||
|
||||
/** 지우기 — 구조물 열이 쓰면 서버가 막고 그 열 이름들을 까닭으로 줌 */
|
||||
export const deleteMaterialCombo = (키: string, 판: string): Promise<unknown> =>
|
||||
scoped(`/material-combos/${enc(키)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
|
||||
/* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */
|
||||
export interface OptionKind {
|
||||
type_id: string;
|
||||
@@ -323,12 +261,7 @@ export const copyArea = async (column: string, from: string): Promise<void> => {
|
||||
await scoped(`/area/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
|
||||
};
|
||||
|
||||
/** 다른 열(`from`) 재료산출 내용(알약 연결 포함)을 받는 열(`column`)로 가져옴 — 재료번 · 열은 그대로 */
|
||||
export const copyMaterial = async (column: string, from: string): Promise<void> => {
|
||||
await scoped(`/material/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
|
||||
};
|
||||
|
||||
/** 회사 · 개인 층의 집계표 항목(열 하나 + 도면 · 면적 · 재료 파일 + 자재조합 줄)을 내 개인 층으로 복사 —
|
||||
/** 회사 · 개인 층의 집계표 항목(열 하나 + 도면 · 면적 파일)을 내 개인 층으로 복사 —
|
||||
* 새 SS 키로 만들고 `본뜬` 표시 · `fromOwner` = 회사 층 회사 · 개인 층 「회사/사용자」 */
|
||||
export const copyItemToPersonal = (body: {
|
||||
fromLayer: Layer;
|
||||
@@ -365,15 +298,8 @@ export const fetchSources = (id: string): Promise<unknown> => call(`/projects/${
|
||||
export const readFilled = (id: string, name: string): Promise<TemplateDoc> =>
|
||||
call(`/projects/${enc(id)}/tables/${enc(name)}/filled`);
|
||||
|
||||
/* --- 선택 입력 (PLAN 25-1b) — 항목(집계표 열 하나) 일위대가의 로직별 입력 목록 · 입력 자리 연결 ---
|
||||
* 길은 층 항목 길(`/layers/{층}/items/{SS 키}/…`) · 층 폴더 저장은 연결만(값은 안 받음). */
|
||||
|
||||
/** 층 항목 목록 한 줄 — 열 id ↔ SS 키 */
|
||||
export interface ItemRow {
|
||||
key: string;
|
||||
열: string;
|
||||
일위대가: string | null;
|
||||
}
|
||||
/* --- 넣을 값 입력 목록(39-3) — 일위대가 담은 로직마다 입력(고르기 · 범위 · 자동값) ---
|
||||
* 길은 `M02_MasterTemplete_Labor_Api` 의 `fetchLogicInputs` · 모양만 여기 */
|
||||
|
||||
/** 로직의 입력 하나 — 고르기(선택지) · 범위 · 자동값은 보이기만 */
|
||||
export interface ItemInput {
|
||||
@@ -399,42 +325,6 @@ export interface ItemInputs {
|
||||
빠진로직: string[];
|
||||
}
|
||||
|
||||
/** 입력 자리 — 면적산출 알약 · 재료산출 알약 · 선택 컨테이너 · 안 씀(null) */
|
||||
export type InputSeat = "면적" | "재료" | "선택";
|
||||
|
||||
export interface InputLine {
|
||||
로직: string;
|
||||
입력: string;
|
||||
자리: InputSeat | null;
|
||||
알약?: string;
|
||||
}
|
||||
|
||||
export interface InputLinks {
|
||||
판: string | null;
|
||||
줄: InputLine[];
|
||||
}
|
||||
|
||||
const itemPath = (layer: Layer, key: string, tail: string): string =>
|
||||
owned(layer, `/layers/${layer}/items/${enc(key)}/${tail}`);
|
||||
|
||||
export const fetchItemRows = (layer: Layer): Promise<ItemRow[]> =>
|
||||
call<ItemRow[]>(owned(layer, `/items?layer=${layer}`));
|
||||
|
||||
export const fetchItemInputs = (layer: Layer, key: string): Promise<ItemInputs> =>
|
||||
call(itemPath(layer, key, "inputs"));
|
||||
|
||||
export const readInputLinks = (layer: Layer, key: string): Promise<InputLinks> =>
|
||||
call(itemPath(layer, key, "input-links"));
|
||||
|
||||
/** 판이 다르면 409(`StaleError`) · 없는 키는 422 */
|
||||
export const saveInputLinks = (
|
||||
layer: Layer,
|
||||
key: string,
|
||||
판: string | null,
|
||||
줄: InputLine[],
|
||||
): Promise<{ 판: string }> =>
|
||||
call(itemPath(layer, key, "input-links"), { method: "PUT", body: JSON.stringify({ 판, 줄 }) });
|
||||
|
||||
/* --- 구조물 라이브러리(PLAN 26-1 · 26-3a) — 폴더 경로 깊이 자유 · 고침은 층 쓰기 권한 --- */
|
||||
export interface TreeItem {
|
||||
key: string;
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
개인에 저장 · 프로젝트에서 새 항목은 그 층의 새 키(본뜬 곳은 열 `본뜬`). 회사 · 개인 층에 남은
|
||||
옛 `SS` 키는 맞출 때 새 모양으로 옮기고(숫자 여섯 그대로 · 겹치면 다음 번호) 옛 키는 줄 `옛키` 에
|
||||
남김 — 프로젝트 복사본이 들고 있는 옛 키로도 찾음(`find`).
|
||||
줄 = `{키, 열, 경로, 이름, 종류, 분류1~3(경로 앞 넷 — 옛 코드가 읽음), 단위, 일위대가, 자재조합,
|
||||
도번, 면적번, 재료번, 바인딩, 펼침, 옛키?}`. `M02_MasterTemplete_Store.py` 가 커서 떼어 냄 —
|
||||
줄 = `{키, 열, 경로, 이름, 종류, 분류1~3(경로 앞 넷 — 옛 코드가 읽음), 단위, 일위대가,
|
||||
도번, 면적번, 바인딩, 펼침, 옛키?}`. `M02_MasterTemplete_Store.py` 가 커서 떼어 냄 —
|
||||
저장소는 `store.sync_combos` · `store.read_combos` · `store.find_combo` 이름으로 이 파일을 부름.
|
||||
"""
|
||||
|
||||
@@ -75,7 +75,6 @@ def _row(
|
||||
**tree.old_fields(path),
|
||||
"단위": col.get("단위"),
|
||||
"일위대가": col.get("일위대가"),
|
||||
"자재조합": col.get("자재조합"),
|
||||
**{field: numbers.get(field, "") for _, field, _, _ in store.PARTS},
|
||||
"바인딩": col.get("바인딩"),
|
||||
"펼침": col.get("펼침"),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
가리키게 함(PLAN 15장 · 검토 `14_조합편집검토.md` 1장).
|
||||
· 일위대가 조합(UA · UC · UP) — 새 줄은 지금 층 조합 저장소가 만듦(`Combo_Store` · PLAN 27-2c:
|
||||
마스터 층 = UA · 회사 층 = UC · 개인 층 = UP · 고르기는 세 층 중 이 층이 쓸 수 있는 것)
|
||||
· 자재 조합(UM · 마스터 템플릿 `자재조합.json`) — M02 자재 조합 저장소가 만듦(표 「다음」 번호)
|
||||
|
||||
조합은 여러 열 · 모든 프로젝트가 같이 씀 — 제자리 고치지 않음. 담은 것(차례 · 메모까지)이
|
||||
이미 있는 조합과 같으면 새로 안 만들고 그 키(원래 키 먼저) · 새 줄의 본뜬키 = 원래 키 · 소유 현장.
|
||||
@@ -13,9 +12,7 @@ from __future__ import annotations
|
||||
from typing import Any, Callable
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
|
||||
_OWNER = "현장"
|
||||
|
||||
@@ -41,7 +38,7 @@ def _branch(
|
||||
name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""집계표 판 검사 → 같은 내용 찾기 · 없으면 `make(이름, 줄들)` 로 새 줄 → 집계표 그 열 `field`.
|
||||
`slot` = 담은 칸(담은로직 · 담은자재) · `ident` = 담은 줄에서 견주는 칸(로직 · 메모 / 코드)."""
|
||||
`slot` = 담은 칸(담은로직) · `ident` = 담은 줄에서 견주는 칸(로직 · 메모)."""
|
||||
table = _table(column, version)
|
||||
if not isinstance(items, list) or not items:
|
||||
raise store.StoreError(400, "담은 것이 비었음")
|
||||
@@ -84,11 +81,9 @@ def _point(table: dict, column: str, field: str, key: str) -> str:
|
||||
|
||||
|
||||
def use_combo(column: str, field: str, key: str, version: str = "") -> dict[str, Any]:
|
||||
"""있는 조합을 이 열에 씀 — `field` 일위대가(UA) · 자재조합(UM) · 조합은 그대로(고르기만)."""
|
||||
"""있는 조합을 이 열에 씀 — `field` 일위대가(UA) · 조합은 그대로(고르기만)."""
|
||||
if field == "일위대가":
|
||||
name = _combo(key).get("이름")
|
||||
elif field == "자재조합":
|
||||
name = material_combo(key).get("이름")
|
||||
else:
|
||||
raise store.StoreError(400, f"모르는 조합 종류 「{field}」")
|
||||
판 = _point(_table(column, version), column, field, key)
|
||||
@@ -133,31 +128,3 @@ def branch_combo(
|
||||
return _branch(
|
||||
column, version, "일위대가", rows, "담은로직", ("로직", "메모"), items, source, make, name
|
||||
)
|
||||
|
||||
|
||||
def material_combo(key: str) -> dict[str, Any]:
|
||||
return matset.one(key)
|
||||
|
||||
|
||||
def branch_material_combo(
|
||||
column: str, source: str, items: list[dict], name: str, version: str = ""
|
||||
) -> dict[str, Any]:
|
||||
"""자재 조합 갈라 내기 — `source` 가 빈 글이면 이 열의 첫 자재 조합(본뜬키 null)."""
|
||||
was = material_combo(source) if source else {}
|
||||
items = [
|
||||
{k: x.get(k) for k in ("코드", "이름", "규격", "단위")}
|
||||
for x in items or []
|
||||
if isinstance(x, dict)
|
||||
]
|
||||
|
||||
def make(made_name: str, _rows) -> str:
|
||||
row = {"이름": made_name, "담은자재": items, "본뜬키": source or None, "소유": _OWNER}
|
||||
return matset.create(row)["키"]
|
||||
|
||||
heads = next((c for c in store._structure_columns() if c["id"] == column), {})
|
||||
default = tree.name_of(tree.path_of(heads)) if heads else column # 경로 이음(26-1)
|
||||
name = name.strip() or str(was.get("이름") or default or column)
|
||||
rows = matset.listing()["줄"]
|
||||
return _branch(
|
||||
column, version, "자재조합", rows, "담은자재", ("코드",), items, source, make, name
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_CardShell.ts
|
||||
* 항목 카드 공용 틀(PLAN 31-7) — 도면 정보 · 면적산출 · 재료산출 · 선택 입력(관리 화면 · 고르기 모달)이
|
||||
* 항목 카드 공용 틀(PLAN 31-7) — 도면 정보 · 면적산출 · 넣을 값(관리 화면 · 고르기 모달)이
|
||||
* 내용만 넣음. 모양 · 높이 · 스크롤은 `M02_MasterTemplete_Structure.css`.
|
||||
* 머리 = 제목 줄(h3 · 제목 · 저장 안 한 점 ● · 오른쪽 단추 자리) + 한 줄 부제(번호 · 이름 · 넘치면 말줄임)
|
||||
* 본문 = 카드 높이를 채움 · 넘치면 카드 안 스크롤 · 상태 = 불러오는 중 · 실패 + [다시 불러오기] · 비었음
|
||||
|
||||
@@ -73,45 +73,3 @@
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 재료산출 — 오른쪽 조합 패널 「이 열의 조합」 칸: 담은 자재 표 */
|
||||
.m02-mat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 패널 폭이 좁아도 줄이 넘치지 않게 — 이름 · 규격 칸이 줄어듦 */
|
||||
.m02-mat .m02-mat__row {
|
||||
grid-template-columns: 5.5rem minmax(0, 1.4fr) minmax(0, 1fr) 2.5rem 3rem auto;
|
||||
}
|
||||
|
||||
.m02-mat__title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-12);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.m02-mat__row {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem minmax(6rem, 1.4fr) minmax(4rem, 1fr) 3rem 4rem auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-mat__row--head {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m02-mat__foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* 면적산출 편집 화면 — 오른쪽 조합 패널 「이 열의 조합」 칸의 조합 줄 + [조합 고치기](시스템 관리자만).
|
||||
* 일위대가 조합(UA)은 여러 열 · 모든 프로젝트가 같이 씀 → 제자리 고치지 않고 고친 내용으로 새 조합을 만들어
|
||||
* 이 열만 가리킴(서버 `branchCombo`) · 원래 조합은 그대로. 담은 로직 표는 M01 조합 화면과 같은 줄 모양.
|
||||
* 갈라 내기 모달(`askBranch`)은 재료산출 자재 목록(`M02_MasterTemplete_Materials.ts`)도 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_ComboPanel.ts
|
||||
* 면적산출 · 재료산출 편집 화면 오른쪽 안쪽 패널 — 좌측 패널과 같은 공용 틀(사이드바 카드 · 접기) ·
|
||||
* 면적산출 편집 화면 오른쪽 안쪽 패널 — 좌측 패널과 같은 공용 틀(사이드바 카드 · 접기) ·
|
||||
* 공용 폭 손잡이(`ui_template_resizer` · 폭 기억 · 최소 폭) · 공용 접기 손잡이(`ui_template_overlay`).
|
||||
* · 「이 열의 조합」 — 면적 = 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) ·
|
||||
* 재료 = 담은 자재 표(`M02_MasterTemplete_Materials.ts`) — 넣는 쪽이 `setMine` 으로 갈아 끼움
|
||||
* · 조합 목록 — 면적 = 일위대가 조합(이 층이 쓸 수 있는 마스터 UA · 회사 UC · 개인 UP · 27-2c) ·
|
||||
* 재료 = 자재 조합(UM) · 찾기 · 이 열 조합 강조 ·
|
||||
* · 「이 열의 조합」 — 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) ·
|
||||
* 넣는 쪽이 `setMine` 으로 갈아 끼움
|
||||
* · 조합 목록 — 일위대가 조합(이 층이 쓸 수 있는 마스터 UA · 회사 UC · 개인 UP · 27-2c) ·
|
||||
* 찾기 · 이 열 조합 강조 ·
|
||||
* 하나 골라 [이 열에 쓰기] = 집계표 그 열 키만 바꿈(서버 `use-combo` · 조합은 그대로)
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -21,7 +21,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { getActiveLayer, listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { getActiveLayer, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api";
|
||||
import "./M02_MasterTemplete_ComboPanel.css";
|
||||
|
||||
@@ -30,10 +30,8 @@ const TEXT = {
|
||||
mine: "이 열의 조합",
|
||||
noneArea: "이 열에 일위대가 조합 없음",
|
||||
listArea: "일위대가 조합 목록",
|
||||
listMaterial: "자재 조합 목록",
|
||||
search: "키 · 이름으로 찾기",
|
||||
logics: "로직 {n}",
|
||||
items: "자재 {n}",
|
||||
origin: "본뜬 {value}",
|
||||
current: "이 열",
|
||||
empty: "맞는 조합 없음",
|
||||
@@ -61,7 +59,7 @@ interface Row {
|
||||
}
|
||||
|
||||
export interface ComboPanelOptions {
|
||||
kind: "area" | "material";
|
||||
kind: "area";
|
||||
/** 집계표 열 id */
|
||||
column: string;
|
||||
isAdmin: boolean;
|
||||
@@ -71,7 +69,7 @@ export interface ComboPanelOptions {
|
||||
|
||||
export interface ComboPanel {
|
||||
root: HTMLElement;
|
||||
/** 「이 열의 조합」 부분을 갈아 끼움(면적 = 조합 줄 · 재료 = 담은 자재 표) */
|
||||
/** 「이 열의 조합」 부분을 갈아 끼움(조합 줄) */
|
||||
setMine: (node: HTMLElement | null) => void;
|
||||
/** 목록 · 이 열 조합 키를 다시 받음(갈라 낸 뒤 등) */
|
||||
refresh: () => Promise<void>;
|
||||
@@ -79,7 +77,6 @@ export interface ComboPanel {
|
||||
}
|
||||
|
||||
export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
|
||||
const area = opts.kind === "area";
|
||||
let rows: Row[] = [];
|
||||
let current: string | null = null;
|
||||
let picked: string | null = null;
|
||||
@@ -97,10 +94,7 @@ export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
|
||||
const use = createButton({ label: TEXT.use, variant: "filled", size: "sm" });
|
||||
const body = el("div", {
|
||||
className: "m02-cpanel__body ui-sidebar-panel",
|
||||
children: [
|
||||
card(TEXT.mine, mine),
|
||||
card(area ? TEXT.listArea : TEXT.listMaterial, find.root, list),
|
||||
],
|
||||
children: [card(TEXT.mine, mine), card(TEXT.listArea, find.root, list)],
|
||||
});
|
||||
attachCollapsible(body);
|
||||
|
||||
@@ -138,7 +132,7 @@ export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
|
||||
...(shown.length
|
||||
? shown.map((r) => {
|
||||
const meta = [
|
||||
fill(area ? TEXT.logics : TEXT.items, r.수),
|
||||
fill(TEXT.logics, r.수),
|
||||
r.본뜬키 ? fill(TEXT.origin, r.본뜬키) : "",
|
||||
r.키 === current ? TEXT.current : "",
|
||||
];
|
||||
@@ -177,26 +171,17 @@ export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
|
||||
try {
|
||||
const [base, got] = await Promise.all([
|
||||
readColumnBase(opts.column),
|
||||
area
|
||||
? fetchUsableCombos(getActiveLayer()).then((cs) =>
|
||||
cs.map((c) => ({
|
||||
키: c.키,
|
||||
이름: c.이름,
|
||||
수: c.count, // 목록 요약엔 담은로직이 없음 — 수만 옴
|
||||
본뜬키: c.본뜬키 ?? null,
|
||||
})),
|
||||
)
|
||||
: listMaterialCombos().then((d) =>
|
||||
d.줄.map((m) => ({
|
||||
키: m.키,
|
||||
이름: m.이름,
|
||||
수: m.담은자재.length,
|
||||
본뜬키: m.본뜬키,
|
||||
})),
|
||||
),
|
||||
fetchUsableCombos(getActiveLayer()).then((cs) =>
|
||||
cs.map((c) => ({
|
||||
키: c.키,
|
||||
이름: c.이름,
|
||||
수: c.count, // 목록 요약엔 담은로직이 없음 — 수만 옴
|
||||
본뜬키: c.본뜬키 ?? null,
|
||||
})),
|
||||
),
|
||||
]);
|
||||
if (mineSeq !== seq) return;
|
||||
current = (area ? base.일위대가 : base.자재조합) ?? null;
|
||||
current = base.일위대가 ?? null;
|
||||
rows = got;
|
||||
} catch (error) {
|
||||
if (mineSeq !== seq) return;
|
||||
@@ -213,7 +198,7 @@ export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
|
||||
use.disabled = true;
|
||||
try {
|
||||
const got = await setColumnCombo(opts.column, {
|
||||
종류: area ? "일위대가" : "자재조합",
|
||||
종류: "일위대가",
|
||||
키: row.키,
|
||||
});
|
||||
showToast(fill(TEXT.used, `${got.키} ${got.이름}`), "success");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Combo_Api.ts
|
||||
* 조합 고치기 · 자재 목록 화면이 함께 쓰는 읽기 — 집계표 열이 가리키는 조합 키 · 같이 쓰는 다른 열 수 ·
|
||||
* 서버 까닭 글. 갈라 내기 길(`branchCombo` · `branchMaterialCombo` · `fetchMaterialCombo`)은 `M02_MasterTemplete_Api_Fetch.ts`.
|
||||
* 조합 고치기 화면이 쓰는 읽기 — 집계표 열이 가리키는 조합 키 · 같이 쓰는 다른 열 수 ·
|
||||
* 서버 까닭 글. 갈라 내기 길(`branchCombo`)은 `M02_MasterTemplete_Api_Fetch.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { getActiveLayer, readTemplate } from "./M02_MasterTemplete_Api_Fetch";
|
||||
@@ -12,17 +12,15 @@ const STRUCTURE_TABLE = "구조물집계표";
|
||||
/** 집계표 열이 가리키는 조합 키 + 같은 조합을 쓰는 다른 열 수 */
|
||||
export interface ColumnBase {
|
||||
일위대가: string | null;
|
||||
자재조합: string | null;
|
||||
다른열일위대가: number;
|
||||
다른열자재조합: number;
|
||||
}
|
||||
|
||||
/** 서버가 준 까닭 글 */
|
||||
export const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
|
||||
/** 집계표(시스템 층)에서 이 열의 일위대가 · 자재조합 키와 같이 쓰는 다른 열 수 */
|
||||
/** 집계표(시스템 층)에서 이 열의 일위대가 키와 같이 쓰는 다른 열 수 */
|
||||
export async function readColumnBase(column: string): Promise<ColumnBase> {
|
||||
type Col = { id: string; 일위대가?: string | null; 자재조합?: string | null };
|
||||
type Col = { id: string; 일위대가?: string | null };
|
||||
const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch(
|
||||
() => null,
|
||||
);
|
||||
@@ -32,8 +30,6 @@ export async function readColumnBase(column: string): Promise<ColumnBase> {
|
||||
key ? cols.filter((c) => c.id !== column && pick(c) === key).length : 0;
|
||||
return {
|
||||
일위대가: mine?.일위대가 || null,
|
||||
자재조합: mine?.자재조합 || null,
|
||||
다른열일위대가: same(mine?.일위대가, (c) => c.일위대가),
|
||||
다른열자재조합: same(mine?.자재조합, (c) => c.자재조합),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/* 「선택 입력」 카드 — 로직마다 묶은 입력 줄 + 줄마다 자리 고르기. 색은 테마 토큰. */
|
||||
|
||||
.m02-inputs__summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-12);
|
||||
align-items: baseline;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-inputs__count {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 카드 본문이 남는 높이를 채우고 넘치면 안에서 스크롤 */
|
||||
.m02-inputs__body {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-height: var(--m02-struct-body-min);
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m02-inputs__logic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-inputs__logic-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
align-items: baseline;
|
||||
margin: 0;
|
||||
padding-bottom: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-inputs__logic-title small,
|
||||
.m02-inputs__row small {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.m02-inputs__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.m02-inputs__info {
|
||||
display: flex;
|
||||
flex: 1 1 12rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px var(--spacing-8);
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-inputs__name {
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-inputs__desc,
|
||||
.m02-inputs__hint {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
/* 자동값 — 흐리게(입력 값이 아니라 보기용 첫 선택지) */
|
||||
.m02-inputs__auto {
|
||||
flex-basis: 100%;
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.m02-inputs__seat {
|
||||
flex: none;
|
||||
max-width: 100%;
|
||||
padding: 4px var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-inputs__seat:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
"""M02 항목 선택 입력(PLAN 25-1b · 25-2a) — 입력 목록 · 항목 연결 파일 · 알약.
|
||||
|
||||
항목 = 구조물집계표조합(SS) 한 줄 → 그 열의 일위대가(UA) 조합 → 담은 단가산출 로직마다 입력 전부.
|
||||
로직마다 갈라 보이고 이름이 같아도 합치지 않음(로직마다 값이 다를 수 있음) · 입력 목록은 그 로직
|
||||
줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만 · M01 조합 미리 보기와
|
||||
같음) · 자동값 = `master_auto.values`(보기용 · 저장 안 함).
|
||||
|
||||
항목 연결 파일 = 층 폴더 `inputs/<열id>.json` = `{판, 줄: [{로직, 입력, 자리, 알약?}]}` —
|
||||
판은 저장마다 1 씩 늘고, 연결만 적음(값은 프로젝트 몫). 자리 면적 · 재료 = 그 시트의 알약
|
||||
(키 `입력|{로직}|{입력}`) · 선택 = 「선택 입력」 카드 · null = 안 씀.
|
||||
M01 은 읽기만(`M01_MasterData_Store` · `_Store_Combo` · `master_auto`) · 권한은 부르는 쪽 몫.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_Labor_Inputs import _num, combo_logics
|
||||
|
||||
PLACES = ("면적", "재료", "선택", None)
|
||||
PILL_HEAD = "입력"
|
||||
|
||||
|
||||
def pill_key(logic: str, name: str) -> str:
|
||||
return f"{PILL_HEAD}|{logic}|{name}"
|
||||
|
||||
|
||||
def _item(key: str) -> dict[str, Any]:
|
||||
"""지금 층(`store.at`)의 항목 줄 — 없으면 404."""
|
||||
row = store.find_combo(key) # 옛 키(회사 · 개인 층 SS)로도
|
||||
if row is None:
|
||||
raise store.StoreError(404, f"없는 항목 「{key}」")
|
||||
return row
|
||||
|
||||
|
||||
def _combo_row(ua: str) -> dict[str, Any] | None:
|
||||
return combos.find(ua) # 마스터 UA · 회사 UC · 개인 UP(27-2c)
|
||||
|
||||
|
||||
def inputs(key: str) -> dict[str, Any]:
|
||||
"""25-1b 입력 목록 — `{일위대가, 이름, 로직: [{로직, 이름, 결과단위, 입력: [...]}], 빠진로직}`.
|
||||
일위대가 없는 항목은 로직 빈 목록 · 몸은 `M02_Labor_Inputs`(일위대가 입력 모달과 한 벌)."""
|
||||
return labor_inputs.inputs_of(_item(key).get("일위대가"))
|
||||
|
||||
|
||||
# ── 항목 연결 파일 ─────────────────────────────────────
|
||||
|
||||
|
||||
def read_file(column: str) -> dict[str, Any] | None:
|
||||
"""지금 층의 `inputs/<열id>.json` 그대로 — 없으면 None(열 지우기 · 복사 · 스냅샷이 씀)."""
|
||||
path = store.inputs_path(column)
|
||||
return json.loads(path.read_bytes()) if path.is_file() else None
|
||||
|
||||
|
||||
def write_file(column: str, doc: dict[str, Any]) -> None:
|
||||
"""지금 층에 연결 파일을 그대로 씀(복사용 — 검사 · 판 비교 없음)."""
|
||||
path = store.inputs_path(column)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path, doc)
|
||||
|
||||
|
||||
def _known(key: str) -> dict[str, set[str]]:
|
||||
"""{로직 키: {입력 이름}} — 그 항목 조합이 지금 담은 것."""
|
||||
_, logics, _ = combo_logics(_item(key).get("일위대가"))
|
||||
return {str(lg.get("키")): {str(s.get("이름")) for s in lg.get("입력") or []} for lg in logics}
|
||||
|
||||
|
||||
def read_links(key: str) -> dict[str, Any]:
|
||||
"""항목 연결 — 없으면 `{판: null, 줄: []}` · 조합이 바뀌어 사라진 로직 · 입력 줄은
|
||||
`사라짐: true`."""
|
||||
doc = read_file(_item(key)["열"]) or {"판": None, "줄": []}
|
||||
known = _known(key)
|
||||
rows = []
|
||||
for row in doc.get("줄") or []:
|
||||
gone = str(row.get("입력")) not in known.get(str(row.get("로직")), set())
|
||||
rows.append({**row, "사라짐": True} if gone else row)
|
||||
return {"판": doc.get("판"), "줄": rows}
|
||||
|
||||
|
||||
def _clean_rows(key: str, rows: Any) -> list[dict[str, Any]]:
|
||||
"""없는 로직 · 입력 · 모르는 자리 · 같은 입력 두 번은 422 · 알약 키는 서버가 붙임."""
|
||||
if not isinstance(rows, list):
|
||||
raise store.StoreError(422, "「줄」 이 목록 아님")
|
||||
known = _known(key)
|
||||
out, seen, errors = [], set(), []
|
||||
for at, row in enumerate(rows, start=1):
|
||||
if not isinstance(row, dict):
|
||||
errors.append(f"줄{at} · 묶음 아님")
|
||||
continue
|
||||
logic, name, place = str(row.get("로직") or ""), str(row.get("입력") or ""), row.get("자리")
|
||||
if logic not in known:
|
||||
errors.append(f"줄{at} · 이 항목 일위대가에 없는 로직 「{logic}」")
|
||||
elif name not in known[logic]:
|
||||
errors.append(f"줄{at} · 로직 {logic} 에 없는 입력 「{name}」")
|
||||
elif place not in PLACES:
|
||||
errors.append(f"줄{at} · 모르는 자리 「{place}」 — 면적 · 재료 · 선택 · null")
|
||||
elif (logic, name) in seen:
|
||||
errors.append(f"줄{at} · 같은 입력 두 번 「{logic}|{name}」")
|
||||
else:
|
||||
seen.add((logic, name))
|
||||
one: dict[str, Any] = {"로직": logic, "입력": name, "자리": place}
|
||||
if place in ("면적", "재료"):
|
||||
one["알약"] = pill_key(logic, name)
|
||||
out.append(one)
|
||||
if errors:
|
||||
raise store.StoreError(422, {"errors": errors})
|
||||
return out
|
||||
|
||||
|
||||
def write_links(key: str, version: Any, rows: Any) -> dict[str, Any]:
|
||||
"""항목 연결 저장 — 받은 `판` 이 지금 판(없으면 null)과 다르면 409 · 저장마다 판 + 1."""
|
||||
column = _item(key)["열"]
|
||||
clean = _clean_rows(key, rows)
|
||||
with store._LOCK:
|
||||
have = (read_file(column) or {}).get("판")
|
||||
if version != have:
|
||||
raise store.StoreError(409, {"stale": [f"inputs/{column}"], "판": have})
|
||||
doc = {"판": int(have or 0) + 1, "줄": clean}
|
||||
write_file(column, doc)
|
||||
return doc
|
||||
|
||||
|
||||
# ── 알약 — 면적산출 · 재료산출 시트 ───────────────────────
|
||||
|
||||
|
||||
def pills(key: str, place: str) -> dict[str, Any]:
|
||||
"""그 시트 알약 — 면적이면 조합 물량 알약(M01 `combo_pills` · 권한 무관 읽기) + 자리를
|
||||
그 시트로 이은 입력 알약 · 재료면 이은 입력 알약만. 입력 알약 = `{키, 이름, 단위, 로직, 입력}` —
|
||||
이름은 로직마다 갈라 「입력 · 로직 이름」(같은 이름 입력이 여러 로직에 있어도 안 합침)."""
|
||||
if place not in ("면적", "재료"):
|
||||
raise store.StoreError(422, f"모르는 자리 「{place}」 — 면적 · 재료")
|
||||
ua = _item(key).get("일위대가")
|
||||
quantity: list[dict[str, Any]] = []
|
||||
if place == "면적" and ua and (combo := _combo_row(str(ua))):
|
||||
quantity = combo_store.combo_pills(combo)["물량"]
|
||||
_, logics, _ = combo_logics(ua)
|
||||
by_key = {str(lg.get("키")): lg for lg in logics}
|
||||
linked = []
|
||||
for row in read_links(key)["줄"]:
|
||||
if row.get("자리") != place or row.get("사라짐"):
|
||||
continue
|
||||
logic = by_key[str(row["로직"])]
|
||||
spec = next(s for s in logic.get("입력") or [] if s.get("이름") == row["입력"])
|
||||
short = combo_store._TAIL.sub("", str(logic.get("이름") or "")) or row["로직"]
|
||||
linked.append(
|
||||
{
|
||||
"키": pill_key(row["로직"], row["입력"]),
|
||||
"이름": f"{row['입력']} · {short}",
|
||||
"단위": spec.get("단위"),
|
||||
"로직": row["로직"],
|
||||
"입력": row["입력"],
|
||||
}
|
||||
)
|
||||
return _num({"물량": quantity, "입력": linked})
|
||||
|
||||
|
||||
def copy_between(source: Path, target: Path, source_column: str, target_column: str) -> bool:
|
||||
"""다른 층(또는 같은 층) 열의 연결 파일을 새 열로 — 판은 1 부터 · 없으면 아무것도 안 함."""
|
||||
with store.at(source):
|
||||
doc = read_file(source_column)
|
||||
if doc is None:
|
||||
return False
|
||||
with store.at(target):
|
||||
write_file(target_column, {"판": 1, "줄": doc.get("줄") or []})
|
||||
return True
|
||||
@@ -1,290 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Inputs.ts
|
||||
* 구조물집계표 아래 넷째 카드 「선택 입력」(PLAN 25-2c) — 고른 열(항목) 일위대가의 로직마다 묶어
|
||||
* 입력 목록을 보이고, 입력마다 자리(면적산출 알약 · 재료산출 알약 · 선택 · 안 씀)를 사용자가 고름.
|
||||
*
|
||||
* · 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음 · 이름이 같은 입력도 로직마다 따로.
|
||||
* · 마스터 층에서는 값을 받지 않음 — 자리 연결만(값은 프로젝트 복사본 몫 · 25-2b).
|
||||
* · 저장은 집계표 [저장]과 같은 단추 흐름 — 여기는 고른 것을 초안으로 들고 있다가 `save()` 가 서버로 보냄
|
||||
* (자동저장 없음). 고름이 바뀌면 `onDirty` 로 메인에 알림(떠날 때 물음).
|
||||
* · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import {
|
||||
fetchItemInputs,
|
||||
fetchItemRows,
|
||||
getActiveLayer,
|
||||
readInputLinks,
|
||||
saveInputLinks,
|
||||
type InputSeat,
|
||||
type ItemInputs,
|
||||
type ItemRow,
|
||||
type Layer,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
autoText,
|
||||
countOf,
|
||||
inputKey,
|
||||
linesOf,
|
||||
pillsOf,
|
||||
seatsOf,
|
||||
SEAT_OPTIONS,
|
||||
valueHint,
|
||||
type SeatMap,
|
||||
} from "./M02_MasterTemplete_Inputs_Model";
|
||||
import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell";
|
||||
import "./M02_MasterTemplete_Inputs.css";
|
||||
|
||||
const TEXT = {
|
||||
title: "선택 입력",
|
||||
count: "로직 {logics} · 입력 {inputs}",
|
||||
chosen: "면적산출 {area} · 재료산출 {material} · 선택 {pick}",
|
||||
auto: "자동값",
|
||||
noItem: "구조물 조합을 먼저 저장하면 입력 목록이 나옴",
|
||||
noCombo: "일위대가를 먼저 고름 — 구조물 조합 일위대가 줄에서 고름",
|
||||
unsavedCombo: "고른 일위대가는 구조물 조합을 저장하면 입력 목록이 나옴",
|
||||
noLogic: "이 일위대가에 담긴 로직이 없음",
|
||||
missing: "빠진 로직 {value}",
|
||||
failed: "입력 목록을 못 받음 — {value}",
|
||||
seat: "자리",
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const fill = (text: string, values: Record<string, string | number>): string =>
|
||||
text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? ""));
|
||||
|
||||
/** 열 하나의 초안 — 서버 목록 · 판 · 고른 자리 · 바뀜 */
|
||||
interface Draft {
|
||||
key: string;
|
||||
판: string | null;
|
||||
list: ItemInputs;
|
||||
seats: SeatMap;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export interface InputsCardOptions {
|
||||
/** 이 층에서 고칠 수 있나 — 아니면 자리 고르기가 잠김 */
|
||||
isAdmin: boolean;
|
||||
/** 고름이 바뀜 — 메인의 「저장 안 한 고침」 표시 */
|
||||
onDirty: () => void;
|
||||
}
|
||||
|
||||
export interface InputsCardHandle {
|
||||
root: HTMLElement;
|
||||
/** 고른 열 — `combo` = 집계표에 지금 고른 일위대가(저장 전일 수 있음) · null 이면 카드 비움 */
|
||||
show: (colId: string | null, combo: string | null) => Promise<void>;
|
||||
/** 고른 게 저장 안 됐나 */
|
||||
dirty: () => boolean;
|
||||
/** 바뀐 열마다 서버에 저장 — 실패하면 던짐(판이 다르면 `StaleError`) */
|
||||
save: () => Promise<void>;
|
||||
/** 항목 목록(열 ↔ 키)을 다시 받게 함 — 집계표 [저장] 뒤 */
|
||||
invalidate: () => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createInputsCard(opts: InputsCardOptions): InputsCardHandle {
|
||||
const drafts = new Map<string, Draft>();
|
||||
let rows: Promise<ItemRow[]> | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const summary = el("div", { className: "m02-inputs__summary" });
|
||||
const body = el("div", { className: "m02-inputs__body" });
|
||||
// 공용 카드 틀(31-7) — 부제 = 요약 · [저장]은 없음(집계표 [저장]과 함께) · 점(●) = 고른 것 저장 전
|
||||
const shell = cardShell({ title: TEXT.title, className: "m02-inputs", sub: [summary], body });
|
||||
const root = shell.root;
|
||||
const clear = (): void => {
|
||||
summary.replaceChildren();
|
||||
body.replaceChildren();
|
||||
};
|
||||
|
||||
const itemRows = (): Promise<ItemRow[]> =>
|
||||
(rows ??= fetchItemRows(getActiveLayer()).catch((error: unknown) => {
|
||||
rows = null;
|
||||
throw error;
|
||||
}));
|
||||
|
||||
function paintSummary(draft: Draft): void {
|
||||
const count = countOf(draft.list);
|
||||
// 목록에 있는 입력의 자리만 셈 — 서버 목록에서 빠진 입력의 옛 고름은 안 셈(저장 때도 빠짐)
|
||||
const listed = new Set(
|
||||
draft.list.로직.flatMap((l) => l.입력.map((i) => inputKey(l.로직, i.이름))),
|
||||
);
|
||||
const seats = [...draft.seats].filter(([key]) => listed.has(key)).map(([, seat]) => seat);
|
||||
const by = (seat: InputSeat): number => seats.filter((s) => s === seat).length;
|
||||
summary.replaceChildren(
|
||||
el("span", { className: "m02-inputs__count", text: fill(TEXT.count, count) }),
|
||||
el("span", {
|
||||
className: "m02-inputs__chosen",
|
||||
text: fill(TEXT.chosen, { area: by("면적"), material: by("재료"), pick: by("선택") }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function paint(draft: Draft): void {
|
||||
paintSummary(draft);
|
||||
const groups = draft.list.로직.map((logic) =>
|
||||
el("div", {
|
||||
className: "m02-inputs__logic",
|
||||
attrs: { "data-logic": logic.로직 },
|
||||
children: [
|
||||
el("h4", {
|
||||
className: "m02-inputs__logic-title",
|
||||
children: [
|
||||
el("span", { text: logic.이름 }),
|
||||
...(logic.결과단위 ? [el("small", { text: logic.결과단위 })] : []),
|
||||
el("small", { className: "m02-inputs__logic-key", text: logic.로직 }),
|
||||
],
|
||||
}),
|
||||
...logic.입력.map((input) => {
|
||||
const key = inputKey(logic.로직, input.이름);
|
||||
const select = el("select", {
|
||||
className: "m02-inputs__seat",
|
||||
attrs: { "aria-label": `${logic.이름} ${input.이름} ${TEXT.seat}` },
|
||||
children: SEAT_OPTIONS.map((o) =>
|
||||
el("option", { text: o.label, attrs: { value: o.value } }),
|
||||
),
|
||||
}) as HTMLSelectElement;
|
||||
select.value = draft.seats.get(key) ?? "";
|
||||
select.disabled = !opts.isAdmin;
|
||||
select.addEventListener("change", () => {
|
||||
if (select.value) draft.seats.set(key, select.value as InputSeat);
|
||||
else draft.seats.delete(key);
|
||||
draft.dirty = true;
|
||||
paintSummary(draft);
|
||||
shell.setDirty(true);
|
||||
opts.onDirty();
|
||||
});
|
||||
const hint = valueHint(input);
|
||||
const auto = autoText(input);
|
||||
return el("div", {
|
||||
className: "m02-inputs__row",
|
||||
attrs: { "data-input": input.이름 },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m02-inputs__info",
|
||||
children: [
|
||||
el("span", { className: "m02-inputs__name", text: input.이름 }),
|
||||
...(input.단위 ? [el("small", { text: input.단위 })] : []),
|
||||
...(input.설명
|
||||
? [el("small", { className: "m02-inputs__desc", text: input.설명 })]
|
||||
: []),
|
||||
...(hint ? [el("small", { className: "m02-inputs__hint", text: hint })] : []),
|
||||
...(auto
|
||||
? [
|
||||
el("small", {
|
||||
className: "m02-inputs__auto",
|
||||
text: `${TEXT.auto} ${auto}`,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
select,
|
||||
],
|
||||
});
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const missing = draft.list.빠진로직;
|
||||
body.replaceChildren(
|
||||
...(groups.length ? groups : [cardNote(TEXT.noLogic)]),
|
||||
...(missing.length ? [cardNote(fill(TEXT.missing, { value: missing.join(", ") }))] : []),
|
||||
);
|
||||
}
|
||||
|
||||
async function show(colId: string | null, combo: string | null): Promise<void> {
|
||||
const mine = ++seq;
|
||||
clear();
|
||||
shell.setDirty(!!colId && !!drafts.get(colId)?.dirty);
|
||||
if (!colId) return;
|
||||
const kept = drafts.get(colId);
|
||||
if (kept) return paint(kept);
|
||||
const stop = shell.loading(); // 0.3초 넘으면 「불러오는 중」(31-7 · 네 카드 같게)
|
||||
try {
|
||||
const layer: Layer = getActiveLayer();
|
||||
const row = (await itemRows()).find((r) => r.열 === colId);
|
||||
if (mine !== seq) return;
|
||||
stop();
|
||||
if (!row) return shell.note(combo ? TEXT.unsavedCombo : TEXT.noItem);
|
||||
if (!row.일위대가) return shell.note(combo ? TEXT.unsavedCombo : TEXT.noCombo);
|
||||
const [list, links] = await Promise.all([
|
||||
fetchItemInputs(layer, row.key),
|
||||
readInputLinks(layer, row.key),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (!list.일위대가) return shell.note(TEXT.noCombo);
|
||||
const draft: Draft = {
|
||||
key: row.key,
|
||||
판: links.판,
|
||||
list,
|
||||
seats: seatsOf(links),
|
||||
dirty: false,
|
||||
};
|
||||
drafts.set(colId, draft);
|
||||
paint(draft);
|
||||
} catch (error) {
|
||||
// 실패 = 카드 안 까닭 + [다시 불러오기](31-7)
|
||||
if (mine === seq)
|
||||
shell.fail(fill(TEXT.failed, { value: why(error) }), () => void show(colId, combo));
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
show,
|
||||
dirty: () => [...drafts.values()].some((d) => d.dirty),
|
||||
save: async () => {
|
||||
const layer = getActiveLayer();
|
||||
for (const draft of drafts.values()) {
|
||||
if (!draft.dirty) continue;
|
||||
const got = await saveInputLinks(
|
||||
layer,
|
||||
draft.key,
|
||||
draft.판,
|
||||
linesOf(draft.list, draft.seats),
|
||||
);
|
||||
draft.판 = got.판;
|
||||
draft.dirty = false;
|
||||
}
|
||||
shell.setDirty(false);
|
||||
},
|
||||
invalidate: () => {
|
||||
rows = null;
|
||||
for (const [id, draft] of drafts) if (!draft.dirty) drafts.delete(id); // 서버 것으로 다시 읽음
|
||||
},
|
||||
destroy: () => {
|
||||
seq += 1;
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 그 열의 면적산출 · 재료산출 편집 화면 알약 — 자리를 그 시트로 고른 입력(프로젝트 층은 25-2b 몫이라 없음) */
|
||||
export async function inputPills(column: string, seat: "면적" | "재료"): Promise<LinkItem[]> {
|
||||
const layer = getActiveLayer();
|
||||
if (layer === "project") return [];
|
||||
try {
|
||||
const row = (await fetchItemRows(layer)).find((r) => r.열 === column);
|
||||
if (!row?.일위대가) return [];
|
||||
const [list, links] = await Promise.all([
|
||||
fetchItemInputs(layer, row.key),
|
||||
readInputLinks(layer, row.key),
|
||||
]);
|
||||
return pillsOf(list, links, seat);
|
||||
} catch {
|
||||
return []; // 서버 길이 아직 없거나 못 읽어도 시트 편집은 그대로
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 39-3 에 `M02_Labor_Values.ts` 로 옮김 · 옛 이름 그대로 내보냄 ── */
|
||||
export {
|
||||
createInputsValueCard,
|
||||
type InputsValueData,
|
||||
type InputsValueHandle,
|
||||
type InputsValueOptions,
|
||||
} from "./M02_Labor_Values";
|
||||
@@ -1,101 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Inputs_Model.ts
|
||||
* 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록.
|
||||
* 값 글 · 값 칸 규칙은 `M02_Labor_Values_Model.ts`(일위대가 입력 모달과 한 벌 · 여기서 다시 내보냄).
|
||||
* 약속 = PLAN 25-1b: 입력 키 `{로직키}|{입력이름}` · 알약 키 `입력|{로직키}|{입력이름}` ·
|
||||
* 같은 이름의 입력도 로직마다 따로. 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import type {
|
||||
InputLine,
|
||||
InputLinks,
|
||||
InputSeat,
|
||||
ItemInput,
|
||||
ItemInputs,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
/** 화면 글 ↔ 자리 — 「안 씀」 = 빈 글 */
|
||||
export const SEAT_OPTIONS: { value: "" | InputSeat; label: string }[] = [
|
||||
{ value: "", label: "안 씀" },
|
||||
{ value: "면적", label: "면적산출 알약" },
|
||||
{ value: "재료", label: "재료산출 알약" },
|
||||
{ value: "선택", label: "선택" },
|
||||
];
|
||||
|
||||
export const inputKey = (logic: string, name: string): string => `${logic}|${name}`;
|
||||
export const pillKey = (logic: string, name: string): string => `입력|${logic}|${name}`;
|
||||
|
||||
/** 자리 고름 표 — 키 = 입력 키 · 값 = 자리(안 쓰면 키 없음) */
|
||||
export type SeatMap = Map<string, InputSeat>;
|
||||
|
||||
export function seatsOf(links: InputLinks | null): SeatMap {
|
||||
const out: SeatMap = new Map();
|
||||
for (const line of links?.줄 ?? [])
|
||||
if (line.자리) out.set(inputKey(line.로직, line.입력), line.자리);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 저장할 줄 — 자리를 고른 것만(없는 것 = 안 씀) · 면적 · 재료는 알약 키를 같이 */
|
||||
export function linesOf(list: ItemInputs, seats: SeatMap): InputLine[] {
|
||||
const out: InputLine[] = [];
|
||||
for (const logic of list.로직)
|
||||
for (const input of logic.입력) {
|
||||
const seat = seats.get(inputKey(logic.로직, input.이름));
|
||||
if (!seat) continue;
|
||||
out.push({
|
||||
로직: logic.로직,
|
||||
입력: input.이름,
|
||||
자리: seat,
|
||||
...(seat === "선택" ? {} : { 알약: pillKey(logic.로직, input.이름) }),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const countOf = (list: ItemInputs): { logics: number; inputs: number } => ({
|
||||
logics: list.로직.length,
|
||||
inputs: list.로직.reduce((sum, logic) => sum + logic.입력.length, 0),
|
||||
});
|
||||
|
||||
/** 그 시트(면적산출 · 재료산출)의 알약 — 고른 자리가 그 시트인 입력만 · 「로직 이름 · 입력 이름」 */
|
||||
export function pillsOf(
|
||||
list: ItemInputs,
|
||||
links: InputLinks | null,
|
||||
seat: "면적" | "재료",
|
||||
): LinkItem[] {
|
||||
const seats = seatsOf(links);
|
||||
const out: LinkItem[] = [];
|
||||
for (const logic of list.로직)
|
||||
for (const input of logic.입력) {
|
||||
if (seats.get(inputKey(logic.로직, input.이름)) !== seat) continue;
|
||||
out.push({
|
||||
키: pillKey(logic.로직, input.이름),
|
||||
이름: `${logic.이름} · ${input.이름}`,
|
||||
...(input.단위 ? { 단위: input.단위 } : {}),
|
||||
고정: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 값 함수 — 일위대가 입력 모달과 한 벌(39-3 · `M02_Labor_Values_Model.ts` 로 옮김 · 옛 이름 그대로 내보냄) ── */
|
||||
export { autoText, choicesOf, rangeOf, valueHint, type ValueDoc } from "./M02_Labor_Values_Model";
|
||||
|
||||
/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 자리 「선택」 인 입력만 로직마다 묶어 값 · 확인 표시 ── */
|
||||
|
||||
/** 자리가 「선택」 인 입력만 로직 차례로 묶음 — 값 받기 카드가 보이는 입력 */
|
||||
export function pickedGroups(
|
||||
list: ItemInputs,
|
||||
links: InputLinks | null,
|
||||
): { logic: ItemInputs["로직"][number]; inputs: ItemInput[] }[] {
|
||||
const picked = new Set(
|
||||
[...seatsOf(links)].filter(([, seat]) => seat === "선택").map(([key]) => key),
|
||||
);
|
||||
return list.로직
|
||||
.map((logic) => ({
|
||||
logic,
|
||||
inputs: logic.입력.filter((input) => picked.has(inputKey(logic.로직, input.이름))),
|
||||
}))
|
||||
.filter((group) => group.inputs.length > 0);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_ItemCards.ts
|
||||
* 항목 카드 — 도면 정보 · 면적산출 · 재료산출(보기만 + [수정]) + 덧붙는 카드(선택 입력 …).
|
||||
* 항목 카드 — 도면 정보 · 면적산출(보기만 + [수정]) + 덧붙는 카드(넣을 값 …).
|
||||
* M02 구조물 설계 컨테이너(`M02_MasterTemplete_Structure.ts`)에서 떼어 냄(계획서 25-2d) — 항목 고르기
|
||||
* 모달(`ui_template_item_picker_detail.ts`)이 **같은 카드**를 쓰려고. 문서를 어디서 읽는지는
|
||||
* 자료 자리(`ItemSource`)가 정함 · 카드는 몰라도 됨.
|
||||
* 카드 겉 틀 · 제목 줄 · 부제 · [저장] · ? · 점(●) · 상태 글은 공용 틀(`M02_MasterTemplete_CardShell` · 31-7).
|
||||
* 면적산출 · 재료산출 = 스프레드시트 · 부품은 카드를 열 때만 `import()`(다른 페이지 번들에 안 섞임).
|
||||
* 덧붙는 카드는 `extraCards` 로 끼움 — 선택 입력 카드(`M02_MasterTemplete_Inputs.ts`)가 그 자리.
|
||||
* `sheetEdit` 가 있으면 면적산출 · 재료산출 카드를 미리보기에서 고침(31-3) — 스프레드시트 `preview`(메뉴 없이
|
||||
* 면적산출 = 스프레드시트 · 부품은 카드를 열 때만 `import()`(다른 페이지 번들에 안 섞임).
|
||||
* 덧붙는 카드는 `extraCards` 로 끼움 — 고르기 모달 넣을 값 카드(`M02_Labor_Values.ts`)가 그 자리.
|
||||
* `sheetEdit` 가 있으면 면적산출 카드를 미리보기에서 고침(31-3) — 스프레드시트 `preview`(메뉴 없이
|
||||
* 수식 입력 줄 · 칸 고침 · 단축키) + 아래 알약 줄 + 카드 [저장] · 없으면 보기만(고르기 모달).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -43,12 +43,10 @@ export type SheetKind = Exclude<EditKind, "structure">;
|
||||
export const PART_FIELD: Record<EditKind, keyof StructureNumbers> = {
|
||||
structure: "도번",
|
||||
area: "면적번",
|
||||
material: "재료번",
|
||||
};
|
||||
export const PART_WORD: Record<EditKind, string> = {
|
||||
structure: "도면",
|
||||
area: "면적산출",
|
||||
material: "재료산출",
|
||||
};
|
||||
|
||||
const TEXT = {
|
||||
@@ -92,7 +90,7 @@ const emptyBook = (book: Workbook): boolean =>
|
||||
(s) => !s.연결?.length && Object.values(s.칸 ?? {}).every((c) => (c.값 ?? "") === "" && !c.식),
|
||||
);
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
/** 스프레드시트는 면적산출 · 재료산출 카드를 열 때만 받음 */
|
||||
/** 스프레드시트는 면적산출 카드를 열 때만 받음 */
|
||||
const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet");
|
||||
|
||||
export interface ItemCardsOptions {
|
||||
@@ -100,23 +98,23 @@ export interface ItemCardsOptions {
|
||||
source: () => ItemSource;
|
||||
/** [수정] 단추를 보일까 — 부를 때마다 다시 봄 */
|
||||
editable: () => boolean;
|
||||
/** [도면 수정] · [면적산출 수정] · [재료산출 수정] */
|
||||
/** [도면 수정] · [면적산출 수정] */
|
||||
onEdit: (kind: EditKind) => void;
|
||||
/** 문서에 번호가 없을 때 쓸 번호(집계표 번호 줄) */
|
||||
numberOf?: (kind: EditKind, column: string) => string | undefined;
|
||||
/** 못 읽었을 때 카드에 까닭을 보일까 — 기본 보임(M02 는 번호가 있는 열만 · 없는 열은 「없음」) */
|
||||
warnOnFail?: (column: string) => boolean;
|
||||
/** 세 카드 뒤에 덧붙는 카드(선택 입력 …) */
|
||||
/** 카드 뒤에 덧붙는 카드(넣을 값 …) */
|
||||
extraCards?: HTMLElement[];
|
||||
/** 카드 가운데 하나라도 저장 안 한 고침이 생김 · 없어짐(PLAN 31-4 · 31-7) */
|
||||
onDirty?: (dirty: boolean) => void;
|
||||
/** 면적산출 · 재료산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */
|
||||
/** 면적산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */
|
||||
sheetEdit?: SheetEditOptions;
|
||||
/** 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 다른 항목의 이 칸을 골라 덮어씀 · 없으면 단추 없음 */
|
||||
onImport?: (kind: EditKind) => void;
|
||||
}
|
||||
|
||||
/** 카드 스프레드시트를 다루는 손잡이 — 알약 · 자재 조합 판이 씀 */
|
||||
/** 카드 스프레드시트를 다루는 손잡이 — 알약이 씀 */
|
||||
export interface SheetCardControl {
|
||||
column: string;
|
||||
label: string;
|
||||
@@ -131,7 +129,7 @@ export interface SheetCardControl {
|
||||
export interface SheetEditOptions {
|
||||
/** 지금 고칠 수 있나 — 부를 때마다 다시 봄 */
|
||||
can: () => boolean;
|
||||
/** 아래 알약 줄(입력 연결) · 카드 아래 덧붙는 판(재료산출 = 자재 조합) — 전체 편집기와 같은 부품 */
|
||||
/** 아래 알약 줄(입력 연결) · 카드 아래 덧붙는 판 — 전체 편집기와 같은 부품 */
|
||||
links: (
|
||||
kind: SheetKind,
|
||||
control: SheetCardControl,
|
||||
@@ -150,7 +148,7 @@ export interface ItemCardsHandle {
|
||||
relabel: (labels: Partial<Record<EditKind, string | null>>) => void;
|
||||
/** 지금 보이는 열 id */
|
||||
column: () => string | null;
|
||||
/** 카드(도면 · 면적산출 · 재료산출)에 저장 안 한 고침이 있나 — 페이지가 떠날 때 물음 */
|
||||
/** 카드(도면 · 면적산출)에 저장 안 한 고침이 있나 — 페이지가 떠날 때 물음 */
|
||||
dirty: () => boolean;
|
||||
/** 고친 카드를 모두 저장(도면 · 엑셀) — 실패하면 던짐 */
|
||||
saveAll: () => Promise<void>;
|
||||
@@ -253,7 +251,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
preview ? preview.write() : Promise.resolve(),
|
||||
);
|
||||
|
||||
/** 면적산출 · 재료산출 카드 — 스프레드시트 미리보기 · 카드 고침(31-3) */
|
||||
/** 면적산출 카드 — 스프레드시트 미리보기 · 카드 고침(31-3) */
|
||||
interface SheetCard extends Card {
|
||||
kind: SheetKind;
|
||||
host: HTMLElement;
|
||||
@@ -275,7 +273,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
} as SheetCard;
|
||||
return card;
|
||||
};
|
||||
const sheets = [sheetCard("area"), sheetCard("material")];
|
||||
const sheets = [sheetCard("area")];
|
||||
// 면적산출 카드 [알약] — 아래 알약 줄 보임 · 숨김(기본 숨김 · 계정 취향 · 상세는 늘 보임)
|
||||
const pills = createButton({ label: TEXT.pills, variant: "ghost", size: "sm" });
|
||||
pills.title = TEXT.pillsTip;
|
||||
@@ -294,7 +292,6 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
const defaults: Record<EditKind, string> = {
|
||||
structure: TEXT.edit,
|
||||
area: fill(TEXT.editSheet, PART_WORD.area),
|
||||
material: fill(TEXT.editSheet, PART_WORD.material),
|
||||
};
|
||||
|
||||
const root = el("div", {
|
||||
@@ -335,7 +332,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
c.doc = next as Workbook;
|
||||
}
|
||||
|
||||
/** 미리보기 수정 — 메뉴 없이 수식 입력 줄 · 칸 고침 · 아래 알약 줄 · 덧붙는 판(재료산출 = 자재 조합) */
|
||||
/** 미리보기 수정 — 메뉴 없이 수식 입력 줄 · 칸 고침 · 아래 알약 줄 · 덧붙는 판 */
|
||||
const paintEditable = async (
|
||||
c: SheetCard,
|
||||
mine: number,
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
type Layer,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
/** 집계표 열 하나에 딸린 문서 셋 — 구조물 도면 · 면적산출 · 재료산출 */
|
||||
export type ItemPart = "structure" | "area" | "material";
|
||||
/** 집계표 열 하나에 딸린 문서 — 구조물 도면 · 면적산출 */
|
||||
export type ItemPart = "structure" | "area";
|
||||
|
||||
export interface ItemSource {
|
||||
/** CAD 자동백업 칸을 가르는 층 — 프로젝트 복사본이면 `project` */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""M02 층 집계표 항목 — 회사 · 개인 층 빈 뼈대 · 항목 목록 · 항목 복사(PLAN 21-1 · 21-2).
|
||||
|
||||
항목 = 구조물집계표조합(SS) 한 줄 — 그 열의 일위대가(UA) · 자재조합(UM) ·
|
||||
도번 SD · 면적번 SA · 재료번 SM.
|
||||
항목 = 구조물집계표조합(SS) 한 줄 — 그 열의 일위대가(UA) ·
|
||||
도번 SD · 면적번 SA.
|
||||
층 폴더(시스템 · 회사 · 개인)는 모양이 같아 저장소 함수를 `store.at(층 폴더)` 안에서 그대로 부름.
|
||||
권한은 부르는 쪽(`M02_MasterTemplete_Router_Items.py`) 몫 — 여기는 폴더와 파일만.
|
||||
"""
|
||||
@@ -15,8 +15,6 @@ from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
# 21-1 목록 칸 — SS 줄에서 그대로(키는 `key`)
|
||||
@@ -30,16 +28,14 @@ ITEM_FIELDS = (
|
||||
"분류3",
|
||||
"단위",
|
||||
"일위대가",
|
||||
"자재조합",
|
||||
"도번",
|
||||
"면적번",
|
||||
"재료번",
|
||||
)
|
||||
|
||||
|
||||
def ensure(folder: Path) -> bool:
|
||||
"""회사 · 개인 층 첫 진입 — 빈 집계표(열 0 · 층 이름 · 줄 머리는 시스템 것) ·
|
||||
조합 장부(`다음` 1) · 자재 조합 빈 표 · 종류 폴더.
|
||||
조합 장부(`다음` 1) · 종류 폴더.
|
||||
이미 집계표가 있으면 아무것도 안 함(시스템 층은 늘 있음)."""
|
||||
table = Path(folder) / "table" / f"{store.STRUCTURE_TABLE}.json"
|
||||
if table.is_file():
|
||||
@@ -60,31 +56,24 @@ def ensure(folder: Path) -> bool:
|
||||
return False
|
||||
atomic_write_json(table, doc)
|
||||
store.sync_combos()
|
||||
mat = Path(folder) / matset.FILE
|
||||
if not mat.is_file():
|
||||
atomic_write_json(mat, matset._load()[0])
|
||||
return True
|
||||
|
||||
|
||||
def items(folder: Path) -> list[dict[str, Any]]:
|
||||
"""그 층 항목 — SS 줄마다 `{key, 열, 경로, 이름, 종류, 분류1~3, 단위, 일위대가, 자재조합,
|
||||
도번, 면적번, 재료번, names}` · 경로 = 폴더 이름 배열 · 이름 = 경로 이음(26-1 · 종류 ·
|
||||
"""그 층 항목 — SS 줄마다 `{key, 열, 경로, 이름, 종류, 분류1~3, 단위, 일위대가,
|
||||
도번, 면적번, names}` · 경로 = 폴더 이름 배열 · 이름 = 경로 이음(26-1 · 종류 ·
|
||||
분류1~3 은 경로 앞 넷) · `names` = 화면 이름(키 대신 · 없으면 null) —
|
||||
일위대가 = 조합 이름 · 자재조합 = 그 층 자재 조합 이름 ·
|
||||
도번 · 면적번 · 재료번 = 항목(열) 이름."""
|
||||
일위대가 = 조합 이름 ·
|
||||
도번 · 면적번 = 항목(열) 이름."""
|
||||
with store.at(folder):
|
||||
ua = combos.names() # 마스터 · 이 층이 쓸 수 있는 회사 · 개인 조합(27-2c)
|
||||
rows = store.read_combos().get("줄", [])
|
||||
um = {str(r.get("키")): str(r.get("이름") or "") for r in matset.listing()["줄"]}
|
||||
out = []
|
||||
for r in rows:
|
||||
head = r.get("이름")
|
||||
names = {
|
||||
"일위대가": ua.get(str(r.get("일위대가"))) if r.get("일위대가") else None,
|
||||
"자재조합": um.get(str(r.get("자재조합"))) if r.get("자재조합") else None,
|
||||
**{
|
||||
k: (head or r.get("열")) if r.get(k) else None for k in ("도번", "면적번", "재료번")
|
||||
},
|
||||
**{k: (head or r.get("열")) if r.get(k) else None for k in ("도번", "면적번")},
|
||||
}
|
||||
out.append(
|
||||
{
|
||||
@@ -142,8 +131,7 @@ def copy_item(
|
||||
path: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 바인딩 그대로 ·
|
||||
도면 · 일위대가(새 SU · 조합 키는 안 받음 · 39-1b) · 면적산출 · 재료산출 내용(알약 연결 포함) ·
|
||||
항목 연결 · 자재 조합 줄(새 UM) 복사 ·
|
||||
도면 · 일위대가(새 SU · 조합 키는 안 받음 · 39-1b) · 면적산출 내용(알약 연결 포함) 복사 ·
|
||||
새 번호 · 새 SS 키 ·
|
||||
열 `본뜬` = `origin`(원래 층 · 주인 · 키) · `path` = 새 자리(폴더 + 이름 · 없으면 원래 경로 ·
|
||||
붙여넣기 PLAN 30-2). 돌려줌 `{key, 열, 이름}`."""
|
||||
@@ -159,24 +147,8 @@ def copy_item(
|
||||
docs[kind] = store.read(kind, col_id)["문서"]
|
||||
except store.StoreError:
|
||||
pass # 없는 파일은 새 층에서 빈 문서로 섬
|
||||
um = None
|
||||
if col.get("자재조합"):
|
||||
try:
|
||||
um = matset.one(col["자재조합"])
|
||||
except store.StoreError:
|
||||
um = None # 가리키던 줄이 없으면 비움
|
||||
ensure(target)
|
||||
with store.at(target):
|
||||
new_um = None
|
||||
if um:
|
||||
names = matset.listing()["줄"]
|
||||
taken = {str(r.get("이름") or "") for r in names}
|
||||
name, n = str(um.get("이름") or key), 2
|
||||
base = name
|
||||
while name in taken:
|
||||
name, n = f"{base} ({n})", n + 1
|
||||
got = {k: um.get(k) for k in ("담은자재", "소유", "비고")}
|
||||
new_um = matset.create({**got, "이름": name, "본뜬키": None})["키"]
|
||||
table = store.read("table", store.STRUCTURE_TABLE)
|
||||
doc = table["문서"]
|
||||
new_id = _fresh_id(col_id, {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)})
|
||||
@@ -184,9 +156,9 @@ def copy_item(
|
||||
**json.loads(json.dumps(col)),
|
||||
"id": new_id,
|
||||
"일위대가": None, # 정본 = 일위대가 문서(아래 복사)
|
||||
"자재조합": new_um,
|
||||
"본뜬": origin,
|
||||
}
|
||||
made.pop("자재조합", None) # 옛 자재 조합 칸은 안 옮김
|
||||
if path:
|
||||
made.update({"경로": list(path), "머리": list(path)}) # 머리 줄 수는 저장 맞춤이 채움
|
||||
doc["열"] = [*doc.get("열", []), made]
|
||||
@@ -201,5 +173,4 @@ def copy_item(
|
||||
atomic_write_json(store._path(kind, new_id), body)
|
||||
store._refresh_labor(new_id) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시
|
||||
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
|
||||
inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1)
|
||||
return {"key": ss["키"], "열": new_id, "이름": ss["이름"]}
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Materials.ts
|
||||
* 재료산출 편집 화면 — 엑셀 옆 접히는 판: 그 열의 자재 조합(UM) 담은 자재 표 + [자재 더하기] · [빼기] · [적용].
|
||||
* 자재 조합도 여러 열이 같이 쓸 수 있어 [적용] 때 일위대가처럼 모달 후 새 조합으로 갈라 냄
|
||||
* (서버 `branchMaterialCombo`) · 원래 조합은 그대로. 빼는 자재가 칸에 이어져 있으면 확인 후 연결도 풂.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import type { CellLink } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import {
|
||||
branchMaterialCombo,
|
||||
fetchMaterialCombo,
|
||||
type MaterialCombo,
|
||||
type MaterialItem,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import { askBranch, branchName, branchToast } from "./M02_MasterTemplete_Combo";
|
||||
import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api";
|
||||
|
||||
const TEXT = {
|
||||
combo: "자재 조합",
|
||||
branched: "본뜬 조합 {value}",
|
||||
none: "자재 조합 없음 — 자재를 더하면 새로 만듦",
|
||||
code: "코드",
|
||||
name: "이름",
|
||||
spec: "규격",
|
||||
unit: "단위",
|
||||
cell: "이은 칸",
|
||||
remove: "빼기",
|
||||
add: "자재 추가",
|
||||
apply: "적용",
|
||||
undo: "되돌리기",
|
||||
empty: "담은 자재가 없음",
|
||||
noItems: "담은 자재가 없어 적용 안 됨 — 자재를 하나 이상 담음",
|
||||
already: "이미 담은 자재 — {value}",
|
||||
unlinkAsk: "{value} 자재는 {cell} 칸에 이어져 있음 — 빼면 연결도 풂",
|
||||
unlinkOk: "빼고 연결 풂",
|
||||
loadFailed: "자재 조합을 못 읽음 — {value}",
|
||||
askBody:
|
||||
"이 자재 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀 · 원래 조합은 그대로",
|
||||
askFirst:
|
||||
"이 열에 새 자재 조합을 만듦 — 담은 자재 목록으로 새 조합을 만들고 이 열이 가리킴 · 같은 내용의 자재 조합이 있으면 그것을 씀",
|
||||
askAlone:
|
||||
"이 자재 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀",
|
||||
failed: "자재 조합을 못 고침 — {value}",
|
||||
};
|
||||
|
||||
const fill = (text: string, values: Record<string, string | number>): string =>
|
||||
Object.entries(values).reduce((s, [k, v]) => s.replace(`{${k}}`, String(v)), text);
|
||||
|
||||
/** 이 열의 자재 조합 — 키(없으면 빈 글) · 같이 쓰는 다른 열 수 · 담은 자재 */
|
||||
export interface MaterialState {
|
||||
key: string;
|
||||
others: number;
|
||||
combo: MaterialCombo | null;
|
||||
}
|
||||
|
||||
export async function loadMaterialState(column: string): Promise<MaterialState> {
|
||||
const base = await readColumnBase(column);
|
||||
const key = base.자재조합 ?? "";
|
||||
let combo: MaterialCombo | null = null;
|
||||
if (key) {
|
||||
try {
|
||||
combo = await fetchMaterialCombo(key);
|
||||
} catch (error) {
|
||||
showToast(fill(TEXT.loadFailed, { value: reasonOf(error) }), "error");
|
||||
}
|
||||
}
|
||||
return { key, others: base.다른열자재조합, combo };
|
||||
}
|
||||
|
||||
/** 알약 막대 자재 항목 ↔ 담은 자재 — 알약 키 = 자재 코드 */
|
||||
export const toPills = (items: MaterialItem[]): LinkItem[] =>
|
||||
items.map((m) => {
|
||||
const 자재 = {
|
||||
코드: m.코드,
|
||||
이름: m.이름 ?? "",
|
||||
규격: m.규격 ?? "",
|
||||
단위: m.단위 ?? "",
|
||||
};
|
||||
return { 키: m.코드, 이름: 자재.이름, 단위: 자재.단위, 자재 };
|
||||
});
|
||||
|
||||
export interface MaterialPanel {
|
||||
root: HTMLElement;
|
||||
/** 알약 막대에서 목록이 바뀜 — 표만 다시 그림(알약은 이미 맞음) */
|
||||
setItems: (items: MaterialItem[]) => void;
|
||||
/** 연결이 바뀜(이음 · 풂 · 되돌리기) — 이은 칸을 다시 그림 */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export interface MaterialPanelOptions {
|
||||
/** 집계표 열 id */
|
||||
column: string;
|
||||
state: MaterialState;
|
||||
/** 표에서 더하기 · 빼기 · 되돌리기 — 알약 막대를 이 목록으로 맞춤 */
|
||||
onChange: (items: MaterialItem[]) => void;
|
||||
/** 이은 칸이 있는 줄을 누름 — 알약을 눌러 그 칸으로 감(깜빡임) */
|
||||
goTo: (code: string) => void;
|
||||
/** 구조물 이름 — 새 이름 자동 미리 채움 */
|
||||
label: string;
|
||||
isAdmin: boolean;
|
||||
/** 지금 시트 문서의 연결 전부 */
|
||||
links: () => CellLink[];
|
||||
/** 자재 코드에 이어진 칸 연결을 풂(시트 문서를 고치고 고침 표시) */
|
||||
unlink: (codes: string[]) => void;
|
||||
/** [적용]으로 이 열이 새 · 다른 자재 조합을 가리킴 — 조합 목록 다시 받기 */
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
/** 옆 판 — 이 열에 자재 조합이 없어도 판은 뜸(더하면 새로 만듦) */
|
||||
export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel {
|
||||
let key = opts.state.key;
|
||||
let others = opts.state.others;
|
||||
let combo = opts.state.combo;
|
||||
let saved: MaterialItem[] = combo?.담은자재 ?? [];
|
||||
let items: MaterialItem[] = [...saved];
|
||||
const sig = (list: MaterialItem[]): string => JSON.stringify(list.map((m) => m.코드));
|
||||
|
||||
const title = el("div", { className: "m02-mat__title" });
|
||||
const table = el("div", { className: "m02-mat__table" });
|
||||
const foot = el("div", { className: "m02-mat__foot" });
|
||||
// 오른쪽 조합 패널의 「이 열의 조합」 칸에 들어감(접기 · 폭은 패널이 함)
|
||||
const root = el("div", { className: "m02-mat", children: [title, table, foot] });
|
||||
|
||||
const cellsOf = (code: string): string[] =>
|
||||
opts
|
||||
.links()
|
||||
.filter((l) => (l.자재?.코드 ?? l.키) === code)
|
||||
.map((l) => l.칸);
|
||||
|
||||
const paint = (): void => {
|
||||
title.replaceChildren(
|
||||
el("span", { text: key ? `${TEXT.combo} ${key} ${combo?.이름 ?? ""}`.trim() : TEXT.none }),
|
||||
...(opts.isAdmin && combo?.본뜬키
|
||||
? [
|
||||
el("span", {
|
||||
className: "m02-combo__origin",
|
||||
text: fill(TEXT.branched, { value: combo.본뜬키 }),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
const head = el("div", {
|
||||
className: "m02-mat__row m02-mat__row--head",
|
||||
children: [TEXT.code, TEXT.name, TEXT.spec, TEXT.unit, TEXT.cell, ""].map((t) =>
|
||||
el("span", { text: t }),
|
||||
),
|
||||
});
|
||||
const rows = items.map((m, i) => {
|
||||
const cells = cellsOf(m.코드);
|
||||
const acts = el("span", { className: "m02-mat__acts" });
|
||||
if (opts.isAdmin) {
|
||||
const del = createButton({ label: TEXT.remove, variant: "ghost", size: "sm" });
|
||||
del.addEventListener("click", async () => {
|
||||
if (
|
||||
cells.length &&
|
||||
!(await showConfirmDialog(
|
||||
fill(TEXT.unlinkAsk, { value: m.이름 ?? m.코드, cell: cells.join(", ") }),
|
||||
TEXT.unlinkOk,
|
||||
))
|
||||
)
|
||||
return;
|
||||
items.splice(i, 1);
|
||||
if (cells.length) opts.unlink([m.코드]); // 확인 글대로 바로 풂(알약 막대 「목록에서 빼기」와 같음)
|
||||
paint();
|
||||
opts.onChange(items);
|
||||
});
|
||||
acts.append(del);
|
||||
}
|
||||
const row = el("div", {
|
||||
className: `m02-mat__row${cells.length ? " is-linked" : ""}`,
|
||||
attrs: { "data-material": m.코드 },
|
||||
children: [m.코드, m.이름 ?? "", m.규격 ?? "", m.단위 ?? "", cells.join(", ")]
|
||||
.map((t) => el("span", { text: t }))
|
||||
.concat(acts),
|
||||
});
|
||||
if (cells.length) row.addEventListener("click", () => opts.goTo(m.코드));
|
||||
return row;
|
||||
});
|
||||
table.replaceChildren(
|
||||
head,
|
||||
...(rows.length ? rows : [el("p", { className: "m02-struct__empty", text: TEXT.empty })]),
|
||||
);
|
||||
paintFoot();
|
||||
};
|
||||
|
||||
const paintFoot = (): void => {
|
||||
if (!opts.isAdmin) return foot.replaceChildren();
|
||||
const add = createButton({ label: TEXT.add, variant: "ghost", size: "sm" });
|
||||
add.addEventListener("click", () =>
|
||||
pickMaterial((m) => {
|
||||
if (items.some((x) => x.코드 === m.코드))
|
||||
return showToast(fill(TEXT.already, { value: m.코드 }), "info");
|
||||
items.push(m);
|
||||
paint();
|
||||
opts.onChange(items);
|
||||
}),
|
||||
);
|
||||
const buttons = [add];
|
||||
if (sig(items) !== sig(saved)) {
|
||||
const apply = createButton({ label: TEXT.apply, variant: "filled", size: "sm" });
|
||||
apply.addEventListener("click", async () => {
|
||||
if (!items.length) return showToast(TEXT.noItems, "error");
|
||||
const message = !key
|
||||
? TEXT.askFirst
|
||||
: others
|
||||
? fill(TEXT.askBody, { n: others + 1 })
|
||||
: TEXT.askAlone;
|
||||
const name = await askBranch(message, branchName(combo?.이름 ?? "", opts.label));
|
||||
if (name === null) return;
|
||||
apply.disabled = true;
|
||||
try {
|
||||
const result = await branchMaterialCombo(opts.column, {
|
||||
본뜬키: key,
|
||||
담은자재: items,
|
||||
이름: name,
|
||||
});
|
||||
branchToast(result, TEXT.combo);
|
||||
key = result.키;
|
||||
others = 0;
|
||||
combo = await fetchMaterialCombo(key).catch(() => null);
|
||||
saved = combo?.담은자재 ?? [...items];
|
||||
items = [...saved];
|
||||
paint();
|
||||
opts.onApplied?.();
|
||||
} catch (error) {
|
||||
apply.disabled = false;
|
||||
showToast(fill(TEXT.failed, { value: reasonOf(error) }), "error");
|
||||
}
|
||||
});
|
||||
const undo = createButton({ label: TEXT.undo, variant: "ghost", size: "sm" });
|
||||
undo.addEventListener("click", () => {
|
||||
items = [...saved];
|
||||
paint();
|
||||
opts.onChange(items);
|
||||
});
|
||||
buttons.push(apply, undo);
|
||||
}
|
||||
foot.replaceChildren(...buttons);
|
||||
};
|
||||
|
||||
paint();
|
||||
return {
|
||||
root,
|
||||
setItems: (list) => {
|
||||
items = [...list];
|
||||
paint();
|
||||
},
|
||||
refresh: paint,
|
||||
};
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
"""M02 자재 조합 저장소 — 마스터 템플릿 표 `자재조합.json`(구조물집계표조합.json 과 같은 자리).
|
||||
|
||||
줄 = 키 UM · 이름 · 담은자재(코드 · 이름 · 규격 · 단위) · 본뜬키 · 소유 · 비고 — 수량 · 단가는 안 둠
|
||||
(재료산출 몫). 키는 표 안 「다음」 번호(지운 키는 다시 안 씀) · 집계표 열 `자재조합` 칸이 가리킴.
|
||||
틀 검사 = `master_material_combo.check_form`(check_master 와 같은 것) · 판 = 파일 sha256 앞 16자.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
|
||||
mmc = m01.cm.mmc # 자재 조합 틀(`resources/master_data/scripts/master_material_combo.py`)
|
||||
FILE = mmc.FILE
|
||||
OWNERS = mmc.OWNERS
|
||||
_HEAD = "UM"
|
||||
|
||||
|
||||
def _load() -> tuple[dict[str, Any], str]:
|
||||
"""(표, 판) — 파일이 없으면 빈 표 · 판 빈 글."""
|
||||
path = store.root() / FILE
|
||||
if not path.is_file():
|
||||
return {"그룹": mmc.GROUP, "판": "2026", "비고": [], "다음": 1, "줄": []}, ""
|
||||
raw = path.read_bytes()
|
||||
return json.loads(raw), store.version_of(raw)
|
||||
|
||||
|
||||
def _find(doc: dict, key: str) -> dict[str, Any]:
|
||||
row = next((r for r in doc["줄"] if r.get("키") == key), None)
|
||||
if row is None:
|
||||
raise store.StoreError(404, f"없는 자재 조합 「{key}」")
|
||||
return row
|
||||
|
||||
|
||||
def listing() -> dict[str, Any]:
|
||||
"""표 통째 — 판 · 줄(담은 자재 포함 · 작아서 한 번에) · 쓰는곳(키 → 쓰는 구조물 열 이름들)."""
|
||||
doc, version = _load()
|
||||
used: dict[str, list[str]] = {}
|
||||
for col in store._structure_columns():
|
||||
if key := col.get("자재조합"):
|
||||
used.setdefault(key, []).append(_name(col))
|
||||
return {"판": version, "줄": doc["줄"], "쓰는곳": used}
|
||||
|
||||
|
||||
def _name(col: dict) -> str:
|
||||
return tree.name_of(tree.path_of(col)) or col["id"] # 경로 이음(26-1)
|
||||
|
||||
|
||||
def one(key: str) -> dict[str, Any]:
|
||||
return _find(_load()[0], key)
|
||||
|
||||
|
||||
def users_of(key: str) -> list[str]:
|
||||
"""구조물집계표에서 `자재조합` 칸이 이 키인 열 — 머리 글(없으면 열 id)."""
|
||||
out = []
|
||||
for col in store._structure_columns():
|
||||
if col.get("자재조합") == key:
|
||||
out.append(_name(col))
|
||||
return out
|
||||
|
||||
|
||||
def _clean(row: Any, key: str, keep: dict | None = None) -> dict[str, Any]:
|
||||
"""서버가 잡는 칸 — 키 · 열 한 벌 · 자재 줄 네 칸 · 본뜬키는 만들 때만(고칠 때는 있던 것)."""
|
||||
if not isinstance(row, dict):
|
||||
raise store.StoreError(400, "자재 조합 줄이 묶음 아님")
|
||||
name = row.get("이름")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise store.StoreError(400, "자재 조합 줄에 「이름」 이 없거나 글자 아님")
|
||||
got = row.get("담은자재")
|
||||
got = [
|
||||
{c: m.get(c) for c in mmc.ITEM_COLS} if isinstance(m, dict) else m
|
||||
for m in (got if isinstance(got, list) else [])
|
||||
]
|
||||
known = {str(r.get("키")) for d in m01.loaded()[0].values() for r in d.get("줄") or []}
|
||||
lost = [str(m.get("코드")) for m in got if isinstance(m, dict) and m.get("코드") not in known]
|
||||
if lost:
|
||||
raise store.StoreError(422, {"errors": [f"마스터에 없는 자재 {' · '.join(lost)}"]})
|
||||
base = keep or row
|
||||
owner = base.get("소유") or OWNERS[0]
|
||||
return {
|
||||
"키": key,
|
||||
"이름": name.strip(),
|
||||
"담은자재": got,
|
||||
"본뜬키": base.get("본뜬키") or None,
|
||||
"소유": owner,
|
||||
"비고": row.get("비고") or None,
|
||||
}
|
||||
|
||||
|
||||
def _short(name: str, at: int = 30) -> str:
|
||||
return name if len(name) <= at else name[:at] + "…"
|
||||
|
||||
|
||||
def _check_name(rows: list, key: str, name: str) -> None:
|
||||
"""이름 겹침 · 긴 이름 — 틀 검사(파일명 · 키가 섞인 글) 앞에서 쉬운 말 한 줄로 막음."""
|
||||
if len(name) > mmc.MAX_이름:
|
||||
raise store.StoreError(422, {"errors": [f"이름이 너무 김 — {mmc.MAX_이름}자까지"]})
|
||||
if any(r.get("키") != key and str(r.get("이름", "")).strip() == name for r in rows):
|
||||
raise store.StoreError(
|
||||
422, {"errors": [f"「{_short(name)}」 이름의 자재 조합이 이미 있음 — 다른 이름으로"]}
|
||||
)
|
||||
|
||||
|
||||
def _save(doc: dict, version: str) -> str:
|
||||
"""틀 검사 → 판이 같을 때만 씀 → 새 판."""
|
||||
if errors := mmc.check_form(FILE, doc):
|
||||
raise store.StoreError(422, {"errors": errors})
|
||||
path = store.root() / FILE
|
||||
with store._LOCK:
|
||||
have = store.version_of(path.read_bytes()) if path.is_file() else ""
|
||||
if have != (version or ""):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
||||
atomic_write_json(path, doc)
|
||||
return store.version_of(path.read_bytes())
|
||||
|
||||
|
||||
def create(row: Any, version: str | None = None) -> dict[str, Any]:
|
||||
"""새 줄 — 키 = 「다음」 번호 · 본뜬키는 있는 다른 키만. `version` None = 지금 판(갈라 내기)."""
|
||||
doc, have = _load()
|
||||
key = f"{_HEAD}{int(doc.get('다음') or 1):06d}"
|
||||
fixed = _clean(row, key)
|
||||
if fixed["본뜬키"] and fixed["본뜬키"] not in {r.get("키") for r in doc["줄"]}:
|
||||
raise store.StoreError(400, f"본뜬키 「{fixed['본뜬키']}」 — 있는 다른 자재 조합 키여야 함")
|
||||
_check_name(doc["줄"], key, fixed["이름"])
|
||||
doc = {**doc, "다음": int(doc.get("다음") or 1) + 1, "줄": [*doc["줄"], fixed]}
|
||||
return {"키": key, "판": _save(doc, have if version is None else version), "줄": fixed}
|
||||
|
||||
|
||||
def edit(key: str, row: Any, version: str) -> dict[str, Any]:
|
||||
doc, _ = _load()
|
||||
fixed = _clean(row, key, keep=_find(doc, key))
|
||||
_check_name(doc["줄"], key, fixed["이름"])
|
||||
doc = {**doc, "줄": [fixed if r.get("키") == key else r for r in doc["줄"]]}
|
||||
return {"키": key, "판": _save(doc, version), "줄": fixed}
|
||||
|
||||
|
||||
def delete(key: str, version: str) -> dict[str, Any]:
|
||||
"""지우기 — 구조물 열이 쓰면 409 + 열 이름들(지우지 않음)."""
|
||||
doc, _ = _load()
|
||||
_find(doc, key)
|
||||
if users := users_of(key):
|
||||
msg = f"구조물 {len(users)} 곳이 쓰는 자재 조합이라 못 지움 — {' · '.join(users)}"
|
||||
raise store.StoreError(409, {"errors": [msg], "users": users})
|
||||
doc = {**doc, "줄": [r for r in doc["줄"] if r.get("키") != key]}
|
||||
return {"판": _save(doc, version)}
|
||||
@@ -1,267 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Matset.ts
|
||||
* 좌측 「자재 조합」 컨테이너 — 찾기 · 목록(키 맨 앞) · 새로 · 본떠 만들기 · 지우기(컨테이너 안) ·
|
||||
* 줄을 누르면 메인 칸에 상세: 이름 · 비고 · 본뜬키 · 담은 자재 표(빼기) · [자재 더하기] · [저장].
|
||||
* 표 = 마스터 템플릿 `자재조합.json`(키 UM · 서버 `M02_MasterTemplete_Matset.py`) · 지우기는 쓰는 열이
|
||||
* 있으면 서버가 막음. 재료산출 옆 판이 갈라 낸 조합도 같은 표라 여기 보임(펼칠 때 새로 받음).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createMaterialCombo,
|
||||
deleteMaterialCombo,
|
||||
editMaterialCombo,
|
||||
listMaterialCombos,
|
||||
StaleError,
|
||||
type MaterialCombo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import { tm } from "./M02_MasterTemplete_Matset_Text";
|
||||
|
||||
export interface MatsetSectionOptions {
|
||||
isAdmin: boolean;
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 메인 칸에 상세를 띄움 — 고침 표시 손잡이를 돌려받음 */
|
||||
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 메인 칸을 비움(지운 뒤) */
|
||||
close: () => void;
|
||||
/** 상세를 열었음 — 좌측 다른 줄 고름 표시를 지움 */
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export interface MatsetSection {
|
||||
title: string;
|
||||
body: HTMLElement;
|
||||
/** 새로 · 본떠 만들기 · 지우기 줄(관리자만 · 아니면 빈 목록) */
|
||||
actions: HTMLElement[];
|
||||
/** 표를 새로 받아 목록을 다시 그림 */
|
||||
refresh: () => Promise<void>;
|
||||
/** 다른 양식을 열었음 — 고름 표시만 지움 */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
|
||||
export function buildMatsetSection(opt: MatsetSectionOptions): MatsetSection {
|
||||
let all: MaterialCombo[] = [];
|
||||
let version = "";
|
||||
let used: Record<string, string[]> = {};
|
||||
let q = "";
|
||||
/** 열린 상세의 키 — 새(저장 전)는 빈 글 · 안 열렸으면 null */
|
||||
let openKey: string | null = null;
|
||||
|
||||
const find = createInputField({ type: "search", placeholder: tm("Search") });
|
||||
const list = el("div", { className: "m02-side__items ui-sidebar-list" });
|
||||
const body = el("div", { className: "m02-side__items", children: [find.root, list] });
|
||||
find.input.addEventListener("input", () => {
|
||||
q = find.input.value.trim().toLowerCase();
|
||||
paintList();
|
||||
});
|
||||
|
||||
const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" });
|
||||
const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" });
|
||||
const btnDel = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" });
|
||||
const actions = opt.isAdmin
|
||||
? [el("div", { className: "m02-side__row", children: [btnNew, btnCopy, btnDel] })]
|
||||
: [];
|
||||
const paintButtons = (): void => {
|
||||
btnCopy.disabled = btnDel.disabled = !openKey;
|
||||
};
|
||||
|
||||
function paintList(): void {
|
||||
const shown = all.filter((x) => !q || `${x.키} ${x.이름}`.toLowerCase().includes(q));
|
||||
list.replaceChildren(
|
||||
...(shown.length
|
||||
? shown.map((x) => {
|
||||
const b = el("button", {
|
||||
className: `m02-side__item ui-sidebar-row${x.키 === openKey ? " is-active" : ""}`,
|
||||
text: `${x.키} ${x.이름}`,
|
||||
attrs: { type: "button", "data-matset-key": x.키 },
|
||||
});
|
||||
b.addEventListener("click", () => void select(x.키));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: tm("Empty") })]),
|
||||
);
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
try {
|
||||
({ 판: version, 줄: all, 쓰는곳: used } = await listMaterialCombos());
|
||||
} catch (error) {
|
||||
showToast(tm("Load_Failed", why(error)), "error");
|
||||
}
|
||||
paintList();
|
||||
paintButtons();
|
||||
}
|
||||
|
||||
async function select(key: string): Promise<void> {
|
||||
if (key === openKey || !(await opt.confirmLeave())) return;
|
||||
const row = all.find((x) => x.키 === key);
|
||||
if (row) draw(structuredClone(row), false);
|
||||
}
|
||||
|
||||
/** 메인 칸 상세 — `row` 는 고칠 사본(저장 전까지 표에 안 들어감) */
|
||||
function draw(row: MaterialCombo, isNew: boolean): void {
|
||||
openKey = isNew ? "" : row.키;
|
||||
const edit = opt.isAdmin;
|
||||
const field = (label: string, name: "이름" | "비고") => {
|
||||
const f = createInputField({ type: "text", label });
|
||||
f.input.value = row[name] ?? "";
|
||||
f.input.disabled = !edit;
|
||||
f.input.setAttribute("data-matset-field", name);
|
||||
f.input.addEventListener("input", () => {
|
||||
if (name === "이름") row.이름 = f.input.value;
|
||||
else row.비고 = f.input.value || null;
|
||||
setDirty(true);
|
||||
});
|
||||
return f.root;
|
||||
};
|
||||
const table = el("div", { className: "m02-matset__table" });
|
||||
const paintItems = (): void => {
|
||||
const head = el("div", {
|
||||
className: "m02-mat__row m02-mat__row--head",
|
||||
children: [tm("Col_Code"), tm("Col_Name"), tm("Col_Spec"), tm("Col_Unit"), "", ""].map(
|
||||
(t) => el("span", { text: t }),
|
||||
),
|
||||
});
|
||||
const rows = row.담은자재.map((m, i) => {
|
||||
const acts = el("span");
|
||||
if (edit) {
|
||||
const del = createButton({ label: tm("Remove"), variant: "ghost", size: "sm" });
|
||||
del.addEventListener("click", () => {
|
||||
row.담은자재.splice(i, 1);
|
||||
setDirty(true);
|
||||
paintItems();
|
||||
});
|
||||
acts.append(del);
|
||||
}
|
||||
return el("div", {
|
||||
className: "m02-mat__row",
|
||||
attrs: { "data-matset-item": m.코드 },
|
||||
children: [m.코드, m.이름 ?? "", m.규격 ?? "", m.단위 ?? "", ""]
|
||||
.map((t) => el("span", { text: t }))
|
||||
.concat(acts),
|
||||
});
|
||||
});
|
||||
table.replaceChildren(
|
||||
head,
|
||||
...(rows.length ? rows : [el("p", { className: "m02-side__empty", text: tm("NoItem") })]),
|
||||
);
|
||||
};
|
||||
paintItems();
|
||||
|
||||
const add = createButton({ label: tm("AddItem"), variant: "ghost" });
|
||||
add.addEventListener("click", () =>
|
||||
pickMaterial((m) => {
|
||||
if (row.담은자재.some((x) => x.코드 === m.코드)) return;
|
||||
row.담은자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위 });
|
||||
setDirty(true);
|
||||
paintItems();
|
||||
}),
|
||||
);
|
||||
const save = createButton({ label: tm("Save"), variant: "filled" });
|
||||
save.addEventListener("click", async () => {
|
||||
if (!row.이름.trim()) return showToast(tm("NeedName"), "error");
|
||||
if (!row.담은자재.length) return showToast(tm("NeedItem"), "error");
|
||||
save.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
const { 키, ...rest } = row;
|
||||
const done = isNew
|
||||
? await createMaterialCombo(version, rest)
|
||||
: await editMaterialCombo(키, version, row);
|
||||
setDirty(false);
|
||||
showToast(tm("Saved"), "success");
|
||||
await refresh();
|
||||
draw(structuredClone(done.줄), false);
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError) {
|
||||
showToast(tm("Stale"), "error");
|
||||
await refresh();
|
||||
} else showToast(tm("Failed", why(error)), "error");
|
||||
} finally {
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const node = el("div", {
|
||||
className: "m02-matset",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m02-matset__fields",
|
||||
children: [
|
||||
field(tm("Head_Name"), "이름"),
|
||||
field(tm("Head_Note"), "비고"),
|
||||
el("p", {
|
||||
className: "m02-side__empty",
|
||||
text: `${tm("Head_Origin")}: ${row.본뜬키 ?? tm("Origin_Self")}`,
|
||||
attrs: { "data-matset-origin": row.본뜬키 ?? "" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("h3", { className: "m02-matset__head", text: tm("Items_Title") }),
|
||||
table,
|
||||
...(edit ? [el("div", { className: "m02-side__row", children: [add, save] })] : []),
|
||||
],
|
||||
});
|
||||
const setDirty = opt.show(isNew ? tm("New") : `${row.키} ${row.이름}`, node);
|
||||
if (isNew) {
|
||||
setDirty(true);
|
||||
node.querySelector<HTMLInputElement>('[data-matset-field="이름"]')?.focus(); // 바로 이름 적기
|
||||
}
|
||||
opt.onOpen();
|
||||
paintList();
|
||||
paintButtons();
|
||||
}
|
||||
|
||||
btnNew.addEventListener("click", async () => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
draw({ 키: "", 이름: "", 담은자재: [], 본뜬키: null, 소유: "현장", 비고: null }, true);
|
||||
});
|
||||
btnCopy.addEventListener("click", async () => {
|
||||
const from = all.find((x) => x.키 === openKey);
|
||||
if (!from || !(await opt.confirmLeave())) return;
|
||||
const copy = structuredClone(from);
|
||||
draw({ ...copy, 키: "", 이름: "", 본뜬키: from.키, 소유: "현장" }, true); // 이름은 새로 적음
|
||||
});
|
||||
btnDel.addEventListener("click", async () => {
|
||||
const key = openKey;
|
||||
if (!key) return;
|
||||
await refresh(); // 최신 판 · 쓰는 열로
|
||||
// 쓰는 열이 있으면 확인 창 없이 바로 알림(서버도 막음)
|
||||
const users = used[key] ?? [];
|
||||
if (users.length) return showToast(tm("InUse", users.join(" · ")), "error");
|
||||
if (!(await showConfirmDialog(tm("DeleteAsk", key)))) return;
|
||||
try {
|
||||
await deleteMaterialCombo(key, version);
|
||||
showToast(tm("Deleted"), "success");
|
||||
openKey = null;
|
||||
opt.close();
|
||||
} catch (error) {
|
||||
showToast(tm("Failed", why(error)), "error");
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
|
||||
paintButtons();
|
||||
return {
|
||||
title: tm("Title"),
|
||||
body,
|
||||
actions,
|
||||
refresh,
|
||||
clear: () => {
|
||||
if (openKey === null) return;
|
||||
openKey = null;
|
||||
paintList();
|
||||
paintButtons();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Matset_Text.ts
|
||||
* 좌측 「자재 조합」 컨테이너 · 상세 화면 글자 — [한국어, 영어]
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["자재 조합", "Material sets"],
|
||||
Search: ["키 · 이름으로 찾기", "Search by key or name"],
|
||||
Empty: ["자재 조합 없음", "No material sets"],
|
||||
New: ["새 자재 조합(저장 전)", "New material set (unsaved)"],
|
||||
Save: ["저장", "Save"],
|
||||
InUse: [
|
||||
"{value} 열이 쓰는 자재 조합이라 못 지움",
|
||||
"Used by the column(s) {value} — cannot delete",
|
||||
],
|
||||
DeleteAsk: ["자재 조합 {value} 을 지울까요?", "Delete material set {value}?"],
|
||||
Saved: ["저장함", "Saved"],
|
||||
Deleted: ["지움", "Deleted"],
|
||||
Stale: [
|
||||
"그 사이 자재 조합 표가 바뀜 — 목록을 새로 받았으니 다시 고쳐 주세요",
|
||||
"The table changed meanwhile — reloaded, please edit again",
|
||||
],
|
||||
NeedName: ["이름을 적어야 저장됨", "A name is required"],
|
||||
NeedItem: ["담은 자재가 하나는 있어야 저장됨", "Add at least one material"],
|
||||
Load_Failed: ["자재 조합을 못 받음 — {value}", "Could not load material sets — {value}"],
|
||||
Failed: ["자재 조합을 못 고침 — {value}", "Could not change the material set — {value}"],
|
||||
Head_Name: ["이름", "Name"],
|
||||
Head_Note: ["비고", "Note"],
|
||||
Head_Origin: ["본뜬키", "Copied from"],
|
||||
Origin_Self: ["원본", "Original"],
|
||||
Items_Title: ["담은 자재", "Materials in it"],
|
||||
Col_Code: ["코드", "Code"],
|
||||
Col_Name: ["이름", "Name"],
|
||||
Col_Spec: ["규격", "Spec"],
|
||||
Col_Unit: ["단위", "Unit"],
|
||||
Remove: ["빼기", "Remove"],
|
||||
AddItem: ["자재 추가", "Add material"],
|
||||
NoItem: ["담은 자재 없음", "No materials yet"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type MatsetTextKey = keyof typeof TEXT;
|
||||
|
||||
export function tm(key: MatsetTextKey, value = ""): string {
|
||||
const entry = TEXT[key];
|
||||
return (entry[currentLanguageIndex as 0 | 1] ?? entry[0]).replace("{value}", value);
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
|
||||
}
|
||||
const kind = current;
|
||||
optionList.replaceChildren(
|
||||
el("h3", { className: "m02-matset__head", text: kind.name }),
|
||||
el("h3", { className: "m02-options__head", text: kind.name }),
|
||||
...kind.options.map((o) => {
|
||||
const box = el("input", {
|
||||
attrs: { type: "checkbox", "data-options-key": o.key },
|
||||
|
||||
@@ -11,7 +11,6 @@ from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Branch as branch
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Options as options
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
@@ -30,18 +29,6 @@ class BranchBody(BaseModel):
|
||||
판: str = "" # 집계표 판 — 주면 어긋날 때 409
|
||||
|
||||
|
||||
class MaterialBranchBody(BaseModel):
|
||||
본뜬키: str = "" # 빈 글 = 이 열의 첫 자재 조합
|
||||
담은자재: list[dict[str, Any]]
|
||||
이름: str = ""
|
||||
판: str = ""
|
||||
|
||||
|
||||
class MatsetBody(BaseModel):
|
||||
판: str = "" # 자재 조합 표 판 — 어긋나면 409
|
||||
줄: dict[str, Any]
|
||||
|
||||
|
||||
class OptionsBody(BaseModel):
|
||||
판: str = "" # 옵션 표시 표 판 — 어긋나면 409
|
||||
표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김
|
||||
@@ -72,7 +59,7 @@ def get_structure_combos() -> dict:
|
||||
|
||||
@router.post("/structures/{column}")
|
||||
def post_structure(column: str) -> dict:
|
||||
"""구조물 도면 새로 — 열 id 하나 · 도번 자동 · 빈 면적산출 · 재료산출도 · 이미 있으면 409."""
|
||||
"""구조물 도면 새로 — 열 id 하나 · 도번 자동 · 빈 면적산출도 · 이미 있으면 409."""
|
||||
return _call(store.create_structure, column)
|
||||
|
||||
|
||||
@@ -82,58 +69,18 @@ def post_structure_combo(column: str, body: BranchBody) -> dict:
|
||||
return _call(branch.branch_combo, column, body.본뜬키, body.담은로직, body.이름, body.판)
|
||||
|
||||
|
||||
@router.post("/structures/{column}/material-combo")
|
||||
def post_structure_material_combo(column: str, body: MaterialBranchBody) -> dict:
|
||||
"""자재 조합 갈라 내기 — 같은 규칙(같은 자재 집합이면 있는 키 · 아니면 새 UM)."""
|
||||
return _call(
|
||||
branch.branch_material_combo, column, body.본뜬키, body.담은자재, body.이름, body.판
|
||||
)
|
||||
|
||||
|
||||
class UseComboBody(BaseModel):
|
||||
종류: str # 일위대가 · 자재조합
|
||||
종류: str # 일위대가
|
||||
키: str
|
||||
판: str = "" # 집계표 판 — 주면 어긋날 때 409
|
||||
|
||||
|
||||
@router.post("/structures/{column}/use-combo")
|
||||
def post_structure_use_combo(column: str, body: UseComboBody) -> dict:
|
||||
"""있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 · 자재조합 키만 바꿈(조합은 그대로)."""
|
||||
"""있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 키만 바꿈(조합은 그대로)."""
|
||||
return _call(branch.use_combo, column, body.종류, body.키, body.판)
|
||||
|
||||
|
||||
# 자재 조합 표(마스터 템플릿 `자재조합.json` · 키 UM) — 좌측 「자재 조합」 컨테이너
|
||||
@router.get("/material-combos")
|
||||
def get_material_combos() -> dict:
|
||||
"""표 통째 — `{판, 줄}`(줄마다 담은 자재 포함)."""
|
||||
return _call(matset.listing)
|
||||
|
||||
|
||||
@router.get("/material-combos/{key}")
|
||||
def get_material_combo(key: str) -> dict:
|
||||
"""자재 조합 한 줄(키 UM) — 키 · 이름 · 담은자재 · 본뜬키 · 소유 · 비고."""
|
||||
return _call(branch.material_combo, key)
|
||||
|
||||
|
||||
@router.post("/material-combos")
|
||||
def post_material_combo(body: MatsetBody) -> dict:
|
||||
"""새로 · 본떠 만들기(`줄.본뜬키`) — 키는 표 「다음」 번호 · `{키, 판, 줄}`."""
|
||||
return _call(matset.create, body.줄, body.판)
|
||||
|
||||
|
||||
@router.put("/material-combos/{key}")
|
||||
def put_material_combo(key: str, body: MatsetBody) -> dict:
|
||||
"""고치기 — 이름 · 비고 · 담은자재(본뜬키 · 소유는 있던 것)."""
|
||||
return _call(matset.edit, key, body.줄, body.판)
|
||||
|
||||
|
||||
@router.delete("/material-combos/{key}")
|
||||
def delete_material_combo(key: str, 판: str = "") -> dict:
|
||||
"""지우기 — 구조물 열이 쓰면 409 `{errors, users}`."""
|
||||
return _call(matset.delete, key, 판)
|
||||
|
||||
|
||||
# 구조물 옵션 보이기 — B05/B06 폼에 보일 옵션(마스터 템플릿 `구조물옵션표시.json`)
|
||||
@router.get("/structure-options")
|
||||
def get_structure_options() -> dict:
|
||||
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션 있는 종류와 그 옵션."""
|
||||
@@ -157,11 +104,6 @@ def post_area_copy(column: str, source: str = Query(alias="from")) -> dict:
|
||||
return _call(store.copy_from, "area", column, source)
|
||||
|
||||
|
||||
@router.post("/material/{column}/copy")
|
||||
def post_material_copy(column: str, source: str = Query(alias="from")) -> dict:
|
||||
return _call(store.copy_from, "material", column, source)
|
||||
|
||||
|
||||
@router.get("/templates/{kind}/{name}")
|
||||
def get_template(kind: str, name: str) -> dict:
|
||||
return _call(store.read, kind, name)
|
||||
|
||||
@@ -27,15 +27,11 @@ from config.config_db import get_db_pool
|
||||
from M02_MasterTemplete import M02_Item_Paste as paste_store
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Branch as branch
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import (
|
||||
BranchBody,
|
||||
MaterialBranchBody,
|
||||
MatsetBody,
|
||||
UseComboBody,
|
||||
)
|
||||
|
||||
@@ -72,11 +68,6 @@ Session = dict[str, Any]
|
||||
NO_COMPANY = access.NO_COMPANY
|
||||
|
||||
|
||||
class InputLinksBody(BaseModel):
|
||||
판: int | None = None
|
||||
줄: list[Any] = []
|
||||
|
||||
|
||||
class TreeBody(BaseModel):
|
||||
"""26-1 나무 고침 — `{op, 경로, 이름?, 새경로?, 키?, 판}` (+ 항목 더하기 때 `단위?`)."""
|
||||
|
||||
@@ -197,8 +188,7 @@ def get_items(
|
||||
all: bool = False, # noqa: A002 — 21-1 약속 이름 `?all=1`
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
"""21-1 — 한 층이면 `[{key, 열, 종류, 분류1~3, 단위, 일위대가, 자재조합, 도번, 면적번,
|
||||
재료번}]` ·
|
||||
"""21-1 — 한 층이면 `[{key, 열, 종류, 분류1~3, 단위, 일위대가, 도번, 면적번}]` ·
|
||||
`?all=1` 이면 세 층 `[{layer, owner, items}]`(회사 · 개인은 세션 것 · 회사 없으면 빈 목록)."""
|
||||
if all:
|
||||
return [_listing(session, one, None) for one in ("system", "company", "personal")]
|
||||
@@ -207,7 +197,7 @@ def get_items(
|
||||
|
||||
@router.post("/layers/personal/items/copy")
|
||||
def copy_item(body: CopyItemBody, session: Session = Depends(verify_session)) -> dict:
|
||||
"""회사 · 마스터 항목 → 내 개인 층 새 항목(열 · 세 파일 · 자재 조합 줄 · 새 SS 키 · `본뜬`)."""
|
||||
"""회사 · 마스터 항목 → 내 개인 층 새 항목(열 · 세 파일 · 새 SS 키 · `본뜬`)."""
|
||||
target = _root(session, "personal", None, write=True)
|
||||
source = _root(session, body.from_layer, body.from_owner)
|
||||
owner = access.grant(session, body.from_layer, body.from_owner).owner
|
||||
@@ -218,50 +208,6 @@ def copy_item(body: CopyItemBody, session: Session = Depends(verify_session)) ->
|
||||
raise HTTPException(status_code=e.status, detail=e.detail) from e
|
||||
|
||||
|
||||
# ── 항목 선택 입력(PLAN 25-1b · 25-2a) — 읽기 = 층 읽기 권한(시스템 층 = 로그인 누구나) ──
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/items/{key}/inputs")
|
||||
def get_item_inputs(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
"""그 항목 일위대가가 담은 로직마다 입력 전부(합치지 않음) · 자동값 — M01 길은 관리자 전용이라
|
||||
일반 사용자는 이 길로 읽음."""
|
||||
return _call(_root(session, layer, owner), inputs_store.inputs, key)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/items/{key}/input-links")
|
||||
def get_input_links(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
return _call(_root(session, layer, owner), inputs_store.read_links, key)
|
||||
|
||||
|
||||
@router.put("/layers/{layer}/items/{key}/input-links")
|
||||
def put_input_links(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
body: InputLinksBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""본문 `{판, 줄}` — 판 다르면 409 · 없는 로직 · 입력 키 422 · 층 쓰기 권한."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, inputs_store.write_links, key, body.판, body.줄)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/items/{key}/pills")
|
||||
def get_item_pills(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
place: Literal["면적", "재료"],
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""면적산출 · 재료산출 시트 알약 — `{물량: [조합 물량], 입력: [그 시트로 이은 입력]}`."""
|
||||
return _call(_root(session, layer, owner), inputs_store.pills, key, place)
|
||||
|
||||
|
||||
# ── 폴더 구조(PLAN 26-1 · 26-2a) — 읽기 = 층 읽기 권한 · 고침 = 층 쓰기 권한 ──
|
||||
|
||||
|
||||
@@ -375,19 +321,6 @@ def post_combo(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/structures/{column}/material-combo")
|
||||
def post_material_combo_branch(
|
||||
layer: Layer,
|
||||
column: str,
|
||||
body: MaterialBranchBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
args = (column, body.본뜬키, body.담은자재, body.이름, body.판)
|
||||
return _call(folder, branch.branch_material_combo, *args)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/structures/{column}/use-combo")
|
||||
def post_use_combo(
|
||||
layer: Layer,
|
||||
@@ -412,7 +345,7 @@ def _copy_practice(column: str, key: str) -> dict[str, Any]:
|
||||
@router.post("/layers/{layer}/{kind}/{column}/copy")
|
||||
def post_copy(
|
||||
layer: Layer,
|
||||
kind: Literal["structures", "labor", "area", "material"],
|
||||
kind: Literal["structures", "labor", "area"],
|
||||
column: str,
|
||||
source: str = Query(alias="from"),
|
||||
owner: str | None = None,
|
||||
@@ -430,47 +363,3 @@ def post_copy(
|
||||
return _call(folder, _copy_practice, column, source)
|
||||
origin = _root(session, from_layer, from_owner) if from_layer else None
|
||||
return _call(folder, store.copy_from, part, column, source, origin)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/material-combos")
|
||||
def get_matsets(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
|
||||
return _call(_root(session, layer, owner), matset.listing)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/material-combos/{key}")
|
||||
def get_matset(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
):
|
||||
return _call(_root(session, layer, owner), matset.one, key)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/material-combos")
|
||||
def post_matset(
|
||||
layer: Layer,
|
||||
body: MatsetBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
return _call(_root(session, layer, owner, write=True), matset.create, body.줄, body.판)
|
||||
|
||||
|
||||
@router.put("/layers/{layer}/material-combos/{key}")
|
||||
def put_matset(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
body: MatsetBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
return _call(_root(session, layer, owner, write=True), matset.edit, key, body.줄, body.판)
|
||||
|
||||
|
||||
@router.delete("/layers/{layer}/material-combos/{key}")
|
||||
def delete_matset(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
판: str = "",
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
return _call(_root(session, layer, owner, write=True), matset.delete, key, 판)
|
||||
|
||||
@@ -336,8 +336,8 @@ async def save_layer_template(
|
||||
return await asyncio.to_thread(
|
||||
_store_save, folder, kind, name, body.판 or "", document, layer
|
||||
)
|
||||
if kind in ("area", "material"):
|
||||
# 면적산출 · 재료산출 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음
|
||||
if kind == "area":
|
||||
# 면적산출 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음
|
||||
# (브라우저 값 버림 · 5장 ②)
|
||||
try:
|
||||
document = await asyncio.to_thread(spreadsheet.with_server_values, document)
|
||||
|
||||
@@ -26,7 +26,7 @@ FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_te
|
||||
KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈
|
||||
# 지금 다루는 층 폴더 — 층 라우터가 `at(층 폴더)` 로 바꿈 · 안 바꾸면 시스템 층(`FOLDER`).
|
||||
# 회사 · 개인 층도 시스템과 같은 모양(`table/구조물집계표.json` · 조합 장부 ·
|
||||
# structure/area/material · 자재조합.json — PLAN 21-1)이라 아래 함수는 그대로 씀
|
||||
# structure/area — PLAN 21-1)이라 아래 함수는 그대로 씀
|
||||
_ROOT: ContextVar[Path | None] = ContextVar("m02_layer_root", default=None)
|
||||
|
||||
|
||||
@@ -45,9 +45,8 @@ def at(folder: str | Path) -> Iterator[Path]:
|
||||
|
||||
|
||||
# 구조물집계표 구조물 열(꼴=수) 마다 세 파일(파일 이름 = 열 id) —
|
||||
# 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/` · 재료산출 `material/`
|
||||
# (둘 다 스프레드시트 통합문서)
|
||||
# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 뒷자리 — 한 열의 세 번호는 같은 뒷자리 · 한 번 준 번호는
|
||||
# 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/`(스프레드시트 통합문서)
|
||||
# 번호 = 도번 SD · 면적번 SA + 뒷자리 — 한 열의 번호는 같은 뒷자리 · 한 번 준 번호는
|
||||
# 파일에 남아 안 바뀜(PLAN 13장). 뒷자리 두 모양:
|
||||
# 옛 8자리 `SD01020300` = [종류][분류1][분류2][분류3] 두 자리씩(그대로 유효 · 더는 새로 안 줌)
|
||||
# 새 일련 `SD-000001` = 붙임표 + 6자리 · 자리 뜻 없음(PLAN 26-1 · 26-4a) — 옛 8자리 ·
|
||||
@@ -57,7 +56,7 @@ def at(folder: str | Path) -> Iterator[Path]:
|
||||
STRUCTURE_TABLE = "구조물집계표"
|
||||
# 프로그램이 이름으로 부르는 양식 — 지우면 그 기능이 죽음(B07 이름은 `B07_DesignDetail_Engine_*`)
|
||||
IN_USE = {
|
||||
("table", STRUCTURE_TABLE): "구조물 도면 · 면적산출 · 재료산출 · 조합 장부가 씀",
|
||||
("table", STRUCTURE_TABLE): "구조물 도면 · 면적산출 · 조합 장부가 씀",
|
||||
("drawing", "00_template_A1"): "B07 도면 도각",
|
||||
("drawing", "00_template_compass"): "B07 방위표",
|
||||
("drawing", "00_template_cover"): "B07 표지",
|
||||
@@ -67,11 +66,10 @@ PARTS = (
|
||||
("structure", "도번", "SD", None),
|
||||
("labor", "일위대가번", "SU", None), # 일위대가 — `M02_Labor_Doc`(39장)
|
||||
("area", "면적번", "SA", "면적산출"),
|
||||
("material", "재료번", "SM", "재료산출"),
|
||||
)
|
||||
_KEY = {kind: key for kind, key, _, _ in PARTS}
|
||||
SHEETS = ("area", "material") # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음
|
||||
_CODE = re.compile(r"^S[DAMU](\d{8}|-\d{6})$")
|
||||
SHEETS = ("area",) # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음
|
||||
_CODE = re.compile(r"^S[DAU](\d{8}|-\d{6})$")
|
||||
_OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음
|
||||
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
|
||||
# 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties
|
||||
@@ -106,16 +104,6 @@ def _path(kind: str, name: str) -> Path:
|
||||
return root() / kind / f"{name}.json"
|
||||
|
||||
|
||||
# 항목 연결(PLAN 25-1b) — 열 id 마다 `inputs/<열id>.json` · 종류 폴더(KINDS) 밖 · 읽기 · 쓰기는
|
||||
# `M02_MasterTemplete_Inputs` 몫 · 열 지우기 · 분류 더하기 복사는 여기서 같이
|
||||
INPUTS = "inputs"
|
||||
|
||||
|
||||
def inputs_path(column: str) -> Path:
|
||||
"""그 열의 항목 연결 파일 자리 — 이름 규칙은 종류 파일과 같음."""
|
||||
return _path("structure", column).parent.parent / INPUTS / f"{column}.json"
|
||||
|
||||
|
||||
def _info(kind: str, path: Path) -> dict[str, Any]:
|
||||
raw = path.read_bytes()
|
||||
stamp = datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds")
|
||||
@@ -153,14 +141,14 @@ def _check_skeleton(kind: str, doc: Any) -> None:
|
||||
|
||||
def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> dict[str, Any]:
|
||||
"""`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀.
|
||||
구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출 · 재료산출을 번호와 같이
|
||||
구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출을 번호와 같이
|
||||
만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음).
|
||||
통합문서(area · material)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음
|
||||
통합문서(area)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음
|
||||
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘 · 일위대가(labor)는
|
||||
`M02_Labor_Doc.server` 가 정리 · `계산값` 을 새로 적음(면적산출 저장 때 그 열 일위대가도).
|
||||
번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
|
||||
번호 칸(도번 · 일위대가번 · 면적번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
|
||||
표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이
|
||||
원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
|
||||
원본 열의 일위대가 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
|
||||
`screen` = 화면 표 [저장](PLAN 26-7e) — 있던 항목의 경로 · 층 이름 · 빈 폴더는 저장본 그대로
|
||||
(정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`)."""
|
||||
path = _path(kind, name)
|
||||
@@ -187,10 +175,9 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
|
||||
for part in _KEY:
|
||||
_ensure(part, column, source)
|
||||
_refresh_labor(column) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시
|
||||
_copy_inputs(source, column)
|
||||
backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게
|
||||
prune_removed_structures()
|
||||
sync_combos() # 번호가 다 선 뒤 — 조합 표 도번 · 면적번 · 재료번 칸
|
||||
sync_combos() # 번호가 다 선 뒤 — 조합 표 도번 · 면적번 칸
|
||||
return info
|
||||
|
||||
|
||||
@@ -206,7 +193,7 @@ def delete(kind: str, name: str, version: str | None = None) -> None:
|
||||
path.unlink()
|
||||
|
||||
|
||||
# ── 구조물 도면 · 면적산출 · 재료산출 ─────────────────────
|
||||
# ── 구조물 도면 · 면적산출 ─────────────────────
|
||||
|
||||
|
||||
def _docs(kind: str) -> list[tuple[str, Any]]:
|
||||
@@ -225,7 +212,7 @@ def _code(doc: Any, key: str) -> str | None:
|
||||
|
||||
|
||||
def _codes() -> dict[str, str]:
|
||||
"""`{열 id: 뒷자리}` — 구조물 도면 도번 먼저 · 없으면 면적번 · 재료번."""
|
||||
"""`{열 id: 뒷자리}` — 구조물 도면 도번 먼저 · 없으면 면적번."""
|
||||
codes: dict[str, str] = {}
|
||||
for kind, key, _, _ in reversed(PARTS):
|
||||
codes.update({name: c for name, doc in _docs(kind) if (c := _code(doc, key))})
|
||||
@@ -233,7 +220,7 @@ def _codes() -> dict[str, str]:
|
||||
|
||||
|
||||
def structure_numbers() -> dict[str, dict[str, str]]:
|
||||
"""`{열 id: {도번, 면적번, 재료번}}` — 집계표 줄 · 좌측 줄이 한 번에 받음(없는 쪽은 빈 글)."""
|
||||
"""`{열 id: {도번, 면적번}}` — 집계표 줄 · 좌측 줄이 한 번에 받음(없는 쪽은 빈 글)."""
|
||||
out: dict[str, dict[str, str]] = {}
|
||||
for kind, key, _, _ in PARTS:
|
||||
for name, doc in _docs(kind):
|
||||
@@ -285,7 +272,7 @@ def _retired() -> list[str]:
|
||||
|
||||
def _blank(kind: str, column: str) -> dict[str, Any]:
|
||||
"""빈 문서(번호는 부르는 쪽이 붙임) — 구조물 도면 = 도각 없음 · 기본 도면층 하나 /
|
||||
면적산출 · 재료산출 = 진짜 빈 시트 하나(식 없어 `계산값` 없음 · 첫 [저장] 때 서버가 적음)."""
|
||||
면적산출 = 진짜 빈 시트 하나(식 없어 `계산값` 없음 · 첫 [저장] 때 서버가 적음)."""
|
||||
if kind == "labor":
|
||||
return {
|
||||
"종류": "일위대가",
|
||||
@@ -336,8 +323,7 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool:
|
||||
|
||||
def _take_origins(doc: Any) -> dict[str, str]:
|
||||
"""표 열의 `본뜬열` 을 떼어 냄 → `{새 열: 원본 열}` (구조물 파일이 아직 없는 열만) ·
|
||||
그 열에 자재조합 키가 비었으면 원본 열 것을 받음 · 일위대가는 원본 열 문서를 새 번호로 복사
|
||||
(`_ensure` · 39-1b — 조합 키는 안 받음)."""
|
||||
일위대가는 원본 열 문서를 새 번호로 복사(`_ensure` · 39-1b — 조합 키는 안 받음)."""
|
||||
cols = (
|
||||
[c for c in (doc.get("열") or []) if isinstance(c, dict)] if isinstance(doc, dict) else []
|
||||
)
|
||||
@@ -350,13 +336,12 @@ def _take_origins(doc: Any) -> dict[str, str]:
|
||||
if (root() / "structure" / f"{col['id']}.json").is_file():
|
||||
continue
|
||||
out[col["id"]] = source
|
||||
col["자재조합"] = col.get("자재조합") or by_id[source].get("자재조합")
|
||||
return out
|
||||
|
||||
|
||||
def create_structure(column: str) -> dict[str, Any]:
|
||||
"""구조물 열 하나의 구조물 도면 새로 — 도번 SD + 새 일련(`-` + 6자리) · 이미 있으면 409.
|
||||
일위대가 · 면적산출 · 재료산출 파일이 없으면 빈 문서도 같이 만듦(같은 뒷자리)."""
|
||||
일위대가 · 면적산출 파일이 없으면 빈 문서도 같이 만듦(같은 뒷자리)."""
|
||||
if column not in _column_ids():
|
||||
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」")
|
||||
if not _ensure("structure", column):
|
||||
@@ -394,26 +379,13 @@ def copy_from(
|
||||
return info
|
||||
|
||||
|
||||
def _copy_inputs(source: str, column: str) -> None:
|
||||
"""분류 더하기로 복사한 새 열 — 원본 열의 항목 연결을 판 1 로(이미 있으면 그대로)."""
|
||||
have, got = inputs_path(column), inputs_path(source)
|
||||
if have.is_file() or not got.is_file():
|
||||
return
|
||||
have.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(have, {"판": 1, "줄": json.loads(got.read_bytes()).get("줄") or []})
|
||||
|
||||
|
||||
def prune_removed_structures() -> list[str]:
|
||||
"""집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일 · 항목 연결을 같이 지움 —
|
||||
번호는 비워 둠(다른 열 번호를 안 당김)."""
|
||||
"""집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 같이 지움 —
|
||||
번호는 비워 둠(다른 열 번호를 안 당김) · 옛 `inputs/` 는 안 건드림."""
|
||||
keep = set(_column_ids())
|
||||
gone: list[str] = []
|
||||
codes: set[str] = set()
|
||||
with _LOCK:
|
||||
for path in sorted((root() / INPUTS).glob("[!.]*.json")):
|
||||
if path.stem not in keep:
|
||||
path.unlink()
|
||||
gone.append(f"{INPUTS}/{path.stem}")
|
||||
for kind, key, _, _ in PARTS:
|
||||
for path in sorted((root() / kind).glob("[!.]*.json")):
|
||||
if path.stem not in keep:
|
||||
@@ -456,7 +428,7 @@ def _move_basis() -> list[str]:
|
||||
def renumber() -> dict[str, list[str]]:
|
||||
"""한 번 — 옛 산출근거를 면적산출로 옮김 · 옛 번호(6자리 · 구-NN · 표-NN · 없음)를 8자리로
|
||||
(6자리는 끝에 00 · 번호 없는 열은 열 차례로 일련번호) ·
|
||||
구조물 아닌 열 파일 지움 · 빠진 파일(재료산출 등) 만듦. 내용은 그대로 · 두 번 돌려도 같음."""
|
||||
구조물 아닌 열 파일 지움 · 빠진 파일(면적산출 등) 만듦. 내용은 그대로 · 두 번 돌려도 같음."""
|
||||
moved = _move_basis()
|
||||
gone = prune_removed_structures()
|
||||
changed: list[str] = []
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Structure.ts
|
||||
* 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 · 재료산출 카드(보기만 + [수정]).
|
||||
* 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 카드(보기만 + [수정]).
|
||||
* 카드 셋은 좌측 패널과 같은 공용 ui-sidebar 틀 — 고침은 [수정] 이 여는 M02 메인 칸 전체 편집 화면
|
||||
* (`M02_MasterTemplete_UI_Main.ts`)이 맡음.
|
||||
* 면적산출 · 재료산출 = 스프레드시트(`A00_Common/spreadsheet/`) · 열마다 따로 파일(`area|material/<열 id>.json`) ·
|
||||
* 면적산출 = 스프레드시트(`A00_Common/spreadsheet/`) · 열마다 따로 파일(`area/<열 id>.json`) ·
|
||||
* 부품은 카드를 열 때만 `import()` 로 받음(다른 페이지 번들에 안 섞임).
|
||||
*
|
||||
* 구조물 도면은 집계표 열마다 하나(`resources/master_template/structure/<열 id>.json`) ·
|
||||
* 서버가 열마다 미리 만들어 둠(번호 자동 · 새 열도 집계표 저장 때 서버가 같이 만듦 — `M02_MasterTemplete_Store.py`).
|
||||
* 이 칸은 표 부품 host 밖 형제 자리에 붙음 — 집계표는 그대로 두고 여기만 열마다 다시 그림.
|
||||
* 집계표 일위대가 · 도면 · 면적산출 · 재료산출 줄의 조회 후 선택(`pick`)도 여기 — 일위대가 = M01 일위대가
|
||||
* 집계표 일위대가 · 도면 · 면적산출 줄의 조회 후 선택(`pick`)도 여기 — 일위대가 = M01 일위대가
|
||||
* 목록에서 고름 · 나머지 = 다른 열 것을 골라 내용만 복사해 가져옴(번호 · 열은 그대로 · 서버가 복사).
|
||||
* 글자는 이 파일에 둠(도면 부품 `M02_MasterTemplete_Drawing.ts` 와 같은 방식).
|
||||
* ========================================================================== */
|
||||
@@ -23,11 +23,9 @@ import {
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
|
||||
import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api";
|
||||
import {
|
||||
copyArea,
|
||||
copyMaterial,
|
||||
copyStructure,
|
||||
fetchLaborPills,
|
||||
fetchStructureNumbers,
|
||||
@@ -46,10 +44,8 @@ import {
|
||||
type SheetEditOptions,
|
||||
type SheetKind,
|
||||
} from "./M02_MasterTemplete_ItemCards";
|
||||
import { buildMaterialPanel, loadMaterialState, toPills } from "./M02_MasterTemplete_Materials";
|
||||
import { layerSource } from "./M02_MasterTemplete_ItemSource";
|
||||
import { pickHere, pickedName, type From } from "./M02_MasterTemplete_ItemPick";
|
||||
import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs";
|
||||
import "./M02_MasterTemplete_Structure.css";
|
||||
|
||||
export type { EditKind, SheetKind } from "./M02_MasterTemplete_ItemCards";
|
||||
@@ -72,26 +68,24 @@ const TEXT = {
|
||||
copyFailed: "복사 실패 — {value}",
|
||||
noPills: "이 열 일위대가에 담은 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
|
||||
pillsFailed: "일위대가 알약을 못 받음 — {value}",
|
||||
discardCards: "카드(도면 · 면적산출 · 재료산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
};
|
||||
|
||||
/** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 · 복사 */
|
||||
const COPY: Record<EditKind, (to: string, from: string) => Promise<void>> = {
|
||||
structure: copyStructure,
|
||||
area: copyArea,
|
||||
material: copyMaterial,
|
||||
};
|
||||
/** 집계표 줄 열쇠 → 문서 */
|
||||
const ROW_KIND: Record<string, EditKind> = {
|
||||
"s:drawing": "structure",
|
||||
"s:area": "area",
|
||||
"s:material": "material",
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
|
||||
/** `{열 id: {도번, 면적번, 재료번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
|
||||
/** `{열 id: {도번, 면적번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
|
||||
export type ColumnNumbers = Record<string, StructureNumbers>;
|
||||
|
||||
/** 일위대가 목록 — 이 층이 쓸 수 있는 세 층 조합(27-2c) · 고를 때마다 받음(층을 바꿔 다시 들어와도 맞게) */
|
||||
@@ -209,7 +203,7 @@ export interface StructureColumn {
|
||||
꼴?: string | null;
|
||||
}
|
||||
|
||||
/** 구조물 열 = 꼴=수 — NO · 측점 · 비고 같은 글 열은 도면 · 면적산출 · 재료산출 · 번호가 없음(서버와 같은 규칙) */
|
||||
/** 구조물 열 = 꼴=수 — NO · 측점 · 비고 같은 글 열은 도면 · 면적산출 · 번호가 없음(서버와 같은 규칙) */
|
||||
export const isStructureColumn = (col: StructureColumn): boolean => col.꼴 === "수";
|
||||
|
||||
/** 관리 화면 「구조물 조합」에서 숨기는 것(PLAN 26-7b · 30-7) — NO · 측점 · 비고 열 · 전구간 줄(본문 줄) ·
|
||||
@@ -277,17 +271,13 @@ export const columnHead = (col: StructureColumn): string =>
|
||||
export const structureLabel = (number: string, col: StructureColumn): string =>
|
||||
[number, columnHead(col)].filter(Boolean).join(" ");
|
||||
|
||||
/** 면적산출 · 재료산출 편집 화면 아래 알약 막대 — 면적 = 그 열 일위대가의 로직 물량 「이름|단위」 + 자재 「자재|MT키」
|
||||
* (서버 pills 길 · 39-4 · 다 `고정` — 더하기 · 빼기는 일위대가 쪽) · 재료 = 자재DB 찾기.
|
||||
/** 면적산출 편집 화면 아래 알약 막대 — 그 열 일위대가의 로직 물량 「이름|단위」 + 자재 「자재|MT키」
|
||||
* (서버 pills 길 · 39-4 · 다 `고정` — 더하기 · 빼기는 일위대가 쪽).
|
||||
* 알약이 없으면 까닭 글(`notice`)을 같이 돌려줌 */
|
||||
export async function pillsFor(
|
||||
kind: SheetKind,
|
||||
_kind: SheetKind, // 종류는 면적산출 하나 — 부르는 쪽 모양 그대로
|
||||
column: string,
|
||||
): Promise<{ links: LinkOptions; notice?: string }> {
|
||||
if (kind === "material") {
|
||||
const own = await inputPills(column, "재료");
|
||||
return { links: { 항목: own, 모드: "자재", 자재찾기: searchMaterials } };
|
||||
}
|
||||
const none = (notice: string) => ({ links: { 항목: [], 모드: "입력" as const }, notice });
|
||||
try {
|
||||
const { 알약 } = await fetchLaborPills(column);
|
||||
@@ -304,86 +294,33 @@ export async function pillsFor(
|
||||
}
|
||||
}
|
||||
|
||||
/** 카드 미리보기 수정의 알약 · 자재 조합 판(31-3) — 전체 편집기(`UI_Main` `mountSheet`)와 같은 부품 ·
|
||||
* 면적 = 그 열 일위대가 입력값(`pillsFor`) · 재료 = 자재 조합 담은 자재 + 선택 입력 · 아래 자재 조합 판 */
|
||||
/** 카드 미리보기 수정의 알약(31-3) — 전체 편집기(`UI_Main` `mountSheet`)와 같은 부품(`pillsFor`) */
|
||||
async function cardLinks(
|
||||
kind: SheetKind,
|
||||
ctl: SheetCardControl,
|
||||
isAdmin: boolean,
|
||||
): ReturnType<SheetEditOptions["links"]> {
|
||||
if (kind === "area") return { links: (await pillsFor("area", ctl.column)).links };
|
||||
const [state, own] = await Promise.all([
|
||||
loadMaterialState(ctl.column),
|
||||
inputPills(ctl.column, "재료"), // 「선택 입력」 카드가 재료산출로 고른 입력
|
||||
]);
|
||||
let items = state.combo?.담은자재 ?? [];
|
||||
const links = (): LinkOptions => ({
|
||||
항목: [...toPills(items), ...own],
|
||||
모드: "자재",
|
||||
자재찾기: searchMaterials,
|
||||
// 알약 막대에서 추가 · 빼기 — 아래 판 표를 같은 목록으로
|
||||
onItemsChange: (list) => {
|
||||
items = list.flatMap((i) => (i.자재 ? [{ ...i.자재 }] : []));
|
||||
panel.setItems(items);
|
||||
},
|
||||
});
|
||||
const panel = buildMaterialPanel({
|
||||
column: ctl.column,
|
||||
label: ctl.label,
|
||||
isAdmin,
|
||||
state,
|
||||
links: () =>
|
||||
ctl
|
||||
.handle()
|
||||
?.getDoc()
|
||||
.시트.flatMap((s) => s.연결 ?? []) ?? [],
|
||||
// 판 표에서 고침 — 알약 막대만 이 목록으로 다시 만듦(저장 안 한 시트 고침은 그대로)
|
||||
onChange: (list) => {
|
||||
items = list;
|
||||
ctl.remount(links());
|
||||
},
|
||||
goTo: (code) =>
|
||||
ctl.host
|
||||
.querySelector<HTMLElement>(`.ss-link.is-linked[data-key="${CSS.escape(code)}"]`)
|
||||
?.click(),
|
||||
unlink: (codes) => {
|
||||
const book = ctl.handle()?.getDoc();
|
||||
if (!book) return;
|
||||
for (const sheet of book.시트) {
|
||||
const rest = (sheet.연결 ?? []).filter((l) => !codes.includes(l.자재?.코드 ?? l.키));
|
||||
if (rest.length) sheet.연결 = rest;
|
||||
else delete sheet.연결;
|
||||
}
|
||||
ctl.handle()?.setDoc(book);
|
||||
ctl.touched();
|
||||
},
|
||||
});
|
||||
return { links: links(), below: panel.root, refresh: panel.refresh };
|
||||
return { links: (await pillsFor(kind, ctl.column)).links };
|
||||
}
|
||||
|
||||
export interface StructurePanelOptions {
|
||||
/** 시스템 관리자만 저장함(구조물 도면은 서버가 미리 만들어 둠) */
|
||||
isAdmin: boolean;
|
||||
/** [도면 수정] · [면적산출 수정] · [재료산출 수정] — 그 열을 CAD · 스프레드시트 전체 편집으로 엶
|
||||
/** [도면 수정] · [면적산출 수정] — 그 열을 CAD · 스프레드시트 전체 편집으로 엶
|
||||
* (페이지의 `open`) · label 은 화면 제목에 씀 */
|
||||
onEdit: (column: string, label: string, kind: EditKind) => void;
|
||||
/** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 「선택 입력」 카드에서 자리를 골랐음 — 저장 안 한 고침 표시 */
|
||||
onDirty?: () => void;
|
||||
/** 카드 고침을 버리지 않고 머묾 — 표의 고른 열을 되돌림(31-3) */
|
||||
keepColumn?: (colId: string) => void;
|
||||
}
|
||||
|
||||
export interface StructurePanelHandle {
|
||||
root: HTMLElement;
|
||||
/** `{열 id: {도번, 면적번, 재료번}}` — 집계표 `drawingLabel` · `areaLabel` · `materialLabel` 이 씀 */
|
||||
/** `{열 id: {도번, 면적번}}` — 집계표 `drawingLabel` · `areaLabel` 이 씀 */
|
||||
numbers: () => ColumnNumbers;
|
||||
/** 번호 목록 다시 받기 */
|
||||
reload: () => Promise<void>;
|
||||
/** 「선택 입력」 카드에서 고른 자리를 서버에 저장 — 집계표 [저장] 뒤에 부름(실패하면 던짐) */
|
||||
saveInputs: () => Promise<void>;
|
||||
/** 카드에 저장 안 한 고침(도면 31-4 · 면적산출 · 재료산출 31-3) — 페이지가 떠날 때 함께 물음 */
|
||||
/** 카드에 저장 안 한 고침(도면 31-4 · 면적산출 31-3) — 페이지가 떠날 때 함께 물음 */
|
||||
dirty: () => boolean;
|
||||
/** 고친 카드 저장(도면 · 엑셀) — 집계표 [저장] 이 카드를 다시 읽기 전에 부름(실패하면 던짐) */
|
||||
saveCards: () => Promise<void>;
|
||||
@@ -392,7 +329,7 @@ export interface StructurePanelHandle {
|
||||
/** 보던 열을 다시 읽음 — 집계표 [저장] 뒤(서버가 새 열 파일 · 번호를 만듦) */
|
||||
refresh: (columns: StructureColumn[]) => void;
|
||||
/** 집계표 조회 후 선택(`onPick`) — 일위대가 = 고른 키(「비움」 = 빈 글 · 닫으면 null) ·
|
||||
* 도면 · 면적산출 · 재료산출 = 다른 열 것을 복사해 가져옴(돌려주는 글 없음) */
|
||||
* 도면 · 면적산출 = 다른 열 것을 복사해 가져옴(돌려주는 글 없음) */
|
||||
pick: (row: string, colId: string | null, columns: StructureColumn[]) => Promise<string | null>;
|
||||
destroy: () => void;
|
||||
}
|
||||
@@ -403,10 +340,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
let column: string | null = null;
|
||||
let columnLabel = "";
|
||||
|
||||
const inputs: InputsCardHandle = createInputsCard({
|
||||
isAdmin: opts.isAdmin,
|
||||
onDirty: () => opts.onDirty?.(),
|
||||
});
|
||||
// 카드 셋은 항목 카드 한 벌(`ItemCards`) — 여기서는 지금 층에서 열 하나를 읽어 넘기기만.
|
||||
const cards = createItemCards({
|
||||
source: layerSource,
|
||||
@@ -415,13 +348,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
numberOf: (kind, id) => numbers[id]?.[PART_FIELD[kind]],
|
||||
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
|
||||
warnOnFail: (id) => !!numbers[id],
|
||||
extraCards: [inputs.root],
|
||||
// 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길
|
||||
onImport: (kind) => void pickCopy(kind, shown ?? undefined),
|
||||
// 면적산출 · 재료산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길
|
||||
// 면적산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길
|
||||
sheetEdit: {
|
||||
can: () => opts.isAdmin,
|
||||
links: (kind, ctl) => cardLinks(kind, ctl, opts.isAdmin),
|
||||
links: (kind, ctl) => cardLinks(kind, ctl),
|
||||
save: async (kind, at, doc, version) =>
|
||||
(await saveTemplate(getActiveLayer(), kind, at, null, version, doc)).판,
|
||||
},
|
||||
@@ -432,9 +364,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
shown = col;
|
||||
column = col?.id ?? null;
|
||||
columnLabel = col ? columnHead(col) : "";
|
||||
if (!col) void inputs.show(null, null);
|
||||
// 집계표에 지금 고른 일위대가(저장 전일 수 있음) — 카드가 「먼저 저장」 안내를 고름
|
||||
else void inputs.show(col.id, (col as { 일위대가?: string | null }).일위대가 ?? null);
|
||||
return cards.load(col ? { id: col.id, label: columnLabel } : null);
|
||||
}
|
||||
|
||||
@@ -472,7 +401,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
const same = (from: From): boolean => from.layer === here.layer && from.owner === here.owner;
|
||||
const picked = await pickHere({
|
||||
title: `${fill(TEXT.pickCopy, word)} — ${target}`,
|
||||
kind: kind === "material" ? "item" : kind,
|
||||
kind,
|
||||
skip: (from, row) => same(from) && row.열 === to.id,
|
||||
ask: (p) =>
|
||||
TEXT.overwrite
|
||||
@@ -519,9 +448,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
numbers: () => numbers,
|
||||
reload: async () => {
|
||||
numbers = await fetchStructureNumbers().catch(() => ({}));
|
||||
inputs.invalidate(); // 새 열 · 바뀐 일위대가를 서버 항목 목록에서 다시 찾게
|
||||
},
|
||||
saveInputs: () => inputs.save(),
|
||||
dirty: () => cards.dirty(),
|
||||
saveCards: () => cards.saveAll(),
|
||||
show,
|
||||
@@ -532,7 +459,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
},
|
||||
pick,
|
||||
destroy: () => {
|
||||
inputs.destroy();
|
||||
cards.destroy();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -247,7 +247,7 @@ def apply(
|
||||
_taken_item(out, made)
|
||||
col: dict[str, Any] = {"id": _fresh_id(out), "단위": body.get("단위"), "꼴": "수"}
|
||||
# 항목 추가 창에서 고른 일위대가(PLAN 31-2) — 없으면 비움 · 단위는 안 받아도 됨
|
||||
col.update({"일위대가": body.get("일위대가") or None, "자재조합": None})
|
||||
col["일위대가"] = body.get("일위대가") or None
|
||||
_set_path(col, made)
|
||||
_place(out, col)
|
||||
note.update({"경로": made, "열": col["id"]})
|
||||
|
||||
@@ -26,21 +26,10 @@ import {
|
||||
type StructureColumn,
|
||||
type StructurePanelHandle,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { inputPills } from "./M02_MasterTemplete_Inputs";
|
||||
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel";
|
||||
import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel";
|
||||
import {
|
||||
buildMaterialPanel,
|
||||
loadMaterialState,
|
||||
toPills,
|
||||
type MaterialPanel,
|
||||
type MaterialState,
|
||||
} from "./M02_MasterTemplete_Materials";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
|
||||
import type { MaterialItem } from "./M02_MasterTemplete_Api_Fetch";
|
||||
export interface Selection {
|
||||
layer: Layer;
|
||||
projectId: string | null;
|
||||
@@ -67,7 +56,6 @@ type Mods = Record<string, () => Promise<unknown>>;
|
||||
const NUMBER_FIELD: Partial<Record<Kind, string>> = {
|
||||
structure: "도번",
|
||||
area: "면적번",
|
||||
material: "재료번",
|
||||
labor: "일위대가번",
|
||||
};
|
||||
// 표 부품(sub3) · 도면 부품(sub2) — 내보낸 이름으로 찾음
|
||||
@@ -141,7 +129,7 @@ export function createMain(
|
||||
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
|
||||
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
|
||||
const save = createButton({ label: L("M02_Save"), variant: "filled" });
|
||||
// 구조물 도면 · 면적산출 · 재료산출 상세 화면 → 구조물집계표(그 열 고른 채)
|
||||
// 구조물 도면 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채)
|
||||
const back = createButton({ label: L("M02_Back"), variant: "ghost", size: "sm" });
|
||||
const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴
|
||||
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
|
||||
@@ -173,7 +161,7 @@ export function createMain(
|
||||
title.classList.toggle("m02-main__title--fold", combo);
|
||||
title.title = combo ? L("M02_TableFold") : "";
|
||||
if (!combo) {
|
||||
host.hidden = false; // 다른 상세(도면 · 면적 · 재료)는 늘 펼침
|
||||
host.hidden = false; // 다른 상세(도면 · 면적)는 늘 펼침
|
||||
title.classList.remove("is-folded");
|
||||
}
|
||||
};
|
||||
@@ -191,14 +179,11 @@ export function createMain(
|
||||
let sheet: SheetEditorHandle | null = null;
|
||||
let panel: StructurePanelHandle | null = null;
|
||||
let sheetHandle: SpreadsheetHandle | null = null;
|
||||
let matPanel: MaterialPanel | null = null;
|
||||
let matState: MaterialState | null = null;
|
||||
let cpanel: ComboPanel | null = null; // 재료산출 오른쪽 조합 패널(프로젝트 층 아님)
|
||||
let apanel: AreaPanel | null = null; // 면적산출 오른쪽 일위대가 패널(프로젝트 층 아님 · 39-4)
|
||||
let picked = "A1"; // 면적산출 시트에서 지금 고른 칸 — 칸 지정 모달 [지금 고른 칸으로]
|
||||
let seq = 0;
|
||||
let dirty = false;
|
||||
/** 저장 안 한 고침 — 이 화면 + 카드(도면 31-4 · 면적산출 · 재료산출 31-3) */
|
||||
/** 저장 안 한 고침 — 이 화면 + 카드(도면 31-4 · 면적산출 31-3) */
|
||||
const busy = (): boolean => dirty || !!panel?.dirty();
|
||||
|
||||
const guard = (event: BeforeUnloadEvent): void => {
|
||||
@@ -240,10 +225,6 @@ export function createMain(
|
||||
editor = null;
|
||||
sheet = null;
|
||||
sheetHandle = null;
|
||||
matPanel = null;
|
||||
matState = null;
|
||||
cpanel?.destroy();
|
||||
cpanel = null;
|
||||
apanel?.destroy();
|
||||
apanel = null;
|
||||
panel?.destroy();
|
||||
@@ -252,12 +233,11 @@ export function createMain(
|
||||
slot.replaceChildren();
|
||||
};
|
||||
|
||||
/** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 · 재료산출 카드 */
|
||||
/** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 카드 */
|
||||
function buildPanel(layer: Layer): StructurePanelHandle {
|
||||
const made = createStructurePanel({
|
||||
isAdmin,
|
||||
confirmLeave,
|
||||
onDirty: () => (dirty = true), // 「선택 입력」 카드에서 자리를 고름 — 집계표 [저장]으로 함께 저장
|
||||
keepColumn: (colId) => sheet?.selectColumn(colId), // 카드 고침을 안 버림 — 고른 열 되돌림
|
||||
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
|
||||
onEdit: (column, label, kind) => {
|
||||
@@ -268,38 +248,23 @@ export function createMain(
|
||||
return made;
|
||||
}
|
||||
|
||||
/** 면적산출 · 재료산출 스프레드시트 — 아래 알약 막대 · 면적 = 그 열 일위대가 알약(`pillsFor`) ·
|
||||
* 재료 = 자재DB 찾기 · 프로젝트 층 아니면 면적 = 오른쪽 일위대가 패널(담은 로직 · 자재목록 · 칸 지정) ·
|
||||
* 재료 = 그 열 자재 조합 담은 자재(알약 항목 = 옆 판 표 = 같은 목록) + 옆 판
|
||||
* (`pillItems` = 옆 판에서 고친 목록으로 알약 막대만 다시 만들 때) */
|
||||
/** 면적산출 스프레드시트 — 아래 알약 막대 = 그 열 일위대가 알약(`pillsFor`) ·
|
||||
* 프로젝트 층 아니면 오른쪽 일위대가 패널(담은 로직 · 자재목록 · 칸 지정) */
|
||||
async function mountSheet(
|
||||
doc: unknown,
|
||||
kind: "area" | "material",
|
||||
kind: "area",
|
||||
at: Selection,
|
||||
readOnly: boolean,
|
||||
pillItems?: MaterialItem[],
|
||||
): Promise<void> {
|
||||
const mine = seq;
|
||||
try {
|
||||
const master = at.layer !== "project" && kind === "material";
|
||||
if (master && !matState) matState = await loadMaterialState(at.name);
|
||||
const [{ createSpreadsheet }, pills, own] = await Promise.all([
|
||||
const [{ createSpreadsheet }, pills] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
master ? null : pillsFor(kind, at.name),
|
||||
master ? inputPills(at.name, "재료") : [], // 「선택 입력」 카드가 재료산출로 고른 입력
|
||||
pillsFor(kind, at.name),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (pills?.notice) showNotice([pills.notice]);
|
||||
const links: LinkOptions =
|
||||
pills?.links ??
|
||||
({
|
||||
항목: [...toPills(pillItems ?? matState?.combo?.담은자재 ?? []), ...own],
|
||||
모드: "자재",
|
||||
자재찾기: searchMaterials,
|
||||
// 알약 막대에서 더하기 · 빼기 — 옆 판 표를 같은 목록으로
|
||||
onItemsChange: (list) =>
|
||||
matPanel?.setItems(list.flatMap((i) => (i.자재 ? [{ ...i.자재 }] : []))),
|
||||
} satisfies LinkOptions);
|
||||
if (pills.notice) showNotice([pills.notice]);
|
||||
const links: LinkOptions = pills.links;
|
||||
const handle: SpreadsheetHandle = createSpreadsheet(
|
||||
host,
|
||||
doc as Parameters<typeof createSpreadsheet>[1],
|
||||
@@ -308,7 +273,6 @@ export function createMain(
|
||||
tabs: false, // 시트 하나 · 탭 줄 없음
|
||||
onChange: () => {
|
||||
dirty = !readOnly;
|
||||
matPanel?.refresh(); // 알약을 이음 · 풂 — 옆 판 「이은 칸」 바로 갱신
|
||||
apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함
|
||||
},
|
||||
onSelect: (s) => (picked = s.칸),
|
||||
@@ -332,71 +296,11 @@ export function createMain(
|
||||
host.append(apanel.root);
|
||||
return;
|
||||
}
|
||||
// 오른쪽 조합 패널 — 한 번 만들고 시트를 다시 붙여도 그대로(늘 시트 뒤)
|
||||
cpanel ??= buildComboPanel({
|
||||
kind,
|
||||
column: at.name,
|
||||
isAdmin,
|
||||
onUsed: () => remount(kind, at, readOnly, true),
|
||||
});
|
||||
host.append(cpanel.root);
|
||||
if (matPanel || !matState) return;
|
||||
matPanel = buildMaterialPanel({
|
||||
column: at.name,
|
||||
label: at.label ?? at.name,
|
||||
isAdmin,
|
||||
state: matState,
|
||||
onApplied: () => void cpanel?.refresh(),
|
||||
links: () => sheetHandle?.getDoc().시트.flatMap((s) => s.연결 ?? []) ?? [],
|
||||
// 옆 판 표에서 고침 — 알약 막대만 이 목록으로 다시 만듦(저장 안 한 시트 고침은 그대로)
|
||||
onChange: (items) => {
|
||||
const kept = sheetHandle?.getDoc();
|
||||
if (!kept) return;
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
void mountSheet(kept, kind, at, readOnly, items);
|
||||
},
|
||||
goTo: (code) =>
|
||||
host
|
||||
.querySelector<HTMLElement>(`.ss-link.is-linked[data-key="${CSS.escape(code)}"]`)
|
||||
?.click(),
|
||||
unlink: (codes) => {
|
||||
const book = sheetHandle?.getDoc();
|
||||
if (!book) return;
|
||||
for (const s of book.시트) {
|
||||
const rest = (s.연결 ?? []).filter((l) => !codes.includes(l.자재?.코드 ?? l.키));
|
||||
if (rest.length) s.연결 = rest;
|
||||
else delete s.연결;
|
||||
}
|
||||
sheetHandle?.setDoc(book);
|
||||
dirty = !readOnly;
|
||||
},
|
||||
});
|
||||
cpanel.setMine(matPanel.root);
|
||||
} catch {
|
||||
empty(L("M02_NoSpreadsheet"));
|
||||
}
|
||||
}
|
||||
|
||||
/** 시트만 다시 붙임(알약을 새로 받음) — 저장 안 한 시트 고침은 그대로 옮김 ·
|
||||
* `fresh` = 이 열 조합이 바뀜(재료는 자재 조합 · 담은 자재 표를 새로 받음) */
|
||||
async function remount(
|
||||
kind: "area" | "material",
|
||||
at: Selection,
|
||||
readOnly: boolean,
|
||||
fresh: boolean,
|
||||
): Promise<void> {
|
||||
const kept = sheetHandle?.getDoc();
|
||||
if (sel !== at || !kept) return;
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
if (fresh && kind === "material") {
|
||||
matPanel = null;
|
||||
matState = null;
|
||||
}
|
||||
await mountSheet(kept, kind, at, readOnly);
|
||||
}
|
||||
|
||||
async function mount(doc: unknown, at: Selection): Promise<void> {
|
||||
const readOnly = !canEdit(at.layer, isAdmin);
|
||||
host.replaceChildren();
|
||||
@@ -406,8 +310,8 @@ export function createMain(
|
||||
"createSheet",
|
||||
);
|
||||
if (!create) return empty(L("M02_NoSheet"));
|
||||
// 구조물집계표(프로젝트 층 아님)만 — 도면 · 면적산출 · 재료산출 줄에 번호 · 칸을 고르면 아래 컨테이너 ·
|
||||
// 고칠 수 있으면 일위대가 · 도면 · 면적산출 · 재료산출 칸은 조회 후 선택
|
||||
// 구조물집계표(프로젝트 층 아님)만 — 도면 · 면적산출 줄에 번호 · 칸을 고르면 아래 컨테이너 ·
|
||||
// 고칠 수 있으면 일위대가 · 도면 · 면적산출 칸은 조회 후 선택
|
||||
const struct =
|
||||
at.layer !== "project" && at.name === STRUCTURE_TABLE ? buildPanel(at.layer) : null;
|
||||
if (struct) await struct.reload();
|
||||
@@ -427,7 +331,6 @@ export function createMain(
|
||||
? {
|
||||
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
|
||||
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
|
||||
materialLabel: (colId: string) => struct.numbers()[colId]?.재료번 ?? "",
|
||||
combo: {
|
||||
...(readOnly || !extras.onHeadRename
|
||||
? {}
|
||||
@@ -472,8 +375,8 @@ export function createMain(
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (at.kind === "area" || at.kind === "material") {
|
||||
// 면적산출 · 재료산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을
|
||||
if (at.kind === "area") {
|
||||
// 면적산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을
|
||||
// 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리)
|
||||
return mountSheet(doc, at.kind, at, readOnly);
|
||||
}
|
||||
@@ -529,7 +432,7 @@ export function createMain(
|
||||
)
|
||||
: got.문서;
|
||||
if (mine !== seq) return;
|
||||
// 구조물 도면 · 면적산출 · 재료산출은 열 id 로 열리니 머리 맨 앞에 번호(「SD01010100 돌기슭막이 …」)
|
||||
// 구조물 도면 · 면적산출은 열 id 로 열리니 머리 맨 앞에 번호(「SD01010100 돌기슭막이 …」)
|
||||
const field = NUMBER_FIELD[next.kind];
|
||||
const number = field && (got.문서 as Record<string, unknown> | null)?.[field];
|
||||
if (number) title.textContent = `${String(number)} ${next.label ?? next.name}`;
|
||||
@@ -551,11 +454,11 @@ export function createMain(
|
||||
try {
|
||||
const edited = editor ? await editor.getDoc() : loaded;
|
||||
// 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번은 읽은 그대로.
|
||||
// 면적산출 · 재료산출은 스프레드시트 문서로 통째 갈아 끼우되 번호는 계약 밖(엔진이 모름)이라 옮겨 붙임.
|
||||
// 면적산출은 스프레드시트 문서로 통째 갈아 끼우되 번호는 계약 밖(엔진이 모름)이라 옮겨 붙임.
|
||||
const doc =
|
||||
at.kind === "structure" && loaded
|
||||
? { ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] }
|
||||
: (at.kind === "area" || at.kind === "material") && loaded
|
||||
: at.kind === "area" && loaded
|
||||
? { ...(loaded as Record<string, unknown>), ...(edited as Record<string, unknown>) }
|
||||
: edited;
|
||||
const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc);
|
||||
@@ -569,7 +472,6 @@ export function createMain(
|
||||
await panel.reload();
|
||||
sheet.recalc();
|
||||
panel.refresh(sheet.getDoc()?.열 ?? []);
|
||||
await panel.saveInputs(); // 「선택 입력」 카드의 자리 고름 — 같은 [저장]에서
|
||||
}
|
||||
showNotice([]);
|
||||
showToast(L("M02_Saved"), "success");
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무 → 수평선 → 하위 구조물 도면 · 일위대가 ·
|
||||
* 면적산출 · 재료산출 · 자재 조합 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 면적산출 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
|
||||
* 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) ·
|
||||
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`.
|
||||
* 구조물 도면 · 일위대가 · 면적산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
|
||||
* 서버가 미리 만들어 둠(도번 SD · 일위대가번 SU · 면적번 SA + 같은 뒷자리) ·
|
||||
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」).
|
||||
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
|
||||
* 구조물 라이브러리는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
|
||||
* ========================================================================== */
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
structureLabel,
|
||||
type StructureColumn,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
|
||||
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
|
||||
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
@@ -50,7 +49,7 @@ const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
];
|
||||
/** 구조물 열마다 한 줄씩인 하위 목록 — 종류(편집 칸이 여는 `Kind`) · 번호 칸 · 제목 ·
|
||||
* `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가(SU) → 면적산출 */
|
||||
type ColumnKind = "structure" | "labor" | "area" | "material";
|
||||
type ColumnKind = "structure" | "labor" | "area";
|
||||
const COLUMN_KINDS: {
|
||||
kind: ColumnKind;
|
||||
key: keyof StructureNumbers;
|
||||
@@ -60,7 +59,6 @@ const COLUMN_KINDS: {
|
||||
{ kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true },
|
||||
{ kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true },
|
||||
{ kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true },
|
||||
{ kind: "material", key: "재료번", label: () => L("M02_KindMaterial"), all: false },
|
||||
];
|
||||
/** 「전체 항목」 줄이 있는 하위 — 누르면 H 목록 화면(`onAll`) */
|
||||
export type AllKind = "structure" | "labor" | "area";
|
||||
@@ -143,24 +141,12 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
del.addEventListener("click", () => void deleteCurrent());
|
||||
return [el("div", { className: "m02-side__row", children: [btnNew, copy, del] })];
|
||||
};
|
||||
const matset = buildMatsetSection({
|
||||
isAdmin: canEdit(layer, opt.isAdmin),
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
close: () => opt.onOpen(null),
|
||||
onOpen: () => (options.clear(), practice?.clear(), (allPicked = null), paint()),
|
||||
});
|
||||
const matsetSection = section(matset.title, matset.body, ...matset.actions);
|
||||
// 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게
|
||||
matsetSection.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
|
||||
if (matsetSection.classList.contains("is-collapsed")) void matset.refresh();
|
||||
});
|
||||
// 구조물 옵션 보이기 — 구조물 집계 양식 아래 별도 줄 하나(누르면 메인 칸에 종류별 옵션 체크)
|
||||
const options = buildOptionsSection({
|
||||
isAdmin: canEdit(layer, opt.isAdmin),
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (matset.clear(), practice?.clear(), (allPicked = null), paint()),
|
||||
onOpen: () => (practice?.clear(), (allPicked = null), paint()),
|
||||
});
|
||||
/** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */
|
||||
const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => {
|
||||
@@ -177,7 +163,6 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }),
|
||||
el("hr", { className: "m02-side__rule" }),
|
||||
...columnSections,
|
||||
matsetSection,
|
||||
);
|
||||
summary.classList.remove("is-collapsed");
|
||||
summary.classList.add("m02-side__summary");
|
||||
@@ -198,7 +183,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
? buildPracticeSection({
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (matset.clear(), options.clear(), (allPicked = null), paint()),
|
||||
onOpen: () => (options.clear(), (allPicked = null), paint()),
|
||||
})
|
||||
: null;
|
||||
const listHost = el("div", {
|
||||
@@ -288,21 +273,19 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
|
||||
}
|
||||
|
||||
/** 고름 표시는 여기서만 — 목록 · 열 줄 · 자재 조합 줄을 함께 그려야 두 줄이 동시에 안 남음
|
||||
* (양식이 열려 있으면 자재 조합 고름은 지움 · 자재 조합 상세는 열린 양식이 없음) */
|
||||
/** 고름 표시는 여기서만 — 목록 · 열 줄 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음
|
||||
* (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */
|
||||
function paint(): void {
|
||||
if (opt.getOpen()) allPicked = null;
|
||||
paintList();
|
||||
paintColumns();
|
||||
paintButtons();
|
||||
if (opt.getOpen()) matset.clear();
|
||||
if (opt.getOpen()) options.clear();
|
||||
if (opt.getOpen()) practice?.clear();
|
||||
}
|
||||
|
||||
async function selectAll(kind: AllKind): Promise<void> {
|
||||
if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;
|
||||
matset.clear();
|
||||
options.clear();
|
||||
practice?.clear();
|
||||
allPicked = kind;
|
||||
@@ -351,7 +334,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
await Promise.all([refreshStructure(), matset.refresh()]);
|
||||
await refreshStructure();
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
|
||||
@@ -155,23 +155,8 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 좌측 「자재 조합」 상세(메인 칸) — 이름 · 비고 · 본뜬키 + 담은 자재 표(줄 모양은 재료산출 옆 판 `m02-mat__row`) */
|
||||
.m02-matset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
max-width: 56rem;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-matset__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-matset__head {
|
||||
/* 구조물 옵션 보이기 — 종류 제목 */
|
||||
.m02-options__head {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
POST …/items {structure, layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날}
|
||||
POST …/items/new {structure, 경로, 이름, 단위} 새 항목(개인 층에도) · 회사 없음 409
|
||||
GET …/items/{폴더}/{종류} {종류, 판, 내용}
|
||||
PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적 · 재료는 서버 계산값
|
||||
PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적은 서버 계산값
|
||||
POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림)
|
||||
POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감)
|
||||
POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력)
|
||||
일위대가 문서면 줄마다 수량 · 반영 + 자재 · 기준합 · 반영합
|
||||
종류 = 도면 · 면적 · 재료 · 자재조합 · 입력연결 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) ·
|
||||
종류 = 도면 · 면적 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) ·
|
||||
`refresh` = 옛 이름(재적용).
|
||||
⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404).
|
||||
"""
|
||||
@@ -33,7 +33,7 @@ from M02_MasterTemplete.M02_Item_Source import item_name
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"])
|
||||
|
||||
Kind = Literal["도면", "면적", "재료", "자재조합", "입력연결", "입력값", "고른자재", "일위대가"]
|
||||
Kind = Literal["도면", "면적", "입력값", "고른자재", "일위대가"]
|
||||
|
||||
|
||||
class WriteBody(BaseModel):
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""M02 양식 층 — 네 층의 자리 · 읽기 · 쓰기 · 복사 · manifest.
|
||||
|
||||
층 넷 (PLAN 10-5):
|
||||
system `resources/master_template/{table,drawing,structure,area,material}/<이름>.json` (git)
|
||||
company `storage/{회사}/templates/{table,drawing,structure,area,material}/`
|
||||
personal `storage/{회사}/{사용자}/templates/{table,drawing,structure,area,material}/`
|
||||
system `resources/master_template/{table,drawing,structure,area}/<이름>.json` (git)
|
||||
company `storage/{회사}/templates/{table,drawing,structure,area}/`
|
||||
personal `storage/{회사}/{사용자}/templates/{table,drawing,structure,area}/`
|
||||
project `storage/{회사}/{사용자}/{프로젝트}/templates/{…같은 다섯}/`
|
||||
+ 초기 사본 `templates/_initial/`
|
||||
|
||||
@@ -26,9 +26,9 @@ from common_util.common_util_json import atomic_write_json
|
||||
from config import config_system
|
||||
|
||||
LAYERS = ("system", "company", "personal", "project")
|
||||
# area = 면적산출 · material = 재료산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`)
|
||||
# area = 면적산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`)
|
||||
# labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`)
|
||||
KINDS = ("table", "drawing", "structure", "labor", "area", "material")
|
||||
KINDS = ("table", "drawing", "structure", "labor", "area")
|
||||
TEMPLATES_DIRNAME = "templates"
|
||||
INITIAL_DIRNAME = "_initial"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
@@ -171,7 +171,6 @@ _SKELETON = {
|
||||
"structure": ("도면.entities",),
|
||||
"labor": ("담은로직",),
|
||||
"area": ("시트",),
|
||||
"material": ("시트",),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user