feat(b08): 거리이정표 메인 탭 · 경로 하위 탭 추가 · 삭제 · 공사지 · 기준점 · 표 합 짝 · 그림 올리기 (244-1)

- 메인 탭 「거리이정표」 · 하위 탭 [거리이정표] 표 + 경로 탭(이름 = 업체 이름) · [+ 추가] · [삭제]
- 경로 탭 = 구간 km(더블클릭) · 도로 구분 ▾ · 표 줄(항목 · 목적지 · 비고) · 경로 그림 하나
- 좌측 패널 카드 = 공사지(중점 측점 · 기본 노선 ÷ 2) · 기준점(있음 · 이름 · 주소 · 방위)
- 표 합 = 파이썬 · TS 짝(거울 시험) · 정본 = storage B08_Quantity/milestone · [저장] 한 줄에서 PUT

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 20:53:01 +09:00
co-authored by Claude Opus 5.5
parent 77afadf8c7
commit a52fe78751
15 changed files with 1476 additions and 4 deletions
+2
View File
@@ -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).
@@ -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
+127
View File
@@ -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<string, "s2" | "s1" | "un"> = { "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;
}
@@ -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}
@@ -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);
}
+198
View File
@@ -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<MilestoneRoute> = {}): 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<void> => {
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"),
);
}
@@ -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<Dest, string> = { 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 };
}
@@ -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<string, string>;
}
/** 8방 이름 */
export const DIR_TEXT: Record<string, string> = {
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 = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
/** 저장본 읽기(프로젝트마다 한 번 · force = 다시) */
export async function loadMilestone(projectId: string, force = false): Promise<Payload> {
if (!saved || savedFor !== projectId || force) {
saved = await request<Payload>(`/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<string, string> => saved?.images ?? {};
export const hasMilestoneDraft = (projectId: string): boolean =>
readState<MilestoneData>("b08-milestone", projectId) !== null;
/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */
export function liveMilestone(projectId: string): MilestoneData {
return clone(readState<MilestoneData>("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<void> {
const draft = readState<MilestoneData>("b08-milestone", projectId);
if (!draft) return;
saved = await request<Payload>(`/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<Response> {
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<void> {
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<void> {
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<string> {
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];
}
+7 -1
View File
@@ -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);
+4 -1
View File
@@ -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());
}
+2
View File
@@ -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).
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
+1 -1
View File
@@ -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,
@@ -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_고르기_메뉴_이동_그대로():
@@ -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
+1
View File
@@ -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"],