From a52fe78751a99eed6d8516d2e3fa3e9097b28a89 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:53:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EB=A9=94=EC=9D=B8=20=ED=83=AD=20=C2=B7=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=ED=95=98=EC=9C=84=20=ED=83=AD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20=EC=82=AD=EC=A0=9C=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=EC=82=AC=EC=A7=80=20=C2=B7=20=EA=B8=B0=EC=A4=80=EC=A0=90=20?= =?UTF-8?q?=C2=B7=20=ED=91=9C=20=ED=95=A9=20=EC=A7=9D=20=C2=B7=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=20=EC=98=AC=EB=A6=AC=EA=B8=B0=20(244-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메인 탭 「거리이정표」 · 하위 탭 [거리이정표] 표 + 경로 탭(이름 = 업체 이름) · [+ 추가] · [삭제] - 경로 탭 = 구간 km(더블클릭) · 도로 구분 ▾ · 표 줄(항목 · 목적지 · 비고) · 경로 그림 하나 - 좌측 패널 카드 = 공사지(중점 측점 · 기본 노선 ÷ 2) · 기준점(있음 · 이름 · 주소 · 방위) - 표 합 = 파이썬 · TS 짝(거울 시험) · 정본 = storage B08_Quantity/milestone · [저장] 한 줄에서 PUT Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/b_page_state.ts | 2 + B08_Quantity/B08_Quantity_Milestone_Calc.py | 73 ++++ B08_Quantity/B08_Quantity_Milestone_Calc.ts | 127 +++++++ B08_Quantity/B08_Quantity_Router_Milestone.py | 259 +++++++++++++ B08_Quantity/B08_Quantity_UI_Milestone.css | 45 +++ B08_Quantity/B08_Quantity_UI_Milestone.ts | 198 ++++++++++ .../B08_Quantity_UI_Milestone_Route.ts | 339 ++++++++++++++++++ .../B08_Quantity_UI_Milestone_State.ts | 224 ++++++++++++ B08_Quantity/B08_Quantity_UI_Options.ts | 8 +- B08_Quantity/B08_Quantity_UI_Page.ts | 5 +- main.py | 2 + resources/tester/test_122_b08_options.py | 2 +- .../tester/test_243_1_double_click_tables.py | 3 +- resources/tester/test_244_1_b08_milestone.py | 192 ++++++++++ ui_template/ui_template_locale_b4.ts | 1 + 15 files changed, 1476 insertions(+), 4 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Milestone_Calc.py create mode 100644 B08_Quantity/B08_Quantity_Milestone_Calc.ts create mode 100644 B08_Quantity/B08_Quantity_Router_Milestone.py create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone.css create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone.ts create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_Route.ts create mode 100644 B08_Quantity/B08_Quantity_UI_Milestone_State.ts create mode 100644 resources/tester/test_244_1_b08_milestone.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index cc12ad8eb..0399b7ad2 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -185,6 +185,8 @@ export const STATE_REGISTRY = { "b08-hydro": { bucket: "draft", scope: "project", version: 3 }, /** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */ "b08-options": { bucket: "draft", scope: "project" }, + /** B08 거리이정표 입력 `{site, base, routes}` 한 벌 — [저장] 때 프로젝트 B08_Quantity/milestone 으로(244-1). */ + "b08-milestone": { bucket: "draft", scope: "project" }, /** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */ "b09-estimation": { bucket: "draft", scope: "project" }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py new file mode 100644 index 000000000..3d4ae22c6 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -0,0 +1,73 @@ +"""B08 거리이정표 표 합(PLAN 244-1) — 화면 없음 · 저장 안 함. + +⚠ 짝 = `B08_Quantity_Milestone_Calc.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 · + 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. + 조작 중은 TS 짝이 바로 셈 · [저장] · 읽기 때 서버가 이 파일로 다시 셈. + +표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 +시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 · +포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) · +끝수 = 소수 셋째 자리(반올림). +""" + +from __future__ import annotations + +import math +from typing import Any + +ROADS = ("2차포장", "1차포장", "비포장") +KEYS = {"2차포장": "s2", "1차포장": "s1", "비포장": "un"} + + +def r3(value: float) -> float: + """소수 셋째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음).""" + return math.floor(value * 1000 + 0.5) / 1000 + + +def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None) -> None: + for leg in legs or []: + key = KEYS.get(leg.get("road")) + if key: + sums[key] += float(leg.get("km") or 0) + + +def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float: + """시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000.""" + station = (data.get("site") or {}).get("mid_station_m") + if station is None: + station = mid_default_m + return float(station or 0) / 1000 + + +def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list[dict[str, Any]]: + """표 줄 — 경로 차례 · 경로 안 줄 차례.""" + site = data.get("site") or {} + base = data.get("base") or {} + mid = {"road": site.get("mid_road") or "비포장", "km": mid_km(data, mid_default_m)} + out: list[dict[str, Any]] = [] + for route in data.get("routes") or []: + for index, row in enumerate(route.get("rows") or []): + sums = {"s2": 0.0, "s1": 0.0, "un": 0.0} + add_legs(sums, route.get("legs")) + if base.get("enabled") and route.get("via_base", True): + add_legs(sums, base.get("legs")) + if row.get("dest") == "mid": + add_legs(sums, [mid]) + paved = sums["s2"] + sums["s1"] + out.append( + { + "route": route.get("id"), + "index": index, + "item": row.get("item") or "", + "company": route.get("name") or "", + "dest": row.get("dest") or "start", + "s2": r3(sums["s2"]), + "s1": r3(sums["s1"]), + "un": r3(sums["un"]), + "paved": r3(paved), + "unpaved": r3(sums["un"]), + "total": r3(paved + sums["un"]), + "note": row.get("note") or "", + } + ) + return out diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts new file mode 100644 index 000000000..4856fc234 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -0,0 +1,127 @@ +/* ============================================================================= + * B08_Quantity_Milestone_Calc.ts + * B08 거리이정표 표 합(PLAN 244-1) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림). + * + * ⚠ 짝 = `B08_Quantity_Milestone_Calc.py`(같은 식) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. + * 조작 중은 이 파일이 바로 셈 · [저장] · 읽기 때 서버가 파이썬 짝으로 다시 셈. + * + * 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 + * 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 · + * 끝수 = 소수 셋째 자리(반올림) + * ========================================================================== */ + +export const ROADS = ["2차포장", "1차포장", "비포장"] as const; +export type Road = (typeof ROADS)[number]; +/** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */ +export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const; +export type Dir = (typeof DIRS)[number]; +export type Dest = "start" | "mid"; + +export interface Leg { + road: Road; + km: number | null; +} + +export interface MilestoneRow { + item: string; + dest: Dest; + note: string; +} + +export interface MilestoneRoute { + id: string; + /** 업체 이름 = 하위 탭 이름 */ + name: string; + address: string; + /** 기준점 거침(기준점 없으면 무시 · 시점으로 바로) */ + via_base: boolean; + dir: Dir | null; + lon?: number | null; + lat?: number | null; + /** 업체 → 기준점(거침) · 시점 구간 */ + legs: Leg[]; + rows: MilestoneRow[]; +} + +export interface MilestoneData { + site: { mid_station_m: number | null; mid_road: Road }; + base: { + enabled: boolean; + name: string; + address: string; + dir: Dir | null; + lon?: number | null; + lat?: number | null; + /** 기준점 → 시점 공통 구간 */ + legs: Leg[]; + }; + routes: MilestoneRoute[]; +} + +export interface MilestoneTableRow { + route: string; + index: number; + item: string; + company: string; + dest: Dest; + s2: number; + s1: number; + un: number; + paved: number; + unpaved: number; + total: number; + note: string; +} + +const KEYS: Record = { "2차포장": "s2", "1차포장": "s1", 비포장: "un" }; + +/** 소수 셋째 자리 반올림(파이썬 짝 floor(x × 1000 + 0.5) 과 같음) */ +export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 1000; + +function addLegs(sums: Record<"s2" | "s1" | "un", number>, legs: Leg[] | undefined): void { + for (const leg of legs ?? []) { + const key = KEYS[leg.road]; + if (key) sums[key] += Number(leg.km ?? 0); + } +} + +/** 시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000 */ +export function midKm(data: MilestoneData, midDefaultM: number | null): number { + const station = data.site?.mid_station_m ?? midDefaultM; + return Number(station ?? 0) / 1000; +} + +/** 표 줄 — 경로 차례 · 경로 안 줄 차례 */ +export function computeMilestone( + data: MilestoneData, + midDefaultM: number | null, +): MilestoneTableRow[] { + const base = data.base; + const mid: Leg = { road: data.site?.mid_road ?? "비포장", km: midKm(data, midDefaultM) }; + const out: MilestoneTableRow[] = []; + for (const route of data.routes ?? []) { + (route.rows ?? []).forEach((row, index) => { + const sums = { s2: 0, s1: 0, un: 0 }; + addLegs(sums, route.legs); + if (base?.enabled && route.via_base !== false) addLegs(sums, base.legs); + if (row.dest === "mid") addLegs(sums, [mid]); + const paved = sums.s2 + sums.s1; + out.push({ + route: route.id, + index, + item: row.item ?? "", + company: route.name ?? "", + dest: row.dest ?? "start", + s2: r3(sums.s2), + s1: r3(sums.s1), + un: r3(sums.un), + paved: r3(paved), + unpaved: r3(sums.un), + total: r3(paved + sums.un), + note: row.note ?? "", + }); + }); + } + return out; +} diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py new file mode 100644 index 000000000..18e2cd950 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -0,0 +1,259 @@ +"""B08 거리이정표 길(PLAN 244-1) — 공사지 · 기준점 · 경로(업체) · 표 · 경로 그림. DB 표 없음. + + GET /api/projects/{id}/quantity/milestone {data, site, table, images} + PUT /api/projects/{id}/quantity/milestone [저장] — 입력만 · 표는 서버가 셈 + POST /api/projects/{id}/quantity/milestone/image?route= 경로 그림 올리기(하나 · 교체) + GET /api/projects/{id}/quantity/milestone/image?route= 그림 내려받기 + DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기 + +정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 그림 · +폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님). +그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움. +공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 +(수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). +표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝). +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any, Literal +from uuid import UUID + +from fastapi import APIRouter, File, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field, ValidationError, model_validator + +from B03_FileInput.B03_FileInput_Engine import save_upload_stream +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3 +from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from common_util.common_util_initial_snapshot import design_route_csv_path +from common_util.common_util_json import atomic_write_json +from common_util.common_util_route_geometry import ( + expected_route_csv_path, + interpolate_vertex, + planned_route_initial_path, + planned_route_working_path, + read_planned_route_csv, +) + +router = APIRouter(prefix="/api/projects", tags=["B08 Milestone"]) +PATH = "/{project_id}/quantity/milestone" +FOLDER = Path("B08_Quantity") / "milestone" +DATA_FILE = "milestone.json" +IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") +ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$" + +Road = Literal["2차포장", "1차포장", "비포장"] +Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"] + + +class Leg(BaseModel): + road: Road = "2차포장" + km: float | None = Field(default=None, ge=0, le=10000) + + +class Row(BaseModel): + item: str = Field(default="", max_length=100) + dest: Literal["start", "mid"] = "start" + note: str = Field(default="", max_length=200) + + +class Place(BaseModel): + name: str = Field(default="", max_length=100) + address: str = Field(default="", max_length=200) + dir: Dir | None = None + lon: float | None = Field(default=None, ge=-180, le=180) + lat: float | None = Field(default=None, ge=-90, le=90) + legs: list[Leg] = Field(default_factory=list, max_length=20) + + +class Route(Place): + id: str = Field(pattern=ROUTE_ID) + via_base: bool = True + rows: list[Row] = Field(default_factory=list, max_length=50) + + +class Base(Place): + enabled: bool = False + + +class Site(BaseModel): + mid_station_m: float | None = Field(default=None, ge=0, le=1_000_000) + mid_road: Road = "비포장" + + +class Milestone(BaseModel): + site: Site = Field(default_factory=Site) + base: Base = Field(default_factory=Base) + routes: list[Route] = Field(default_factory=list, max_length=100) + + @model_validator(mode="after") + def _unique_ids(self) -> Milestone: + ids = [route.id for route in self.routes] + if len(ids) != len(set(ids)): + raise ValueError("경로 id 가 겹칩니다.") + return self + + +def folder(root: str | Path) -> Path: + return Path(root) / FOLDER + + +def read_data(root: str | Path) -> dict[str, Any]: + """저장본 — 없거나 깨졌으면 빈 꼴.""" + path = folder(root) / DATA_FILE + try: + return Milestone.model_validate_json(path.read_text(encoding="utf-8")).model_dump() + except (OSError, ValidationError, json.JSONDecodeError): + return Milestone().model_dump() + + +def _route_vertices(root: Path) -> list[Any]: + for path in ( + planned_route_working_path(root), + planned_route_initial_path(root), + expected_route_csv_path(root), + design_route_csv_path(root), + ): + route = read_planned_route_csv(path) if path.is_file() else None + if route is not None and len(route.vertices) >= 2: + return route.vertices + return [] + + +def _lonlat(root: Path, x: float, y: float) -> list[float] | None: + """작업 좌표계 → 경도 · 위도(다이어그램 방위 · 244-3 지도) — 못 하면 None.""" + try: + from pyproj import Transformer + + from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + + crs = project_epsg_from_prj(root) + lon, lat = Transformer.from_crs(crs, "EPSG:4326", always_xy=True).transform(x, y) + return [round(lon, 7), round(lat, 7)] + except Exception: # 좌표계 못 읽음 = 좌표 없음(방위는 사용자 8방) + return None + + +def site_info(root: str | Path, mid_station_m: float | None) -> dict[str, Any]: + """노선 연장 · 중점 기본(연장 ÷ 2) · 시점 · 중점 경위도.""" + root = Path(root) + vertices = _route_vertices(root) + if not vertices: + return {"length_m": None, "mid_default_m": None, "start_lonlat": None, "mid_lonlat": None} + length = vertices[-1].chainage_m + mid_default = r3(length / 2) + mx, my, _ = interpolate_vertex( + vertices, mid_default if mid_station_m is None else mid_station_m + ) + return { + "length_m": r3(length), + "mid_default_m": mid_default, + "start_lonlat": _lonlat(root, vertices[0].x, vertices[0].y), + "mid_lonlat": _lonlat(root, mx, my), + } + + +def route_images(root: str | Path, route_id: str) -> list[Path]: + path = folder(root) + if not path.is_dir(): + return [] + return sorted( + f for f in path.glob(f"{route_id}.*") if f.is_file() and f.suffix.lower() in IMAGE_EXT + ) + + +def images(root: str | Path) -> dict[str, str]: + """경로 id → 그림 파일 이름(폴더가 목록).""" + path = folder(root) + if not path.is_dir(): + return {} + return { + f.stem: f.name + for f in sorted(path.iterdir()) + if f.is_file() and f.suffix.lower() in IMAGE_EXT and not f.name.startswith(".") + } + + +def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]: + site = site_info(root, data["site"]["mid_station_m"]) + return { + "data": data, + "site": site, + "table": compute_milestone(data, site["mid_default_m"]), + "images": images(root), + } + + +def save_data(root: str | Path, body: Milestone) -> dict[str, Any]: + data = body.model_dump() + path = folder(root) + path.mkdir(parents=True, exist_ok=True) + atomic_write_json(path / DATA_FILE, data) + keep = {route["id"] for route in data["routes"]} + for stem, name in images(root).items(): + if stem not in keep: + (path / name).unlink(missing_ok=True) + return payload(root, data) + + +def _route_id(route: str) -> str: + try: + return Route(id=route).id + except ValidationError as error: + raise HTTPException(status_code=400, detail="경로 id 가 바르지 않습니다.") from error + + +@router.get(PATH) +async def milestone_read(project_id: UUID) -> dict[str, Any]: + root = await _project_root(project_id) + return await asyncio.to_thread(lambda: payload(root, read_data(root))) + + +@router.put(PATH) +async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]: + root = await _project_root(project_id) + return await asyncio.to_thread(save_data, root, body) + + +@router.post(PATH + "/image") +async def milestone_image_upload( + project_id: UUID, route: str = Query(...), file: UploadFile = File(...) +) -> dict[str, Any]: + route_id = _route_id(route) + ext = Path(file.filename or "").suffix.lower() + if ext not in IMAGE_EXT: + raise HTTPException(status_code=400, detail="그림 파일(png · jpg · gif · webp · bmp)만.") + root = await _project_root(project_id) + path = folder(root) + path.mkdir(parents=True, exist_ok=True) + try: + await save_upload_stream(file, path / f".{route_id}{ext}.new") + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + for old in route_images(root, route_id): + old.unlink(missing_ok=True) + (path / f".{route_id}{ext}.new").replace(path / f"{route_id}{ext}") + return {"name": f"{route_id}{ext}"} + + +@router.get(PATH + "/image") +async def milestone_image(project_id: UUID, route: str = Query(...)): + route_id = _route_id(route) + root = await _project_root(project_id) + found = route_images(root, route_id) + if not found: + raise HTTPException(status_code=404, detail="경로 그림이 없습니다.") + return FileResponse(found[0]) + + +@router.delete(PATH + "/image") +async def milestone_image_delete(project_id: UUID, route: str = Query(...)) -> dict[str, Any]: + route_id = _route_id(route) + root = await _project_root(project_id) + for old in route_images(root, route_id): + old.unlink(missing_ok=True) + return {"name": None} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css new file mode 100644 index 000000000..be1adfa9a --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -0,0 +1,45 @@ +/* B08 거리이정표(244) — 경로 컨테이너 · 단추 줄 · 그림. 색은 테마 변수만(밝음 · 어두움 같이). */ + +.b08-milestone { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.b08-milestone__route { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-16); +} + +.b08-milestone__route h4 { + margin: var(--spacing-8) 0 0; + font-size: var(--text-caption); + color: var(--color-text-secondary); +} + +.b08-milestone__head { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-16); +} + +.b08-milestone__head > .ui-field { + min-width: 160px; +} + +.b08-milestone__bar { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.b08-milestone__image { + max-width: 100%; + max-height: 70vh; + object-fit: contain; + align-self: flex-start; + border: 1px solid var(--color-border); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts new file mode 100644 index 000000000..d215bb7df --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -0,0 +1,198 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone.ts + * B08 메인 탭 「거리이정표」(PLAN 244-1) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름) + + * [+ 추가] · [삭제]. + * + * [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고) + * 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈 + * 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도 + * 경로 탭 = `…_UI_Milestone_Route` · 데이터 · [저장] = `…_UI_Milestone_State`(좌측 패널 [저장] 한 줄) + * ========================================================================== */ + +import "./B08_Quantity_UI_Milestone.css"; +import { createDataTable, type DataColumn } from "@ui/ui_template_data_table"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { + liveMilestone, + loadMilestone, + onMilestoneChange, + putMilestone, + siteInfo, +} from "./B08_Quantity_UI_Milestone_State"; +import { + DEST_TEXT, + editRoute, + pickAtCell, + routeView, + trackCells, + type MilestoneView, +} from "./B08_Quantity_UI_Milestone_Route"; + +const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; +const COLUMNS: DataColumn[] = [ + { key: "item", label: "항목", width: 120 }, + { key: "company", label: "업체 이름", width: 120 }, + { key: "dest", label: "목적지", width: 72 }, + { key: "s2", label: "2차포장", group: ["도로 구분 (km)"], ...KM }, + { key: "s1", label: "1차포장", group: ["도로 구분 (km)"], ...KM }, + { key: "un", label: "비포장", group: ["도로 구분 (km)"], ...KM }, + { key: "paved", label: "포장", group: ["계 (km)"], ...KM }, + { key: "unpaved", label: "비포장", group: ["계 (km)"], ...KM }, + { key: "total", label: "합계", group: ["계 (km)"], ...KM }, + { key: "note", label: "비고", width: 160 }, +]; + +/** 새 경로(업체) — 캐시에 넣고 id 를 돌려줌 */ +export function addRoute(projectId: string, init: Partial = {}): string { + const data = liveMilestone(projectId); + const id = `r${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`; + data.routes.push({ + id, + name: `업체 ${data.routes.length + 1}`, + address: "", + via_base: true, + dir: null, + legs: [{ road: "2차포장", km: null }], + rows: [{ item: "", dest: "start", note: "" }], + ...init, + }); + putMilestone(projectId, data); + return id; +} + +/** [거리이정표] 하위 탭 — 표(+ 244-2 다이어그램 자리) */ +function tableView(projectId: string): MilestoneView { + let keys: Array<{ route: string; index: number }> = []; + const table = createDataTable({ + columns: COLUMNS, + rows: [], + caption: "거리이정표", + empty: "", + editOnDouble: true, + editable: (_i, key) => key === "item" || key === "company" || key === "note", + onEdit: (i, key, text) => { + const at = keys[i]; + if (!at) return; + editRoute(projectId, at.route, (_, r) => { + if (key === "company") r.name = text; + else if (r.rows[at.index]) r.rows[at.index][key as "item" | "note"] = text; + }); + }, + onCellClick: (i, key) => { + const at = keys[i]; + if (key !== "dest" || !at) return; + pickAtCell( + (["start", "mid"] as const).map((dest) => [ + DEST_TEXT[dest], + () => + editRoute(projectId, at.route, (_, r) => { + if (r.rows[at.index]) r.rows[at.index].dest = dest; + }), + ]), + ); + }, + }); + trackCells(table.root); + table.root.dataset.b08Milestone = "table"; + const none = el("p", { + className: "ui-general-block__empty", + text: "경로가 없습니다 — 하위 탭 줄 [+ 추가]로 업체를 넣으세요.", + }); + const refresh = (): void => { + const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null); + keys = rows.map(({ route, index }) => ({ route, index })); + none.hidden = rows.length > 0; + table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` }))); + }; + refresh(); + return { root: el("div", { className: "b08-milestone", children: [none, table.root] }), refresh }; +} + +export function renderMilestone(host: HTMLElement, projectId: string): void { + const tabs = el("div", { + className: "b08-quantity__tabs b08-structure__tabs", + attrs: { role: "tablist" }, + }); + const view = el("div"); + let current = "table"; + let body: MilestoneView | null = null; + + const drawTabs = (): void => { + const routes = liveMilestone(projectId).routes; + const tab = (id: string, label: string): HTMLButtonElement => { + const button = createButton({ + label, + variant: id === current ? "filled" : "ghost", + size: "sm", + onClick: () => id !== current && open(id), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(id === current)); + button.dataset.b08MilestoneTab = id; + return button; + }; + const add = createButton({ + label: "+ 추가", + size: "sm", + variant: "ghost", + onClick: () => open(addRoute(projectId)), + }); + add.dataset.b08MilestoneAdd = ""; + const remove = createButton({ + label: "삭제", + size: "sm", + variant: "danger", + onClick: () => void removeCurrent(), + }); + remove.disabled = current === "table"; + remove.dataset.b08MilestoneDelete = ""; + tabs.replaceChildren( + tab("table", "거리이정표"), + ...routes.map((r, i) => tab(r.id, r.name.trim() || `업체 ${i + 1}`)), + add, + remove, + ); + }; + + const removeCurrent = async (): Promise => { + const data = liveMilestone(projectId); + const route = data.routes.find((r) => r.id === current); + if (!route) return; + if ( + !(await showConfirmDialog( + `「${route.name || "업체"}」 경로를 지울까요? — [저장] 때 반영`, + "삭제", + )) + ) + return; + data.routes = data.routes.filter((r) => r.id !== route.id); + putMilestone(projectId, data); // 알림이 없는 경로 탭을 보고 [거리이정표] 로 옮김 + }; + + function open(id: string): void { + current = id; + drawTabs(); + body = id === "table" ? tableView(projectId) : routeView(projectId, id); + view.replaceChildren(body.root); + } + + host.replaceChildren( + tabs, + el("p", { className: "ui-general-block__empty", text: "거리이정표를 불러오는 중…" }), + ); + loadMilestone(projectId).then( + () => { + host.replaceChildren(tabs, view); + open("table"); + onMilestoneChange(host, () => { + // 지운 경로 탭이면 [거리이정표] 로 · 아니면 탭 이름 · 몸만 다시 셈(입력 칸은 그대로) + if (current !== "table" && !liveMilestone(projectId).routes.some((r) => r.id === current)) + return open("table"); + drawTabs(); + body?.refresh(); + }); + }, + (error: unknown) => showToast(`거리이정표를 불러오지 못했습니다 — ${String(error)}`, "error"), + ); +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts new file mode 100644 index 000000000..9c3671293 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -0,0 +1,339 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_Route.ts + * 거리이정표 경로 하위 탭(PLAN 244-1) — 컨테이너 하나 = 업체 한 곳 · 그림 하나. + * + * 위 = 업체 이름(= 하위 탭 이름) · 주소 · 방위(기준점 · 없으면 공사지 기준 8방) · 기준점 거침 + * 구간 표 = 업체 → 기준점(또는 시점) · 기준점 → 시점(공통 · 모든 경로가 같이 씀) · 시점 → 중점(측점 km) · + * 구간마다 km(더블클릭 손 입력) · 도로 구분 ▾(2차포장 · 1차포장 · 비포장) + * 표 줄 = 항목 · 목적지 ▾ · 비고 — [거리이정표] 표에 이 업체 줄로 나감 + * 그림 = 올리는 즉시 서버(교체 · 지우기) · 나머지 조작 = 캐시(`…_UI_Milestone_State`) · [저장] 때 서버 + * ========================================================================== */ + +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { + createMapContextMenu, + type MapContextMenu, + type MapContextMenuItem, +} from "@ui/ui_template_context_menu"; +import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { + ROADS, + midKm, + type Dest, + type Dir, + type MilestoneData, + type MilestoneRoute, +} from "./B08_Quantity_Milestone_Calc"; +import { + deleteImage, + dirOptions, + imageBlobUrl, + liveMilestone, + putMilestone, + routeImages, + siteInfo, + uploadImage, +} from "./B08_Quantity_UI_Milestone_State"; + +let menu: MapContextMenu | null = null; +let lastCell: Element | null = null; + +/** 표 칸 누름 자리 기억 — 「▾」 메뉴를 그 칸 아래에 엶 */ +export function trackCells(root: HTMLElement): void { + root.addEventListener("click", (e) => (lastCell = (e.target as Element).closest("td")), true); +} + +export function pickAtCell(items: MapContextMenuItem[]): void { + const box = lastCell?.getBoundingClientRect(); + if (!box) return; + menu ??= createMapContextMenu("b08-milestone"); + if (!menu.element.isConnected) { + menu.element.style.position = "fixed"; + document.body.append(menu.element); + } + menu.open(box.left, box.bottom, items); +} + +/** 고른 줄 강조(공용 표 is-focus · 다시 그리지 않음 — 더블클릭 그대로) */ +function markRow(root: HTMLElement, index: number | null): void { + root.querySelectorAll("tr.is-focus").forEach((tr) => tr.classList.remove("is-focus")); + if (index !== null) root.querySelector(`tr[data-i="${index}"]`)?.classList.add("is-focus"); +} + +export const DEST_TEXT: Record = { start: "시점", mid: "중점" }; + +/** 그 경로를 고쳐 캐시에 */ +export function editRoute( + projectId: string, + routeId: string, + fn: (data: MilestoneData, route: MilestoneRoute) => void, +): void { + const data = liveMilestone(projectId); + const route = data.routes.find((r) => r.id === routeId); + if (!route) return; + fn(data, route); + putMilestone(projectId, data); +} + +const kmOf = (text: string): number | null | undefined => { + if (!text.trim()) return null; + const value = Number(text.replace(/,/g, "")); + return Number.isFinite(value) && value >= 0 ? value : undefined; // undefined = 그대로 +}; + +type LegRef = { group: "own" | "base" | "mid"; i: number }; + +const LEG_COLUMNS: DataColumn[] = [ + { key: "span", label: "구간", width: 200 }, + { key: "road", label: "도로 구분", width: 96 }, + { key: "km", label: "km", kind: "number", decimals: 3, trim: true, width: 80 }, +]; +const ROW_COLUMNS: DataColumn[] = [ + { key: "item", label: "항목", width: 140 }, + { key: "dest", label: "목적지", width: 80 }, + { key: "note", label: "비고", width: 180 }, +]; + +export interface MilestoneView { + root: HTMLElement; + refresh: () => void; +} + +export function routeView(projectId: string, routeId: string): MilestoneView { + const edit = (fn: (data: MilestoneData, route: MilestoneRoute) => void): void => + editRoute(projectId, routeId, fn); + + // 위 — 업체 이름 · 주소 · 방위 · 기준점 거침 + const name = createInputField({ label: "업체 이름", compact: true }); + const address = createInputField({ label: "주소", compact: true }); + const dir = createSelectField({ label: "방위", options: dirOptions, compact: true }); + const via = createCheckbox("기준점 거침"); + name.input.dataset.b08Milestone = "route_name"; + name.input.addEventListener("input", () => edit((_, r) => (r.name = name.input.value))); + address.input.addEventListener("input", () => edit((_, r) => (r.address = address.input.value))); + dir.select.addEventListener("change", () => + edit((_, r) => (r.dir = (dir.select.value || null) as Dir | null)), + ); + via.input.addEventListener("change", () => edit((_, r) => (r.via_base = via.input.checked))); + const head = el("div", { + className: "b08-milestone__head", + children: [name.root, address.root, dir.root, via.root], + }); + + // 구간 표 + let legRefs: LegRef[] = []; + let pickedLeg: number | null = null; + const legRows = (d: MilestoneData, r: MilestoneRoute): DataRow[] => { + const useBase = d.base.enabled && r.via_base; + const baseName = d.base.name || "기준점"; + const who = r.name || "업체"; + const rows: Array<[LegRef, string, string, number | null]> = [ + ...r.legs.map((leg, i): [LegRef, string, string, number | null] => [ + { group: "own", i }, + `${who} → ${useBase ? baseName : "시점"}`, + leg.road, + leg.km, + ]), + ...(useBase ? d.base.legs : []).map((leg, i): [LegRef, string, string, number | null] => [ + { group: "base", i }, + `${baseName} → 시점 (공통)`, + leg.road, + leg.km, + ]), + [ + { group: "mid", i: 0 }, + "시점 → 중점 (측점)", + d.site.mid_road, + midKm(d, siteInfo()?.mid_default_m ?? null), + ], + ]; + legRefs = rows.map(([ref]) => ref); + return rows.map(([, span, road, km]) => ({ span, road: `${road} ▾`, km })); + }; + const setLeg = (ref: LegRef, fn: (leg: { road: string; km: number | null }) => void): void => + edit((d, r) => { + if (ref.group === "mid") { + const leg = { road: d.site.mid_road as string, km: null }; + fn(leg); + d.site.mid_road = leg.road as MilestoneData["site"]["mid_road"]; + } else fn((ref.group === "own" ? r.legs : d.base.legs)[ref.i]); + }); + const legs = createDataTable({ + columns: LEG_COLUMNS, + rows: [], + caption: "구간", + empty: "", + editOnDouble: true, + editable: (i, key) => key === "km" && legRefs[i]?.group !== "mid", + onEdit: (i, _key, text) => { + const km = kmOf(text); + if (km !== undefined) setLeg(legRefs[i], (leg) => (leg.km = km)); + else refresh(); + }, + onCellClick: (i, key) => { + if (key !== "road") return; + pickAtCell(ROADS.map((road) => [road, () => setLeg(legRefs[i], (leg) => (leg.road = road))])); + }, + onRowClick: (i) => markRow(legs.root, (pickedLeg = i)), + }); + trackCells(legs.root); + legs.root.dataset.b08Milestone = "legs"; + const addOwn = createButton({ + label: "+ 업체 구간", + size: "sm", + variant: "ghost", + onClick: () => edit((_, r) => r.legs.push({ road: "2차포장", km: null })), + }); + const addBase = createButton({ + label: "+ 공통 구간", + size: "sm", + variant: "ghost", + onClick: () => edit((d) => d.base.legs.push({ road: "1차포장", km: null })), + }); + const dropLeg = createButton({ + label: "고른 구간 지우기", + size: "sm", + variant: "ghost", + onClick: () => { + const ref = pickedLeg === null ? null : legRefs[pickedLeg]; + if (!ref || ref.group === "mid") return showToast("지울 구간 줄을 먼저 고르세요.", "info"); + pickedLeg = null; + edit((d, r) => (ref.group === "own" ? r.legs : d.base.legs).splice(ref.i, 1)); + }, + }); + + // 표 줄 + let pickedRow: number | null = null; + const rows = createDataTable({ + columns: ROW_COLUMNS, + rows: [], + caption: "표 줄", + empty: "", + editOnDouble: true, + editable: (_i, key) => key === "item" || key === "note", + onEdit: (i, key, text) => + edit((_, r) => { + if (r.rows[i]) r.rows[i][key as "item" | "note"] = text; + }), + onCellClick: (i, key) => { + if (key !== "dest") return; + pickAtCell( + (["start", "mid"] as Dest[]).map((dest) => [ + DEST_TEXT[dest], + () => edit((_, r) => r.rows[i] && (r.rows[i].dest = dest)), + ]), + ); + }, + onRowClick: (i) => markRow(rows.root, (pickedRow = i)), + }); + trackCells(rows.root); + rows.root.dataset.b08Milestone = "rows"; + const addRow = createButton({ + label: "+ 줄", + size: "sm", + variant: "ghost", + onClick: () => edit((_, r) => r.rows.push({ item: "", dest: "start", note: "" })), + }); + const dropRow = createButton({ + label: "고른 줄 지우기", + size: "sm", + variant: "ghost", + onClick: () => { + if (pickedRow === null) return showToast("지울 줄을 먼저 고르세요.", "info"); + const at = pickedRow; + pickedRow = null; + edit((_, r) => r.rows.splice(at, 1)); + }, + }); + + // 그림 — 경로 하나 = 그림 하나 + const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } }); + const noImage = el("p", { className: "ui-general-block__empty", text: "올린 그림 없음" }); + let shown: string | null | undefined; + const loadImage = (force = false): void => { + const name = routeImages()[routeId] ?? null; + if (!force && name === shown) return; + shown = name; + img.hidden = !name; + noImage.hidden = !!name; + if (img.src) URL.revokeObjectURL(img.src); + img.removeAttribute("src"); + if (name) + imageBlobUrl(projectId, routeId).then( + (url) => (img.src = url), + (error: unknown) => showToast(`그림을 못 읽었습니다 — ${String(error)}`, "error"), + ); + }; + const file = el("input", { attrs: { type: "file", accept: "image/*" } }); + file.hidden = true; + file.addEventListener("change", () => { + const picked = file.files?.[0]; + file.value = ""; + if (picked) + uploadImage(projectId, routeId, picked).then( + () => loadImage(true), + (error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"), + ); + }); + const upload = createButton({ + label: "그림 올리기", + size: "sm", + variant: "filled", + onClick: () => file.click(), + }); + const removeImage = createButton({ + label: "그림 지우기", + size: "sm", + variant: "ghost", + onClick: () => + void deleteImage(projectId, routeId).catch((error: unknown) => + showToast(`그림을 지우지 못했습니다 — ${String(error)}`, "error"), + ), + }); + + const bar = (...items: HTMLElement[]): HTMLElement => + el("div", { className: "b08-milestone__bar", children: items }); + const root = el("section", { + className: "b08-milestone__route ui-card", + attrs: { "data-b08-milestone-route": routeId }, + children: [ + head, + el("h4", { text: "구간 (km 더블클릭 · 도로 구분 ▾)" }), + legs.root, + bar(addOwn, addBase, dropLeg), + el("h4", { text: "표 줄" }), + rows.root, + bar(addRow, dropRow), + el("h4", { text: "경로 그림" }), + bar(upload, removeImage, file), + noImage, + img, + ], + }); + + const refresh = (): void => { + const d = liveMilestone(projectId); + const r = d.routes.find((x) => x.id === routeId); + if (!r) return; + if (document.activeElement !== name.input) name.input.value = r.name; + if (document.activeElement !== address.input) address.input.value = r.address; + dir.select.value = r.dir ?? ""; + via.input.checked = r.via_base; + via.root.hidden = !d.base.enabled; + addBase.hidden = !(d.base.enabled && r.via_base); + legs.setRows(legRows(d, r)); + rows.setRows(r.rows.map((row) => ({ ...row, dest: `${DEST_TEXT[row.dest]} ▾` }))); + markRow(legs.root, pickedLeg); + markRow(rows.root, pickedRow); + loadImage(); + }; + refresh(); + return { root, refresh }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts new file mode 100644 index 000000000..6a53b8625 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -0,0 +1,224 @@ +/* ============================================================================= + * B08_Quantity_UI_Milestone_State.ts + * 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길 + 좌측 패널 카드(공사지 · 기준점). + * + * 저장본 = GET …/quantity/milestone(서버 정본 · 표는 서버가 셈) · 조작 = 캐시 `b08-milestone` 즉시 + + * 표는 TS 짝(`…_Milestone_Calc`)이 바로 셈 · [저장](좌측 패널 한 줄) = PUT → 캐시 비움 · 자동 저장 없음. + * 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴). + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { request } from "./B08_Quantity_UI_Hydro"; +import { DIRS, ROADS, type MilestoneData, type Road } from "./B08_Quantity_Milestone_Calc"; + +export interface SiteInfo { + length_m: number | null; + mid_default_m: number | null; + start_lonlat: [number, number] | null; + mid_lonlat: [number, number] | null; +} + +interface Payload { + data: MilestoneData; + site: SiteInfo; + images: Record; +} + +/** 8방 이름 */ +export const DIR_TEXT: Record = { + N: "북", + NE: "북동", + E: "동", + SE: "남동", + S: "남", + SW: "남서", + W: "서", + NW: "북서", +}; + +export const EMPTY: MilestoneData = { + site: { mid_station_m: null, mid_road: "비포장" }, + base: { enabled: false, name: "", address: "", dir: null, legs: [] }, + routes: [], +}; + +const changes = new EventTarget(); +let saved: Payload | null = null; +let savedFor: string | null = null; + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +/** 저장본 읽기(프로젝트마다 한 번 · force = 다시) */ +export async function loadMilestone(projectId: string, force = false): Promise { + if (!saved || savedFor !== projectId || force) { + saved = await request(`/projects/${projectId}/quantity/milestone`); + savedFor = projectId; + changes.dispatchEvent(new Event("change")); + } + return saved; +} + +export const siteInfo = (): SiteInfo | null => saved?.site ?? null; +export const routeImages = (): Record => saved?.images ?? {}; + +export const hasMilestoneDraft = (projectId: string): boolean => + readState("b08-milestone", projectId) !== null; + +/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */ +export function liveMilestone(projectId: string): MilestoneData { + return clone(readState("b08-milestone", projectId) ?? saved?.data ?? EMPTY); +} + +/** 고친 한 벌을 캐시에 · 화면에 알림 */ +export function putMilestone(projectId: string, data: MilestoneData): void { + writeState("b08-milestone", data, projectId); + changes.dispatchEvent(new Event("change")); +} + +/** host 가 붙어 있는 동안 바뀔 때마다 fn — 떨어진 뒤 첫 알림에서 스스로 뗌 */ +export function onMilestoneChange(host: HTMLElement, fn: () => void): void { + const listener = (): void => { + if (host.isConnected) fn(); + else changes.removeEventListener("change", listener); + }; + changes.addEventListener("change", listener); +} + +/** [저장] — 캐시가 있을 때만 PUT · 서버가 표를 다시 셈 */ +export async function saveMilestone(projectId: string): Promise { + const draft = readState("b08-milestone", projectId); + if (!draft) return; + saved = await request(`/projects/${projectId}/quantity/milestone`, { + method: "PUT", + body: JSON.stringify(draft), + }); + savedFor = projectId; + clearState("b08-milestone", projectId); + changes.dispatchEvent(new Event("change")); +} + +const imageUrl = (projectId: string, routeId: string): string => + `${API_BASE_URL}/projects/${projectId}/quantity/milestone/image?route=${encodeURIComponent(routeId)}`; + +async function call(input: string, init: RequestInit = {}): Promise { + const response = await fetch(input, { credentials: "include", ...init }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; + throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`); + } + return response; +} + +/** 경로 그림 — 올리기(교체) · 지우기는 즉시 서버 · 목록 고쳐 알림 */ +export async function uploadImage(projectId: string, routeId: string, file: File): Promise { + const form = new FormData(); + form.append("file", file); + const body = (await ( + await call(imageUrl(projectId, routeId), { method: "POST", body: form }) + ).json()) as { name: string }; + if (saved) saved.images = { ...saved.images, [routeId]: body.name }; + changes.dispatchEvent(new Event("change")); +} + +export async function deleteImage(projectId: string, routeId: string): Promise { + await call(imageUrl(projectId, routeId), { method: "DELETE" }); + if (saved) { + const rest = { ...saved.images }; + delete rest[routeId]; + saved.images = rest; + } + changes.dispatchEvent(new Event("change")); +} + +/** 그림 → 화면 주소(blob · 쿠키 포함) */ +export async function imageBlobUrl(projectId: string, routeId: string): Promise { + return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); +} + +export const roadOptions = ROADS.map((r) => ({ value: r, text: r })); +export const dirOptions = [ + { value: "", text: "방위 —" }, + ...DIRS.map((d) => ({ value: d, text: DIR_TEXT[d] })), +]; + +const km = (m: number | null | undefined): string => + m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); + +/** 좌측 패널 「거리이정표」 카드 몸 — 공사지(시점 · 중점) · 기준점(있음 · 이름 · 주소 · 방위) */ +export function milestoneCardBody(projectId: string | null): HTMLElement[] { + const mid = createInputField({ label: "중점 측점 (m)", type: "number", compact: true, min: 0 }); + const midRoad = createSelectField({ + label: "시점 → 중점 도로", + options: roadOptions, + compact: true, + }); + const siteHint = el("p", { className: "b08-options__hint" }); + const has = createCheckbox("기준점 있음"); + const name = createInputField({ label: "기준점 이름", compact: true }); + const address = createInputField({ label: "기준점 주소", compact: true }); + const dir = createSelectField({ + label: "공사지에서 본 방위", + options: dirOptions, + compact: true, + }); + mid.input.dataset.b08Milestone = "mid_station_m"; + has.input.dataset.b08Milestone = "base_enabled"; + const baseFields = el("div", { children: [name.root, address.root, dir.root] }); + + const show = (): void => { + if (!projectId) return; + const data = liveMilestone(projectId); + const site = siteInfo(); + mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : ""; + if (document.activeElement !== mid.input) + mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m); + midRoad.select.value = data.site.mid_road; + siteHint.textContent = + `시점 = 노선 첫 점(측점 0) · 노선 ${km(site?.length_m)} km · ` + + `중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`; + has.input.checked = data.base.enabled; + baseFields.hidden = !data.base.enabled; + if (document.activeElement !== name.input) name.input.value = data.base.name; + if (document.activeElement !== address.input) address.input.value = data.base.address; + dir.select.value = data.base.dir ?? ""; + }; + const edit = (fn: (data: MilestoneData) => void): void => { + if (!projectId) return; + const data = liveMilestone(projectId); + fn(data); + putMilestone(projectId, data); + }; + mid.input.addEventListener("input", () => + edit((d) => { + const value = Number(mid.input.value); + d.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null; + }), + ); + midRoad.select.addEventListener("change", () => + edit((d) => (d.site.mid_road = midRoad.select.value as Road)), + ); + has.input.addEventListener("change", () => + edit((d) => { + d.base.enabled = has.input.checked; + if (d.base.enabled && !d.base.legs.length) d.base.legs = [{ road: "1차포장", km: null }]; + }), + ); + name.input.addEventListener("input", () => edit((d) => (d.base.name = name.input.value))); + address.input.addEventListener("input", () => + edit((d) => (d.base.address = address.input.value)), + ); + dir.select.addEventListener("change", () => + edit((d) => (d.base.dir = (dir.select.value || null) as MilestoneData["base"]["dir"])), + ); + + const host = el("div", { + children: [mid.root, midRoad.root, siteHint, has.root, baseFields], + }); + show(); + onMilestoneChange(host, show); // 첫 알림(읽기 끝)은 패널이 붙은 뒤 + if (projectId) void loadMilestone(projectId).catch(() => undefined); + return [host]; +} diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts index a6f645812..fe5160be2 100644 --- a/B08_Quantity/B08_Quantity_UI_Options.ts +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -1,12 +1,15 @@ /* ============================================================================= * B08_Quantity_UI_Options.ts - * 좌측 패널(PLAN 122 · 160-1) — 메인 탭과 같은 컨테이너 넷(구조물 집계표 · 자재 · 토공 · 수리집수면적표 · 접이) + * 좌측 패널(PLAN 122 · 160-1 · 244-1) — 메인 탭과 같은 컨테이너 다섯(구조물 집계표 · 자재 · 토공 · 수리집수면적표 · + * 거리이정표 · 접이) * + [저장] 줄. * * 구조물 집계표 › 배관 1본 길이(m) — 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` * 자재 › 할증 모래 · 자갈 · 시멘트 %(PLAN 124) — 총괄자재수량집계표 그 세 줄 · 0~100 * 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 100 − 값 · 0~100) * 수리집수면적표 — 빈 자리(다음 옵션) + * 거리이정표 › 공사지(중점 측점 · 시점 → 중점 도로) · 기준점 — `…_UI_Milestone_State`(캐시 `b08-milestone` · + * [저장] 한 번에 같이 PUT) * 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) · * [저장] = PUT …/quantity/options → 캐시 비움 · 열린 탭을 서버 정본으로 다시 그림 · 자동 저장 없음. * ========================================================================== */ @@ -24,6 +27,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { clampPct } from "./B08_Quantity_Options_Calc"; import { PANEL_SURCHARGE, type Cells } from "./B08_Quantity_Material_Calc"; +import { milestoneCardBody, saveMilestone } from "./B08_Quantity_UI_Milestone_State"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -210,6 +214,7 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void surcharge: body.material_surcharge_pct, }; clearState("b08-options", projectId); + await saveMilestone(projectId); show(); showToast("수량 산출 옵션을 저장했습니다.", "success"); onSaved(); @@ -237,6 +242,7 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void card("material", "B08_Quantity_Tab_Materials", ...surchargeBlock), card("earthwork", "B08_Quantity_Tab_Earthwork", rockRow, hint("암 전체 기준 · 두 칸 합 100")), card("hydrology", "B08_Quantity_Tab_Hydrology", hint("다음 옵션 자리")), + card("milestone", "B08_Quantity_Tab_Milestone", ...milestoneCardBody(projectId)), actions, ); attachCollapsible(panel); diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 78f27502f..385aebf8c 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -2,7 +2,7 @@ * B08_Quantity_UI_Page.ts * 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61) * - * 탭 = 구조물 집계표(61-1) · 자재 · 토공 · 수리집수면적표 (PLAN 95-1 · 160-1). 위 탭 단추 = 공용 + * 탭 = 구조물 집계표(61-1) · 자재 · 토공 · 수리집수면적표 (PLAN 95-1 · 160-1) · 거리이정표(244-1). 위 탭 단추 = 공용 * 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1). 구조물도는 집계표 행을 눌러 여는 모달(95-3). * 자재 아래 보조 탭 = 총괄자재수량집계표 · 콘크리트소요자재집계표 · 자재수량집계표(121-1 `…_UI_Material`). * 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀. @@ -22,6 +22,7 @@ import { renderMaterialSheet } from "./B08_Quantity_UI_Material"; import { renderHydro } from "./B08_Quantity_UI_Hydro"; import { renderEarthwork } from "./B08_Quantity_UI_Earthwork"; import { createOptionsPanel } from "./B08_Quantity_UI_Options"; +import { renderMilestone } from "./B08_Quantity_UI_Milestone"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -32,6 +33,7 @@ const TABS = [ { id: "material", label: "B08_Quantity_Tab_Materials" }, { id: "earthwork", label: "B08_Quantity_Tab_Earthwork" }, { id: "hydrology", label: "B08_Quantity_Tab_Hydrology" }, + { id: "milestone", label: "B08_Quantity_Tab_Milestone" }, ] as const; type TabId = (typeof TABS)[number]["id"]; @@ -101,6 +103,7 @@ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): voi } if (tab === "hydrology" && projectId) return void renderHydro(view, projectId); if (tab === "earthwork" && projectId) return void renderEarthwork(view, projectId); + if (tab === "milestone" && projectId) return renderMilestone(view, projectId); view.append(pendingNote()); } diff --git a/main.py b/main.py index 35dec32f1..05ca3a1fc 100644 --- a/main.py +++ b/main.py @@ -69,6 +69,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router from B08_Quantity.B08_Quantity_Router_Options import router as b08_options_router from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_router from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router +from B08_Quantity.B08_Quantity_Router_Milestone import router as b08_milestone_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router @@ -656,6 +657,7 @@ app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_summary_router, dependencies=protected_with_company) app.include_router(b08_material_router, dependencies=protected_with_company) app.include_router(b08_options_router, dependencies=protected_with_company) +app.include_router(b08_milestone_router, dependencies=protected_with_company) app.include_router(b09_estimation_router, dependencies=protected_with_company) # B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3). # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py index fa255f5d9..ac34f1342 100644 --- a/resources/tester/test_122_b08_options.py +++ b/resources/tester/test_122_b08_options.py @@ -140,7 +140,7 @@ def test_좌측_패널_컨테이너_셋과_저장_줄(): panel = (ROOT / "B08_Quantity/B08_Quantity_UI_Options.ts").read_text(encoding="utf-8") assert "leftPanel: createOptionsPanel(projectId, () => select(current))" in page # 160-1 — 메인 탭 차례 = 구조물 집계표 · 자재 · 토공 · 수리 · 카드도 같은 차례(1본 → 집계표 · 할증 → 자재) - tabs = ("summary", "material", "earthwork", "hydrology") + tabs = ("summary", "material", "earthwork", "hydrology", "milestone") # 244-1 거리이정표 ids = re.findall(r'\{ id: "(\w+)", label: "B08_Quantity_Tab_', page) assert ids == [ *tabs, diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index 08ea4367a..61be0ddae 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -127,7 +127,8 @@ def test_B08_고칠_칸_있는_표_모두_editOnDouble(): if "editable:" in block: seen += 1 assert "editOnDouble: true" in block, p.name - assert seen == 1 # 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + # 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + 거리이정표 셋(표 · 구간 · 표 줄 · 244-1) + assert seen == 4 def test_고르기_메뉴_이동_그대로(): diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py new file mode 100644 index 000000000..a4e3141aa --- /dev/null +++ b/resources/tester/test_244_1_b08_milestone.py @@ -0,0 +1,192 @@ +"""244-1 B08 거리이정표 — 표 합 거울(파이썬 · TS) · 저장 왕복(임시 storage) · 그림 · +하위 탭 추가 · 삭제 · 중점 기본. + +견본 = 실무 「거리이정표」 시트(거창군청 → 병곡리 968-31 → 시점 · 김천역 → 임도중점) +장비 = 2차 23.8 + 1차 0.6 + 비포장 0.8 → 포장 24.4 · 비포장 0.8 · 합계 25.2. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + + +def _leg(road, km): + return {"road": road, "km": km} + + +SAMPLE = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리 968-31", "address": "", "dir": "E", + "legs": [_leg("1차포장", 0.6), _leg("비포장", 0.8)]}, + "routes": [ + {"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": "W", + "legs": [_leg("2차포장", 23.8)], + "rows": [{"item": "장비(20톤)", "dest": "start", "note": ""}, + {"item": "장비(8톤)", "dest": "start", "note": "같은 길"}]}, + {"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": "N", + "legs": [_leg("2차포장", 61.2), _leg("1차포장", 2.05), _leg("비포장", None)], + "rows": [{"item": "철근", "dest": "mid", "note": ""}]}, + ], +} # fmt: skip + + +def _run_ts(data, mid_default): + calc = ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Calc.ts" + body = ( + f"\nconst d = {json.dumps(data, ensure_ascii=False)};" + f"\nconsole.log(JSON.stringify(computeMilestone(d, {json.dumps(mid_default)})));" + ) + script = IMPORTS.sub("", calc.read_text(encoding="utf-8")) + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_견본_표_합(): + rows = compute_milestone(SAMPLE, 1500.0) + a, b, c = rows + assert (a["s2"], a["s1"], a["un"]) == (23.8, 0.6, 0.8) + assert (a["paved"], a["unpaved"], a["total"]) == (24.4, 0.8, 25.2) + assert b["item"] == "장비(8톤)" and b["total"] == 25.2 and b["company"] == "거창군청" + # 기준점 안 거침 · 목적지 중점 = 시점 → 중점(기본 1500 ÷ 1000) 비포장 더함 · 빈 km = 0 + assert (c["s2"], c["s1"], c["un"], c["total"]) == (61.2, 2.05, 1.5, 64.75) + # 사용자 중점 측점 · 기준점 없음 = 공통 구간 안 셈 + no_base = {**SAMPLE, "base": {**SAMPLE["base"], "enabled": False}, + "site": {"mid_station_m": 420, "mid_road": "1차포장"}} # fmt: skip + a2, _, c2 = compute_milestone(no_base, 1500.0) + assert a2["total"] == 23.8 and (c2["s1"], c2["un"]) == (2.47, 0) + + +def test_거울_TS(): + for mid in (1500.0, None): + assert _run_ts(SAMPLE, mid) == compute_milestone(SAMPLE, mid) + odd = {**SAMPLE, "site": {"mid_station_m": 1234.5678, "mid_road": "비포장"}} + assert _run_ts(odd, 10.0) == compute_milestone(odd, 10.0) + + +def _route_csv(root: Path, points): + path = root / "B05_Profile" / "route" / "planned_route.csv" + path.parent.mkdir(parents=True) + lines = ["sequence,x,y"] + [f"{i},{x},{y}" for i, (x, y) in enumerate(points)] + path.write_text("\n".join(lines), encoding="utf-8") + + +def test_중점_기본_노선_절반(tmp_path): + assert ms.site_info(tmp_path, None)["mid_default_m"] is None # 노선 전 = 빈칸 + _route_csv(tmp_path, [(208000, 367000), (209000, 367000), (209000, 367500)]) + site = ms.site_info(tmp_path, None) + assert site["length_m"] == 1500 and site["mid_default_m"] == 750 + lon, lat = site["start_lonlat"] + assert 124 < lon < 132 and 33 < lat < 39 # 작업 좌표계(폴백 5186) → 경위도 + assert site["mid_lonlat"] != site["start_lonlat"] + + +def _client(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + async def root(_project_id): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + app = FastAPI() + app.include_router(ms.router) + return TestClient(app), f"/api/projects/{uuid4()}/quantity/milestone" + + +def test_저장_왕복_그림(tmp_path, monkeypatch): + client, base = _client(tmp_path, monkeypatch) + empty = client.get(base).json() + assert empty["data"]["routes"] == [] and empty["table"] == [] and empty["images"] == {} + _route_csv(tmp_path, [(0, 0), (3000, 0)]) + saved = client.put(base, json=SAMPLE).json() + assert saved["data"] == ms.Milestone.model_validate(SAMPLE).model_dump() + assert saved["table"] == compute_milestone(SAMPLE, 1500.0) # 서버가 다시 셈 + stored = json.loads((tmp_path / "B08_Quantity/milestone/milestone.json").read_text("utf-8")) + assert stored["routes"][0]["name"] == "거창군청" and "table" not in stored + assert client.get(base).json() == saved + # 그림 — 경로 하나 = 그림 하나(교체) · 내려받기 · 지우기 + img = base + "/image" + up = client.post(img, params={"route": "r1"}, files={"file": ("a.PNG", b"png1", "image/png")}) + assert up.json() == {"name": "r1.png"} + client.post(img, params={"route": "r1"}, files={"file": ("b.jpg", b"jpg2", "image/jpeg")}) + assert client.get(base).json()["images"] == {"r1": "r1.jpg"} + assert client.get(img, params={"route": "r1"}).content == b"jpg2" + assert ( + client.post(img, params={"route": "r1"}, files={"file": ("x.pdf", b"1")}).status_code == 400 + ) + assert client.get(img, params={"route": "../x"}).status_code == 400 + client.post(img, params={"route": "r2"}, files={"file": ("c.png", b"png3", "image/png")}) + client.delete(img, params={"route": "r2"}) + assert client.get(img, params={"route": "r2"}).status_code == 404 + # 경로 삭제 뒤 [저장] = 그 경로 그림도 지움 + client.post(img, params={"route": "r2"}, files={"file": ("c.png", b"png3", "image/png")}) + one = {**SAMPLE, "routes": SAMPLE["routes"][:1]} + assert client.put(base, json=one).json()["images"] == {"r1": "r1.jpg"} + # 겹친 id · 나쁜 값 = 거절 + twice = {**SAMPLE, "routes": [SAMPLE["routes"][0]] * 2} + assert client.put(base, json=twice).status_code == 422 + bad = {**SAMPLE, "site": {"mid_station_m": -1, "mid_road": "비포장"}} + assert client.put(base, json=bad).status_code == 422 + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_메인_탭_하위_탭_추가_삭제(): + page = _read("B08_Quantity/B08_Quantity_UI_Page.ts") + assert '{ id: "milestone", label: "B08_Quantity_Tab_Milestone" }' in page + assert 'if (tab === "milestone" && projectId) return renderMilestone(view, projectId);' in page + assert 'B08_Quantity_Tab_Milestone: ["거리이정표"' in _read( + "ui_template/ui_template_locale_b4.ts" + ) + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert 'tab("table", "거리이정표")' in ui and "routes.map((r, i) => tab(r.id," in ui + assert 'label: "+ 추가"' in ui and "open(addRoute(projectId))" in ui + assert 'label: "삭제"' in ui and 'remove.disabled = current === "table"' in ui + assert "data.routes.filter((r) => r.id !== route.id)" in ui + assert "editOnDouble: true" in ui and "computeMilestone(liveMilestone(projectId)" in ui + route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") + assert route.count("editOnDouble: true") == 2 and "uploadImage(projectId, routeId" in route + # 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음 + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert 'writeState("b08-milestone"' in state and 'clearState("b08-milestone"' in state + assert state.count('method: "PUT"') == 1 and "setTimeout" not in state + options = _read("B08_Quantity/B08_Quantity_UI_Options.ts") + assert ( + 'card("milestone", "B08_Quantity_Tab_Milestone", ...milestoneCardBody(projectId))' + in options + ) + assert "await saveMilestone(projectId);" in options + registry = _read("A00_Common/b_page_state.ts") + assert '"b08-milestone": { bucket: "draft", scope: "project" }' in registry + main = _read("main.py") + assert "app.include_router(b08_milestone_router, dependencies=protected_with_company)" in main + # 짝 표기 + assert "짝 = `B08_Quantity_Milestone_Calc.ts`" in _read( + "B08_Quantity/B08_Quantity_Milestone_Calc.py" + ) + assert "짝 = `B08_Quantity_Milestone_Calc.py`" in _read( + "B08_Quantity/B08_Quantity_Milestone_Calc.ts" + ) + for name in ("UI_Milestone.ts", "UI_Milestone_Route.ts", "UI_Milestone_State.ts", + "Router_Milestone.py"): # fmt: skip + rel = f"B08_Quantity/B08_Quantity_{name}" + assert len(_read(rel).splitlines()) <= 700 diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 4dd32bf14..b8433c381 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -18,6 +18,7 @@ export const ui_locales_b4 = { "Insert suggested value — KDS standard soil 0.5 m", ], B08_Quantity_Tab_Hydrology: ["수리집수면적표", "Hydraulic Catchment Table"], + B08_Quantity_Tab_Milestone: ["거리이정표", "Distance Milestones"], B08_Quantity_Tab_Pending: ["차례대로 만들 탭입니다.", "This tab comes later, in order."], B08_Quantity_Tab_Materials: ["자재", "Materials"], B08_Quantity_Tab_MaterialTotal: ["총괄자재수량집계표", "Total Material Quantity Table"],