Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_5' and 'origin/sub_laptop_7' into sub_laptop_4
This commit is contained in:
@@ -269,7 +269,10 @@ async def _resolve(
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
|
||||
requested = parse_pipe_points((payload or {}).get("points"))
|
||||
try:
|
||||
requested = parse_pipe_points((payload or {}).get("points"))
|
||||
except ValueError as error: # 집계 항목(summary_item) 모양이 틀림
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
|
||||
signature = route_signature(context.vertices)
|
||||
stored = (
|
||||
load_pipe_points(context.stored_path, signature, context.vertices) if use_stored else None
|
||||
@@ -349,6 +352,11 @@ async def put_pipe_points(
|
||||
_basin_features(context, detail, points),
|
||||
context.crs,
|
||||
)
|
||||
# 지정한 집계 항목을 프로젝트에 복사 · 안 쓰는 복사본 지움(PLAN 21-3 · 실패해도 저장 유지)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import safe_sync
|
||||
|
||||
await asyncio.to_thread(safe_sync, resolve_stored_project_path(context.stored_path))
|
||||
result = _payload(project_id, context, detail, points, saved=True)
|
||||
result["saved_count"] = saved
|
||||
return result
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
쪽이 앞의 편집을 조용히 지우지 않게.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -32,9 +33,6 @@ from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureListResponse,
|
||||
StructureSaveRequest,
|
||||
StructureSaveResponse,
|
||||
StructureTypesResponse,
|
||||
load_structure_types,
|
||||
registry_schema_version,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PipePoint,
|
||||
@@ -44,6 +42,7 @@ from common_util.common_util_drainage_pipes import (
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Options import TypesWithVisible, with_visible
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,13 +57,10 @@ _PROJECT_PATH_MISSING = {
|
||||
}
|
||||
|
||||
|
||||
@router.get("/structure-types", response_model=StructureTypesResponse)
|
||||
async def read_structure_types() -> StructureTypesResponse:
|
||||
"""구조물 타입 레지스트리 정본을 그대로 돌려준다(화면 폼 생성용)."""
|
||||
return StructureTypesResponse(
|
||||
schema_version=registry_schema_version(),
|
||||
types=list(load_structure_types()),
|
||||
)
|
||||
@router.get("/structure-types", response_model=TypesWithVisible)
|
||||
async def read_structure_types() -> TypesWithVisible:
|
||||
"""구조물 타입 레지스트리 정본 + 옵션마다 `visible`(M02 옵션 보이기 · 폼 생성용)."""
|
||||
return with_visible()
|
||||
|
||||
|
||||
async def _project_root(project_id: UUID) -> str | None:
|
||||
@@ -282,6 +278,10 @@ async def write_structures(
|
||||
base_revision=payload.base_revision,
|
||||
max_chainage_m=await _route_length(project_id),
|
||||
)
|
||||
# 지정한 집계 항목을 프로젝트에 복사 · 안 쓰는 복사본 지움(PLAN 21-3 · 실패해도 저장 유지)
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import safe_sync
|
||||
|
||||
await asyncio.to_thread(safe_sync, root)
|
||||
# 설계에 영향을 주는 변경일 때만 B06 이후를 STALE로 돌린다 — 메모만 고쳐도
|
||||
# 횡단·수량을 다시 돌리게 만들지 않기 위함이다. 응답 플래그는 실제로 STALE
|
||||
# 전파가 **성공했을 때만** true(실패를 성공처럼 알리지 않는다).
|
||||
|
||||
@@ -13,7 +13,9 @@ import os
|
||||
from functools import lru_cache
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from common_util.common_util_summary_item import parse_summary_item
|
||||
|
||||
# 배치형태: 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적).
|
||||
STRUCTURE_PLACEMENTS = ("point", "interval", "site")
|
||||
@@ -156,6 +158,13 @@ class StructureInstance(BaseModel):
|
||||
revision: int = Field(default=0, ge=0)
|
||||
# 부지형 polygon 예약 — 1차 UI는 측점+면적 속성만 쓴다.
|
||||
geometry: dict[str, Any] | None = None
|
||||
# 구조물 집계표 항목(PLAN 21-1) — 없으면 null(옛 파일 호환) · [저장] 뒤 프로젝트 스냅샷
|
||||
summary_item: dict[str, Any] | None = None
|
||||
|
||||
@field_validator("summary_item")
|
||||
@classmethod
|
||||
def validate_summary_item(cls, value: Any) -> dict[str, Any] | None:
|
||||
return parse_summary_item(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_placement_fields(self) -> "StructureInstance":
|
||||
|
||||
@@ -178,7 +178,7 @@ export function buildSide(
|
||||
children: [
|
||||
el("span", {
|
||||
children: [
|
||||
el("span", { className: "m01-tree__caret", text: caret }),
|
||||
el("span", { className: "ui-tree__caret", text: caret }),
|
||||
el("span", { text: node.label }),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -17,24 +17,6 @@
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-tree__kids {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.m01-tree__branch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.m01-tree__caret {
|
||||
display: inline-block;
|
||||
width: var(--spacing-12);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-side__muted,
|
||||
.m01-master__muted {
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -1,44 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Tree.ts
|
||||
* 왼쪽 패널 트리 — 부모(접기/펴기) · 자식(들여쓰기) · 줄 수 표시
|
||||
* 왼쪽 패널 트리 — 공용 `ui_template_tree` 를 그대로 다시 내보냄(부르는 쪽 import 불변)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
|
||||
export interface TreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
/** 줄 수 — null 이면 안 보임 */
|
||||
count: number | null;
|
||||
/** 눌렀을 때 — 없으면 접기/펴기만 */
|
||||
run?: () => void;
|
||||
children?: TreeNode[];
|
||||
/** 처음부터 펴짐 */
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */
|
||||
export type MakeRow = (
|
||||
node: TreeNode,
|
||||
caret: string,
|
||||
depth: number,
|
||||
onClick: () => void,
|
||||
) => HTMLElement;
|
||||
|
||||
export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] {
|
||||
return nodes.map((node) => {
|
||||
if (!node.children?.length) return make(node, "", depth, () => node.run?.());
|
||||
const kids = el("div", {
|
||||
className: "m01-tree__kids",
|
||||
children: renderTree(node.children, make, depth + 1),
|
||||
});
|
||||
kids.hidden = !node.open;
|
||||
const row = make(node, kids.hidden ? "▸" : "▾", depth, () => {
|
||||
kids.hidden = !kids.hidden;
|
||||
const caret = row.querySelector(".m01-tree__caret");
|
||||
if (caret) caret.textContent = kids.hidden ? "▸" : "▾";
|
||||
node.run?.();
|
||||
});
|
||||
return el("div", { className: "m01-tree__branch", children: [row, kids] });
|
||||
});
|
||||
}
|
||||
export { renderTree, type MakeRow, type TreeNode } from "@ui/ui_template_tree";
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""구조물 집계표 항목 스냅샷 — 프로젝트 `templates/items/{폴더}/` (PLAN 21-1 · 21-3).
|
||||
|
||||
[저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤:
|
||||
- 구조물 · 관 지점이 가리키는 항목(`summary_item`)마다 원본 층에서 내용을 복사
|
||||
(`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · `재료.json` SM ·
|
||||
`자재조합.json` UM 줄 · `일위대가.json` UA 줄 + 담은 로직 줄 · `manifest.json`)
|
||||
- 이미 있는 폴더는 내용을 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌어도 프로젝트
|
||||
값은 지정한 때 그대로 · `manifest` 의 쓰는 구조물 목록만 새로
|
||||
- 아무 구조물도 안 쓰는 폴더는 지움(지정 바뀜 · 해제 · 구조물 삭제)
|
||||
원본 층 자리 = `M02_Template_Layers` 경로(시스템 층은 지금 파일) · 일위대가는 M01 시스템 것.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_summary_item import snapshot_folder_name
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ITEMS_DIRNAME = "items"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
TABLE_NAME = "구조물집계표"
|
||||
UM_FILE = "자재조합.json"
|
||||
UA_FILE = "일위대가조합.json" # M01 시스템(`resources/master_data/`)
|
||||
#: 열 id → 스냅샷 파일(구조물 도면 · 면적산출 · 재료산출)
|
||||
PART_FILES = (("structure", "도면.json"), ("area", "면적.json"), ("material", "재료.json"))
|
||||
|
||||
|
||||
def items_dir(project_root: str | Path) -> Path:
|
||||
return layers.project_dir(project_root) / ITEMS_DIRNAME
|
||||
|
||||
|
||||
def layer_root(item: dict[str, Any]) -> Path:
|
||||
"""항목 원본 층 뿌리 — 시스템 · 회사 · 개인(21-2 층 폴더 함수가 오면 그리로)."""
|
||||
if item["layer"] == "system":
|
||||
return layers.system_dir()
|
||||
if item["layer"] == "company":
|
||||
return layers.company_dir(item["owner"])
|
||||
company, user = str(item["owner"]).split("/")
|
||||
return layers.personal_dir(company, user)
|
||||
|
||||
|
||||
def _read(path: Path) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _row(document: Any, key: str) -> dict[str, Any] | None:
|
||||
rows = document.get("줄") if isinstance(document, dict) else None
|
||||
for row in rows or []:
|
||||
if isinstance(row, dict) and row.get("키") == key:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _labor_rows(ua_key: str) -> dict[str, Any] | None:
|
||||
"""UA 줄 + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만 `빠진로직`)."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
ua = _row(_read(Path(m01.FOLDER) / UA_FILE), ua_key)
|
||||
if ua is None:
|
||||
return None
|
||||
wanted = [str(entry.get("로직")) for entry in ua.get("담은로직") or [] if entry.get("로직")]
|
||||
found: dict[str, dict[str, Any]] = {}
|
||||
raw: dict[str, Any] = {} # M01 적재본은 수가 Decimal — 줄은 원본 파일 JSON 그대로 담음
|
||||
for key in wanted:
|
||||
try:
|
||||
file = m01.logic(key)["file"]
|
||||
except m01.StoreError:
|
||||
continue
|
||||
if file not in raw:
|
||||
raw[file] = _read(Path(m01.FOLDER) / file)
|
||||
row = _row(raw[file], key)
|
||||
if row is not None:
|
||||
found[key] = row
|
||||
return {
|
||||
"줄": ua,
|
||||
"로직": [found[key] for key in wanted if key in found],
|
||||
"빠진로직": [key for key in wanted if key not in found],
|
||||
}
|
||||
|
||||
|
||||
def build_snapshot(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""원본 층에서 항목 내용을 모음 — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError."""
|
||||
root = layer_root(item)
|
||||
row = _row(_read(root / layers.COMBO_FILE), item["key"])
|
||||
if row is None:
|
||||
raise LookupError(f"집계 항목 {item['key']} 이 {item['layer']} 층에 없음")
|
||||
column_id = str(row.get("열") or "")
|
||||
table = _read(root / "table" / f"{TABLE_NAME}.json") or {}
|
||||
column = next((c for c in table.get("열") or [] if c.get("id") == column_id), None)
|
||||
files: dict[str, Any] = {"집계.json": {"줄": row, "열": column}}
|
||||
for kind, name in PART_FILES:
|
||||
part = _read(root / kind / f"{column_id}.json") if column_id else None
|
||||
if part is not None:
|
||||
files[name] = part
|
||||
if row.get("자재조합"):
|
||||
um = _row(_read(root / UM_FILE), str(row["자재조합"]))
|
||||
if um is not None:
|
||||
files["자재조합.json"] = um
|
||||
if row.get("일위대가"):
|
||||
labor = _labor_rows(str(row["일위대가"]))
|
||||
if labor is not None:
|
||||
files["일위대가.json"] = labor
|
||||
return files
|
||||
|
||||
|
||||
def assignments(project_root: str | Path) -> dict[str, tuple[dict[str, Any], list[str]]]:
|
||||
"""`{폴더 이름: (항목, 쓰는 구조물 id)}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`)."""
|
||||
found: dict[str, tuple[dict[str, Any], list[str]]] = {}
|
||||
|
||||
def add(item: dict[str, Any] | None, owner_id: str) -> None:
|
||||
if item:
|
||||
found.setdefault(snapshot_folder_name(item), (item, []))[1].append(owner_id)
|
||||
|
||||
_, structures = load_structures(str(project_root))
|
||||
for index, structure in enumerate(structures):
|
||||
add(structure.summary_item, structure.structure_id or f"#{index}")
|
||||
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))):
|
||||
add(point.summary_item, f"pipe@{round(float(point.chainage_m), 3)}")
|
||||
return found
|
||||
|
||||
|
||||
def _version(files: dict[str, Any]) -> str:
|
||||
raw = json.dumps(files, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
return hashlib.sha256(raw).hexdigest()[:16]
|
||||
|
||||
|
||||
def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]:
|
||||
"""지정 항목 스냅샷을 맞춤 — `{만듦, 지움, 못만듦}` 폴더 이름."""
|
||||
base = items_dir(project_root)
|
||||
wanted = assignments(project_root)
|
||||
made: list[str] = []
|
||||
failed: list[str] = []
|
||||
for folder, (item, users) in sorted(wanted.items()):
|
||||
target = base / folder
|
||||
manifest = _read(target / MANIFEST_NAME)
|
||||
if isinstance(manifest, dict):
|
||||
if manifest.get("구조물") != users:
|
||||
atomic_write_json(target / MANIFEST_NAME, {**manifest, "구조물": users})
|
||||
continue
|
||||
try:
|
||||
files = build_snapshot(item)
|
||||
except (LookupError, ValueError) as error:
|
||||
logger.warning("집계 항목 스냅샷을 못 만듦 (%s): %s", folder, error)
|
||||
failed.append(folder)
|
||||
continue
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for name, content in files.items():
|
||||
atomic_write_json(target / name, content)
|
||||
# manifest 는 마지막 — 있으면 다 쓴 폴더(도중에 끊기면 다음 저장이 다시 씀)
|
||||
atomic_write_json(
|
||||
target / MANIFEST_NAME,
|
||||
{
|
||||
"원본": item,
|
||||
"판": _version(files),
|
||||
"적용일": datetime.now().isoformat(timespec="seconds"),
|
||||
"구조물": users,
|
||||
},
|
||||
)
|
||||
made.append(folder)
|
||||
removed: list[str] = []
|
||||
if base.is_dir():
|
||||
for child in sorted(base.iterdir()):
|
||||
if child.is_dir() and child.name not in wanted:
|
||||
shutil.rmtree(child)
|
||||
removed.append(child.name)
|
||||
return {"만듦": made, "지움": removed, "못만듦": failed}
|
||||
|
||||
|
||||
def safe_sync(project_root: str | Path) -> None:
|
||||
"""저장 뒤 부름 — 실패해도 저장은 그대로(스냅샷은 다음 저장이 다시 맞춤)."""
|
||||
try:
|
||||
result = sync_snapshots(project_root)
|
||||
if any(result.values()):
|
||||
logger.info("집계 항목 스냅샷: %s", result)
|
||||
except Exception:
|
||||
logger.exception("집계 항목 스냅샷 맞춤 실패 (저장은 유지): %s", project_root)
|
||||
|
||||
|
||||
def read_snapshot(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""프로젝트 스냅샷의 `집계.json`(`{줄, 열}`) — 없으면 None."""
|
||||
data = _read(items_dir(project_root) / snapshot_folder_name(item) / "집계.json")
|
||||
return data if isinstance(data, dict) else None
|
||||
@@ -250,6 +250,29 @@ export const editMaterialCombo = (
|
||||
export const deleteMaterialCombo = (키: string, 판: string): Promise<unknown> =>
|
||||
call(`/material-combos/${enc(키)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
|
||||
/* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */
|
||||
export interface OptionKind {
|
||||
type_id: string;
|
||||
group: string;
|
||||
name: string;
|
||||
/** 거짓이면 B05 선택지에 안 나오는 종류 */
|
||||
enabled: boolean;
|
||||
options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[];
|
||||
}
|
||||
|
||||
/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */
|
||||
export interface OptionsDoc {
|
||||
판: string;
|
||||
표시: Record<string, Record<string, false>>;
|
||||
종류: OptionKind[];
|
||||
}
|
||||
|
||||
export const fetchStructureOptions = (): Promise<OptionsDoc> => call("/structure-options");
|
||||
|
||||
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
|
||||
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
|
||||
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
|
||||
|
||||
/** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */
|
||||
export const copyStructure = async (column: string, from: string): Promise<void> => {
|
||||
await call(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""M02 구조물 옵션 보이기 — 구조물 종류마다 B05/B06 폼에 보일 옵션을 사용자가 체크.
|
||||
|
||||
정본 = `resources/master_template/구조물옵션표시.json` = `{type_id: {option_key: false}}`
|
||||
— 없는 종류 · 키는 보임(파일이 없어도 모두 보임).
|
||||
저장은 **숨김(false)만** 남김 — 「모두 보임」 = `{}`.
|
||||
옵션 정의 · 옛 값은 그대로 — 폼이 그리는 것만 갈림(`GET /api/projects/structure-types` `visible`).
|
||||
판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureOptionField,
|
||||
StructureType,
|
||||
StructureTypesResponse,
|
||||
load_structure_types,
|
||||
registry_schema_version,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
FILE = "구조물옵션표시.json"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OptionWithVisible(StructureOptionField):
|
||||
"""폼 옵션 한 칸 + 보임 여부(서버가 채움)."""
|
||||
|
||||
visible: bool = True
|
||||
|
||||
|
||||
class TypeWithVisible(StructureType):
|
||||
options: list[OptionWithVisible] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TypesWithVisible(StructureTypesResponse):
|
||||
"""`GET /structure-types` 응답 — 옵션마다 `visible` 칸."""
|
||||
|
||||
types: list[TypeWithVisible]
|
||||
|
||||
|
||||
def _load() -> tuple[dict[str, Any], str]:
|
||||
"""(숨김 표 · 판) — 파일이 없으면 빈 표 · 판 빈 글."""
|
||||
path = store.FOLDER / FILE
|
||||
if not path.is_file():
|
||||
return {}, ""
|
||||
raw = path.read_bytes()
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except ValueError as e:
|
||||
raise store.StoreError(500, f"{FILE} 을 못 읽음 — {e}") from e
|
||||
if not isinstance(doc, dict):
|
||||
raise store.StoreError(500, f"{FILE} 은 종류별 묶음이어야 함")
|
||||
return doc, store.version_of(raw)
|
||||
|
||||
|
||||
def _shown(doc: dict[str, Any], type_id: str, key: str) -> bool:
|
||||
row = doc.get(type_id)
|
||||
return not (isinstance(row, dict) and row.get(key) is False)
|
||||
|
||||
|
||||
def with_visible() -> TypesWithVisible:
|
||||
"""레지스트리 정본 + 보임 여부 — 파일이 깨졌으면 모두 보임(폼이 죽지 않게 · 로그만)."""
|
||||
try:
|
||||
doc = _load()[0]
|
||||
except store.StoreError as e:
|
||||
logger.warning("구조물 옵션 표시 표를 못 읽어 모두 보임으로 냄 — %s", e.detail)
|
||||
doc = {}
|
||||
types = [
|
||||
{
|
||||
**t.model_dump(),
|
||||
"options": [
|
||||
{**o.model_dump(), "visible": _shown(doc, t.type_id, o.key)} for o in t.options
|
||||
],
|
||||
}
|
||||
for t in load_structure_types()
|
||||
]
|
||||
return TypesWithVisible.model_validate(
|
||||
{"schema_version": registry_schema_version(), "types": types}
|
||||
)
|
||||
|
||||
|
||||
def listing() -> dict[str, Any]:
|
||||
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션이 있는 종류와 그 옵션."""
|
||||
doc, version = _load()
|
||||
hidden: dict[str, dict[str, bool]] = {}
|
||||
kinds = []
|
||||
for t in load_structure_types():
|
||||
if not t.options:
|
||||
continue
|
||||
off = {o.key: False for o in t.options if not _shown(doc, t.type_id, o.key)}
|
||||
if off:
|
||||
hidden[t.type_id] = off
|
||||
kinds.append(
|
||||
{
|
||||
"type_id": t.type_id,
|
||||
"group": t.group,
|
||||
"name": t.name,
|
||||
"enabled": t.enabled,
|
||||
"options": [
|
||||
{"key": o.key, "label": o.label, "phase": o.phase, "required": o.required}
|
||||
for o in t.options
|
||||
],
|
||||
}
|
||||
)
|
||||
return {"판": version, "표시": hidden, "종류": kinds}
|
||||
|
||||
|
||||
def _clean(shown: Any) -> dict[str, dict[str, bool]]:
|
||||
"""받은 표 검사 → 숨김만 남김. 없는 종류 · 키 · 참거짓 아닌 값은 422."""
|
||||
if not isinstance(shown, dict):
|
||||
raise store.StoreError(400, "표시는 종류별 묶음이어야 함")
|
||||
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
|
||||
errors: list[str] = []
|
||||
out: dict[str, dict[str, bool]] = {}
|
||||
for type_id, row in shown.items():
|
||||
if type_id not in keys:
|
||||
errors.append(f"없는 구조물 종류 「{type_id}」")
|
||||
elif not isinstance(row, dict):
|
||||
errors.append(f"「{type_id}」 는 옵션별 묶음이어야 함")
|
||||
else:
|
||||
for key, value in row.items():
|
||||
if key not in keys[type_id]:
|
||||
errors.append(f"「{type_id}」 에 없는 옵션 「{key}」")
|
||||
elif not isinstance(value, bool):
|
||||
errors.append(f"「{type_id}.{key}」 는 참 · 거짓이어야 함")
|
||||
elif not value:
|
||||
out.setdefault(type_id, {})[key] = False
|
||||
if errors:
|
||||
raise store.StoreError(422, {"errors": errors})
|
||||
return out
|
||||
|
||||
|
||||
def save(shown: Any, version: str) -> dict[str, Any]:
|
||||
"""고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`."""
|
||||
fixed = _clean(shown)
|
||||
path = store.FOLDER / 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, fixed)
|
||||
return listing()
|
||||
@@ -0,0 +1,220 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Options.ts
|
||||
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도) — 누르면 메인 칸에
|
||||
* 종류 목록(군별) → 고른 종류의 옵션 체크 목록(라벨 · 키 작은 글 · B06 전용 · 필수 표시) ·
|
||||
* [저장] · [모두 보임]. 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
|
||||
* 서버 = `M02_MasterTemplete_Options.py`(마스터 템플릿 `구조물옵션표시.json` · 숨긴 것만 저장).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchStructureOptions,
|
||||
saveStructureOptions,
|
||||
StaleError,
|
||||
type OptionKind,
|
||||
type OptionsDoc,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { to } from "./M02_MasterTemplete_Options_Text";
|
||||
|
||||
export interface OptionsSectionOptions {
|
||||
isAdmin: boolean;
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
|
||||
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export interface OptionsSection {
|
||||
title: string;
|
||||
body: HTMLElement;
|
||||
/** 다른 화면을 열었음 — 고름 표시만 지움 */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
|
||||
export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection {
|
||||
const row = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
text: to("Row"),
|
||||
attrs: { type: "button", "data-options-row": "" },
|
||||
});
|
||||
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
|
||||
row.addEventListener("click", () => void open());
|
||||
|
||||
/** 이 화면이 열려 있는 동안 참 — 다른 것이 열리면 `clear` 가 내림 */
|
||||
let opened = false;
|
||||
const mark = (on: boolean): void => {
|
||||
opened = on;
|
||||
row.classList.toggle("is-active", on);
|
||||
};
|
||||
|
||||
async function open(): Promise<void> {
|
||||
if (opened || !(await opt.confirmLeave())) return;
|
||||
let doc: OptionsDoc;
|
||||
try {
|
||||
doc = await fetchStructureOptions();
|
||||
} catch (error) {
|
||||
return showToast(to("Load_Failed", why(error)), "error");
|
||||
}
|
||||
draw(doc);
|
||||
}
|
||||
|
||||
/** 메인 칸 화면 — `doc.표시` 를 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */
|
||||
function draw(doc: OptionsDoc): void {
|
||||
let version = doc.판;
|
||||
const edit = opt.isAdmin;
|
||||
/** 숨긴 옵션 — 종류 → 키 집합 */
|
||||
const hidden = new Map<string, Set<string>>();
|
||||
const load = (src: OptionsDoc["표시"]): void => {
|
||||
hidden.clear();
|
||||
for (const [id, keys] of Object.entries(src)) hidden.set(id, new Set(Object.keys(keys)));
|
||||
};
|
||||
load(doc.표시);
|
||||
let kinds = doc.종류;
|
||||
let current: OptionKind | null = null;
|
||||
|
||||
const kindList = el("div", { className: "m02-opts__kinds" });
|
||||
const optionList = el("div", { className: "m02-opts__list" });
|
||||
const off = (id: string): number => hidden.get(id)?.size ?? 0;
|
||||
|
||||
const paintKinds = (): void => {
|
||||
const parts: HTMLElement[] = [];
|
||||
let group = "";
|
||||
for (const k of kinds) {
|
||||
if (k.group !== group) {
|
||||
group = k.group;
|
||||
parts.push(
|
||||
el("h4", {
|
||||
className: "m02-opts__group",
|
||||
text: group.length === 1 ? to("Group", group) : group,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const n = off(k.type_id);
|
||||
const b = el("button", {
|
||||
className: `m02-opts__kind${k.type_id === current?.type_id ? " is-active" : ""}`,
|
||||
attrs: { type: "button", "data-options-kind": k.type_id },
|
||||
children: [
|
||||
el("span", { text: k.name }),
|
||||
...(n ? [createTag(to("Hidden", String(n)), "warning")] : []),
|
||||
...(k.enabled ? [] : [createTag(to("Off"), "neutral")]),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
current = k;
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
});
|
||||
parts.push(b);
|
||||
}
|
||||
kindList.replaceChildren(...parts);
|
||||
};
|
||||
|
||||
const paintOptions = (): void => {
|
||||
if (!current) {
|
||||
optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
|
||||
return;
|
||||
}
|
||||
const kind = current;
|
||||
optionList.replaceChildren(
|
||||
el("h3", { className: "m02-matset__head", text: kind.name }),
|
||||
...kind.options.map((o) => {
|
||||
const box = el("input", {
|
||||
attrs: { type: "checkbox", "data-options-key": o.key },
|
||||
}) as HTMLInputElement;
|
||||
box.checked = !hidden.get(kind.type_id)?.has(o.key);
|
||||
box.disabled = !edit;
|
||||
box.addEventListener("change", () => {
|
||||
const set = hidden.get(kind.type_id) ?? new Set<string>();
|
||||
if (box.checked) set.delete(o.key);
|
||||
else set.add(o.key);
|
||||
hidden.set(kind.type_id, set);
|
||||
setDirty(true);
|
||||
paintKinds();
|
||||
});
|
||||
const tags: HTMLElement[] = [];
|
||||
if (o.phase === "detail") tags.push(createTag(to("Detail"), "accent"));
|
||||
if (o.required && o.phase === "b05") {
|
||||
const tag = createTag(to("Required"), "danger");
|
||||
tag.title = to("RequiredTip");
|
||||
tags.push(tag);
|
||||
}
|
||||
return el("label", {
|
||||
className: "m02-opts__option",
|
||||
children: [
|
||||
box,
|
||||
el("span", { text: o.label }),
|
||||
el("small", { className: "m02-opts__key", text: o.key }),
|
||||
...tags,
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const save = createButton({ label: to("Save"), variant: "filled" });
|
||||
const all = createButton({ label: to("ShowAll"), variant: "ghost" });
|
||||
all.addEventListener("click", () => {
|
||||
if (![...hidden.values()].some((s) => s.size)) return;
|
||||
hidden.clear();
|
||||
setDirty(true);
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
});
|
||||
save.addEventListener("click", async () => {
|
||||
const 표시: OptionsDoc["표시"] = {};
|
||||
for (const [id, keys] of hidden)
|
||||
if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const]));
|
||||
save.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
const done = await saveStructureOptions(version, 표시);
|
||||
version = done.판;
|
||||
load(done.표시);
|
||||
setDirty(false);
|
||||
showToast(to("Saved"), "success");
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError) {
|
||||
showToast(to("Stale"), "error");
|
||||
try {
|
||||
const fresh = await fetchStructureOptions();
|
||||
version = fresh.판;
|
||||
kinds = fresh.종류;
|
||||
load(fresh.표시);
|
||||
setDirty(false);
|
||||
current = kinds.find((k) => k.type_id === current?.type_id) ?? null;
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
} catch (again) {
|
||||
showToast(to("Load_Failed", why(again)), "error");
|
||||
}
|
||||
} else showToast(to("Failed", why(error)), "error");
|
||||
} finally {
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const node = el("div", {
|
||||
className: "m02-opts",
|
||||
children: [
|
||||
el("p", { className: "m02-side__empty", text: to("Note") }),
|
||||
el("div", {
|
||||
className: "m02-opts__panes",
|
||||
children: [kindList, optionList],
|
||||
}),
|
||||
...(edit ? [el("div", { className: "m02-side__row", children: [save, all] })] : []),
|
||||
],
|
||||
});
|
||||
const setDirty = opt.show(to("Screen"), node);
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
opt.onOpen();
|
||||
mark(true);
|
||||
}
|
||||
|
||||
return { title: to("Title"), body, clear: () => mark(false) };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Options_Text.ts
|
||||
* 좌측 「구조물 옵션 보이기」 줄 · 상세 화면 글자 — [한국어, 영어]
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["구조물 옵션 보이기", "Structure options shown"],
|
||||
Row: ["종류마다 보일 옵션 고르기", "Choose options per type"],
|
||||
Screen: ["구조물 옵션 보이기", "Structure options shown"],
|
||||
Note: [
|
||||
"체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임",
|
||||
"Only checked options appear in the B05 · B06 forms — definitions and stored values stay",
|
||||
],
|
||||
Group: ["{value}군", "Group {value}"],
|
||||
Hidden: ["숨김 {value}", "{value} hidden"],
|
||||
Off: ["안 쓰는 종류", "Not offered"],
|
||||
NoOption: ["옵션 없음", "No options"],
|
||||
Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"],
|
||||
Detail: ["B06 전용", "B06 only"],
|
||||
Required: ["필수", "Required"],
|
||||
RequiredTip: [
|
||||
"필수 옵션 — 숨기면 B05 폼에서 값을 못 넣어 저장이 막힐 수 있음",
|
||||
"Required — hiding it leaves the B05 form unable to set it",
|
||||
],
|
||||
Save: ["저장", "Save"],
|
||||
ShowAll: ["모두 보임", "Show all"],
|
||||
Saved: ["저장함", "Saved"],
|
||||
Stale: [
|
||||
"그 사이 옵션 표시가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
|
||||
"Changed meanwhile — reloaded, please edit again",
|
||||
],
|
||||
Load_Failed: ["옵션 표시를 못 받음 — {value}", "Could not load option display — {value}"],
|
||||
Failed: ["옵션 표시를 못 저장함 — {value}", "Could not save option display — {value}"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type OptionsTextKey = keyof typeof TEXT;
|
||||
|
||||
export function to(key: OptionsTextKey, value = ""): string {
|
||||
const entry = TEXT[key];
|
||||
return (entry[currentLanguageIndex as 0 | 1] ?? entry[0]).replace("{value}", value);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
|
||||
@@ -41,6 +42,11 @@ class MatsetBody(BaseModel):
|
||||
줄: dict[str, Any]
|
||||
|
||||
|
||||
class OptionsBody(BaseModel):
|
||||
판: str = "" # 옵션 표시 표 판 — 어긋나면 409
|
||||
표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김
|
||||
|
||||
|
||||
def _call(fn, *args):
|
||||
try:
|
||||
return fn(*args)
|
||||
@@ -127,6 +133,19 @@ def delete_material_combo(key: str, 판: str = "") -> dict:
|
||||
return _call(matset.delete, key, 판)
|
||||
|
||||
|
||||
# 구조물 옵션 보이기 — B05/B06 폼에 보일 옵션(마스터 템플릿 `구조물옵션표시.json`)
|
||||
@router.get("/structure-options")
|
||||
def get_structure_options() -> dict:
|
||||
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션 있는 종류와 그 옵션."""
|
||||
return _call(options.listing)
|
||||
|
||||
|
||||
@router.put("/structure-options")
|
||||
def put_structure_options(body: OptionsBody) -> dict:
|
||||
"""고쳐 씀(낡은 판이면 409 · 없는 종류 · 키는 422) — 돌려주는 것 = 새 `{판, 표시, 종류}`."""
|
||||
return _call(options.save, body.표시, body.판)
|
||||
|
||||
|
||||
# 복사 가져오기 — 다른 열(`from`) 내용을 받는 열로 · 번호 · 열은 받는 쪽 그대로
|
||||
@router.post("/structures/{column}/copy")
|
||||
def post_structure_copy(column: str, source: str = Query(alias="from")) -> dict:
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type StructureColumn,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
|
||||
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
@@ -122,13 +123,20 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
close: () => opt.onOpen(null),
|
||||
onOpen: () => paint(),
|
||||
onOpen: () => (options.clear(), 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(), paint()),
|
||||
});
|
||||
// 구조물 집계 양식 — 집계표 줄 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음)
|
||||
const summary = section(
|
||||
L("M02_KindTable"),
|
||||
@@ -142,6 +150,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
summary,
|
||||
section(options.title, options.body),
|
||||
section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
|
||||
],
|
||||
});
|
||||
@@ -217,6 +226,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
paintColumns();
|
||||
paintButtons();
|
||||
if (opt.getOpen()) matset.clear();
|
||||
if (opt.getOpen()) options.clear();
|
||||
}
|
||||
|
||||
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
|
||||
|
||||
@@ -123,3 +123,73 @@
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 좌측 「구조물 옵션 보이기」 화면(메인 칸) — 왼쪽 종류(군별) · 오른쪽 옵션 체크 */
|
||||
.m02-opts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
max-width: 64rem;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-opts__panes {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 20rem) 1fr;
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.m02-opts__kinds,
|
||||
.m02-opts__list {
|
||||
max-height: calc(100vh - 20rem);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-opts__group {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-opts__kind {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-opts__kind:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-opts__kind.is-active {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-opts__option {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-opts__key {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ def collect_items(
|
||||
"start_m": item.start_m,
|
||||
"end_m": item.end_m,
|
||||
"options": _with_defaults(definition, dict(item.options or {})),
|
||||
"summary_item": item.summary_item,
|
||||
}
|
||||
)
|
||||
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))):
|
||||
@@ -120,6 +121,7 @@ def collect_items(
|
||||
"end_m": None,
|
||||
"options": _with_defaults(types.get(type_id), dict(point.options or {})),
|
||||
"관연장": _nearest(lengths, chainage) if type_id == "pipe" else None,
|
||||
"summary_item": point.summary_item,
|
||||
}
|
||||
)
|
||||
return items
|
||||
@@ -186,6 +188,27 @@ def _sources(binding: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [main, *(binding.get("더함") or [])]
|
||||
|
||||
|
||||
def _home(column: dict[str, Any]) -> str:
|
||||
"""펼친 열이면 마스터 열의 묶음 — 지정 항목이 고른 열(묶음)과 견줌."""
|
||||
return _group(_frame(column).get("원열") or column)
|
||||
|
||||
|
||||
def _assigned_match(source: dict[str, Any], item: dict[str, Any], column: dict[str, Any]) -> bool:
|
||||
"""지정 항목 모드 — 구조물이 고른 열(묶음)에만 · 종류 · 조건 · 옵션으로 자동 맞추지 않음.
|
||||
|
||||
값을 읽을 출처 = 바인딩 출처 가운데 그 구조물 종류 것(없으면 주 출처).
|
||||
"""
|
||||
if item.get("열묶음") != _home(column):
|
||||
return False
|
||||
sources = _sources(_frame(column))
|
||||
typed = [entry for entry in sources if entry.get("종류") == item["type_id"]]
|
||||
return source == (typed[0] if typed else sources[0])
|
||||
|
||||
|
||||
def _auto_match(source: dict[str, Any], item: dict[str, Any], _column: dict[str, Any]) -> bool:
|
||||
return _matches(source, item)
|
||||
|
||||
|
||||
def _expansion(source: dict[str, Any], item: dict[str, Any]) -> tuple[str, ...]:
|
||||
values = []
|
||||
for key in source.get("펼침") or []:
|
||||
@@ -313,6 +336,7 @@ def _expand_columns(
|
||||
items: list[dict[str, Any]],
|
||||
variables: dict[str, Any],
|
||||
choices: dict[str, list[str]],
|
||||
match: Any = _auto_match,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, list[tuple[str, str, dict[str, Any]]]]]:
|
||||
"""펼침 열 → 값마다 열 · 묶음은 값마다 한데(관 Φ800 관매설 · 커플링 → Φ1000 …).
|
||||
|
||||
@@ -333,7 +357,7 @@ def _expand_columns(
|
||||
found = groups.setdefault(_group(column), set())
|
||||
for source in _sources(binding):
|
||||
# 값이 빈 구조물도 열은 세움 — 놓였는데 수량이 빈칸인 것이 보여야 함
|
||||
found.update(_expansion(source, item) for item in items if _matches(source, item))
|
||||
found.update(_expansion(source, item) for item in items if match(source, item, column))
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
made: dict[str, list[tuple[str, str, dict[str, Any]]]] = {}
|
||||
@@ -394,14 +418,19 @@ def fill_document(
|
||||
document: dict[str, Any],
|
||||
items: list[dict[str, Any]],
|
||||
types: dict[str, StructureType] | None = None,
|
||||
assigned: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""표 문서 + 설계 구조물 목록 → 채운 표 문서(새 사본)."""
|
||||
"""표 문서 + 설계 구조물 목록 → 채운 표 문서(새 사본).
|
||||
|
||||
`assigned` = 지정 항목 모드(`fill_table`) — 구조물마다 고른 열(`열묶음`)에만 넣음.
|
||||
"""
|
||||
match = _assigned_match if assigned else _auto_match
|
||||
types = types or structure_type_map()
|
||||
doc = copy.deepcopy(document)
|
||||
variables = dict(doc.get("변수") or {})
|
||||
interval = _number(variables.get("측점간격_m")) or 20.0
|
||||
rule_columns = _unexpand(doc.get("열") or [])
|
||||
columns, made = _expand_columns(rule_columns, items, variables, _choices(types))
|
||||
columns, made = _expand_columns(rule_columns, items, variables, _choices(types), match)
|
||||
notes: list[str] = []
|
||||
|
||||
# 줄 자리 — 관은 한 줄에 하나(둘째 관부터 줄을 나눔)
|
||||
@@ -427,7 +456,7 @@ def fill_document(
|
||||
if source.get("값") in (None, "식"):
|
||||
continue
|
||||
for item, slot in zip(items, item_slot, strict=True):
|
||||
if not _matches(source, item):
|
||||
if not match(source, item, column):
|
||||
continue
|
||||
if expanded is not None and list(_expansion(source, item)) != expanded:
|
||||
continue
|
||||
@@ -558,14 +587,62 @@ def is_fillable(document: Any) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def assign_items(
|
||||
project_root: str | Path, document: dict[str, Any], items: list[dict[str, Any]]
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
|
||||
"""지정 항목 모드 준비 — 구조물마다 스냅샷 열 정의로 고른 열을 정함(PLAN 21-0 · 21-3).
|
||||
|
||||
- 미지정 · 스냅샷 없음 = 표에 안 넣음(자동 맞춤 없음) · 알림 한 줄씩
|
||||
- 시스템 층 항목 = 같은 id 열 자리에 스냅샷 열 정의(원본이 바뀌어도 지정한 때 값)
|
||||
- 회사 · 개인 층 항목 = `열id@스냅샷폴더` 새 열(시스템 열과 id · 묶음이 안 겹치게)
|
||||
돌려주는 것 = (열을 맞춘 문서, `열` · `열묶음` 을 단 구조물, 알림).
|
||||
"""
|
||||
from common_util.common_util_summary_item import snapshot_folder_name
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import read_snapshot
|
||||
|
||||
doc = copy.deepcopy(document)
|
||||
rules = _unexpand(doc.get("열") or [])
|
||||
out: list[dict[str, Any]] = []
|
||||
unassigned = missing = 0
|
||||
for item in items:
|
||||
chosen = item.get("summary_item")
|
||||
column = (read_snapshot(project_root, chosen) or {}).get("열") if chosen else None
|
||||
if not chosen or not isinstance(column, dict) or not column.get("id"):
|
||||
unassigned += not chosen
|
||||
missing += bool(chosen)
|
||||
continue
|
||||
new = copy.deepcopy(column)
|
||||
if chosen["layer"] != "system":
|
||||
new["id"] = f"{column['id']}@{snapshot_folder_name(chosen)}"
|
||||
if isinstance(new.get("바인딩"), dict) and new["바인딩"].get("묶음"):
|
||||
new["바인딩"]["묶음"] = new["id"]
|
||||
at = next((i for i, c in enumerate(rules) if c.get("id") == new["id"]), None)
|
||||
if at is None:
|
||||
rules.append(new)
|
||||
else:
|
||||
rules[at] = new
|
||||
out.append({**item, "열": new["id"], "열묶음": _group(new)})
|
||||
doc["열"] = rules
|
||||
notes = []
|
||||
if unassigned:
|
||||
notes.append(f"집계 항목 미지정 {unassigned}건 — 표에 안 들어감")
|
||||
if missing:
|
||||
notes.append(f"집계 항목 복사본 없음 {missing}건 — [저장] 뒤 채워짐")
|
||||
return doc, out, notes
|
||||
|
||||
|
||||
def fill_table(
|
||||
project_root: str | Path,
|
||||
document: dict[str, Any],
|
||||
pipe_lengths: dict[float, float] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""프로젝트 설계로 표 문서를 채움 — 저장 안 함."""
|
||||
"""프로젝트 설계로 표 문서를 채움 — 저장 안 함 · 지정 항목 모드(미지정은 빈칸)."""
|
||||
types = structure_type_map()
|
||||
return fill_document(document, collect_items(project_root, pipe_lengths, types), types)
|
||||
items = collect_items(project_root, pipe_lengths, types)
|
||||
doc, chosen, notes = assign_items(project_root, document, items)
|
||||
filled = fill_document(doc, chosen, types, assigned=True)
|
||||
filled["알림"] = [*notes, *filled.get("알림", [])]
|
||||
return filled
|
||||
|
||||
|
||||
def recalc(document: dict[str, Any]) -> dict[str, Any] | None:
|
||||
|
||||
@@ -35,6 +35,7 @@ from shapely.geometry import LineString, Point
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex
|
||||
from common_util.common_util_summary_item import parse_summary_item
|
||||
from config.config_system import (
|
||||
DRAINAGE_CACHE_DIRNAME,
|
||||
DRAINAGE_DETAIL_FILENAME,
|
||||
@@ -89,6 +90,8 @@ class PipePoint:
|
||||
# 관이 실제로 놓인 자리(사업지 CRS, m). 노선이 바뀌어도 이 자리는 그대로다.
|
||||
x: float | None = None
|
||||
y: float | None = None
|
||||
# 구조물 집계표 항목(PLAN 21-1 · options 밖) — 없으면 None · 모양은 `common_util_summary_item`
|
||||
summary_item: dict[str, Any] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
# 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다.
|
||||
@@ -110,6 +113,8 @@ class PipePoint:
|
||||
if self.x is not None and self.y is not None:
|
||||
payload["x"] = round(float(self.x), 3)
|
||||
payload["y"] = round(float(self.y), 3)
|
||||
if self.summary_item:
|
||||
payload["summary_item"] = self.summary_item
|
||||
return payload
|
||||
|
||||
|
||||
@@ -349,6 +354,7 @@ def parse_pipe_points(values: Any) -> list[PipePoint]:
|
||||
if isinstance(options, dict) and options
|
||||
else None
|
||||
),
|
||||
summary_item=parse_summary_item(item.get("summary_item")),
|
||||
)
|
||||
)
|
||||
points.sort(key=lambda point: point.chainage_m)
|
||||
@@ -399,6 +405,7 @@ def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint])
|
||||
point.start_m = ref.start_m
|
||||
point.end_m = ref.end_m
|
||||
point.options = ref.options
|
||||
point.summary_item = ref.summary_item
|
||||
return base
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""구조물 집계표 항목 참조 `summary_item` — 모양 검사 · 스냅샷 폴더 이름 (PLAN 21-1).
|
||||
|
||||
summary_item = {layer: "system"|"company"|"personal",
|
||||
owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…"}
|
||||
|
||||
구조물 기록(`structures.json` `StructureInstance`) · 관 지점 기록(`pipe_points.json` 점) 두 곳이
|
||||
같은 칸을 씀 — 검사는 여기 한 벌. 없으면 null(옛 파일 호환).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
SUMMARY_LAYERS = ("system", "company", "personal")
|
||||
_KEY = re.compile(r"^SS\d{6}$")
|
||||
_OWNER = {
|
||||
"system": None,
|
||||
"company": re.compile(r"^\d+$"),
|
||||
"personal": re.compile(r"^\d+/\d+$"),
|
||||
}
|
||||
_NAME_MAX = 200
|
||||
|
||||
|
||||
def parse_summary_item(value: Any) -> dict[str, Any] | None:
|
||||
"""모양이 맞으면 `{layer, owner, key, name}` 새 dict · 빈 값은 None · 틀리면 ValueError."""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("집계 항목(summary_item)은 객체여야 합니다.")
|
||||
extra = set(value) - {"layer", "owner", "key", "name"}
|
||||
if extra:
|
||||
raise ValueError(f"집계 항목에 모르는 칸이 있습니다: {', '.join(sorted(extra))}")
|
||||
layer = value.get("layer")
|
||||
if layer not in SUMMARY_LAYERS:
|
||||
raise ValueError("집계 항목 층은 system · company · personal 중 하나입니다.")
|
||||
owner = value.get("owner")
|
||||
rule = _OWNER[layer]
|
||||
if rule is None:
|
||||
if owner is not None:
|
||||
raise ValueError("시스템 층 집계 항목의 owner 는 null 이어야 합니다.")
|
||||
elif not isinstance(owner, str) or not rule.match(owner):
|
||||
shape = "{회사}" if layer == "company" else "{회사}/{사용자}"
|
||||
raise ValueError(f"{layer} 층 집계 항목의 owner 는 「{shape}」 숫자 모양입니다.")
|
||||
key = value.get("key")
|
||||
if not isinstance(key, str) or not _KEY.match(key):
|
||||
raise ValueError("집계 항목 key 는 SS 와 숫자 6자리입니다(예: SS000004).")
|
||||
name = value.get("name")
|
||||
if not isinstance(name, str) or not name.strip() or len(name) > _NAME_MAX:
|
||||
raise ValueError(f"집계 항목 name 은 1~{_NAME_MAX}자 글입니다.")
|
||||
return {"layer": layer, "owner": owner, "key": key, "name": name}
|
||||
|
||||
|
||||
def snapshot_folder_name(item: dict[str, Any]) -> str:
|
||||
"""`{layer}_{owner 슬러그}_{key}` — owner 없음 = `none` · `/` = `-`.
|
||||
|
||||
예: `system_none_SS000004` · `company_3_SS000001` · `personal_3-12_SS000002`.
|
||||
"""
|
||||
owner = item.get("owner")
|
||||
slug = str(owner).replace("/", "-") if owner else "none"
|
||||
return f"{item['layer']}_{slug}_{item['key']}"
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정본 `구조물옵션표시.json`).
|
||||
|
||||
못박는 것
|
||||
- 파일이 없거나 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible.
|
||||
- 저장한 숨김이 그 응답 visible 에 반영 · 다른 종류 · 옵션은 그대로 · 옵션 정의는 안 바뀜.
|
||||
- 저장은 숨김(false)만 남김 · [모두 보임] = `{}` · 없는 종류 · 키 · 참거짓 아님 422 · 낡은 판 409.
|
||||
- 파일이 깨져도 폼 응답은 모두 보임(죽지 않음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Router import router as b05_router
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Options as opt
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_router
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
URL = "/api/m02/structure-options"
|
||||
TYPES = "/api/projects/structure-types"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
app = FastAPI()
|
||||
app.include_router(m02_router)
|
||||
app.include_router(b05_router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _pick() -> tuple[str, str, str]:
|
||||
"""옵션이 둘 이상인 종류 하나 — (type_id, 숨길 키, 남길 키)."""
|
||||
t = next(t for t in load_structure_types() if len(t.options) >= 2)
|
||||
return t.type_id, t.options[0].key, t.options[1].key
|
||||
|
||||
|
||||
def _visible(client: TestClient) -> dict[str, dict[str, bool]]:
|
||||
types = client.get(TYPES).json()["types"]
|
||||
return {t["type_id"]: {o["key"]: o["visible"] for o in t["options"]} for t in types}
|
||||
|
||||
|
||||
def test_시드_파일은_빈_표이고_없어도_모두_보임(client) -> None:
|
||||
assert (
|
||||
json.loads((ROOT / "resources/master_template/구조물옵션표시.json").read_text("utf-8"))
|
||||
== {}
|
||||
)
|
||||
got = client.get(URL).json()
|
||||
assert got["판"] == "" and got["표시"] == {} # 파일 없음
|
||||
assert all(all(v.values()) for v in _visible(client).values())
|
||||
assert client.get(TYPES).json()["schema_version"] == 1
|
||||
|
||||
|
||||
def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client) -> None:
|
||||
tid, off, on = _pick()
|
||||
before = client.get(TYPES).json()["types"]
|
||||
res = client.put(URL, json={"판": "", "표시": {tid: {off: False, on: True}}})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["표시"] == {tid: {off: False}} # 숨김만 남김
|
||||
seen = _visible(client)
|
||||
assert seen[tid][off] is False and seen[tid][on] is True
|
||||
assert sum(not v for row in seen.values() for v in row.values()) == 1 # 다른 것은 그대로
|
||||
after = client.get(TYPES).json()["types"]
|
||||
strip = lambda ts: [ # noqa: E731
|
||||
{**t, "options": [{k: v for k, v in o.items() if k != "visible"} for o in t["options"]]}
|
||||
for t in ts
|
||||
]
|
||||
assert strip(before) == strip(after) # 옵션 정의(라벨 · 필수 · 선택지 …)는 안 바뀜
|
||||
kind = next(k for k in client.get(URL).json()["종류"] if k["type_id"] == tid)
|
||||
assert [o["key"] for o in kind["options"]][:2] == [off, on] and "phase" in kind["options"][0]
|
||||
|
||||
|
||||
def test_모두_보임은_빈_표_판은_저장마다_바뀜(client) -> None:
|
||||
tid, off, _ = _pick()
|
||||
v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"]
|
||||
res = client.put(URL, json={"판": v1, "표시": {}})
|
||||
assert res.status_code == 200 and res.json()["표시"] == {} and res.json()["판"] != v1
|
||||
assert json.loads((store.FOLDER / opt.FILE).read_text("utf-8")) == {}
|
||||
assert all(all(v.values()) for v in _visible(client).values())
|
||||
|
||||
|
||||
def test_없는_종류_키_참거짓_아님은_422_낡은_판은_409(client) -> None:
|
||||
tid, off, _ = _pick()
|
||||
for bad in (
|
||||
{"없는종류": {off: False}},
|
||||
{tid: {"없는키": False}},
|
||||
{tid: {off: "no"}},
|
||||
{tid: [off]},
|
||||
):
|
||||
assert client.put(URL, json={"판": "", "표시": bad}).status_code == 422, bad
|
||||
assert client.put(URL, json={"판": "", "표시": []}).status_code == 422 # 묶음 아님(요청 모양)
|
||||
assert not (store.FOLDER / opt.FILE).exists() # 거절되면 안 씀
|
||||
v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"]
|
||||
assert client.put(URL, json={"판": "낡음", "표시": {}}).status_code == 409
|
||||
assert client.put(URL, json={"판": "", "표시": {}}).status_code == 409 # 처음 판으로 또 쓰기
|
||||
assert client.get(URL).json()["판"] == v1
|
||||
|
||||
|
||||
def test_파일이_깨져도_폼_응답은_모두_보임(client) -> None:
|
||||
(store.FOLDER / opt.FILE).write_text("{깨짐", encoding="utf-8")
|
||||
assert all(all(v.values()) for v in _visible(client).values())
|
||||
assert client.get(URL).status_code == 500 # 관리 화면은 까닭을 그대로 올림
|
||||
|
||||
|
||||
def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에서_지움() -> None:
|
||||
side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
|
||||
assert "section(options.title, options.body)" in side
|
||||
assert (
|
||||
side.index(" summary,")
|
||||
< side.index("section(options.title")
|
||||
< side.index('section(L("M02_KindDrawing")')
|
||||
)
|
||||
assert "onOpen: () => (options.clear(), paint())" in side # 자재 조합을 열면 이 줄 고름 지움
|
||||
assert "onOpen: () => (matset.clear(), paint())" in side # 이 줄을 열면 자재 조합 고름 지움
|
||||
paint = side.split("function paint(): void {")[1].split("\n }\n")[0]
|
||||
assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움
|
||||
api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8")
|
||||
assert (
|
||||
'call("/structure-options")' in api and 'call("/structure-options", { method: "PUT"' in api
|
||||
)
|
||||
ui = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Options.ts").read_text("utf-8")
|
||||
assert 'o.phase === "detail"' in ui # B06 전용 표시
|
||||
@@ -71,8 +71,14 @@ def project(tmp_path: Path) -> Path:
|
||||
return root
|
||||
|
||||
|
||||
def _auto(project: Path, document: dict[str, Any], lengths: dict[float, float]) -> dict[str, Any]:
|
||||
"""바인딩 자동 맞춤(`fill_document` 기본) — 프로젝트 `fill_table` 은 지정 항목 모드(PLAN 21-3)라
|
||||
펼침 · 묶음 · 손 값 규칙은 이 길로 봄."""
|
||||
return fill.fill_document(document, fill.collect_items(project, lengths))
|
||||
|
||||
|
||||
def _filled(project: Path, document: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return fill.fill_table(project, document or _master(), {85.0: 14.0})
|
||||
return _auto(project, document or _master(), {85.0: 14.0})
|
||||
|
||||
|
||||
def _col(doc: dict[str, Any], col_id: str) -> dict[str, Any]:
|
||||
@@ -119,7 +125,7 @@ def test_계산_열은_설계값_딱지_없음(project: Path) -> None:
|
||||
assert "바인딩" not in column and column["펼침틀"]["원열"]["id"] == col_id.split("|")[0]
|
||||
assert _col(doc, "pp_cp|파형강관|1000")["머리"] == ["관공", "파형강관 Φ1000", "커플링밴드"]
|
||||
# 다시 저장 · 채워도 같은 열(계산 열도 원열로 되돌림)
|
||||
again = fill.fill_table(project, fill.strip_design(doc), {85.0: 14.0})
|
||||
again = _auto(project, fill.strip_design(doc), {85.0: 14.0})
|
||||
assert [c["id"] for c in again["열"]] == [c["id"] for c in doc["열"]]
|
||||
|
||||
|
||||
@@ -152,7 +158,7 @@ def test_측점_줄_같은_측점은_한_줄_관은_나눔(project: Path) -> Non
|
||||
assert [row["값"].get("no") for row in doc["줄"][1:]] == [str(i) for i in range(1, 8)]
|
||||
assert "식" not in rows["s200.00~230.00"] # 줄눈 간격이 달라도 열 식이 없으면 줄 식 없음
|
||||
assert doc["알림"] == []
|
||||
bare = fill.fill_table(project, _master(), {})
|
||||
bare = _auto(project, _master(), {})
|
||||
assert bare["알림"] == ["관 연장 없음 2곳 — B06 횡단 설계 전이면 빈칸"]
|
||||
assert not any("pp_len" in key for row in bare["줄"] for key in row["값"])
|
||||
|
||||
|
||||
@@ -208,8 +208,9 @@ def test_채운_표는_작업본을_채워_돌려주고_저장_안_함(
|
||||
)
|
||||
before = layers.version_of(root / "templates/table/구조물집계표.json")
|
||||
got = world["client"].get(f"/api/m02/projects/{P1}/tables/구조물집계표/filled").json()
|
||||
row = next(r for r in got["문서"]["줄"] if r["id"] == "s40.00")
|
||||
assert row["값"]["sta"] == "NO.2" and row["값"]["pp_len|파형강관|1000"] == 12
|
||||
# 프로젝트 채움 = 지정 항목 모드(PLAN 21-3) — 집계 항목 미지정 관은 표에 안 들어감
|
||||
assert not any(r["id"] == "s40.00" for r in got["문서"]["줄"])
|
||||
assert "집계 항목 미지정 1건 — 표에 안 들어감" in got["문서"]["알림"]
|
||||
assert got["결과"] == {"계산": {}} and got["판"] == before
|
||||
assert layers.version_of(root / "templates/table/구조물집계표.json") == before
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""구조물 집계표 항목 지정 · 저장 스냅샷 · 채움 (PLAN 21-3 · 임시 저장소).
|
||||
|
||||
- `summary_item` 칸(구조물 · 관 지점) 모양 검사 · 옛 파일(칸 없음) 호환
|
||||
- 저장 뒤 스냅샷: 지정 → 폴더 생김 · 원본(마스터) 바꿔도 프로젝트 값 그대로 · 지정 바꿈 → 옛 폴더
|
||||
지움 · 해제 → 지움 · 회사 층 자리
|
||||
- 채움(`fill_table`) = 지정 항목 모드 — 고른 열에만 · 미지정은 안 셈 · 스냅샷 열 정의 사용
|
||||
- [초기화]는 `templates/` 를 안 건드림 · 프로젝트 복제 도구는 폴더를 통째 복사
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
|
||||
from common_util import common_util_initial_snapshot as initial
|
||||
from common_util.common_util_drainage_pipes import PipePoint, parse_pipe_points
|
||||
from common_util.common_util_summary_item import parse_summary_item
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snap
|
||||
from M02_MasterTemplete import M02_Table_Fill as fill
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
REAL = config_system.PROJECT_ROOT / "resources/master_template"
|
||||
MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"}
|
||||
RV = {"layer": "system", "owner": None, "key": "SS000001", "name": "돌기슭막이 메쌓기 1.2m"}
|
||||
PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"}
|
||||
|
||||
|
||||
def _copy_layer(target: Path) -> None:
|
||||
target.mkdir(parents=True)
|
||||
for name in (layers.COMBO_FILE, "자재조합.json"):
|
||||
shutil.copy2(REAL / name, target / name)
|
||||
for kind in ("table", "structure", "area", "material"):
|
||||
shutil.copytree(REAL / kind, target / kind)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def world(tmp_path: Path, monkeypatch) -> dict[str, Any]:
|
||||
system = tmp_path / "master"
|
||||
_copy_layer(system)
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
||||
storage = tmp_path / "storage"
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
|
||||
root = storage / "3" / "12" / "proj"
|
||||
(root / "B05_Profile/route").mkdir(parents=True)
|
||||
(root / "B04_PreProcess/drainage/edits").mkdir(parents=True)
|
||||
return {"system": system, "storage": storage, "root": root}
|
||||
|
||||
|
||||
def _save(root: Path, structures: list[dict], points: list[dict]) -> dict[str, list[str]]:
|
||||
"""화면 [저장] 두 곳(구조물 · 관 지점)이 쓴 뒤 서버가 맞추는 것과 같게."""
|
||||
for index, item in enumerate(structures):
|
||||
item.setdefault("structure_id", f"st{index}")
|
||||
item.setdefault("placement", "interval" if "start_m" in item else "point")
|
||||
(root / "B05_Profile/route/structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
(root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text(
|
||||
json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
return snap.sync_snapshots(root)
|
||||
|
||||
|
||||
def _dry(summary: dict | None) -> dict[str, Any]:
|
||||
wall = {"type_id": "masonry_dry", "chainage_m": 85, "start_m": 80, "end_m": 90}
|
||||
return {**wall, "options": {"height_m": 2.0, "length_m": 10}, "summary_item": summary}
|
||||
|
||||
|
||||
def _wet() -> dict[str, Any]:
|
||||
wall = {"type_id": "masonry_wet", "chainage_m": 120, "start_m": 120, "end_m": 130}
|
||||
return {**wall, "options": {"height_m": 2.5, "length_m": 10}}
|
||||
|
||||
|
||||
def _pipe(summary: dict | None) -> dict[str, Any]:
|
||||
options = {"pipe_kind": "파형강관", "pipe_diameter_mm": "1000"}
|
||||
return {"chainage_m": 40.0, "source": "user", "options": options, "summary_item": summary}
|
||||
|
||||
|
||||
def _items(root: Path) -> Path:
|
||||
return root / "templates" / "items"
|
||||
|
||||
|
||||
# ── 칸 모양 ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
{"layer": "project", "owner": None, "key": "SS000001", "name": "x"},
|
||||
{"layer": "system", "owner": "3", "key": "SS000001", "name": "x"},
|
||||
{"layer": "company", "owner": "3/12", "key": "SS000001", "name": "x"},
|
||||
{"layer": "personal", "owner": "3", "key": "SS000001", "name": "x"},
|
||||
{"layer": "system", "owner": None, "key": "UA000001", "name": "x"},
|
||||
{"layer": "system", "owner": None, "key": "SS000001", "name": ""},
|
||||
{"layer": "system", "owner": None, "key": "SS000001", "name": "x", "more": 1},
|
||||
"SS000001",
|
||||
],
|
||||
)
|
||||
def test_모양이_틀린_항목은_거절(bad):
|
||||
with pytest.raises(ValueError):
|
||||
parse_summary_item(bad)
|
||||
with pytest.raises(ValueError):
|
||||
StructureInstance(type_id="x", placement="point", chainage_m=1, summary_item=bad)
|
||||
with pytest.raises(ValueError):
|
||||
parse_pipe_points([{"chainage_m": 1.0, "summary_item": bad}])
|
||||
|
||||
|
||||
def test_옛_파일은_칸_없음_그대로_읽고_씀():
|
||||
item = StructureInstance(type_id="x", placement="point", chainage_m=1)
|
||||
assert item.summary_item is None
|
||||
point = parse_pipe_points([{"chainage_m": 10.0, "source": "user"}])[0]
|
||||
assert point.summary_item is None and "summary_item" not in point.as_dict()
|
||||
owned = {"layer": "personal", "owner": "3/12", "key": "SS000002", "name": "내 옹벽"}
|
||||
again = parse_pipe_points([PipePoint(chainage_m=5.0, summary_item=owned).as_dict()])[0]
|
||||
assert again.summary_item == owned
|
||||
|
||||
|
||||
# ── 스냅샷 ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_지정하면_폴더가_생기고_내용이_원본과_같음(world):
|
||||
root = world["root"]
|
||||
result = _save(root, [_dry(MS), _wet()], [_pipe(PIPE)])
|
||||
assert result == {
|
||||
"만듦": ["system_none_SS000011", "system_none_SS000022"],
|
||||
"지움": [],
|
||||
"못만듦": [],
|
||||
}
|
||||
folder = _items(root) / "system_none_SS000022"
|
||||
got = {path.name for path in folder.iterdir()}
|
||||
assert {"집계.json", "도면.json", "면적.json", "재료.json", "manifest.json"} <= got
|
||||
total = json.loads((folder / "집계.json").read_text(encoding="utf-8"))
|
||||
assert total["줄"]["키"] == "SS000022" and total["열"]["id"] == "ms"
|
||||
drawing = json.loads((world["system"] / "structure/ms.json").read_text(encoding="utf-8"))
|
||||
assert json.loads((folder / "도면.json").read_text(encoding="utf-8")) == drawing
|
||||
manifest = json.loads((folder / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["원본"] == MS and manifest["구조물"] == ["st0"] and len(manifest["판"]) == 16
|
||||
pipe = json.loads(
|
||||
(_items(root) / "system_none_SS000011/manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert pipe["구조물"] == ["pipe@40.0"]
|
||||
# 일위대가 UA 줄 + 담은 로직 줄(M01 시스템)
|
||||
labor = json.loads(
|
||||
(_items(root) / "system_none_SS000011/일위대가.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert labor["줄"]["키"] == "UA000036"
|
||||
assert [row["키"] for row in labor["로직"]] + labor["빠진로직"] == [
|
||||
entry["로직"] for entry in labor["줄"]["담은로직"]
|
||||
]
|
||||
|
||||
|
||||
def test_원본을_바꿔도_프로젝트_값은_그대로(world):
|
||||
root, system = world["root"], world["system"]
|
||||
_save(root, [_dry(MS)], [])
|
||||
before = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8")
|
||||
table = json.loads((system / "table/구조물집계표.json").read_text(encoding="utf-8"))
|
||||
for column in table["열"]:
|
||||
if column["id"] == "ms":
|
||||
column["바인딩"]["머리틀"] = ["바뀐 돌쌓기", "{0}", "{1}"]
|
||||
(system / "table/구조물집계표.json").write_text(
|
||||
json.dumps(table, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
assert _save(root, [_dry(MS)], [])["만듦"] == [] # 다시 저장해도 안 다시 씀
|
||||
after = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8")
|
||||
assert after == before
|
||||
doc = fill.fill_table(root, table)
|
||||
heads = [c["머리"][0] for c in doc["열"] if c["id"].startswith("ms|")]
|
||||
assert heads == ["돌쌓기"] # 채움도 스냅샷 열 정의
|
||||
|
||||
|
||||
def test_지정_바꿈_해제_삭제면_옛_폴더를_지움(world):
|
||||
root = world["root"]
|
||||
_save(root, [_dry(MS), _dry(MS)], [])
|
||||
manifest = _items(root) / "system_none_SS000022/manifest.json"
|
||||
assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st0", "st1"]
|
||||
# 한 구조물만 바꿈 — 같은 항목을 쓰는 구조물이 남으면 폴더 유지 · 목록만 줄어듦
|
||||
result = _save(root, [_dry(RV), _dry(MS)], [])
|
||||
assert result["만듦"] == ["system_none_SS000001"] and result["지움"] == []
|
||||
assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st1"]
|
||||
# 해제 · 삭제
|
||||
assert _save(root, [_dry(None)], [])["지움"] == ["system_none_SS000001", "system_none_SS000022"]
|
||||
assert list(_items(root).iterdir()) == []
|
||||
|
||||
|
||||
def test_회사_층_항목은_회사_폴더에서_복사(world):
|
||||
root = world["root"]
|
||||
company = world["storage"] / "3" / "templates"
|
||||
_copy_layer(company)
|
||||
combo = json.loads((company / layers.COMBO_FILE).read_text(encoding="utf-8"))
|
||||
for row in combo["줄"]:
|
||||
if row["키"] == "SS000022":
|
||||
row["분류3"] = "회사 것"
|
||||
(company / layers.COMBO_FILE).write_text(json.dumps(combo, ensure_ascii=False), "utf-8")
|
||||
owned = {**MS, "layer": "company", "owner": "3"}
|
||||
assert _save(root, [_dry(owned)], [])["만듦"] == ["company_3_SS000022"]
|
||||
total = json.loads((_items(root) / "company_3_SS000022/집계.json").read_text("utf-8"))
|
||||
assert total["줄"]["분류3"] == "회사 것"
|
||||
# 채움 — 회사 항목은 시스템 열과 안 겹치는 새 열
|
||||
doc = fill.fill_table(
|
||||
root, json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8"))
|
||||
)
|
||||
ids = [c["id"] for c in doc["열"] if "@" in c["id"]]
|
||||
assert ids == ["ms@company_3_SS000022|메쌓기|2.0"]
|
||||
|
||||
|
||||
def test_원본에_없는_항목은_못만듦으로_남김(world):
|
||||
gone = {**MS, "key": "SS999999"}
|
||||
assert _save(world["root"], [_dry(gone)], [])["못만듦"] == ["system_none_SS999999"]
|
||||
|
||||
|
||||
# ── 채움 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_채움은_지정한_열에만_미지정은_안_셈(world):
|
||||
root = world["root"]
|
||||
_save(root, [_dry(MS), _wet()], [_pipe(PIPE), {**_pipe(None), "chainage_m": 60.0}])
|
||||
table = json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8"))
|
||||
doc = fill.fill_table(root, table, {40.0: 12.0, 60.0: 9.0})
|
||||
values = {row["id"]: row["값"] for row in doc["줄"] if not row.get("고정")}
|
||||
assert set(values) == {"s80.00~90.00", "s40.00"} # 미지정 옹벽 · 관은 줄도 없음
|
||||
assert values["s80.00~90.00"]["ms|메쌓기|2.0"] == 10 # 메쌓기는 더함 출처(종류)로
|
||||
assert values["s40.00"]["pp_len|파형강관|1000"] == 12
|
||||
assert not any("찰쌓기" in c["id"] for c in doc["열"]) # 자동 맞춤 없음
|
||||
assert "집계 항목 미지정 2건 — 표에 안 들어감" in doc["알림"]
|
||||
|
||||
|
||||
def test_스냅샷이_없으면_알림(world):
|
||||
root = world["root"]
|
||||
_save(root, [_dry(MS)], [])
|
||||
shutil.rmtree(_items(root))
|
||||
doc = fill.fill_table(
|
||||
root, json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8"))
|
||||
)
|
||||
assert "집계 항목 복사본 없음 1건 — [저장] 뒤 채워짐" in doc["알림"]
|
||||
|
||||
|
||||
# ── 초기화 · 복제 ────────────────────────────────────
|
||||
|
||||
|
||||
def test_초기화는_templates_를_안_건드리고_복제는_통째로():
|
||||
assert not any(tree.startswith("templates") for tree in initial._FILE_TREES)
|
||||
tool = (config_system.PROJECT_ROOT / "db_management/tools_clone_project.py").read_text("utf-8")
|
||||
assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates/items 포함)
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 항목 고르기 자료 쪽 검증 — 나무 · 찾기 · 돌려줄 summary_item · 폴더 줄 · 가짜 응답(21-1 모양).
|
||||
* 실행: node --experimental-strip-types resources/tester/ui/item-picker-data.test.ts
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildTree,
|
||||
filterRows,
|
||||
folderLines,
|
||||
isCurrent,
|
||||
pathIds,
|
||||
rowId,
|
||||
toSummary,
|
||||
type TreeLabels,
|
||||
} from "../../../ui_template/ui_template_item_picker_data.ts";
|
||||
import { fakeCopy, fakeItems } from "../../../ui_template/ui_template_item_picker_fake.ts";
|
||||
|
||||
const labels: TreeLabels = {
|
||||
layers: { system: "마스터", company: "회사", personal: "개인" },
|
||||
unclassified: "분류 없음",
|
||||
};
|
||||
const rows = fakeItems();
|
||||
|
||||
// 나무 — 그룹은 늘 마스터 · 회사 · 개인 차례 · 그룹 → 종류 → 분류1 → 잎
|
||||
const tree = buildTree(rows, labels);
|
||||
assert.deepEqual(
|
||||
tree.map((g) => [g.label, g.count]),
|
||||
[
|
||||
["마스터", 5],
|
||||
["회사", 2],
|
||||
["개인", 1],
|
||||
],
|
||||
);
|
||||
const system = tree[0];
|
||||
assert.deepEqual(
|
||||
system.children?.map((k) => [k.label, k.count]),
|
||||
[
|
||||
["돌기슭막이", 3],
|
||||
["콘크리트포장", 1],
|
||||
["관보호공", 1],
|
||||
],
|
||||
);
|
||||
const cat = system.children?.[0].children?.[0];
|
||||
assert.equal(cat?.label, "메쌓기");
|
||||
assert.equal(cat?.count, 2);
|
||||
const leaf = cat?.children?.[0];
|
||||
assert.equal(leaf?.label, "돌기슭막이 메쌓기 1.2m 기초유");
|
||||
assert.equal(leaf?.tip, "SS000001"); // 키는 툴팁
|
||||
assert.equal(leaf?.count, null);
|
||||
assert.ok(leaf?.row);
|
||||
|
||||
// 분류가 빈 항목은 「분류 없음」 한 칸에 모임
|
||||
const company = tree[1];
|
||||
assert.equal(company.children?.[1].children?.[0].label, "분류 없음");
|
||||
|
||||
// 빈 그룹도 한 줄은 남음
|
||||
const onlySystem = buildTree(
|
||||
rows.filter((r) => r.layer === "system"),
|
||||
labels,
|
||||
);
|
||||
assert.deepEqual(
|
||||
onlySystem.map((g) => [g.id, g.count, g.children?.length]),
|
||||
[
|
||||
["system", 5, 3],
|
||||
["company", 0, 0],
|
||||
["personal", 0, 0],
|
||||
],
|
||||
);
|
||||
|
||||
// 잎 id 는 층 · 주인 · 키 — 같은 키라도 층이 다르면 다른 줄
|
||||
const twin = { ...rows[0], layer: "company" as const, owner: "샘플회사" };
|
||||
assert.notEqual(rowId(rows[0]), rowId(twin));
|
||||
|
||||
// 찾기 — 이름 · 키 · 연결 이름 · 여러 낱말은 모두 들어 있어야 함 · 빈 글은 전부
|
||||
assert.equal(filterRows(rows, "").length, rows.length);
|
||||
assert.equal(filterRows(rows, " ").length, rows.length);
|
||||
assert.deepEqual(
|
||||
filterRows(rows, "옹벽").map((r) => r.key),
|
||||
["SS000102"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterRows(rows, "ss000004").map((r) => r.key),
|
||||
["SS000004"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterRows(rows, "돌기슭막이 개인").map((r) => r.key),
|
||||
["SS000201"],
|
||||
);
|
||||
assert.equal(filterRows(rows, "없는이름").length, 0);
|
||||
|
||||
// 돌려줄 summary_item — 마스터는 owner null · 회사 · 개인은 주인
|
||||
assert.deepEqual(toSummary(rows[0]), {
|
||||
layer: "system",
|
||||
owner: null,
|
||||
key: "SS000001",
|
||||
name: "돌기슭막이 메쌓기 1.2m 기초유",
|
||||
});
|
||||
const mine = rows.find((r) => r.layer === "personal");
|
||||
assert.ok(mine);
|
||||
assert.deepEqual(toSummary(mine), {
|
||||
layer: "personal",
|
||||
owner: "샘플회사/사용자",
|
||||
key: "SS000201",
|
||||
name: "돌기슭막이 메쌓기 1.2m(개인)",
|
||||
});
|
||||
// layer 없는 줄(옛 서버)은 마스터로 봄
|
||||
assert.equal(toSummary({ key: "SS000009", 열: "임시" }).layer, "system");
|
||||
|
||||
// 지금 지정된 항목인지
|
||||
assert.ok(isCurrent(toSummary(mine), mine));
|
||||
assert.ok(!isCurrent(toSummary(mine), rows[0]));
|
||||
assert.ok(!isCurrent(null, rows[0]));
|
||||
|
||||
// 처음 펼칠 자리
|
||||
assert.deepEqual(pathIds(rows[0], labels), [
|
||||
"system",
|
||||
"system|돌기슭막이",
|
||||
"system|돌기슭막이|메쌓기",
|
||||
]);
|
||||
|
||||
// 폴더 모양 — 도면 · 면적산출 · 재료산출은 항목 이름 · 번호는 툴팁 · 값이 없으면 빈 글
|
||||
const lines = folderLines(rows[0]);
|
||||
assert.deepEqual(
|
||||
lines.map((l) => l.kind),
|
||||
["drawing", "area", "material", "combo", "unitPrice"],
|
||||
);
|
||||
assert.equal(lines[0].text, "돌기슭막이 메쌓기 1.2m 기초유");
|
||||
assert.equal(lines[0].tip, "SD000001");
|
||||
assert.equal(folderLines({ key: "SS1", 열: "빈 항목" })[3].text, "");
|
||||
|
||||
// 가짜 복사 — 새 개인 줄이 생기고 키가 겹치지 않음
|
||||
const before = fakeItems();
|
||||
const copied = fakeCopy(before[0]);
|
||||
const after = fakeItems();
|
||||
assert.equal(after.length, before.length + 1);
|
||||
assert.equal(copied.layer, "personal");
|
||||
assert.equal(new Set(after.map(rowId)).size, after.length);
|
||||
|
||||
console.log("항목 고르기 자료 시험 통과");
|
||||
@@ -0,0 +1,101 @@
|
||||
/* 항목 고르기 모달 — 왼쪽 나무 · 오른쪽 폴더 모양 · 아래 단추 */
|
||||
.ui-item-picker {
|
||||
width: min(900px, 94vw);
|
||||
}
|
||||
|
||||
.ui-item-picker .ui-modal__body {
|
||||
height: min(60vh, 520px);
|
||||
}
|
||||
|
||||
.ui-item-picker__cols {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 5fr) minmax(260px, 6fr);
|
||||
gap: var(--spacing-12);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ui-item-picker__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ui-item-picker__name {
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-heading);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ui-item-picker__where,
|
||||
.ui-item-picker__hint {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-item-picker__hint {
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-item-picker__folder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin: 0;
|
||||
padding: 0 0 0 var(--spacing-12);
|
||||
border-left: 2px solid var(--color-border);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ui-item-picker__folder li {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ui-item-picker__folder-label {
|
||||
flex: 0 0 5.5em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ui-item-picker__none {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ui-item-picker__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker.ts
|
||||
* 항목 고르기 모달 — 구조물집계표 항목(SS 한 줄)을 마스터 · 회사 · 개인 세 그룹 나무에서 고름.
|
||||
* 키(SS · UA …)는 앞에 내세우지 않고 이름으로 보임 · 키는 마우스를 올리면(PLAN.md 21-0).
|
||||
*
|
||||
* const result = await openItemPicker({ current, canCopy });
|
||||
* result.action = "apply"(result.item = 21-1 `summary_item`) | "clear" | "cancel"
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_item_picker.css";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton, createInputField, el, showToast } from "./ui_template_elements";
|
||||
import { fakeCopy, fakeItems } from "./ui_template_item_picker_fake";
|
||||
import {
|
||||
buildTree,
|
||||
filterRows,
|
||||
folderLines,
|
||||
isCurrent,
|
||||
layerOf,
|
||||
ownerOf,
|
||||
pathIds,
|
||||
rowId,
|
||||
toSummary,
|
||||
type FolderKind,
|
||||
type PickNode,
|
||||
type PickerRow,
|
||||
type SummaryItem,
|
||||
type TreeLabels,
|
||||
} from "./ui_template_item_picker_data";
|
||||
import { t } from "./ui_template_locale";
|
||||
import { openModal } from "./ui_template_modal";
|
||||
import { createTreeRow, renderTree, type TreeNode } from "./ui_template_tree";
|
||||
|
||||
export type { SummaryItem } from "./ui_template_item_picker_data";
|
||||
|
||||
export interface ItemPickerOptions {
|
||||
/** 지금 지정된 항목 — 없으면 null */
|
||||
current: SummaryItem | null;
|
||||
/** [개인용으로 복사]를 쓸 수 있나 — 개인 층에 쓸 수 있을 때(회사 등록 뒤) */
|
||||
canCopy: boolean;
|
||||
}
|
||||
|
||||
export type ItemPickerResult =
|
||||
{ action: "apply"; item: SummaryItem } | { action: "clear" } | { action: "cancel" };
|
||||
|
||||
const MANAGE_HASH = "#/m02-master-template?layer=personal";
|
||||
|
||||
const FOLDER_LABEL: Record<FolderKind, () => string> = {
|
||||
drawing: () => t("M02_PickerDrawing"),
|
||||
area: () => t("M02_PickerArea"),
|
||||
material: () => t("M02_PickerMaterial"),
|
||||
combo: () => t("M02_PickerCombo"),
|
||||
unitPrice: () => t("M02_PickerUnitPrice"),
|
||||
};
|
||||
|
||||
const labels = (): TreeLabels => ({
|
||||
layers: {
|
||||
system: t("M02_PickerSystem"),
|
||||
company: t("M02_PickerCompany"),
|
||||
personal: t("M02_PickerPersonal"),
|
||||
},
|
||||
unclassified: t("M02_PickerNoClass"),
|
||||
});
|
||||
|
||||
/** 서버가 막은 까닭 그대로 */
|
||||
function reasonOf(body: unknown, status: number): string {
|
||||
const detail = (body as { detail?: unknown } | null)?.detail;
|
||||
return typeof detail === "string" && detail ? detail : `HTTP ${status}`;
|
||||
}
|
||||
|
||||
/** 실서버 응답 — 아직 없으면(404 · 405) 21-1 모양 가짜 응답. ponytail: 21-2 서버가 오면 가짜를 지움 */
|
||||
async function loadRows(): Promise<PickerRow[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/m02/items?all=1`, { credentials: "include" });
|
||||
if (response.status === 404 || response.status === 405) return fakeItems();
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
PickerRow[] | { items?: PickerRow[]; detail?: unknown } | null;
|
||||
if (!response.ok) throw new Error(reasonOf(body, response.status));
|
||||
return Array.isArray(body) ? body : (body?.items ?? []);
|
||||
}
|
||||
|
||||
/** 개인용으로 복사 — 새로 생긴 줄의 키를 돌려줌(서버가 안 주면 null) */
|
||||
async function copyToPersonal(row: PickerRow): Promise<string | null> {
|
||||
const response = await fetch(`${API_BASE_URL}/m02/layers/personal/items/copy`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from_layer: layerOf(row), from_owner: ownerOf(row), key: row.key }),
|
||||
});
|
||||
if (response.status === 404 || response.status === 405) return fakeCopy(row).key;
|
||||
const body = (await response.json().catch(() => null)) as { key?: string } | null;
|
||||
if (!response.ok) throw new Error(reasonOf(body, response.status));
|
||||
return body?.key ?? null;
|
||||
}
|
||||
|
||||
const message = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResult> {
|
||||
return new Promise((resolve) => {
|
||||
let rows: PickerRow[] = [];
|
||||
let chosen: PickerRow | null = null;
|
||||
/** 접었다 펼친 것을 다시 그려도 남기려고 펼친 줄 id 를 기억 */
|
||||
const opened = new Set<string>();
|
||||
const leafButtons = new Map<string, HTMLElement>();
|
||||
|
||||
const search = createInputField({ placeholder: t("M02_PickerSearch"), compact: true });
|
||||
const treeHost = el("div", { className: "ui-item-picker__tree" });
|
||||
const detail = el("div", { className: "ui-item-picker__detail" });
|
||||
const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" });
|
||||
const clearButton = createButton({ label: t("M02_PickerClear"), variant: "ghost" });
|
||||
const copyButton = createButton({ label: t("M02_PickerCopy"), variant: "ghost" });
|
||||
const manageButton = createButton({ label: t("M02_PickerManage"), variant: "ghost" });
|
||||
let close = (): void => {};
|
||||
|
||||
const finish = (result: ItemPickerResult): void => {
|
||||
resolve(result);
|
||||
close();
|
||||
};
|
||||
|
||||
const hint = (text: string): HTMLElement =>
|
||||
el("p", { className: "ui-item-picker__hint", text });
|
||||
|
||||
const paintDetail = (): void => {
|
||||
if (!chosen) {
|
||||
detail.replaceChildren(hint(t("M02_PickerHint")));
|
||||
} else {
|
||||
const where = [labels().layers[layerOf(chosen)], chosen.종류, chosen.분류1]
|
||||
.filter(Boolean)
|
||||
.join(" › ");
|
||||
const small = (text: string): HTMLElement =>
|
||||
el("p", { className: "ui-item-picker__where", text });
|
||||
detail.replaceChildren(
|
||||
el("h4", {
|
||||
className: "ui-item-picker__name",
|
||||
text: chosen.열,
|
||||
attrs: { title: chosen.key },
|
||||
}),
|
||||
small(where),
|
||||
...(chosen.단위 ? [small(`${t("M02_PickerUnit")} ${chosen.단위}`)] : []),
|
||||
el("ul", {
|
||||
className: "ui-item-picker__folder",
|
||||
children: folderLines(chosen).map((line) =>
|
||||
el("li", {
|
||||
attrs: line.tip ? { title: line.tip } : {},
|
||||
children: [
|
||||
el("span", {
|
||||
className: "ui-item-picker__folder-label",
|
||||
text: FOLDER_LABEL[line.kind](),
|
||||
}),
|
||||
el("span", {
|
||||
className: line.text ? "" : "ui-item-picker__none",
|
||||
text: line.text || t("M02_PickerNone"),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const layer = chosen ? layerOf(chosen) : null;
|
||||
applyButton.disabled = !chosen;
|
||||
clearButton.disabled = !opts.current;
|
||||
copyButton.hidden = !(chosen && layer !== "personal" && opts.canCopy);
|
||||
manageButton.hidden = !(chosen && layer === "personal");
|
||||
};
|
||||
|
||||
const choose = (row: PickerRow): void => {
|
||||
chosen = row;
|
||||
for (const [id, button] of leafButtons)
|
||||
button.classList.toggle("is-active", id === rowId(row));
|
||||
paintDetail();
|
||||
};
|
||||
|
||||
const toTree = (node: PickNode, searching: boolean): TreeNode => ({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
count: node.count,
|
||||
tip: node.tip,
|
||||
open: searching || opened.has(node.id),
|
||||
run: node.row ? () => choose(node.row as PickerRow) : undefined,
|
||||
children: node.children?.map((child) => toTree(child, searching)),
|
||||
});
|
||||
|
||||
const paintTree = (): void => {
|
||||
leafButtons.clear();
|
||||
const text = search.input.value;
|
||||
const searching = text.trim() !== "";
|
||||
const groups = buildTree(filterRows(rows, text), labels())
|
||||
// 찾는 중에는 결과가 없는 그룹을 감춤
|
||||
.filter((group) => !searching || (group.count ?? 0) > 0)
|
||||
.map((group) => toTree(group, searching));
|
||||
if (!groups.length) {
|
||||
treeHost.replaceChildren(hint(t("M02_PickerNoMatch")));
|
||||
return;
|
||||
}
|
||||
treeHost.replaceChildren(
|
||||
...renderTree(groups, (node, caret, depth, onClick) => {
|
||||
const button = createTreeRow(node, caret, depth, () => {
|
||||
onClick();
|
||||
if (!node.children?.length) return;
|
||||
if (button.querySelector(".ui-tree__caret")?.textContent === "▾") opened.add(node.id);
|
||||
else opened.delete(node.id);
|
||||
});
|
||||
if (node.run) {
|
||||
leafButtons.set(node.id, button);
|
||||
if (chosen && node.id === rowId(chosen)) button.classList.add("is-active");
|
||||
}
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const load = async (pickKey: string | null): Promise<void> => {
|
||||
treeHost.replaceChildren(hint(t("M02_PickerLoading")));
|
||||
try {
|
||||
rows = await loadRows();
|
||||
} catch (error) {
|
||||
treeHost.replaceChildren(
|
||||
hint(t("M02_PickerLoadFailed").replace("{value}", message(error))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const start = pickKey
|
||||
? rows.find((row) => layerOf(row) === "personal" && row.key === pickKey)
|
||||
: rows.find((row) => isCurrent(opts.current, row));
|
||||
if (start) {
|
||||
for (const id of pathIds(start, labels())) opened.add(id);
|
||||
chosen = start;
|
||||
} else {
|
||||
opened.add("system"); // 지정된 게 없으면 첫 그룹만 펼침
|
||||
}
|
||||
paintTree();
|
||||
paintDetail();
|
||||
treeHost.querySelector(".is-active")?.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
|
||||
search.input.addEventListener("input", paintTree);
|
||||
applyButton.addEventListener(
|
||||
"click",
|
||||
() => chosen && finish({ action: "apply", item: toSummary(chosen) }),
|
||||
);
|
||||
clearButton.addEventListener("click", () => finish({ action: "clear" }));
|
||||
manageButton.addEventListener("click", () => {
|
||||
finish({ action: "cancel" });
|
||||
location.hash = MANAGE_HASH;
|
||||
});
|
||||
copyButton.addEventListener("click", async () => {
|
||||
if (!chosen) return;
|
||||
copyButton.disabled = true;
|
||||
try {
|
||||
const key = await copyToPersonal(chosen);
|
||||
showToast(t("M02_PickerCopied"), "success");
|
||||
await load(key);
|
||||
} catch (error) {
|
||||
showToast(t("M02_PickerCopyFailed").replace("{value}", message(error)), "error");
|
||||
} finally {
|
||||
copyButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
close = openModal({
|
||||
title: t("M02_PickerTitle"),
|
||||
closeLabel: t("M02_Close"),
|
||||
dialogClass: "ui-item-picker",
|
||||
// 닫기 단추 · Esc · 바깥 누르기 = 취소
|
||||
beforeClose: () => {
|
||||
resolve({ action: "cancel" });
|
||||
return true;
|
||||
},
|
||||
mount: (body) => {
|
||||
body.append(
|
||||
el("div", {
|
||||
className: "ui-item-picker__cols",
|
||||
children: [
|
||||
el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }),
|
||||
detail,
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-item-picker__actions",
|
||||
children: [copyButton, manageButton, clearButton, applyButton],
|
||||
}),
|
||||
);
|
||||
paintDetail();
|
||||
void load(null);
|
||||
},
|
||||
}).close;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_data.ts
|
||||
* 항목 고르기의 자료 쪽(화면 없음) — 항목 목록 → 나무 · 찾기 · 폴더 줄 · 돌려줄 `summary_item`.
|
||||
* 모양은 PLAN.md 21-1 약속 그대로: 항목 목록 API 한 줄 = `PickerRow`, 참조 = `SummaryItem`.
|
||||
* ========================================================================== */
|
||||
|
||||
export type PickerLayer = "system" | "company" | "personal";
|
||||
|
||||
/** 21-1 `summary_item` — 구조물 기록에 붙는 항목 참조 */
|
||||
export interface SummaryItem {
|
||||
layer: PickerLayer;
|
||||
owner: string | null;
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** `GET /api/m02/items` 한 줄(21-1) — `?all=1` 은 줄마다 `layer` · `owner` 가 붙음 */
|
||||
export interface PickerRow {
|
||||
key: string;
|
||||
열: string;
|
||||
종류?: string;
|
||||
분류1?: string;
|
||||
분류2?: string;
|
||||
분류3?: string;
|
||||
단위?: string;
|
||||
일위대가?: string;
|
||||
자재조합?: string;
|
||||
도번?: string;
|
||||
면적번?: string;
|
||||
재료번?: string;
|
||||
layer?: PickerLayer;
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
/** 그룹 차례 = 마스터 · 회사 · 개인 */
|
||||
export const PICKER_LAYERS: readonly PickerLayer[] = ["system", "company", "personal"];
|
||||
|
||||
export interface PickNode {
|
||||
id: string;
|
||||
/** 그룹 · 종류 · 분류 이름 — 잎은 항목 이름 */
|
||||
label: string;
|
||||
/** 마우스를 올렸을 때 — 잎은 키 */
|
||||
tip?: string;
|
||||
/** 아래 잎 수 — 잎은 null */
|
||||
count: number | null;
|
||||
children?: PickNode[];
|
||||
/** 잎이면 그 항목 */
|
||||
row?: PickerRow;
|
||||
}
|
||||
|
||||
export const layerOf = (row: PickerRow): PickerLayer => row.layer ?? "system";
|
||||
export const ownerOf = (row: PickerRow): string | null =>
|
||||
layerOf(row) === "system" ? null : (row.owner ?? null);
|
||||
|
||||
/** 같은 항목의 자리표 — 층 · 주인 · 키 */
|
||||
export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`;
|
||||
|
||||
export function toSummary(row: PickerRow): SummaryItem {
|
||||
return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: row.열 };
|
||||
}
|
||||
|
||||
export function isCurrent(current: SummaryItem | null, row: PickerRow): boolean {
|
||||
return (
|
||||
!!current &&
|
||||
current.layer === layerOf(row) &&
|
||||
(current.owner ?? null) === ownerOf(row) &&
|
||||
current.key === row.key
|
||||
);
|
||||
}
|
||||
|
||||
/** 찾기 — 이름 · 종류 · 분류 · 키 · 연결 이름에 글이 들어 있는 줄(빈 글이면 전부) */
|
||||
export function filterRows(rows: PickerRow[], text: string): PickerRow[] {
|
||||
const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return rows;
|
||||
return rows.filter((row) => {
|
||||
const hay = [
|
||||
row.열,
|
||||
row.종류,
|
||||
row.분류1,
|
||||
row.분류2,
|
||||
row.분류3,
|
||||
row.key,
|
||||
row.일위대가,
|
||||
row.자재조합,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return words.every((word) => hay.includes(word));
|
||||
});
|
||||
}
|
||||
|
||||
export interface TreeLabels {
|
||||
layers: Record<PickerLayer, string>;
|
||||
/** 종류 · 분류1 이 비었을 때 */
|
||||
unclassified: string;
|
||||
}
|
||||
|
||||
/** 그룹 → 종류 → 분류1 → 항목(잎). 서버가 준 차례를 지킴 · 빈 그룹도 한 줄 남김. */
|
||||
export function buildTree(rows: PickerRow[], labels: TreeLabels): PickNode[] {
|
||||
return PICKER_LAYERS.map((layer) => {
|
||||
const mine = rows.filter((row) => layerOf(row) === layer);
|
||||
const kinds = new Map<string, Map<string, PickerRow[]>>();
|
||||
for (const row of mine) {
|
||||
const kind = row.종류 || labels.unclassified;
|
||||
const cat = row.분류1 || labels.unclassified;
|
||||
const cats = kinds.get(kind) ?? new Map<string, PickerRow[]>();
|
||||
cats.set(cat, [...(cats.get(cat) ?? []), row]);
|
||||
kinds.set(kind, cats);
|
||||
}
|
||||
const kindNodes: PickNode[] = [...kinds].map(([kind, cats]) => {
|
||||
const catNodes: PickNode[] = [...cats].map(([cat, leaves]) => ({
|
||||
id: `${layer}|${kind}|${cat}`,
|
||||
label: cat,
|
||||
count: leaves.length,
|
||||
children: leaves.map((row) => ({
|
||||
id: rowId(row),
|
||||
label: row.열,
|
||||
tip: row.key,
|
||||
count: null,
|
||||
row,
|
||||
})),
|
||||
}));
|
||||
return {
|
||||
id: `${layer}|${kind}`,
|
||||
label: kind,
|
||||
count: catNodes.reduce((sum, node) => sum + (node.count ?? 0), 0),
|
||||
children: catNodes,
|
||||
};
|
||||
});
|
||||
return { id: layer, label: labels.layers[layer], count: mine.length, children: kindNodes };
|
||||
});
|
||||
}
|
||||
|
||||
/** 잎까지 가는 길의 나무 줄 id — 처음부터 펼칠 자리 */
|
||||
export function pathIds(row: PickerRow, labels: TreeLabels): string[] {
|
||||
const layer = layerOf(row);
|
||||
const kind = row.종류 || labels.unclassified;
|
||||
const cat = row.분류1 || labels.unclassified;
|
||||
return [layer, `${layer}|${kind}`, `${layer}|${kind}|${cat}`];
|
||||
}
|
||||
|
||||
export type FolderKind = "drawing" | "area" | "material" | "combo" | "unitPrice";
|
||||
|
||||
export interface FolderLine {
|
||||
kind: FolderKind;
|
||||
/** 보이는 이름 */
|
||||
text: string;
|
||||
/** 마우스를 올렸을 때 — 키 · 번호 */
|
||||
tip: string;
|
||||
}
|
||||
|
||||
/** 오른쪽 폴더 모양 — 도면 · 면적산출 · 재료산출은 그 항목(열) 이름 · 자재조합 · 일위대가는 서버 값 */
|
||||
export function folderLines(row: PickerRow): FolderLine[] {
|
||||
return [
|
||||
{ kind: "drawing", text: row.열, tip: row.도번 ?? "" },
|
||||
{ kind: "area", text: row.열, tip: row.면적번 ?? "" },
|
||||
{ kind: "material", text: row.열, tip: row.재료번 ?? "" },
|
||||
{ kind: "combo", text: row.자재조합 ?? "", tip: row.자재조합 ?? "" },
|
||||
{ kind: "unitPrice", text: row.일위대가 ?? "", tip: row.일위대가 ?? "" },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_fake.ts
|
||||
* 항목 목록 서버(`GET /api/m02/items` · 복사 `POST /api/m02/layers/personal/items/copy`, 21-2)가
|
||||
* 아직 없을 때만 쓰는 가짜 응답 — 21-1 모양 그대로. 실서버가 오면 이 파일과 부르는 자리를 지움.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { PickerRow } from "./ui_template_item_picker_data";
|
||||
|
||||
const row = (
|
||||
key: string,
|
||||
열: string,
|
||||
종류: string,
|
||||
분류1: string,
|
||||
layer: PickerRow["layer"],
|
||||
extra: Partial<PickerRow> = {},
|
||||
): PickerRow => ({
|
||||
key,
|
||||
열,
|
||||
종류,
|
||||
분류1,
|
||||
분류2: "",
|
||||
분류3: "",
|
||||
단위: "m",
|
||||
일위대가: `${열} 일위대가`,
|
||||
자재조합: `${열} 자재`,
|
||||
도번: `SD${key.slice(2)}`,
|
||||
면적번: `SA${key.slice(2)}`,
|
||||
재료번: `SM${key.slice(2)}`,
|
||||
layer,
|
||||
owner: layer === "company" ? "샘플회사" : layer === "personal" ? "샘플회사/사용자" : null,
|
||||
...extra,
|
||||
});
|
||||
|
||||
let personalSeq = 900;
|
||||
|
||||
const memory: PickerRow[] = [
|
||||
row("SS000001", "돌기슭막이 메쌓기 1.2m 기초유", "돌기슭막이", "메쌓기", "system"),
|
||||
row("SS000002", "돌기슭막이 메쌓기 1.5m 기초유", "돌기슭막이", "메쌓기", "system"),
|
||||
row("SS000003", "돌기슭막이 찰쌓기 1.2m", "돌기슭막이", "찰쌓기", "system"),
|
||||
row("SS000004", "콘크리트포장 T=20cm B=3m", "콘크리트포장", "일반", "system", { 단위: "㎡" }),
|
||||
row("SS000005", "관보호공 콘크리트 유입", "관보호공", "콘크리트", "system", { 단위: "개소" }),
|
||||
row("SS000101", "돌기슭막이 메쌓기 1.2m(회사)", "돌기슭막이", "메쌓기", "company"),
|
||||
row("SS000102", "옹벽 H=2m(회사)", "옹벽", "", "company"),
|
||||
row("SS000201", "돌기슭막이 메쌓기 1.2m(개인)", "돌기슭막이", "메쌓기", "personal"),
|
||||
];
|
||||
|
||||
export const fakeItems = (): PickerRow[] => memory.map((item) => ({ ...item }));
|
||||
|
||||
/** 복사 — 새 개인 항목을 맨 뒤에 더하고 그 줄을 돌려줌(서버는 새 SS 키를 돌려줄 것) */
|
||||
export function fakeCopy(from: PickerRow): PickerRow {
|
||||
personalSeq += 1;
|
||||
const copy: PickerRow = {
|
||||
...from,
|
||||
key: `SS000${personalSeq}`,
|
||||
layer: "personal",
|
||||
owner: "샘플회사/사용자",
|
||||
};
|
||||
memory.push(copy);
|
||||
return { ...copy };
|
||||
}
|
||||
@@ -56,4 +56,27 @@ export const ui_locales_m2 = {
|
||||
M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"],
|
||||
M02_StructureNone: ["없음", "None"],
|
||||
M02_Back: ["돌아가기", "Back"],
|
||||
M02_PickerTitle: ["항목 고르기", "Pick an item"],
|
||||
M02_PickerSearch: ["항목 찾기", "Search items"],
|
||||
M02_PickerSystem: ["마스터", "Master"],
|
||||
M02_PickerCompany: ["회사", "Company"],
|
||||
M02_PickerPersonal: ["개인", "Personal"],
|
||||
M02_PickerNoClass: ["분류 없음", "Unclassified"],
|
||||
M02_PickerHint: ["왼쪽에서 항목을 고를 것", "Pick an item on the left"],
|
||||
M02_PickerNoMatch: ["찾는 항목 없음", "No matching items"],
|
||||
M02_PickerLoading: ["읽는 중…", "Loading…"],
|
||||
M02_PickerLoadFailed: ["항목을 못 읽음 — {value}", "Failed to load items — {value}"],
|
||||
M02_PickerDrawing: ["도면", "Drawing"],
|
||||
M02_PickerArea: ["면적산출", "Area takeoff"],
|
||||
M02_PickerMaterial: ["재료산출", "Material takeoff"],
|
||||
M02_PickerCombo: ["자재조합", "Material combo"],
|
||||
M02_PickerUnitPrice: ["일위대가", "Unit price"],
|
||||
M02_PickerUnit: ["단위", "Unit"],
|
||||
M02_PickerNone: ["없음", "None"],
|
||||
M02_PickerApply: ["적용", "Apply"],
|
||||
M02_PickerClear: ["해제", "Clear"],
|
||||
M02_PickerCopy: ["개인용으로 복사", "Copy to personal"],
|
||||
M02_PickerManage: ["관리 화면에서 고치기", "Edit in manager"],
|
||||
M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"],
|
||||
M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"],
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* 공용 나무 보기 — 줄 모양은 공용 ui-sidebar-row(ui_template_overlay.css) */
|
||||
.ui-tree__kids {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ui-tree__branch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ui-tree__caret {
|
||||
display: inline-block;
|
||||
width: var(--spacing-12);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ui-tree__count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/* =============================================================================
|
||||
* ui_template_tree.ts
|
||||
* 공용 나무 보기 — 부모(접기/펴기) · 자식(깊이 들임) · 잎 고름. M01 왼쪽 패널과 항목 고르기가 함께 씀.
|
||||
* 줄 모양은 부르는 쪽이 `MakeRow` 로 정함(기본 줄 = `createTreeRow`).
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_tree.css";
|
||||
import { el } from "./ui_template_elements";
|
||||
|
||||
export interface TreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
/** 줄 수 — null 이면 안 보임 */
|
||||
count: number | null;
|
||||
/** 눌렀을 때 — 없으면 접기/펴기만 */
|
||||
run?: () => void;
|
||||
children?: TreeNode[];
|
||||
/** 처음부터 펴짐 */
|
||||
open?: boolean;
|
||||
/** 마우스를 올렸을 때 글(기본 줄) */
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */
|
||||
export type MakeRow = (
|
||||
node: TreeNode,
|
||||
caret: string,
|
||||
depth: number,
|
||||
onClick: () => void,
|
||||
) => HTMLElement;
|
||||
|
||||
export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] {
|
||||
return nodes.map((node) => {
|
||||
if (!node.children?.length) return make(node, "", depth, () => node.run?.());
|
||||
const kids = el("div", {
|
||||
className: "ui-tree__kids",
|
||||
children: renderTree(node.children, make, depth + 1),
|
||||
});
|
||||
kids.hidden = !node.open;
|
||||
const row = make(node, kids.hidden ? "▸" : "▾", depth, () => {
|
||||
kids.hidden = !kids.hidden;
|
||||
const caret = row.querySelector(".ui-tree__caret");
|
||||
if (caret) caret.textContent = kids.hidden ? "▸" : "▾";
|
||||
node.run?.();
|
||||
});
|
||||
return el("div", { className: "ui-tree__branch", children: [row, kids] });
|
||||
});
|
||||
}
|
||||
|
||||
/** 기본 줄 — 화살표 · 글 · 줄 수 · 깊이 들임. 잎 고름 표시(`is-active`)는 부르는 쪽이 켬. */
|
||||
export function createTreeRow(
|
||||
node: TreeNode,
|
||||
caret: string,
|
||||
depth: number,
|
||||
onClick: () => void,
|
||||
): HTMLButtonElement {
|
||||
const button = el("button", {
|
||||
className: "ui-tree__row ui-sidebar-row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", {
|
||||
children: [
|
||||
el("span", { className: "ui-tree__caret", text: caret }),
|
||||
el("span", { text: node.label }),
|
||||
],
|
||||
}),
|
||||
...(node.count === null
|
||||
? []
|
||||
: [el("span", { className: "ui-tree__count", text: node.count.toLocaleString("ko-KR") })]),
|
||||
],
|
||||
});
|
||||
button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`;
|
||||
if (node.tip) button.title = node.tip;
|
||||
button.addEventListener("click", onClick);
|
||||
return button;
|
||||
}
|
||||
Reference in New Issue
Block a user