Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1

This commit is contained in:
2026-10-05 13:50:04 +09:00
138 changed files with 4279 additions and 1204 deletions
+2
View File
@@ -64,6 +64,8 @@ export const STATE_REGISTRY = {
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
/** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */
"m02-card-pills": { bucket: "pref", scope: "global" },
/** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */
"m01-logic-panel": { bucket: "pref", scope: "global" },
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
"routeedit-view": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
+12 -3
View File
@@ -112,6 +112,13 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De
const dockParts: HTMLElement[] = [];
const dock = createDesignDock(view, dockParts, ctx.dock);
// 칸은 공용 스크롤 칸(쪼갠 뒤) 안에 둔다 — 쪼개기 전이면 패널 루트의 단추 묶음 앞.
const place = (element: HTMLElement): void => {
const scroll = root.querySelector<HTMLElement>(":scope > .ui-sidebar-scroll");
if (scroll) scroll.append(element);
else root.insertBefore(element, dock.root);
};
const show = (target: DesignView): void => {
const wanted = new Set(panelSectionsFor(target).map((def) => def.id));
for (const def of panelSectionsFor(target)) {
@@ -120,12 +127,12 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De
const sectionRoot = "root" in made ? made.root : made;
if ("root" in made && made.dock) dock.root.prepend(made.dock);
built.set(def.id, sectionRoot);
root.insertBefore(sectionRoot, dock.root);
place(sectionRoot);
}
// 차례를 정의 순서로 다시 맞춘다 — 늦게 만들어진 칸이 맨 아래에 남지 않게.
for (const def of panelSectionsFor(target)) {
const element = built.get(def.id);
if (element) root.insertBefore(element, dock.root);
if (element) place(element);
}
built.forEach((element, id) => {
// 칸이 스스로 숨긴 것(선택 포인트)은 건드리지 않고, 보기 밖 칸만 가리고 되살린다.
@@ -144,7 +151,9 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De
dock,
setView: (next) => {
dock.setView(next);
root.className = next === "profile" ? "b05-route__panel" : "b06-profile__form";
// 클래스 통째 덮기 금지 — 공용 쪼개기가 붙인 ui-sidebar-fill 이 지워져 단추 묶음이 바닥에서 뜬다.
root.classList.toggle("b05-route__panel", next === "profile");
root.classList.toggle("b06-profile__form", next !== "profile");
show(next);
},
};
+4
View File
@@ -12,6 +12,7 @@
import { ROUTES } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { splitSidebarActions } from "@ui/ui_template_overlay";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav";
import { navigateTo } from "../router";
@@ -229,6 +230,9 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel {
shared.view(),
);
shared.left.append(panel.root);
// 틀이 먼저 섰으면 좌측 자리가 빈 채로 지나갔다 — 붙인 지금 [스크롤 칸][아래 고정 단추]로 쪼갠다.
const body = shared.left.closest<HTMLElement>(".ui-workflow-overlay__body");
if (body) splitSidebarActions(body);
shared.panel = panel;
return panel;
}
+1 -5
View File
@@ -178,7 +178,6 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null)
settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () =>
navigateTo(ROUTES.M01_MASTER_DATA),
),
m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", "master", desc),
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", "master", desc),
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
@@ -193,10 +192,7 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null)
L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"),
L("B01_Dashboard_PermPersonalWrite"),
].join(" · ");
wrap.append(
m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", root, desc),
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc),
);
wrap.append(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc));
return wrap;
}
+3 -1
View File
@@ -17,7 +17,7 @@ import {
API_SURFACE_BUILD_TIMEOUT_MS,
API_TIMEOUT_MS,
} from "@config/config_frontend";
import type { SummaryItem } from "../common_util/common_util_summary_item";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
export interface SurfaceAnalyzeRequest {
@@ -494,6 +494,8 @@ export interface DetailPipePoint {
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖 칸) — 없으면 null. */
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */
group_items?: GroupItems | null;
}
/** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */
@@ -23,6 +23,7 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from common_util.common_util_drainage_context import DrainageContext, load_drainage_context
from common_util.common_util_drainage_detail import DrainageDetail, build_detail
from common_util.common_util_drainage_pipes import (
@@ -30,6 +31,7 @@ from common_util.common_util_drainage_pipes import (
PIPE_SOURCE_USER,
PipePoint,
carry_facility_attributes,
carry_indices,
clear_pipe_points,
load_pipe_points,
parse_pipe_points,
@@ -64,14 +66,40 @@ def _build(
if points:
_retag(detail.pipes, points)
rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes]
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
fresh = [
pick is None
for pick in carry_indices(
[point.chainage_m for point in rebuilt], [point.chainage_m for point in points or []]
)
]
# 계산기는 chainage만 다뤄 시설 종류·구간·옵션이 사라진다 — 원본에서 되붙인다
# (2026-08-17 계곡 통과 시설: 배관/BOX암거/물넘이/세월교).
carried = carry_facility_attributes(rebuilt, points or [])
# 되붙인 뒤에 추천을 얹는다 — 순서가 뒤바뀌면 승계된 사용자 값이 추천에 덮인다.
_apply_recommendations(detail, carried)
_apply_new_defaults([point for point, new in zip(carried, fresh) if new])
return detail, carried
def _apply_new_defaults(points: list[PipePoint]) -> None:
"""새로 놓인 관에만 등록부 기본값을 **빈 키에** 채움(PLAN 46-7 · 추천 뒤 — 추천 관경이 이김).
시설이 생길 때 기본값을 칸에 실제 값으로 넣어 두고 사용자가 고침 — 이미 있던 관의 빈 칸은 그대로
(옛 프로젝트 빈칸 = 미확정 유지 · 사용자 결정 2026-10-05).
"""
types = structure_type_map()
for point in points:
definition = types.get(point.facility)
if definition is None:
continue
options = dict(point.options or {})
for field in definition.options:
if field.key not in options and field.default not in (None, ""):
options[field.key] = field.default
point.options = options or None
def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다."""
by_chainage = {round(point.chainage_m, 2): point.source for point in points}
+12 -1
View File
@@ -12,7 +12,7 @@
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import type { SummaryItem } from "../common_util/common_util_summary_item";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
/** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */
@@ -77,6 +77,15 @@ export interface StructureType {
design_owner: string | null;
reference_only: boolean;
enabled: boolean;
/** 옵션 그룹(M02 PLAN 46) — 폼의 보조 컨테이너(제목 = name) · 안의 옵션 키 · 빈 그룹도 됨. */
groups?: StructureOptionGroup[];
}
/** 옵션 그룹 한 개 — 서버가 M02 「구조물 옵션 보이기」 그룹 표로 채움. */
export interface StructureOptionGroup {
id: string;
name: string;
options: string[];
}
export interface StructureInstance {
@@ -94,6 +103,8 @@ export interface StructureInstance {
geometry: Record<string, unknown> | null;
/** 이 구조물이 뜻하는 구조물 집계표 항목(21-1) — 없으면 null(미지정 · 집계표에 안 들어감). */
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */
group_items?: GroupItems | null;
}
interface StructureTypesResponse {
+8 -6
View File
@@ -382,9 +382,10 @@
"label": "월류 폭",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
"default": 5,
"required": false,
"phase": "detail",
"default_basis": "물넘이 월류 폭 기본 5m — 사용자 확정값(엔진 FORD_PAVEMENT_DEFAULT_WIDTH_M 과 같음)"
},
{
"key": "ford_slope_pct",
@@ -458,9 +459,10 @@
"label": "월류 폭",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
"default": 10,
"required": false,
"phase": "detail",
"default_basis": "세월교 월류 폭 기본 10m — 사용자 확정값(엔진 FORD_DEFAULT_WIDTH_M 과 같음)"
},
{
"key": "wing_in",
@@ -14,7 +14,7 @@ from typing import Any, Iterable
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete.M02_MasterTemplete_Options import hidden_keys
from M02_MasterTemplete.StructOptions.M02_SO_Options import hidden_keys
STRUCTURES_FILE_NAME = "structures.json"
_STAGE_DIR = os.path.join("B05_Profile", "route")
+1 -1
View File
@@ -42,7 +42,7 @@ from common_util.common_util_drainage_pipes import (
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from M02_MasterTemplete.M02_MasterTemplete_Options import TypesWithVisible, with_visible
from M02_MasterTemplete.StructOptions.M02_SO_Options import TypesWithVisible, with_visible
logger = logging.getLogger(__name__)
+8 -1
View File
@@ -15,7 +15,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from common_util.common_util_summary_item import parse_summary_item
from common_util.common_util_summary_item import parse_group_items, parse_summary_item
# 배치형태: 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적).
STRUCTURE_PLACEMENTS = ("point", "interval", "site")
@@ -160,12 +160,19 @@ class StructureInstance(BaseModel):
geometry: dict[str, Any] | None = None
# 구조물 집계표 항목(PLAN 21-1) — 없으면 null(옛 파일 호환) · [저장] 뒤 프로젝트 스냅샷
summary_item: dict[str, Any] | None = None
# 옵션 그룹(M02 PLAN 46)마다 고른 집계 항목 `{그룹 id: summary_item}` — 본체는 `summary_item`
group_items: dict[str, Any] | None = None
@field_validator("summary_item")
@classmethod
def validate_summary_item(cls, value: Any) -> dict[str, Any] | None:
return parse_summary_item(value)
@field_validator("group_items")
@classmethod
def validate_group_items(cls, value: Any) -> dict[str, Any] | None:
return parse_group_items(value)
@model_validator(mode="after")
def validate_placement_fields(self) -> "StructureInstance":
if self.placement == "interval":
+33 -55
View File
@@ -53,13 +53,17 @@ import {
optionalSelect,
OUTLET_REVET_KEYS,
putNumber,
REVET_COMMON_DEFAULTS,
stepper,
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets";
import type { SummaryItem } from "../common_util/common_util_summary_item";
import {
hasLinks,
linkFields,
type GroupItems,
type SummaryItem,
} from "../common_util/common_util_summary_item";
import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford";
import { createHiddenOptions } from "./B05_Profile_UI_Drainage_Facility_Hidden";
@@ -74,11 +78,13 @@ export interface FacilityAttributes {
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖) — null/없음 = 미지정. */
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3) — 없음 = 그룹 미지정. */
group_items?: GroupItems | null;
}
/** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */
function hasExtras(attributes: FacilityAttributes): boolean {
if (attributes.summary_item) return true; // 집계 항목만 지정한 맨 배관도 보관한다
if (hasLinks(attributes)) return true; // 집계 항목(본체 · 그룹)만 지정한 맨 배관도 보관한다
if (attributes.facility !== "pipe") return true;
if (attributes.start_m !== undefined || attributes.end_m !== undefined) return true;
return !!attributes.options && Object.keys(attributes.options).length > 0;
@@ -122,13 +128,13 @@ export function createFacilityStore() {
end_m?: number;
options?: Record<string, string | number>;
summary_item?: SummaryItem | null;
group_items?: GroupItems | null;
}>,
): void {
entries = points
.map((point) => {
// 응답이 정본이다 — 서버는 미지정이면 칸을 안 싣는다(`as_dict`). 예전엔 칸이 없으면 화면이 쥔
// 값을 이어받았는데, 횡단 보기에서 [해제]한 초안이 응답으로 돌아올 때 옛 지정이 되살아났다.
const summary = point.summary_item ?? null;
return {
chainage: point.chainage_m,
attributes: {
@@ -136,7 +142,7 @@ export function createFacilityStore() {
start_m: point.start_m,
end_m: point.end_m,
options: point.options,
...(summary ? { summary_item: summary } : {}),
...linkFields(point),
} as FacilityAttributes,
};
})
@@ -176,9 +182,9 @@ export function createFacilityStore() {
return points.map((point, index) => {
const pick = picks[index];
if (pick === null) return { ...point };
const { summary_item: summary, ...rest } = entries[pick].attributes;
const { summary_item: _s, group_items: _g, ...rest } = entries[pick].attributes;
// 미지정(null)은 칸을 안 싣는다 — 서버가 모르는 칸을 거절하는 동안에도 계산이 안 깨짐.
return { ...point, ...rest, ...(summary ? { summary_item: summary } : {}) };
return { ...point, ...rest, ...linkFields(entries[pick].attributes) };
});
},
};
@@ -239,11 +245,11 @@ export function createFacilityOptionsForm(
root.hidden = true;
// ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ──────────────
// 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③).
const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]);
// 기본값(관종 파형강관 · 관경 1000 · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴(PLAN 46-7).
const pipeMaterial = optionalSelect("— 안 정함 —", ["흄관", "VR관", "파형강관"]);
const pipeDiameter = document.createElement("select");
pipeDiameter.replaceChildren(
new Option("— 안 정함 (제안 Ø1000) —", ""),
new Option("— 안 정함 —", ""),
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
);
// 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다
@@ -257,10 +263,10 @@ export function createFacilityOptionsForm(
// ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ─────────────────
const inletGroup = group("유입구");
// 「안 정함」이면 기슭막이 칸을 보이되(제안) 값은 안 실음 — 기본값을 몰래 확정으로 안 바꿈(판정 ③).
// 「안 정함」이면 기슭막이 칸을 보이되 값은 안 실음.
const inletType = document.createElement("select");
inletType.replaceChildren(
new Option("— 안 정함 (제안 기슭막이) —", ""),
new Option("— 안 정함 —", ""),
...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)),
);
const basinForm = optionalSelect("선택", [
@@ -278,7 +284,7 @@ export function createFacilityOptionsForm(
]);
const basinMaterialField = labeled("형태", basinMaterial, "inlet_basin_material");
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
const basinLength = numberInput("0.1", "0", "제안 2");
const basinLength = numberInput("0.1", "0");
// 기준측점 전 · 후 — 옛 횡단 조정창에만 있던 칸을 폼으로(계획서 24-4a).
const basinSplit = createBasinSplitFields(basinLength);
basinSplit.onChange(() => emit());
@@ -291,11 +297,7 @@ export function createFacilityOptionsForm(
];
const height = (): string =>
String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000));
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(찰)",
height,
});
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { height });
// 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다
// (2026-08-17 사용자 지시 3).
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리(2026-08-29 사용자 지시 4).
@@ -316,12 +318,8 @@ export function createFacilityOptionsForm(
// ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ──
const outletGroup = group("유출구");
const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]);
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(메)",
height,
});
const outletType = optionalSelect("— 안 정함 —", ["기슭막이"]);
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { height });
outletGroup.body.append(
grid(labeled("구조", outletType, "outlet_type"), outletRevet.formField),
...outletRevet.rows,
@@ -342,10 +340,10 @@ export function createFacilityOptionsForm(
// 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다.
// 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만
// 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다.
const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]);
const revetSide = optionalSelect("— 안 정함 —", ["양쪽", "좌", "우"]);
const revetTopRow = grid(labeled("설치 측", revetSide, "side"));
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)");
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)");
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS);
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS);
/** 좌·우 이름표 — B06이 그 측점의 벽 방향을 알려 주면 바뀐다. 기본은 유입=좌. */
let revetSideLabels: { inlet: "좌" | "우"; outlet: "좌" | "우" } = {
inlet: "좌",
@@ -380,12 +378,12 @@ export function createFacilityOptionsForm(
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [
const boxSize = optionalSelect("— 안 정함 —", [
...BOX_SIZE_PRESETS.map(([label]) => label),
BOX_SIZE_CUSTOM,
]);
const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`);
const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`);
const boxWidth = numberInput("0.1", "0");
const boxHeight = numberInput("0.1", "0");
const boxCustomRow = grid(
labeled("본체 폭 (m)", boxWidth, "body_width_m"),
labeled("본체 높이 (m)", boxHeight, "body_height_m"),
@@ -408,33 +406,14 @@ export function createFacilityOptionsForm(
boxWidth.value = String(preset[1]);
boxHeight.value = String(preset[2]);
}
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로(제안은 빈 보기 이름).
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로.
boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM;
}
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford` · 기본값은 제안으로만).
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford`).
// 기본값(등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴 — 제안값 단추 없음(PLAN 46-7).
const ford = createFordFields(() => emit());
// [제안값 넣기] — 누른 때만 빈 칸에 제안값(관종·관경·구조·집수정 길이·설치 측·BOX 규격·날개벽·월류).
// 벽 칸(기슭막이)은 벽마다 단추가 따로 있음. 기본값을 몰래 확정으로 바꾸지 않는다(2026-09-14 판정 ③).
const suggest = document.createElement("button");
suggest.type = "button";
suggest.className = "b05-structure__suggest";
suggest.textContent = "제안값 넣기";
suggest.addEventListener("click", () => {
if (!pipeMaterial.value) pipeMaterial.value = "파형강관";
if (!pipeDiameter.value) pipeDiameter.value = "1000";
if (!inletType.value) inletType.value = INLET_KINDS[0].label;
if (!outletType.value) outletType.value = "기슭막이";
if (!basinLength.value) basinLength.value = "2";
if (!revetSide.value) revetSide.value = "양쪽";
if (current === "box_culvert" && !boxSize.value) boxSize.value = BOX_SIZE_PRESETS[0][0];
wingInFields.fillSuggested();
wingOutFields.fillSuggested();
if (current === "ford_pavement" || current === "ford_bridge") ford.fillSuggested(current);
emit();
});
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
const saveNote = document.createElement("p");
saveNote.className = "b05-structure__owner-note";
@@ -444,7 +423,6 @@ export function createFacilityOptionsForm(
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
grid(suggest),
saveNote,
pipeRow,
wingRow,
@@ -586,7 +564,7 @@ export function createFacilityOptionsForm(
}
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
// ⚠ 저장된 값만 칸에 — 없으면 「안 정함」 · 제안은 빈 보기 이름·회색 글씨(2026-09-14 판정 ③).
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
pipeMaterial.value = text("pipe_kind");
pipeDiameter.value = text("pipe_diameter_mm");
// 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다.
@@ -609,7 +587,7 @@ export function createFacilityOptionsForm(
inletRevet.write(options);
outletRevet.write(options);
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
// 값이 없으면 「안 정함」(첫 프리셋 2.0×2.0 은 제안으로만).
// 값이 없으면 「안 정함」.
const savedWidth = Number.parseFloat(text("body_width_m"));
const savedHeight = Number.parseFloat(text("body_height_m"));
const hasSaved =
@@ -212,27 +212,13 @@ export interface RevetmentKeys {
after?: string;
}
/** 부위별 **제안값** — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m. 관 벽 높이는 관경 기준
* (함수로 받아 볼 때마다 셈).
* ⚠ 제안값은 **칸에 채우지 않고 회색 글씨로만** 보임 — 사용자가 적거나 [제안값 넣기]를 누른 때만
* 값이 됨(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」). 칸에 채워 두면 저장이
* 그 값을 적어 **미확정이 저장 한 번에 확정으로 바뀜** — 사용자가 한 일이 없는데 금액이 섬. */
/** 높이 칸 안내 — 관 벽은 비우면 관경 기준 최소 높이(엔진 `pipe_wall_min_height_m` · 함수로 받아
* 볼 때마다 셈) · 빈 글이면 「설계자 입력」. 형태 · 길이 · 전후 기본값은 등록부가 시설이 생길 때
* 칸에 실제 값으로 넣음(PLAN 46-7 · 제안값 단추 없음). */
export interface RevetmentDefaults {
form: string;
length: string;
height: string | (() => string);
before: string;
after: string;
}
/** 부위와 무관한 공통 제안 치수 — 길이 10m(전 5·후 5)·높이 2.5m(독립 기슭막이 · 관 벽은 관경 기준). */
export const REVET_COMMON_DEFAULTS = {
length: "10",
height: "2.5",
before: "5",
after: "5",
} as const;
export const INLET_REVET_KEYS: RevetmentKeys = {
form: "inlet_revet_form",
length: "inlet_revet_length_m",
@@ -273,16 +259,12 @@ function sabangBasisLines(): string[] {
];
}
export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup {
export function createRevetSideGroup(keys: RevetmentKeys): RevetSideGroup {
const box = group("기슭막이");
const kind = document.createElement("select");
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
// 높이는 제안 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). 옛 2.5 는 근거 없어 걷음.
const fields = createRevetmentFields(keys, {
...REVET_COMMON_DEFAULTS,
form: formDefault,
height: "",
});
// 높이는 기본값 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정).
const fields = createRevetmentFields(keys, { height: "" });
const basis = document.createElement("p");
basis.className = "b05-route__note";
basis.replaceChildren(
@@ -326,24 +308,24 @@ export function createRevetmentFields(
keys: RevetmentKeys,
defaults: RevetmentDefaults,
): RevetmentFields {
// ⚠ 제안값은 **회색 글씨(placeholder)로만** — 칸은 비워 둠(`RevetmentDefaults` 주석).
const suggestHeight = (): string =>
const wallFloor = (): string =>
typeof defaults.height === "function" ? defaults.height() : defaults.height;
const form = document.createElement("select");
form.replaceChildren(
new Option(`제안 ${defaults.form}`, ""),
new Option("— 안 정함 —", ""),
...REVET_FORMS.map((value) => new Option(value, value)),
);
const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null;
// 제안이 없는 칸(독립 기슭막이 높이)은 「설계자 입력」 — 값을 지어내지 않음.
const heightHint = (): string => (suggestHeight() ? `제안 ${suggestHeight()}` : "설계자 입력");
const length = keys.length ? numberInput("0.1", "0") : null;
// 관 벽은 비우면 관경 기준 최소 높이로 섬(엔진) · 독립 기슭막이 높이는 「설계자 입력」.
const heightHint = (): string =>
wallFloor() ? `비우면 관경 기준 ${wallFloor()}` : "설계자 입력";
const height = numberInput("0.1", "0", heightHint());
// 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈.
// 관 벽 기준 높이는 관경을 따라감 — 칸을 볼 때마다 다시 셈.
height.addEventListener("focus", () => (height.placeholder = heightHint()));
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null;
const after = keys.after && length ? numberInput("0.1", "0", `제안 ${defaults.after}`) : null;
const before = keys.before && length ? numberInput("0.1", "0") : null;
const after = keys.after && length ? numberInput("0.1", "0") : null;
let lastSplitEdit: "before" | "after" = "before";
const readNumber = (input: HTMLInputElement | null): number => {
const value = input ? Number.parseFloat(input.value) : Number.NaN;
@@ -367,25 +349,10 @@ export function createRevetmentFields(
const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5), keys.before) : null;
const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5), keys.after) : null;
// [제안값 넣기] — **누른 때만** 빈 칸에 제안값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양.
const fill = document.createElement("button");
fill.type = "button";
fill.className = "b05-structure__suggest";
fill.textContent = "제안값 넣기";
fill.addEventListener("click", () => {
if (!form.value) form.value = defaults.form;
if (length && !length.value) length.value = defaults.length;
if (!height.value) height.value = suggestHeight();
if (before && !before.value) before.value = defaults.before;
if (after && !after.value) after.value = defaults.after;
form.dispatchEvent(new Event("change"));
});
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
const rows = [
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
...(beforeField && afterField ? [grid(beforeField, afterField)] : []),
grid(fill),
];
return {
@@ -393,7 +360,7 @@ export function createRevetmentFields(
rows,
lengthInput: length,
write(options) {
// 저장된 값만 칸에 — 없으면 비워 두고 제안값은 회색 글씨로(몰래 채우지 않음).
// 받은 값만 칸에 — 없으면 비워 둠(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
form.value = text(keys.form);
@@ -447,8 +414,8 @@ export function createBasinSplitFields(length: HTMLInputElement): {
read: (target: Record<string, string | number>) => void;
onChange: (handler: () => void) => void;
} {
const before = numberInput("0.1", "0", "제안 1");
const after = numberInput("0.1", "0", "제안 1");
const before = numberInput("0.1", "0");
const after = numberInput("0.1", "0");
let last: HTMLInputElement = before;
const sync = (): void => {
const total = Number.parseFloat(length.value);
@@ -528,10 +495,6 @@ export const WING_OUT_KEYS: WingKeys = {
angle: "wing_out_angle_deg",
};
/** 날개벽 기본 제원 — 설치 있음·짧은쪽 높이 1m·길이 2m·각도 45°
* (2026-08-17 사용자 확정). 유입·유출이 같은 값을 쓴다. */
const WING_DEFAULTS = { install: "있음", height: "1", length: "2", angle: "45" } as const;
interface WingFields {
root: HTMLFieldSetElement;
/** 횡단도에서 그 측 날개벽·측벽을 고를 때 칸을 세우는 자리(B06 전용, 2026-08-30).
@@ -541,18 +504,16 @@ interface WingFields {
syncVisibility: () => void;
write: (options: Record<string, string | number>) => void;
read: (target: Record<string, string | number>) => void;
/** [제안값 넣기] — 빈 칸에만 기본 제원을 넣음. */
fillSuggested: () => void;
}
/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각.
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴).
* ⚠ 기본 제원(있음·1·2·45, 사용자 확정)은 **제안으로만** — 칸은 비워 둠(2026-09-14 판정 ③). */
* 기본 제원(있음·1·2·45, 사용자 확정)은 등록부 기본값 — 시설이 생길 때 칸에 실제 값(PLAN 46-7). */
export function createWingFields(title: string, keys: WingKeys): WingFields {
const install = optionalSelect(`— 안 정함 (제안 ${WING_DEFAULTS.install}) —`, ["있음", "없음"]);
const height = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.height}`);
const length = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.length}`);
const angle = numberInput("1", "0", `제안 ${WING_DEFAULTS.angle}`);
const install = optionalSelect("— 안 정함 —", ["있음", "없음"]);
const height = numberInput("0.1", "0");
const length = numberInput("0.1", "0");
const angle = numberInput("1", "0");
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자).
const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1), keys.height);
@@ -594,12 +555,5 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
putNumber(target, keys.length, length.value);
putNumber(target, keys.angle, angle.value);
},
fillSuggested() {
if (!install.value) install.value = WING_DEFAULTS.install;
if (!height.value) height.value = WING_DEFAULTS.height;
if (!length.value) length.value = WING_DEFAULTS.length;
if (!angle.value) angle.value = WING_DEFAULTS.angle;
syncVisibility();
},
};
}
@@ -3,10 +3,9 @@
* 물넘이포장·세월교 칸 묶음 — 월류 폭·높이·바닥 경사·포장 두께·길이·련 수 + 개략 단면 줄.
*
* 서브폼 본체(`_Drainage_Facility.ts`)가 700줄에 닿아 떼어 냄(2026-09-14).
* ⚠ 기본값을 칸에 안 채움(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」):
* 월류 폭 세월교 10m·물넘이 5m(사용자 확정)는 **회색 제안**, 월류 높이는 설계유량·폭으로 되짚은
* **필요 최소 수심을 회색 제안**으로만 — 사용자가 적거나 [제안값 넣기]를 누른 때만 값.
* 적은 높이가 필요 수심보다 낮으면 여전히 되돌림(계산값 미만은 안 받음).
* 월류 폭 기본값(세월교 10m · 물넘이 5m · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴
* (PLAN 46-7 · 제안값 단추 없음). 빈 칸 안내 = 엔진이 비운 칸을 읽는 값(폭 기본 · 필요 수심).
* 적은 높이가 필요 수심보다 낮으면 되돌림(계산값 미만은 안 받음).
* ========================================================================== */
import {
@@ -35,7 +34,6 @@ export interface FordFields {
sync: () => void;
write: (kind: FordKind, options: Record<string, string | number>) => void;
read: (kind: FordKind, target: Record<string, string | number>) => void;
fillSuggested: (kind: FordKind) => void;
}
export function createFordFields(emit: () => void): FordFields {
@@ -66,14 +64,15 @@ export function createFordFields(emit: () => void): FordFields {
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let minDepthM: number | null = null;
const suggestedWidth = (kind: FordKind): number =>
/** 폭을 비우면 엔진이 쓰는 폭(`B06_Section_Engine_Culvert.FORD_*_DEFAULT_WIDTH_M` 짝). */
const emptyWidth = (kind: FordKind): number =>
kind === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M;
function sync(): void {
const widthM = Number.parseFloat(width.value || width.dataset.suggested || "");
const widthM = Number.parseFloat(width.value || width.dataset.empty || "");
const section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null;
minDepthM = section ? Number(section.depthM.toFixed(2)) : null;
height.placeholder = minDepthM !== null ? `제안 필요 수심 ${minDepthM.toFixed(2)}` : "";
height.placeholder = minDepthM !== null ? `필요 수심 ${minDepthM.toFixed(2)} 이상` : "";
if (designFlowM3s === null) {
summary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
@@ -121,8 +120,8 @@ export function createFordFields(emit: () => void): FordFields {
options[key] !== undefined ? String(options[key]) : "";
count.value = kind === "ford_bridge" ? text("pipe_count") : "";
width.value = text("ford_width_m");
width.dataset.suggested = String(suggestedWidth(kind));
width.placeholder = `제안 ${suggestedWidth(kind)}`;
width.dataset.empty = String(emptyWidth(kind));
width.placeholder = `비우면 ${emptyWidth(kind)}`;
height.value = text("ford_height_m");
slope.value = text("ford_slope_pct");
thickness.value = text("thickness_cm");
@@ -142,10 +141,5 @@ export function createFordFields(emit: () => void): FordFields {
const pipes = Number.parseInt(count.value, 10);
if (Number.isFinite(pipes) && pipes > 0) target.pipe_count = pipes;
},
fillSuggested(kind) {
if (!width.value) width.value = String(suggestedWidth(kind));
sync();
if (!height.value && minDepthM !== null) height.value = minDepthM.toFixed(2);
},
};
}
@@ -84,6 +84,8 @@ interface MergeableAttributes {
options?: Record<string, string | number>;
/** 집계 항목(options 밖) — 다음 값에 칸이 없으면(undefined) 옛 지정을 그대로 잇는다. */
summary_item?: unknown;
/** 옵션 그룹 집계 항목(PLAN 46-3) — 다음 값에 칸이 없으면(undefined) 옛 지정을 그대로 잇는다. */
group_items?: unknown;
}
/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */
@@ -99,5 +101,14 @@ export function mergeFacilityOptions<T extends MergeableAttributes>(
next.summary_item === undefined && previous.summary_item !== undefined
? { summary_item: previous.summary_item }
: {};
return { ...next, ...summary, options: Object.keys(options).length ? options : undefined };
const groups =
next.group_items === undefined && previous.group_items !== undefined
? { group_items: previous.group_items }
: {};
return {
...next,
...summary,
...groups,
options: Object.keys(options).length ? options : undefined,
};
}
@@ -7,7 +7,7 @@
* 그대로 다시 내보내므로 기존 import 경로는 유효하다.
* ========================================================================== */
import type { SummaryItem } from "../common_util/common_util_summary_item";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
@@ -67,6 +67,8 @@ export interface DrainagePipeReport {
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */
group_items?: GroupItems | null;
}
export interface DrainagePanelCallbacks {
@@ -34,6 +34,7 @@ export function buildPipeReports(
source: (pipe.reason || "user") as PipeSource,
options: attributes?.options,
summary_item: attributes?.summary_item,
group_items: attributes?.group_items,
};
});
}
+3 -3
View File
@@ -9,6 +9,7 @@
import { createPanelResizer } from "@ui/ui_template_resizer";
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights";
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
@@ -19,8 +20,6 @@ const MIN_CHART_HEIGHT = 100;
const MASSHAUL_HANDLE_GUTTER_PX = 13;
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
const MIN_PANEL_HEIGHT = 180;
/** 상한은 위 칸(3D · 횡단 카드)이 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
const MAX_PANEL_HEIGHT_RATIO = 0.9;
/** 접힘·높이는 화면 취향(①) — 두 보기가 **같은 키 하나**를 쓴다(계획서 24-2c). */
const HEIGHT_KEY = stateKey("profile-height") ?? "";
@@ -147,7 +146,8 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin,
);
},
max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
// 상한 = 가운데 칸 높이의 90%(위 칸 3D · 횡단 카드가 다 가려지지 않게) — Heights 와 한 식
max: () => profilePanelMaxHeight(root),
storageKey: HEIGHT_KEY,
});
@@ -19,6 +19,13 @@ const MASSHAUL_HANDLE_GUTTER_PX = 13;
/** 패널 자동 확대 상한 — 메인 리사이저 max와 같은 식이어야 한다(부모 높이 비율). */
const MAX_PANEL_HEIGHT_RATIO = 0.9;
/** 패널 높이 상한(px) — 메인 리사이저 max 와 자동 확대가 같이 쓴다. 기준 = 한 페이지 가운데 칸
* `.design-page__main`(바로 위 `.design-page__profile` 은 display: contents 라 높이 0). */
export function profilePanelMaxHeight(root: HTMLElement): number {
const basis = root.closest<HTMLElement>(".design-page__main") ?? root.parentElement;
return (basis?.clientHeight || window.innerHeight) * MAX_PANEL_HEIGHT_RATIO;
}
export interface HeightCascadeDeps {
/** 패널 루트 — 자리가 모자라면 이 높이를 키운다. */
root: HTMLElement;
@@ -155,9 +162,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
// 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은
// 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은
// 상한으로 clamp되며 패널이 뚝 떨어졌다(2026-08-06 분석).
const growCap = Math.round(
(deps.root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
);
const growCap = Math.round(profilePanelMaxHeight(deps.root));
const grown = Math.min(deps.root.offsetHeight + deficit, growCap);
if (grown > deps.root.offsetHeight + 1) {
deps.root.style.setProperty("--b05-profile-height", `${grown}px`);
@@ -6,7 +6,7 @@
* 이름·필드·주석은 그대로이고, 패널이 다시 `export` 해 옛 임포트 경로도 그대로 동작한다.
* ========================================================================== */
import type { SummaryItem } from "../common_util/common_util_summary_item";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
@@ -27,6 +27,8 @@ export interface RouteProfilePanelCallbacks {
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */
group_items?: GroupItems | null;
}>,
) => void;
/** 테이블에서 구조물 라인을 끌어 옮김. */
+40 -21
View File
@@ -16,7 +16,7 @@
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
* ========================================================================== */
import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
import {
buildCrossSvg,
@@ -97,10 +97,47 @@ function createPane(title: string, empty: string): CrossPane {
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
const box = root.querySelector<HTMLElement>(".b05-routeedit__cross-box")!;
/** 그려 둔 횡단 · 그때 판 크기 · 확대·이동 — 판 크기가 바뀌면 이것으로 다시 짓는다. */
let drawn: CrossPreviewResponse | null = null;
let size = { width: 0, height: 0 };
let zoom: ZoomPanState | undefined;
function draw(preview: CrossPreviewResponse): void {
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라가고
// 글자가 늘어나 보이지 않는다. 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깔고,
// 펼쳐지면 아래 ResizeObserver 가 실제 크기로 다시 짓는다.
const rect = box.getBoundingClientRect();
size = { width: Math.round(rect.width), height: Math.round(rect.height) };
const plot = buildCrossSvg(preview, size.width || 240, size.height || 150);
box.replaceChildren(plot.svg);
observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다.
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
attachSvgZoomPan({
svg: plot.svg,
layer: plot.layer,
widthPx: plot.width,
heightPx: plot.height,
pad: CROSS_PLOT_PAD,
content: plot.content,
wheelZoom: true,
initial: zoom,
onChange: (state) => (zoom = state),
});
}
const observer = new ResizeObserver(() => {
if (!box.isConnected) return observer.disconnect(); // 편집 창이 닫혔다.
const rect = box.getBoundingClientRect();
const width = Math.round(rect.width);
const height = Math.round(rect.height);
if (!drawn || width === 0 || (width === size.width && height === size.height)) return;
draw(drawn);
});
return {
root,
show(preview, intervalM) {
box.replaceChildren();
drawn = preview;
zoom = undefined;
if (!preview) {
station.textContent = "";
foot.textContent = empty;
@@ -109,30 +146,12 @@ function createPane(title: string, empty: string): CrossPane {
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
station.textContent = formatStation(preview.chainage_m, intervalM);
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다.
// 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다.
const rect = box.getBoundingClientRect();
const plot = buildCrossSvg(
preview,
Math.max(Math.round(rect.width), 240),
Math.max(Math.round(rect.height), 150),
);
box.append(plot.svg);
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
attachSvgZoomPan({
svg: plot.svg,
layer: plot.layer,
widthPx: plot.width,
heightPx: plot.height,
pad: CROSS_PLOT_PAD,
content: plot.content,
wheelZoom: true,
});
draw(preview);
foot.textContent = summarizeCross(preview);
},
wait(text) {
box.replaceChildren();
drawn = null;
foot.textContent = text;
},
};
@@ -4,7 +4,8 @@
*
* Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든
* 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. 계획노선(실선)과 예상노선
* (점선) 둘 다 잰다 — 두 점은 한 노선 위에 있어야 하고, 다른 노선을 짚으면 새로 시작한다.
* (점선) 둘 다 잰다 — 첫 점을 찍은 노선을 기억해 둘째 점(과 커서 미리보기)은 그 노선에만
* 붙는다 — 두 노선이 겹쳐 다른 노선이 위에 있어도 무시한다. 길이 = 두 점 누가거리의 차.
*
* 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는
* 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를
@@ -56,6 +57,8 @@ export interface MeasureTool {
hint: () => string;
/** 재고 있나 — 작은 창을 띄울지 정하는 값. */
active: () => boolean;
/** 첫 점만 찍혔을 때 커서 자리 미리보기(첫 점 노선 위). 바뀌었으면 true — 다시 그린다. */
preview: (px: number, py: number) => boolean;
/** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */
pick: (px: number, py: number) => Promise<void>;
/** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */
@@ -109,6 +112,8 @@ export function bindMeasureControls(params: MeasureControlsParams): {
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
let picked: MeasurePoint[] = [];
/** 첫 점만 찍혔을 때 커서가 가리키는 둘째 점 후보. */
let hover: MeasureMark | null = null;
const hint = (): string => {
if (picked.length === 0) return "";
@@ -133,10 +138,12 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
);
};
/** 클릭에 가장 가까운 노선 위 점. 재던 노선이 잡히면 그쪽이 먼저다(겹친 자리에서 튀지 않게). */
/** 클릭에 가장 가까운 노선 위 점. 첫 점이 찍혀 있으면 **그 노선만** 본다(겹친 자리에서 튀지 않게). */
const hitAt = (px: number, py: number): (RoutePointHit & { line: MeasureLine }) | null => {
const lines = params.lines();
const hits = (["planned", "expected"] as const)
const candidates: MeasureLine[] =
picked.length === 1 ? [picked[0].line] : ["planned", "expected"];
const hits = candidates
.map((line) => {
const hit = routePointAtScreen(lines[line], params.toScreen, px, py, MEASURE_HIT_PX);
if (!hit) return null;
@@ -145,30 +152,42 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
})
.filter((hit): hit is NonNullable<typeof hit> => hit !== null);
if (!hits.length) return null;
const keep = picked.length === 1 ? hits.find((hit) => hit.line === picked[0].line) : undefined;
const best = keep ?? hits.reduce((a, b) => (b.distance < a.distance ? b : a));
const best = hits.reduce((a, b) => (b.distance < a.distance ? b : a));
return { point: best.point, chainageM: best.chainageM, line: best.line };
};
return {
marks: () =>
picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM, line: entry.line })),
marks: () => [
...picked.map((entry) => ({
point: entry.point,
chainageM: entry.chainageM,
line: entry.line,
})),
...(picked.length === 1 && hover ? [hover] : []),
],
hint,
active: () => picked.length > 0,
preview(px, py) {
const next = picked.length === 1 ? hitAt(px, py) : null;
if (!next && !hover) return false;
hover = next;
return true;
},
clear() {
hover = null;
if (picked.length === 0) return;
picked = [];
params.onChange();
},
async pick(px, py) {
const hit = hitAt(px, py);
hover = null;
if (!hit) {
picked = []; // 노선을 빗나가면 재던 것을 접는다.
params.onChange();
return;
}
const fresh = picked.length >= 2 || (picked.length === 1 && picked[0].line !== hit.line);
picked = fresh ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
params.onChange();
if (picked.length < 2) return;
const asked = picked;
@@ -71,7 +71,11 @@ export interface RouteEditPointerParams {
/** 돌린 지도의 화면 좌표를 돌리기 전 자리로. */
unrotate: (x: number, y: number) => [number, number];
contourStepM: () => number;
measure: { pick: (px: number, py: number) => unknown; clear: () => void };
measure: {
pick: (px: number, py: number) => unknown;
preview: (px: number, py: number) => boolean;
clear: () => void;
};
/** 재기 단추가 켜져 있나 — 켜져 있으면 노드 위에서도 잰다. */
measureMode: () => boolean;
crossPreview: { open: (chainageM: number) => unknown; refresh: () => unknown };
@@ -287,6 +291,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
params.draw();
return;
}
// 재는 중 — 둘째 점 미리보기는 첫 점 노선 위에만 붙는다.
if ((event.shiftKey || params.measureMode()) && params.measure.preview(px, py)) params.draw();
const overNode = nodeAt(px, py);
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
canvas.style.cursor =
+13 -28
View File
@@ -61,9 +61,8 @@ interface OptionShape {
}
/**
* 등록부 옵션 입력 한 칸 — **저장된 값만 칸에 넣고, 등록부 기본값은 제안으로만** 보임
* (숫자·글 칸은 회색 글씨, 고르기 칸은 빈 보기 이름). 2026-09-14 브레인 판정 「기본값을 몰래
* 확정으로 바꾸지 않는다」 — 칸에 채워 두면 저장이 그 값을 적어 사용자가 한 일 없이 확정이 됨.
* 등록부 옵션 입력 한 칸 — **받은 값만 칸에 넣음**. 새 구조물은 부르는 쪽이 등록부 기본값을 값으로
* 실어 줌(PLAN 46-7 · 제안값 단추 없음) · 옛 기록의 빈 칸은 빈 칸 그대로(미확정).
* 고르기 칸은 늘 빈 보기를 둠 — 첫 보기를 미리 고르지 않음.
*/
export function optionControl(
@@ -71,15 +70,10 @@ export function optionControl(
stored: string | number | undefined,
): HTMLInputElement | HTMLSelectElement {
const value = stored === undefined || stored === null ? "" : String(stored);
const suggested = option.default === null || option.default === "" ? "" : String(option.default);
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
const tip = option.not_in_table ?? option.empty_means ?? "";
if (option.input === "select") {
const blank = suggested
? `— 안 정함 (제안 ${suggested}) —`
: option.required
? "— 선택 —"
: "— 안 정함 —";
const blank = option.required ? "— 선택 —" : "— 안 정함 —";
const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]);
element.value = value;
if (tip) element.title = tip;
@@ -89,7 +83,7 @@ export function optionControl(
option.input === "number" ? numberInput("0.1", "0") : document.createElement("input");
if (option.input !== "number") element.type = "text";
element.value = value;
element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : "";
element.placeholder = option.required ? "필수 입력" : "";
if (tip) element.title = tip;
const limit = option.warn_above;
if (typeof limit === "number") {
@@ -105,24 +99,15 @@ export function optionControl(
return element;
}
/** [제안값 넣기] — **누른 때만** 빈 칸에 등록부 기본값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. */
export function suggestButton(
entries: ReadonlyArray<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }>,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b05-structure__suggest";
button.textContent = "제안값 넣기";
button.addEventListener("click", () => {
const filled = entries.filter((entry) => entry.suggested && !entry.input.value);
filled.forEach((entry) => {
entry.input.value = entry.suggested;
entry.input.dispatchEvent(new Event("input", { bubbles: true }));
});
// 반영은 한 번만 — 칸마다 저장 흐름이 돌지 않게 마지막 칸만 change 를 울림.
filled.at(-1)?.input.dispatchEvent(new Event("change", { bubbles: true }));
});
return button;
/** 새 구조물 칸에 실을 등록부 기본값(PLAN 46-7) — 기본값이 있는 칸만 · 없는 칸은 빈 칸(미확정). */
export function optionDefaults(
options: ReadonlyArray<{ key: string; default: string | number | null }>,
): Record<string, string | number> {
return Object.fromEntries(
options
.filter((option) => option.default !== null && option.default !== "")
.map((option) => [option.key, option.default as string | number]),
);
}
/** 측점번호 + 잔여거리 두 칸 묶음. */
@@ -23,6 +23,7 @@ import {
} from "./B05_Profile_UI_Structures_Fields";
import { createFacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility";
import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row";
import { createGroupBoxes, type GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
export interface StructuresFormElements {
root: HTMLElement;
@@ -52,6 +53,8 @@ export interface StructuresFormElements {
list: HTMLElement;
/** 옵션 · 시설 서브폼 아래 「집계 항목」 줄(PLAN 21-4) — 이름 · [고르기] · [해제]. */
summary: SummaryRow;
/** 본체 집계 항목 아래 옵션 그룹 보조 컨테이너(PLAN 46-3) — 그룹마다 상자 · 집계 항목. */
groupBoxes: GroupBoxes;
}
/** 폼 뼈대를 세운다. 값 채우기·이벤트 배선은 호출한 쪽(패널)이 한다. */
@@ -144,6 +147,7 @@ export function buildStructuresForm(options: {
// 집계 항목 줄 — 종류를 고른 뒤에만 보인다(패널이 `syncPlacementFields` 에서 켠다).
const summary = buildSummaryRow();
summary.root.hidden = true;
const groupBoxes = createGroupBoxes();
body.append(
typeRow,
@@ -153,6 +157,7 @@ export function buildStructuresForm(options: {
optionRow,
facilityOptions.root,
summary.root,
groupBoxes.root,
actions,
);
@@ -178,5 +183,6 @@ export function buildStructuresForm(options: {
resetButton,
list,
summary,
groupBoxes,
};
}
@@ -0,0 +1,225 @@
/* =============================================================================
* B05_Profile_UI_Structures_Groups.ts
* 구조물 폼의 **옵션 그룹 보조 컨테이너**(PLAN 46-3) — M02 「구조물 옵션 보이기」에서 종류마다 만든
* 그룹(`StructureType.groups`)마다 상자 하나(제목 = 그룹 이름) · 그 그룹 옵션 칸 · 「집계 항목」 줄.
*
* · 칸은 새로 그리지 않고 **옮긴다** — 레지스트리 옵션 칸 · 계곡 통과 시설 칸이 붙인
* `data-option-keys` 표식으로 찾아 상자 안으로 옮기고, 제자리에는 표지(주석 노드)를 둔다. 값 읽기 ·
* 이벤트는 칸 참조 그대로라 안 바뀜. 다시 세울 때(`render`)마다 먼저 제자리로 되돌림.
* · 키가 여럿인 칸(본체 규격 · 유입구 구조)은 **키가 다 그 그룹일 때만** 옮김(숨김 규칙과 같음 ·
* 독립 기슭막이 채널 키는 `hiddenFormKeys` 로 넓힘).
* · 그룹 연결 값(`{그룹 id: 참조}`)은 정의가 사라진 그룹 것도 그대로 쥠(값 보존 · 경고 한 줄).
* · 저장은 패널이 한다(`group_items` 칸) — 여기는 값만 쥠.
* ========================================================================== */
import { openItemPicker } from "@ui/ui_template_item_picker";
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
import type { StructureType } from "./B05_Profile_Api_Structures";
import { hiddenFormKeys } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row";
import {
GROUP_JOIN,
type GroupItems,
type SummaryItem,
} from "../common_util/common_util_summary_item";
export interface GroupBoxes {
/** 상자를 담는 자리 — 폼이 본체 「집계 항목」 줄 아래에 붙임. */
root: HTMLElement;
/** 종류 · 칸에 맞춰 상자를 다시 세움 — `scopes` 안 표식 칸을 그룹 상자로 옮김. */
render: (type: StructureType | null, scopes: HTMLElement[], facility: string | null) => void;
get: () => GroupItems;
/** 값만 바꿔 그림 — 저장 · 알림은 안 함(폼 불러오기 · 모달 결과가 씀). */
set: (items: GroupItems) => void;
/** 한 그룹 값 — null = 해제. */
setOne: (groupId: string, item: SummaryItem | null) => void;
/** [고르기] · [해제] 배선 — 패널이 한 번 부른다. */
bind: (handlers: { pick: (groupId: string) => void; clear: (groupId: string) => void }) => void;
}
export function createGroupBoxes(): GroupBoxes {
const root = document.createElement("div");
root.className = "b05-structure__optgroups";
root.dataset.b05Groups = "";
root.hidden = true;
let items: GroupItems = {};
let handlers: { pick: (id: string) => void; clear: (id: string) => void } | null = null;
const rows = new Map<string, SummaryRow>();
/** 옮긴 칸 · 제자리 표지 */
const moved: Array<{ node: HTMLElement; mark: Comment }> = [];
/** 칸이 다 빠져 가린 줄 · 소그룹 상자 */
const emptied: HTMLElement[] = [];
/** 그룹 상자 몸통 — 옮겨 온 칸이 원래 자리처럼 잠기게(측점 확정 전) */
let bodies: HTMLElement[] = [];
let lockSource: HTMLElement | null = null;
const syncLock = (): void => {
const locked = !!lockSource?.inert;
bodies.forEach((body) => {
body.inert = locked;
body.classList.toggle("is-locked", locked);
});
};
const lockWatch = typeof MutationObserver !== "undefined" ? new MutationObserver(syncLock) : null;
const restore = (): void => {
for (const { node, mark } of moved.splice(0)) {
if (mark.isConnected) mark.replaceWith(node);
else node.remove(); // 원래 자리가 다시 그려졌음(레지스트리 칸) — 옛 칸은 버림
}
emptied.splice(0).forEach((node) => node.classList.remove("is-group-emptied"));
};
const paintStale = (known: Set<string>): HTMLElement | null => {
const stale = Object.keys(items).filter((id) => !known.has(id));
if (!stale.length) return null;
const note = document.createElement("p");
note.className = "b05-structure__owner-note";
note.textContent = `정의가 사라진 그룹 연결 ${stale.length}개 — 값만 남음(집계표에는 안 들어감)`;
return note;
};
return {
root,
render(type, scopes, facility) {
restore();
rows.clear();
const groups = type?.groups ?? [];
const boxes: HTMLElement[] = [];
bodies = [];
// 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식)
const off = hiddenFormKeys(
facility,
(type?.options ?? []).filter((o) => o.visible === false).map((o) => o.key),
);
for (const group of groups) {
const keys = hiddenFormKeys(facility, group.options);
const box = document.createElement("fieldset");
box.className = "b05-structure__group b05-structure__optgroup";
box.dataset.b05Group = group.id;
const legend = document.createElement("legend");
legend.textContent = group.name;
const body = document.createElement("div");
body.className = "b05-structure__grid";
for (const scope of scopes) {
const marked = [...scope.querySelectorAll<HTMLElement>("[data-option-keys]")];
const fits = marked.filter((node) =>
(node.dataset.optionKeys ?? "").split(" ").every((key) => key && keys.has(key)),
);
// 바깥 칸만 — 옮길 칸 안의 칸은 따라 옴
for (const node of fits.filter((n) => !fits.some((o) => o !== n && o.contains(n)))) {
const parent = node.parentElement;
const mark = document.createComment(`옵션 그룹 ${group.id}`);
node.before(mark);
body.append(node);
moved.push({ node, mark });
const own = (node.dataset.optionKeys ?? "").split(" ");
node.classList.toggle(
"is-option-hidden",
own.every((key) => off.has(key)),
);
// 칸이 다 빠진 줄 · 소그룹 상자는 가림(빈 줄 · 빈 상자 제목이 안 남게)
for (let at = parent; at && at !== scope; at = at.parentElement) {
if (at.querySelector("input, select, textarea")) break;
at.classList.add("is-group-emptied");
emptied.push(at);
}
}
}
if (!body.childElementCount) {
const none = document.createElement("p");
none.className = "b05-structure__owner-note";
none.textContent = "옵션 없음 — 집계 항목만 지정";
body.append(none);
}
const row = buildSummaryRow();
row.set(items[group.id] ?? null);
row.bind({
pick: () => handlers?.pick(group.id),
clear: () => handlers?.clear(group.id),
});
rows.set(group.id, row);
box.append(legend, body, row.root);
boxes.push(box);
bodies.push(body);
}
const stale = type ? paintStale(new Set(groups.map((g) => g.id))) : null;
root.replaceChildren(...boxes, ...(stale ? [stale] : []));
root.hidden = !type || (!boxes.length && !stale);
// 잠금은 계곡 통과 시설 서브폼(`inert`)을 따름 — 레지스트리 칸은 칸마다 disabled 라 그대로
const facilityRoot = scopes.find((scope) =>
scope.classList.contains("b05-drainage__facility"),
);
if (facilityRoot && facilityRoot !== lockSource) {
lockSource = facilityRoot;
lockWatch?.observe(facilityRoot, { attributes: true, attributeFilter: ["inert"] });
}
syncLock();
},
get: () => ({ ...items }),
set(next) {
items = { ...next };
for (const [id, row] of rows) row.set(items[id] ?? null);
},
setOne(groupId, item) {
if (item) items = { ...items, [groupId]: item };
else {
const { [groupId]: _gone, ...rest } = items;
items = rest;
}
rows.get(groupId)?.set(item);
},
bind(next) {
handlers = next;
},
};
}
/** 본체 · 그룹 「집계 항목」 [고르기] · [해제] 배선 — [고르기]는 21-5 모달, 결과는 폼 값으로만 두고
* 고치는 중이면 곧바로 반영(새 항목은 종전대로 [추가]가 확정) · 안 고른 채 닫으면 그대로.
* 복사본은 구조물마다(고치는 구조물 id · 관 지점 = `pipe@측점`) · 그룹은 `{id}__{그룹 id}` ·
* 추가 전이면 null(원본 보기만). */
export function bindSummaryPickers(opts: {
summary: SummaryRow;
groups: GroupBoxes;
projectId: string | null;
editingId: () => string | null;
selectedPipeChainage: () => number | null;
commitSummary: () => void;
}): void {
const base = (): string | null => {
const pipe = opts.selectedPipeChainage();
return opts.editingId() ?? (pipe !== null ? pipeStructureId(pipe) : null);
};
// canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임
const pick = (current: SummaryItem | null, structureId: string | null) =>
openItemPicker({ current, canCopy: true, projectId: opts.projectId, structureId });
opts.summary.bind({
pick: () =>
void pick(opts.summary.get(), base()).then((result) => {
if (result.action === "cancel") return;
opts.summary.set(result.action === "apply" ? result.item : null);
opts.commitSummary();
}),
clear: () => {
opts.summary.set(null);
opts.commitSummary();
},
});
opts.groups.bind({
pick: (groupId) => {
const id = base();
void pick(
opts.groups.get()[groupId] ?? null,
id ? `${id}${GROUP_JOIN}${groupId}` : null,
).then((result) => {
if (result.action === "cancel") return;
opts.groups.setOne(groupId, result.action === "apply" ? result.item : null);
opts.commitSummary();
});
},
clear: (groupId) => {
opts.groups.setOne(groupId, null);
opts.commitSummary();
},
});
}
+33 -34
View File
@@ -22,10 +22,9 @@ import {
} from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { field } from "./B05_Profile_UI_Structures_Fields";
import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options";
import { openItemPicker } from "@ui/ui_template_item_picker";
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
import { bindSummaryPickers } from "./B05_Profile_UI_Structures_Groups";
import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form";
import {
bindStructuresEvents,
@@ -91,7 +90,15 @@ export function createStructuresSection(
resetButton,
list,
summary,
groupBoxes,
} = form;
/** 옵션 그룹 상자를 지금 종류 · 칸에 맞춰 다시 세움(PLAN 46-3) — 칸을 그린 뒤마다 부름. */
const regroup = (): void =>
groupBoxes.render(
currentType(),
[optionRow, facilityOptions.root],
facilityFormKind(currentType()),
);
/** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신
* 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션
@@ -232,16 +239,17 @@ export function createStructuresSection(
/** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설·독립 기슭막이, 값은 인자로). */
function syncFacilityForm(
options: Record<string, string | number> = {},
options?: Record<string, string | number>,
designFlow: number | null = null,
): void {
const kind = facilityFormKind(currentType());
facilityOptions.setFacility(kind, options, designFlow);
facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow);
// 「구조물 옵션 보이기」에서 끈 옵션은 시설 폼에서도 가림(값은 옛 값 그대로 저장)
facilityOptions.setHiddenOptions(
kind,
(currentType()?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key),
);
regroup();
syncOptionLock();
}
@@ -310,8 +318,14 @@ export function createStructuresSection(
}
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */
function renderOptionFields(values: Record<string, string | number> = {}): void {
renderOptionFieldsInto(optionContext, values);
function renderOptionFields(values?: Record<string, string | number>): void {
renderOptionFieldsInto(optionContext, values ?? newDefaults());
regroup();
}
/** 값 없이 그리면 새 구조물 — 등록부 기본값을 칸에 실제 값으로(PLAN 46-7 · 옛 기록은 받은 값만). */
function newDefaults(): Record<string, string | number> {
return optionDefaults(currentType()?.options ?? []);
}
/** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도
@@ -491,31 +505,18 @@ export function createStructuresSection(
},
summary: () => summary.get(),
setSummary: (value) => summary.set(value),
groupItems: () => groupBoxes.get(),
setGroupItems: (value) => groupBoxes.set(value),
};
bindStructuresEvents(panelContext);
// 집계 항목 — [고르기]는 21-5 모달, 결과는 폼 값으로만 두고 고치는 중이면 곧바로 반영한다
// (새 항목은 종전대로 [추가]가 확정). 안 고른 채 닫으면 그대로.
summary.bind({
pick: () => {
// canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임
void openItemPicker({
current: summary.get(),
canCopy: true,
projectId: sectionOptions.projectId ?? null,
// 복사본은 구조물마다 — 고치는 구조물의 id(관 지점 = `pipe@측점`) · 추가 전이면 null(원본 보기만)
structureId:
editingId ??
(selectedPipeChainage !== null ? pipeStructureId(selectedPipeChainage) : null),
}).then((result) => {
if (result.action === "cancel") return;
summary.set(result.action === "apply" ? result.item : null);
commitSummary();
});
},
clear: () => {
summary.set(null);
commitSummary();
},
// 집계 항목(본체 · 옵션 그룹) — [고르기]는 21-5 모달 · 배선은 `_Structures_Groups`
bindSummaryPickers({
summary,
groups: groupBoxes,
projectId: sectionOptions.projectId ?? null,
editingId: () => editingId,
selectedPipeChainage: () => selectedPipeChainage,
commitSummary,
});
body.insertBefore(field("메모", memoField), actions);
syncButtons();
@@ -643,11 +644,9 @@ export function createStructuresSection(
endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step);
memoField.value = "";
syncPlacementFields();
// 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②).
// 새 구조물 — 등록부 기본값을 칸에 실제 값으로(PLAN 46-7 · 사용자가 고치면 됨).
renderOptionFields();
// BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음).
// 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함.
syncFacilityForm({});
syncFacilityForm();
// 임시 배치도 즉시 진행(A군).
if (type.managed_by) commitTempPipe();
syncButtons();
@@ -18,7 +18,12 @@ import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_D
import type { StationFields } from "./B05_Profile_UI_Structures_Fields";
import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement";
import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary";
import { readSummaryItem, type SummaryItem } from "../common_util/common_util_summary_item";
import {
readGroupItems,
readSummaryItem,
type GroupItems,
type SummaryItem,
} from "../common_util/common_util_summary_item";
import type {
PipeFacilityItem,
StructuresCallbacks,
@@ -69,6 +74,15 @@ export interface StructuresCommitContext {
/** 폼의 「집계 항목」 값(PLAN 21-4) — 저장은 옵션 밖 `summary_item` 칸으로 나간다. */
summary: () => SummaryItem | null;
setSummary: (value: SummaryItem | null) => void;
/** 옵션 그룹 보조 컨테이너마다 고른 항목(PLAN 46-3) — 저장은 `group_items` 칸. */
groupItems: () => GroupItems;
setGroupItems: (value: GroupItems) => void;
}
/** 저장할 그룹 연결 — 고른 것이 있을 때만 칸을 실음(빈 묶음 = 칸 없음). */
function groupField(ctx: StructuresCommitContext): { group_items?: GroupItems } {
const items = ctx.groupItems();
return Object.keys(items).length ? { group_items: items } : {};
}
export function loadForm(ctx: StructuresCommitContext, target: StructureInstance | null): void {
@@ -91,6 +105,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
ctx.memoField.value = target.memo ?? "";
ctx.renderOptionFields(target.options);
ctx.setSummary(readSummaryItem(target.summary_item));
ctx.setGroupItems(readGroupItems(target.group_items));
// 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된
// 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19).
if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) {
@@ -119,6 +134,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
ctx.memoField.value = "";
ctx.renderOptionFields();
ctx.setSummary(null);
ctx.setGroupItems({});
ctx.syncFacilityForm();
}
[ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid());
@@ -166,6 +182,7 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte
ctx.memoField.value = "";
ctx.syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null);
ctx.setSummary(readSummaryItem(pipe.summary_item));
ctx.setGroupItems(readGroupItems(pipe.group_items));
[ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid());
ctx.syncPlacementFields();
ctx.syncButtons();
@@ -201,6 +218,9 @@ export function commitSummaryOnly(ctx: StructuresCommitContext): boolean {
const summary = ctx.summary();
if (summary) next.summary_item = summary;
else delete next.summary_item;
const groups = groupField(ctx);
if (groups.group_items) next.group_items = groups.group_items;
else delete next.group_items;
ctx.structures[index] = next;
emit(ctx);
return true;
@@ -225,6 +245,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
if (Object.keys(options).length) attributes.options = options;
// 집계 항목 — 미지정(null)도 그대로 실어 옛 지정을 풀 수 있게 한다.
attributes.summary_item = ctx.summary();
attributes.group_items = groupField(ctx).group_items ?? null; // 빈 것도 실어 옛 지정을 풂
if (ctx.tempPipeChainage() !== null) {
// 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영.
const from = ctx.tempPipeChainage() as number;
@@ -306,6 +327,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
end_m: end,
options: readOptions(ctx),
...(summary ? { summary_item: summary } : {}),
...groupField(ctx),
memo: ctx.memoField.value.trim(),
placement_source: "manual" as const,
status: "draft" as const,
@@ -347,6 +369,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
};
// 해제 = 칸을 지움(옛 지정이 `...previous` 로 살아 오지 않게)
if (!summary) delete next.summary_item;
if (!groupField(ctx).group_items) delete next.group_items;
ctx.structures[index] = next;
}
records.slice(1).forEach((record) => ctx.structures.push({ ...record, structure_id: null }));
@@ -10,7 +10,7 @@
import { isB05Option, type StructureType } from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields";
import { field, optionControl } from "./B05_Profile_UI_Structures_Fields";
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
/** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */
@@ -56,12 +56,9 @@ export function renderOptionFields(
return;
}
optionRow.hidden = false;
const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> = [];
shown.forEach((option) => {
// ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은
// 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석).
// 받은 값만 칸에 — 새 구조물은 부르는 쪽(패널)이 등록부 기본값을 `values` 로 실어 줌(PLAN 46-7).
const input = optionControl(option, values[option.key]);
suggestions.push({ input, suggested: String(option.default ?? "") });
const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`;
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다
// (고를 수 없는 칸이라 제안이 아니라 프로그램 값).
@@ -72,7 +69,9 @@ export function renderOptionFields(
input.classList.add("is-locked");
input.title = "지금은 고를 수 없는 항목입니다.";
}
optionRow.append(field(label, input, option.default_basis));
const wrap = field(label, input, option.default_basis);
wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3)
optionRow.append(wrap);
input.addEventListener("change", () => ctx.liveCommit());
optionInputs.push({
key: option.key,
@@ -111,9 +110,6 @@ export function renderOptionFields(
input.addEventListener("change", () => refresh(true));
}
});
if (suggestions.some((entry) => entry.suggested)) {
optionRow.append(suggestButton(suggestions));
}
ctx.syncOptionLock();
ctx.syncRangeDisplay();
}
@@ -19,7 +19,7 @@ import {
type StructureType,
} from "./B05_Profile_Api_Structures";
import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection";
import type { SummaryItem } from "../common_util/common_util_summary_item";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
* 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */
@@ -51,6 +51,8 @@ export interface PipeFacilityItem {
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */
group_items?: GroupItems | null;
}
/** 관 지점 정본 한 건 → 목록·폼 항목. 집계 항목(21-1)도 옮긴다 — 안 옮기면 목록 색 · 폼이 잃는다. */
@@ -69,6 +71,7 @@ export function pipeToFacilityItem(
design_flow_m3s: pipe.design_flow_m3s,
options: pipe.options,
summary_item: pipe.summary_item,
group_items: pipe.group_items,
};
}
@@ -187,7 +187,7 @@
.b05-routeedit__cross-station {
color: var(--color-text-secondary);
font-size: var(--text-caption);
font-size: inherit; /* 44-2 — 제목(횡단)과 같은 크기 */
}
/* 횡단 판 — 캔버스 그림이 아니라 **SVG 선**이다(2026-09-12 사용자 지시 ③).
@@ -221,11 +221,6 @@
line-height: 1.3;
}
/* 근거 줄로 옆 칸이 길어져도 [제안값 넣기]가 세로로 늘지 않게. */
.b05-structure__suggest {
align-self: start;
}
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
* (2026-08-17 사용자 지시 2). */
.b05-structure__position-row {
@@ -489,3 +484,34 @@
.b05-drainage__facility .is-option-hidden {
display: none;
}
/* 옵션 그룹 보조 컨테이너(PLAN 46-3) — 그룹마다 상자 하나 · 옮겨 온 칸 · 「집계 항목」 줄 */
.b05-structure__optgroups {
display: flex;
flex-direction: column;
gap: 6px;
}
.b05-structure__optgroups[hidden],
.is-group-emptied {
display: none;
}
.b05-structure__optgroup {
display: flex;
flex-direction: column;
gap: 6px;
}
.b05-structure__optgroup > legend {
font-weight: 600;
opacity: 1;
}
.b05-structure__optgroup .is-option-hidden {
display: none;
}
.b05-structure__optgroup > .b05-structure__grid.is-locked {
opacity: 0.5;
}
@@ -31,7 +31,11 @@ import {
import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { mergeFacilityOptions } from "../B05_Profile/B05_Profile_UI_Drainage_Facility_Merge";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { readSummaryItem } from "../common_util/common_util_summary_item";
import {
linkFields,
readGroupItems,
readSummaryItem,
} from "../common_util/common_util_summary_item";
import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import {
NO_SELECTION,
@@ -180,6 +184,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
source: "user",
options: attributes.options ?? {},
summary_item: attributes.summary_item ?? null,
group_items: attributes.group_items ?? null,
});
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
currentChainageM = at;
@@ -197,13 +202,17 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
// (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다).
if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M)
movePipeTo(fromChainageM, toChainageM);
if (attributes.summary_item !== undefined) {
if (attributes.summary_item !== undefined || attributes.group_items !== undefined) {
// 옮긴 뒤 자리(toChainageM)에서 찾는다 — 안 옮겼으면 from 과 0.51m 안이라 같은 관이다.
const mine = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - toChainageM) < PIPE_MATCH_M,
);
if (mine && (mine.summary_item ?? null)?.key !== attributes.summary_item?.key) {
const before = mine ? JSON.stringify(linkFields(mine)) : "";
if (mine && attributes.summary_item !== undefined)
mine.summary_item = attributes.summary_item;
if (mine && attributes.group_items !== undefined)
mine.group_items = attributes.group_items;
if (mine && JSON.stringify(linkFields(mine)) !== before) {
persistPipes();
section.setPipeFacilities(pipeFacilities);
deps.onStructuresChanged?.();
@@ -294,6 +303,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
end_m: pipe.end_m,
source: pipe.source,
summary_item: readSummaryItem(pipe.summary_item),
group_items: readGroupItems(pipe.group_items),
// 폼 기본값 = 조정창이 쓰던 값(횡단 스펙). 저장된 옵션이 있으면 그쪽이 이긴다.
options: withSpecDefaults(
pipe.options,
@@ -374,7 +384,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}),
...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}),
...(pipe.options ? { options: pipe.options } : {}),
...(pipe.summary_item ? { summary_item: pipe.summary_item } : {}),
...linkFields(pipe),
})),
);
deps.onPipesWritten?.();
@@ -53,6 +53,7 @@ export const CommandLine: FC = () => {
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return; // 한글 조합 중 키는 IME 몫
if (event.key === 'Escape') {
event.preventDefault();
getInputController().handleEscapeKey();
@@ -31,6 +31,8 @@ export function registerCadDebugHook(): void {
opacity: entity.opacity ?? null,
hidden: isEntityHidden(entity),
color: entity.lineColor,
// 글자 객체의 내용 — 한글 입력(IME) 검증이 읽는다
label: (entity as { getLabel?: () => string }).getLabel?.() ?? null,
box: [
Math.round(box.xmin),
Math.round(box.ymin),
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import { ImeSink, isImeKey } from './ime-sink.ts';
import { InputController } from './input-controller.ts';
describe('한글 IME 입력', () => {
it('조합 사건으로 「가나다」 가 들어간다', () => {
let text = '';
const sink = new ImeSink((data) => {
text += data;
});
for (const [steps, done] of [
[['ㄱ', '가'], '가'],
[['ㄴ', '나'], '나'],
[['ㄷ', '다'], '다'],
] as const) {
for (const step of steps) sink.handleCompositionUpdate(step);
expect(sink.composing).toBe(steps[steps.length - 1]);
sink.handleCompositionEnd(done);
}
expect(text).toBe('가나다');
expect(sink.composing).toBe('');
// 조합 중 input 은 compositionend 몫 — 겹쳐 넣지 않음 · 조합 뒤 띄어쓰기는 받음
sink.handleInput({ isComposing: true, data: '다' });
sink.handleInput({ isComposing: false, data: ' ' });
expect(text).toBe('가나다 ');
});
it('조합 중 키는 단축키로 먹지 않는다', () => {
expect(isImeKey({ isComposing: true, keyCode: 0, key: 'r' } as KeyboardEvent)).toBe(true);
expect(isImeKey({ isComposing: false, keyCode: 229, key: 'r' } as KeyboardEvent)).toBe(true);
expect(isImeKey({ isComposing: false, keyCode: 0, key: 'Process' } as KeyboardEvent)).toBe(
true
);
expect(isImeKey({ isComposing: false, keyCode: 76, key: 'l' } as KeyboardEvent)).toBe(false);
const controller = new InputController();
const preventDefault = vi.fn();
controller.handleKeyStroke({
key: 'Process',
keyCode: 229,
isComposing: true,
target: null,
preventDefault,
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent);
expect(preventDefault).not.toHaveBeenCalled();
controller.ime.handleCompositionEnd('가나다');
expect((controller as unknown as { text: string }).text).toBe('가나다');
});
});
@@ -0,0 +1,68 @@
/**
* 한글 IME 받개 — 캔버스에는 글 칸이 없어 조합(compositionstart/update/end)이 생기지 않는다.
* 문자를 기다리는 단계(TEXT · MTEXT · 표 칸 · 지시선 …)에서만 숨은 input 에 초점을 줘 조합을 받고,
* 끝난 글자를 넘긴다. 명령 단계에서는 초점을 풀어 한글 자판이어도 L · REC 같은 영문 명령이 들어간다.
*/
import { getActiveToolActor } from '../state.ts';
/** 조합 중 키 — 단축키·글자 처리가 먹지 않고 IME 에 넘긴다 */
export const isImeKey = (evt: KeyboardEvent): boolean =>
evt.isComposing || evt.keyCode === 229 || evt.key === 'Process';
/** 활성 도구의 지금 단계가 TEXT_INPUT 을 받는가 */
export function activeToolAcceptsText(): boolean {
const snapshot = getActiveToolActor()?.getSnapshot();
return !!snapshot?.machine?.states?.[snapshot?.value]?.config?.on?.TEXT_INPUT;
}
export class ImeSink {
/** 조합 중인 글자 — 커서 옆 입력 칸에 미리 보인다 */
public composing = '';
public element: HTMLInputElement | null = null;
constructor(private readonly onCommit: (text: string) => void) {}
public attach() {
const el = document.createElement('input');
el.className = 'cad-ime-sink';
el.tabIndex = -1;
el.autocomplete = 'off';
el.setAttribute('aria-hidden', 'true');
el.style.cssText =
'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;border:0;padding:0;pointer-events:none';
el.addEventListener('compositionupdate', (evt) => this.handleCompositionUpdate(evt.data));
el.addEventListener('compositionend', (evt) => this.handleCompositionEnd(evt.data));
el.addEventListener('input', (evt) => this.handleInput(evt as InputEvent));
document.body.appendChild(el);
this.element = el;
}
public handleCompositionUpdate(data: string) {
this.composing = data ?? '';
}
public handleCompositionEnd(data: string) {
this.composing = '';
if (this.element) this.element.value = '';
if (data) this.onCommit(data);
}
/** 조합 직후 IME 가 바로 넣는 글자(띄어쓰기 · 숫자) — 조합 중 input 은 compositionend 가 맡는다 */
public handleInput(evt: Pick<InputEvent, 'isComposing' | 'data'>) {
if (evt.isComposing || !evt.data) return;
if (this.element) this.element.value = '';
this.onCommit(evt.data);
}
/** 문자를 기다리면 받개에 초점 · 아니면 놓음. 다른 입력 칸(명령줄 · 대화상자)의 초점은 뺏지 않는다 */
public sync(wantsText: boolean) {
const el = this.element;
if (!el) return;
const active = document.activeElement;
if (wantsText && !active?.closest('input, textarea, select, [contenteditable]')) {
el.focus({ preventScroll: true });
} else if (!wantsText && active === el && !this.composing) {
el.blur();
}
}
}
@@ -65,21 +65,15 @@ import {
type RelativePointInputEvent,
type TextInputEvent,
} from '../tools/tool.types.ts';
const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/;
/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */
const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)';
const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`);
const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`);
/** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */
const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`);
const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`);
/** 거리·각도(도)를 x·y 변위로 바꾼다 */
function polarToPoint(distance: number, degrees: number): Point {
const radians = (degrees * Math.PI) / 180;
return new Point(distance * Math.cos(radians), distance * Math.sin(radians));
}
import { activeToolAcceptsText, ImeSink, isImeKey } from './ime-sink.ts';
import {
ABSOLUTE_POINT_REGEXP,
ABSOLUTE_POLAR_REGEXP,
NUMBER_REGEXP,
polarToPoint,
RELATIVE_POINT_REGEXP,
RELATIVE_POLAR_REGEXP,
} from './input-patterns.ts';
/** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */
const DRAG_SELECT_MIN_PX = 4;
@@ -88,11 +82,15 @@ export class InputController {
private text = '';
/** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */
private leftPressLocation: Point | null = null;
public readonly ime = new ImeSink((data) => {
this.text += data;
});
constructor() {
if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') {
return; // used during unit testing
}
this.ime.attach();
// Listen for keystrokes
document.addEventListener('keydown', (evt) => {
this.handleKeyStroke(evt);
@@ -141,10 +139,11 @@ export class InputController {
CANVAS_INPUT_FIELD_HEIGHT,
CANVAS_INPUT_FIELD_BACKGROUND_COLOR
);
// Draw text in input field
if (this.text) {
this.ime.sync(activeToolAcceptsText());
// Draw text in input field (조합 중인 한글도 함께)
if (this.text || this.ime.composing) {
drawController.drawTextScreen(
this.text,
this.text + this.ime.composing,
new Point(
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2,
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT - 2
@@ -217,6 +216,7 @@ export class InputController {
return;
}
this.sendMouseClick(evt);
this.ime.sync(activeToolAcceptsText()); // 클릭으로 문자 단계에 들면 바로 한글 받개로
}
}
@@ -392,9 +392,11 @@ export class InputController {
}
public handleKeyStroke(evt: KeyboardEvent) {
if ((evt.target as HTMLElement | null)?.closest('input, textarea, select')) {
const target = evt.target as HTMLElement | null;
if (target !== this.ime.element && target?.closest('input, textarea, select')) {
return;
}
if (isImeKey(evt)) return; // 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김
console.log(`key pressed: ${evt.key}`);
if (evt.key === 'F12') {
// F12 => open developer tools
@@ -472,6 +474,7 @@ export class InputController {
} else if (evt.key === 'Enter') {
// User wants to submit the input or submit the action
this.handleEnterKey();
this.ime.sync(activeToolAcceptsText());
} else if (evt.key === 'ArrowDown') {
// Move the screen down
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey));
@@ -517,11 +520,7 @@ export class InputController {
public handleEnterKey() {
// submit the text as input to the active tool and clear the input field
const activeTool = getActiveToolActor();
const activeToolSnapshot = activeTool?.getSnapshot();
const activeToolState = activeToolSnapshot?.value;
const activeToolCanHandleTextInput =
!!activeToolSnapshot?.machine?.states?.[activeToolState]?.config?.on?.TEXT_INPUT;
const activeToolCanHandleTextInput = activeToolAcceptsText();
if (this.text === '') {
console.log('ENTER: ', {
@@ -0,0 +1,17 @@
/** 명령 입력 해석 — 숫자 · 좌표 · 극좌표 꼴 */
import { Point } from '@flatten-js/core';
export const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/;
/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */
const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)';
export const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`);
export const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`);
/** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */
export const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`);
export const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`);
/** 거리·각도(도)를 x·y 변위로 바꾼다 */
export function polarToPoint(distance: number, degrees: number): Point {
const radians = (degrees * Math.PI) / 180;
return new Point(distance * Math.cos(radians), distance * Math.sin(radians));
}
+6
View File
@@ -190,6 +190,12 @@ def get_logic(key: str) -> dict:
return _call(store.logic, key)
@router.get("/logic/source")
def get_logic_source(src: str) -> dict:
"""로직 원문 — 출처 글로 찾은 품셈 본문 절(읽기만)."""
return _call(make.source, src)
@router.get("/logic/auto")
def get_logic_auto(key: str) -> dict:
"""견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님) · 흐름 로직은 설계 예시값."""
@@ -103,6 +103,79 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi
return {"total": len(hits), "sections": hits[: min(max(limit, 1), 1000)]}
# ── 로직 원문(출처 글 → 품셈 본문 절) ──────────────────────────────────
_NUMBER = re.compile(r"(?<![\d-])\d+(?:-\d+)+(?![\d-])")
def _blocks(lines: list[str]) -> list[dict]:
"""md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌."""
out: list[dict] = []
for line in lines:
text = line.strip()
if not text or text.startswith("<!--"):
continue
if text.startswith("#"):
out.append({"h": len(text) - len(text.lstrip("#")), "text": text.lstrip("#").strip()})
elif text.startswith("|"):
cells = [c.strip() for c in text.strip("|").split("|")]
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells):
continue # 머리 · 몸 가름 줄
if not out or "table" not in out[-1]:
out.append({"table": []})
out[-1]["table"].append(cells)
else:
out.append({"p": text})
return out
def _section(book: str, division: str, number: str) -> dict | None:
"""번호 절을 품셈 본문에서 찾음 — 없으면 끝 마디를 하나씩 떼며 위 절로(두 마디까지)."""
parts = number.split("-")
folders = [f for d, no, _t, f in _chapters(book) if d == division and int(no) == int(parts[0])]
for upto in range(len(parts), 1, -1):
want = "-".join(parts[:upto])
for folder in folders:
for md in sorted(folder.glob("*.md")):
lines = md.read_text(encoding="utf-8").split("\n")
for i, line in enumerate(lines):
m = _HEAD.match(line)
if not m or m.group(1) != want:
continue
level = len(line) - len(line.lstrip("#"))
end = next(
(
j
for j in range(i + 1, len(lines))
if _HEAD.match(lines[j])
and len(lines[j]) - len(lines[j].lstrip("#")) <= level
),
len(lines),
)
return {
"number": number,
"matched": want,
"file": md.relative_to(BODY.parent).as_posix(),
"blocks": _blocks(lines[i:end]),
}
return None
def source(src: str) -> dict:
"""로직 출처 글(예 「건설품셈 토목 1-1-2」 · 「산림품셈 3-4-2 1」)로 찾은 품셈 원문 절 —
번호가 여럿이면(「8-1-5 · 8-1-6」) 절마다 · 못 찾으면 found 빈 목록(화면 「원문 연결 없음」)."""
words = src.split()
book = words[0] if words else ""
if book not in BOOKS:
return {"source": src, "found": []}
division = (
next((d for d in cm.DIVISIONS if src.startswith(f"{book} {d} ")), "")
if book == "건설품셈"
else ""
)
found = [_section(book, division, n) for n in dict.fromkeys(_NUMBER.findall(src))]
return {"source": src, "found": [x for x in found if x]}
# ── 표 조건 값 ─────────────────────────────────────────────────────────
def table_options(file: str, key: str) -> dict:
"""표 조건 칸의 값만 — 표 몸을 통째로 받지 않으려는 자리(만들기 입력 제안)."""
+13 -3
View File
@@ -52,6 +52,7 @@ import {
type LogicFlow,
} from "./M01_MasterData_UI_LogicLab_Flow";
import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
import { buildHeadBar } from "./M01_MasterData_UI_Head";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
@@ -129,6 +130,8 @@ export async function mountM01LogicLab(
const editor = el("div", { className: "m01-logic__editor" });
// 로직 상세 두 칸 — 왼쪽 지금 내용 · 오른쪽 흐름도(패널 자리) · 너비 비율 기억
const split = buildSplit(editor);
// 오른쪽 두 패널(흐름도 · 원문) — 하나만 열림 · 흐름도는 이 칸 안에 띄움
const panes = buildLogicSide(split);
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
const calc = el("div", { className: "m01lab__calc" });
const filterHost = el("div");
@@ -273,7 +276,9 @@ export async function mountM01LogicLab(
const startFlow = async (o: Opened): Promise<void> => {
const row = o.row;
if (!row) return;
split.right.replaceChildren(el("p", { className: "m01lab-flow__wait", text: tf("Waiting") }));
panes.flowSplit.right.replaceChildren(
el("p", { className: "m01lab-flow__wait", text: tf("Waiting") }),
);
const own = flowOf(row);
const got = own || o.origKey === null ? null : await fetchFlow(o.origKey, o.file);
// 시험 입력 한 벌 — 열 때 왼쪽 시험 계산 입력을 흐름 변수 예시값으로
@@ -284,7 +289,9 @@ export async function mountM01LogicLab(
if (opened !== o) return;
try {
const made = await mountLogicFlow({
split,
split: panes.flowSplit,
folded: panes.flowFolded(),
onFold: (on) => panes.pick(on ? "none" : "flow"),
flow: clone(o.flowBase),
title: row.이름 || row.키,
readOnly: !admin,
@@ -307,9 +314,10 @@ export async function mountM01LogicLab(
});
if (opened !== o) return made.destroy();
flowNow = made;
panes.bindFlow(made.fold);
made.setExamples(o.values); // 띄우는 사이에 온 자동값까지
} catch (e) {
split.right.replaceChildren(
panes.flowSplit.right.replaceChildren(
el("p", { className: "m01-logic__muted", text: tf("Failed", String(e)) }),
);
}
@@ -344,7 +352,9 @@ export async function mountM01LogicLab(
const show = (next: Opened | null): void => {
flowNow?.destroy();
flowNow = null;
panes.bindFlow(null);
opened = next;
panes.showSource(next?.row?.출처 ?? "");
errors.hidden = true;
drawEditor();
drawCalc();
@@ -1,8 +1,8 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Detail.ts
* 「단가산출 로직」 상세 화면 — 컨테이너 넷을 위에서 아래로
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 단가산출 상세(인력·자재·경비·그 밖 묶음 + 소계)
* ③ 텍스트 수식(자리 — `_LogicLab_Formula.ts`) ④ 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로)
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로)
* ③ 단가산출 상세(인력·자재·경비·그 밖 묶음 + 소계) ④ 텍스트 수식(자리 — `_LogicLab_Formula.ts`)
* 호표 줄을 누르면 그 줄이 쓰는 자료 모달(`_LogicLab_Modal.ts`)
* ========================================================================== */
@@ -70,7 +70,7 @@ export interface DetailContext {
/** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */
ownHost: HTMLElement;
onChange: () => void;
/** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */
/** 시험 계산 칸(`buildCalc` 가 채움) — 둘째 컨테이너 안에 놓음 */
calcHost: HTMLElement;
/** 흐름이 있는 로직 — 이 칸에 왼쪽 「지금 내용」(단가산출 상세 · 텍스트 수식 = `buildFlowView`)을 그림 ·
* 있으면 호표 · 읽는 식 두 묶음 대신 이 칸을 놓음(기본정보 · 시험 계산은 그대로) */
@@ -487,8 +487,8 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
: null;
host.replaceChildren(
infoBox(ctx),
...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]),
section(tl("Calc_Title"), [ctx.calcHost]),
...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]),
);
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
const back = own ? host.querySelector<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
@@ -235,6 +235,9 @@ export interface LogicFlowOpts {
/** 흐름 로직이면 왼쪽 「지금 내용」 자리(LogicLab_Detail 의 flowHost) */
leftHost(): HTMLElement | null;
onChange(lg: CfLogic): void;
/** 패널 접힘 — 부르는 쪽(오른쪽 두 패널)이 정함 */
folded?: boolean;
onFold?(on: boolean): void;
}
export interface LogicFlow {
@@ -244,6 +247,8 @@ export interface LogicFlow {
redrawLeft(): void;
/** 왼쪽 시험 계산 입력이 바뀜 — 같은 이름 변수의 예시값으로(고친 것 아님) */
setExamples(vals: Record<string, string>): void;
/** 패널 접기 · 펴기 */
fold(on: boolean): void;
destroy(): void;
}
@@ -312,6 +317,8 @@ export async function mountLogicFlow(opts: LogicFlowOpts): Promise<LogicFlow> {
readOnly: opts.readOnly,
logics: getLogic,
onResize: (px) => opts.split.setRight(px),
folded: opts.folded,
onFold: opts.onFold,
onChange: (next) => {
lg = next;
opts.onChange(next);
@@ -329,6 +336,7 @@ export async function mountLogicFlow(opts: LogicFlowOpts): Promise<LogicFlow> {
refresh();
},
redrawLeft: left,
fold: (on) => panel.fold(on),
setExamples: (vals) => {
const next = withExamples(lg, vals);
if (JSON.stringify(next) === JSON.stringify(lg)) return;
@@ -0,0 +1,60 @@
/* 로직 상세 오른쪽 두 패널 — 흐름도 · 원문 · 공용 접기 카드(접힌 카드 = 제목 행) */
.m01lab-side {
min-width: 0;
}
.m01lab-side__card,
.m01lab-side__slot {
min-width: 0;
}
/* 카드 제목 행이 접기 단추 — 흐름도 패널 자체 접기 단추 · 테두리는 뺌 */
.m01lab-side .m02-dfp__toggle {
display: none;
}
.m01lab-side .m02-dfp {
border-left: 0;
background: none;
}
.m01lab-src {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m01lab-src__body {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
max-height: 80vh;
overflow: auto;
}
.m01lab-src__head {
margin: var(--spacing-8) 0 0;
}
.m01lab-src__p {
margin: 0;
white-space: pre-wrap;
line-height: 1.6;
}
.m01lab-src__file {
margin: 0;
font-size: var(--text-body-sm);
word-break: break-all;
}
.m01lab-src__none {
margin: 0;
font-weight: 600;
}
.m01lab-src__table td {
white-space: nowrap;
}
@@ -0,0 +1,198 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Side.ts
* 로직 상세 오른쪽 두 패널 — 「흐름도」(다이어그램 · 상세 내용 = M02_DF_Panel) · 「원문」(품셈 본문 절)
* 공용 접기 카드(`ui-collapsible` · 제목 행 누름 · 캐럿) 두 장 — attachCollapsible 토글 위에 하나만 열림 규칙
* 둘 다 접기 됨 · 접힌 카드 = 제목 행만
* 열린 패널은 계정 취향(`m01-logic-panel`)으로 기억 · 원문은 출처 글로 서버가 찾음(`GET /m01/logic/source`)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { el } from "@ui/ui_template_elements";
import { API_BASE_URL } from "@config/config_frontend";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import type { SplitHandle } from "./M01_MasterData_UI_LogicLab_Flow";
import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css";
import "./M01_MasterData_UI_LogicLab_Side.css";
const TEXT = {
Flow: ["흐름도", "Flow"],
Source: ["원문", "Source"],
Loading: ["원문 찾는 중", "Looking up the source"],
None: ["원문 연결 없음", "No linked source"],
From: ["출처", "Source"],
NoSource: ["출처 없음", "No source"],
Near: ["{v} 절이 없어 위 절", "No {v} section — showing the parent"],
} as const;
const ts = (key: keyof typeof TEXT, v = ""): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
/** 둘 다 접힘 — 제목 행 둘이 들어가는 칸 너비 */
const FOLDED = 140;
export type PanelPick = "flow" | "source" | "none";
interface Block {
h?: number;
text?: string;
p?: string;
table?: string[][];
}
interface Found {
number: string;
matched: string;
file: string;
blocks: Block[];
}
export interface LogicSide {
/** 흐름도 패널에 넘길 칸 — right = 흐름도 자리 · setRight = 흐름도 패널이 알린 너비 */
flowSplit: SplitHandle;
flowFolded(): boolean;
pick(next: PanelPick): void;
/** 흐름도 패널이 뜨면 접기 함수 · 내리면 null */
bindFlow(fold: ((on: boolean) => void) | null): void;
/** 연 로직의 출처 글 — 원문 패널이 열려 있으면 찾음 */
showSource(src: string): void;
}
const readPick = (): PanelPick => {
const got = readStateRaw("m01-logic-panel");
return got === "source" || got === "none" ? got : "flow";
};
function blockNode(b: Block): HTMLElement {
if (b.table)
return el("div", {
className: "m01-logic__scroll",
children: [
el("table", {
className: "m01-logic__grid m01lab-src__table",
children: [
el("tbody", {
children: b.table.map((r) =>
el("tr", { children: r.map((c) => el("td", { text: c })) }),
),
}),
],
}),
],
});
if (b.h) return el("h4", { className: "m01lab-src__head", text: b.text ?? "" });
return el("p", { className: "m01lab-src__p", text: b.p ?? "" });
}
export function buildLogicSide(split: SplitHandle): LogicSide {
const flowSlot = el("div", { className: "m01lab-side__slot" });
const body = el("div", { className: "m01lab-src__body" });
const title = el("strong", { className: "m02-dfp__title" });
const card = (label: string, inner: HTMLElement): HTMLElement =>
el("section", {
className: "m01lab-side__card ui-collapsible ui-sidebar-section ui-sidebar-card",
children: [el("h3", { className: "ui-collapsible__title", text: label }), inner],
});
const flowCard = card(ts("Flow"), flowSlot);
const sourceCard = card(
ts("Source"),
el("div", { className: "m01lab-src", children: [title, body] }),
);
const side = el("div", {
className: "m01lab-side ui-sidebar-panel",
children: [flowCard, sourceCard],
});
attachCollapsible(side);
// 공용 토글 뒤 — 연 카드만 남기고 다른 카드는 접음(접었으면 둘 다 닫힘)
side.addEventListener("click", (ev) => {
const at = (ev.target as HTMLElement).closest(".ui-collapsible__title")?.parentElement;
if (at !== flowCard && at !== sourceCard) return;
pick(at.classList.contains("is-collapsed") ? "none" : at === flowCard ? "flow" : "source");
});
split.right.replaceChildren(side);
let now = readPick();
let flowPx: number | null = null;
let flowFold: ((on: boolean) => void) | null = null;
let want = "";
let shown: string | null = null;
/** 카드 안팎 여백(좌우 안쪽 + 테두리) — 흐름도 패널 너비에 더함 */
const pad = (): number => {
const s = getComputedStyle(flowCard);
return ["paddingLeft", "paddingRight", "borderLeftWidth", "borderRightWidth"].reduce(
(n, k) => n + (parseFloat(s[k as "paddingLeft"]) || 0),
0,
);
};
/** 칸 너비 — 원문(또는 흐름도가 아직 안 뜸) = 두 칸 비율 · 흐름도 = 패널 너비 + 카드 여백 · 둘 다 접힘 = 제목 행 */
const width = (): void =>
split.setRight(
now === "source" || (now === "flow" && flowPx === null)
? null
: now === "flow"
? (flowPx ?? 0) + pad()
: FOLDED,
);
const load = (): void => {
if (now !== "source" || shown === want) return;
const src = (shown = want);
title.textContent = src || ts("NoSource");
body.replaceChildren(el("p", { className: "m01-logic__muted", text: ts("Loading") }));
const none = (): void =>
body.replaceChildren(
el("p", { className: "m01lab-src__none", text: ts("None") }),
el("p", { className: "m01-logic__muted", text: `${ts("From")} · ${src || "—"}` }),
);
if (!src.trim()) return none();
void fetch(`${API_BASE_URL}/m01/logic/source?${new URLSearchParams({ src })}`, {
credentials: "include",
})
.then((r) => (r.ok ? (r.json() as Promise<{ found: Found[] }>) : { found: [] }))
.catch(() => ({ found: [] as Found[] }))
.then(({ found }) => {
if (shown !== src) return;
if (!found.length) return none();
body.replaceChildren(
...found.flatMap((f) => [
el("p", {
className: "m01-logic__muted m01lab-src__file",
text: f.matched === f.number ? f.file : `${f.file} · ${ts("Near", f.number)}`,
}),
...f.blocks.map(blockNode),
]),
);
});
};
const pick = (next: PanelPick): void => {
if (next !== now) writeStateRaw("m01-logic-panel", next);
now = next;
flowFold?.(now !== "flow"); // 칸을 줄이기 전에 — 흐름도 패널이 접기 전 너비를 재어 둠
flowCard.classList.toggle("is-collapsed", now !== "flow");
sourceCard.classList.toggle("is-collapsed", now !== "source");
width();
load();
};
pick(now);
return {
flowSplit: {
root: split.root,
right: flowSlot,
setRight: (px) => {
flowPx = px;
width();
},
},
flowFolded: () => now !== "flow",
pick,
bindFlow: (f) => {
flowFold = f;
},
showSource: (src) => {
want = src;
load();
},
};
}
@@ -147,7 +147,7 @@ export function renderInputPanel(
children: [...GROUPS, "다른 로직" as const, "변수" as const].map((k) =>
createButton({
label: k === "변수" ? t("Variable") : k === "다른 로직" ? t("Logic") : t(k),
variant: k === kind ? "filled" : "pill",
variant: k === kind ? "filled" : "ghost",
size: "sm",
onClick: () => k !== kind && on.change(setKind(lg, boxId, k)),
}),
@@ -193,7 +193,7 @@ function renderGroup(
...(["fixed", "rule"] as const).map((m) =>
createButton({
label: m === "fixed" ? t("ModeFixed") : t("ModeRule"),
variant: mode === m ? "filled" : "pill",
variant: mode === m ? "filled" : "ghost",
size: "sm",
onClick: () => ((mode = m), drawChips(), draw()),
}),
@@ -445,7 +445,7 @@ export function renderMaterial(
...(["fixed", "choice", "rule"] as CfMaterialMode[]).map((m) =>
createButton({
label: m === "fixed" ? t("Fixed") : m === "choice" ? t("Choice") : t("Rule"),
variant: mode === m ? "filled" : "pill",
variant: mode === m ? "filled" : "ghost",
size: "sm",
onClick: () => ((mode = m), drawChips(), drawBody()),
}),
+11 -4
View File
@@ -41,6 +41,9 @@ export interface FlowPanelOpts {
logics?(key: string): Promise<CfLogic | null>;
/** 패널 너비가 바뀜(끌기 · 접기 · 펴기) — 부르는 쪽이 칸 너비를 맞춤 */
onResize?(px: number): void;
/** 접힘을 부르는 쪽이 정함(처음 값) — 주면 접기 단추는 onFold 만 부르고 기억도 부르는 쪽 몫 */
folded?: boolean;
onFold?(on: boolean): void;
}
export interface FlowPanel {
@@ -49,6 +52,8 @@ export interface FlowPanel {
destroy(): void;
/** 크게 보기 열기 */
expand(): void;
/** 밖에서 접기 · 펴기(onFold 를 부르지 않음) */
fold(on: boolean): void;
}
/* ── 너비(DOM 없음) ─────────────────────────────────────────────────── */
@@ -105,8 +110,9 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
});
const { diagram, detail, summary, note, undo } = paper.parts;
let width = storedWidth(readStore(WIDTH_KEY));
let folded = readStore(FOLD_KEY) === "1";
const tell = (): void => opts.onResize?.(folded ? STRIP_WIDTH : (width ?? host.clientWidth));
let folded = opts.folded ?? readStore(FOLD_KEY) === "1";
const tell = (): void =>
opts.onResize?.(folded ? STRIP_WIDTH : (width ?? Math.max(PANEL_MIN, host.clientWidth)));
const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") =>
createButton({ label, size: "sm", variant, onClick });
@@ -116,7 +122,7 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
label: tf("Toggle"),
size: "sm",
variant: "ghost",
onClick: () => setFolded(!folded),
onClick: () => (opts.onFold ? opts.onFold(!folded) : setFolded(!folded)),
});
toggle.classList.add("m02-dfp__toggle");
const head = el("header", {
@@ -215,7 +221,7 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
if (on && !folded && width === null && root.isConnected)
width = Math.round(root.getBoundingClientRect().width);
folded = on;
writeStore(FOLD_KEY, on ? "1" : "0");
if (!opts.onFold) writeStore(FOLD_KEY, on ? "1" : "0");
root.classList.toggle("is-folded", on);
open.hidden = on;
strip.hidden = !on;
@@ -290,5 +296,6 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
root.remove();
},
expand,
fold: (on) => on !== folded && setFolded(on),
};
}
+20 -10
View File
@@ -28,7 +28,7 @@ from typing import Any
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
from common_util.common_util_json import atomic_write_json
from common_util.common_util_summary_item import snapshot_folder_name
from common_util.common_util_summary_item import GROUP_JOIN, snapshot_folder_name
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_Item_Source import (
LABOR_FILE,
@@ -48,8 +48,11 @@ MANIFEST_NAME = "manifest.json"
VALUES_FILE = "입력값.json" # {로직키: {입력이름: {값, 확인}}}
PICKS_FILE = "고른자재.json" # {로직키: {호표 차례: MT키}}
LOCAL_FILES = (VALUES_FILE, PICKS_FILE)
#: 복사본 폴더 이름 — 구조물 id(영문 · 숫자 · `_` `-`) 또는 `pipe@측점`
FOLDER_PATTERN = re.compile(r"^(?:pipe@\d+(?:\.\d+)?|[0-9A-Za-z][0-9A-Za-z_-]*)$")
#: 복사본 폴더 이름 — 구조물 id(영문 · 숫자 · `_` `-`) 또는 `pipe@측점` · 옵션 그룹(PLAN 46)은
#: 뒤에 `__{그룹 id}`
FOLDER_PATTERN = re.compile(
r"^(?:pipe@\d+(?:\.\d+)?(?:__g[0-9a-z]{1,16})?|[0-9A-Za-z][0-9A-Za-z_-]*)$"
)
def structures_dir(project_root: str | Path) -> Path:
@@ -130,15 +133,22 @@ def write_snapshot(target: Path, item: dict[str, Any], owner_id: str, extra: dic
def assignments(project_root: str | Path) -> dict[str, dict[str, Any]]:
"""`{구조물 id: 항목}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`) · 지정한 것만."""
"""`{구조물 id: 항목}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`) · 지정한 것만 ·
옵션 그룹(PLAN 46)에 고른 항목은 `{구조물 id}__{그룹 id}` 로 따로(복사본도 따로)."""
found: dict[str, dict[str, Any]] = {}
_, structures = load_structures(str(project_root))
for index, structure in enumerate(structures):
if structure.summary_item:
found[structure.structure_id or f"#{index}"] = structure.summary_item
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))):
if point.summary_item:
found[f"pipe@{round(float(point.chainage_m), 3)}"] = point.summary_item
owners: list[tuple[str, Any]] = [
(s.structure_id or f"#{index}", s) for index, s in enumerate(structures)
]
owners += [
(f"pipe@{round(float(p.chainage_m), 3)}", p)
for p in read_pipe_points_file(pipe_points_path_in(Path(project_root)))
]
for owner, record in owners:
if record.summary_item:
found[owner] = record.summary_item
for gid, item in (record.group_items or {}).items():
found[f"{owner}{GROUP_JOIN}{gid}"] = item
return found
+9 -2
View File
@@ -1,6 +1,6 @@
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 메모, 입력값, 수량}],
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}],
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
@@ -26,6 +26,7 @@ mm = mf.mp.mm
COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
MATERIAL_HEAD = "자재|"
_MAT_COLS = ("코드", "이름", "규격", "단위")
ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음
def _bad(text: str) -> store.StoreError:
@@ -83,10 +84,16 @@ def clean(doc: Any) -> dict[str, Any]:
given = one.get("입력값") or {}
if not isinstance(given, dict):
raise _bad(f"{where} · 입력값이 묶음 아님")
memo = one.get("메모")
memo, alias = one.get("메모"), one.get("별칭")
if alias is not None and not isinstance(alias, str):
raise _bad(f"{where} · 별칭은 글")
alias = (alias or "").strip()
if len(alias) > ALIAS_MAX:
raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지")
lines.append(
{
"로직": code,
"별칭": alias or None,
"메모": str(memo) if memo else None,
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
"수량": _qty(one.get("수량"), where),
@@ -32,7 +32,8 @@ export interface TemplateDoc {
/** 서버가 판 불일치로 막았을 때 */
export class StaleError extends Error {}
async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
/** `/api/m02` 아래 길 부름 — 409 = StaleError · 그 밖 실패 = 서버 까닭 글(하위 폴더도 씀) */
export async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}/m02${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
@@ -192,30 +193,6 @@ export interface StructureCombos {
export const fetchStructureCombos = (): Promise<StructureCombos> => scoped("/structure-combos");
/** 조합 갈라 내기 결과 — `새로만듦` false = 같은 담은 로직의 조합이 이미 있어 그 키를 가리킴 */
/* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */
export interface OptionKind {
type_id: string;
group: string;
name: string;
/** 거짓이면 B05 선택지에 안 나오는 종류 */
enabled: boolean;
options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[];
}
/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */
export interface OptionsDoc {
판: string;
표시: Record<string, Record<string, false>>;
종류: OptionKind[];
}
export const fetchStructureOptions = (): Promise<OptionsDoc> =>
call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 옛 길(시스템 관리자)
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
/** 항목 하나 복사해 가져오기(39-5 · 39-1b) — 받는 열(`column`)에 덮어씀 · 번호 · 열은 그대로 ·
* `source` = 원본 열 id(실무문서면 조합 키) · `from` 없음 = 지금 층 · 층 · 주인 = 다른 층(원본 읽기 권한) ·
* `practice` = 실무문서 조합 → 일위대가(시스템 관리자만) */
@@ -222,7 +222,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
const take = createButton({ label: TEXT.take, variant: "ghost", size: "sm" });
take.title = fill(TEXT.takeTip, PART_WORD[kind]);
take.addEventListener("click", () => opts.onImport?.(kind));
const del = createButton({ label: TEXT.clear, variant: "ghost", size: "sm" });
const del = createButton({ label: TEXT.clear, variant: "danger", size: "sm" });
del.title = fill(TEXT.clearTip, PART_WORD[kind]);
del.addEventListener("click", () => void clearCard(kind));
shell.tools.append(help, saver.button, take, edit, del);
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_ItemList.ts
* 구조물 항목 목록 하나(PLAN 39-5) — 「전체 항목」(도면 · 일위대가 · 면적산출) · 복사 생성 · 항목 하나
* 복사가 같이 씀. 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표
* 구조물 항목 목록 하나(PLAN 39-5) — 복사 생성 · 항목 하나(도면 · 일위대가 · 면적산출) 복사가 같이 씀.
* 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표
* (`m01-master__grid`) 를 M01 파일에서 그대로 import(옮기지 않음).
* 자료 = 층 나무 항목(`GET /layers/{층}/tree` — 서버 `items` 의 `수정일` · `labor` 칸) · 칸은 종류마다.
* 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`).
@@ -142,22 +142,6 @@ export function pageOf<R>(
export const loadItems = async (layer: Layer, owner: string | null): Promise<ItemRow[]> =>
(await fetchTree(layer, owner)).items as ItemRow[];
/** 「전체 항목」 화면(39-5) — 한 층 · 한 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄 누름과 같음) */
export function buildAllItems(
kind: ListKind,
layer: Layer,
owner: string | null,
onOpen: (row: ItemRow) => void,
): HTMLElement {
const list = buildItemList();
loadItems(layer, owner).then(
(rows) => list.show(rows, ITEM_COLS[kind], onOpen),
(error: unknown) =>
list.message(`${TEXT.failed} — ${error instanceof Error ? error.message : String(error)}`),
);
return list.root;
}
export interface ItemListHandle {
root: HTMLElement;
/** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */
@@ -92,18 +92,20 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } {
let seq = 0;
const buttons = tabs.map((tab) => {
const b = el("button", {
className: "m02-layers__btn",
text: tab.label,
attrs: { type: "button", "data-source": tab.id },
});
b.addEventListener("click", () => void show(tab));
const b = createButton({ label: tab.label, variant: "ghost", onClick: () => void show(tab) });
b.classList.add("m02-layers__btn");
b.dataset.source = tab.id;
return b;
});
async function show(tab: Source): Promise<void> {
const mine = ++seq;
buttons.forEach((b) => b.classList.toggle("is-active", b.dataset.source === tab.id));
buttons.forEach((b) => {
const on = b.dataset.source === tab.id;
b.classList.toggle("is-active", on);
b.classList.toggle("ui-btn--filled", on);
b.classList.toggle("ui-btn--ghost", !on);
});
people.root.hidden = tab.id !== "people";
list.message();
try {
@@ -78,6 +78,64 @@
white-space: nowrap;
}
/* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */
.m02-labrow .m02-labrow__optrow td {
padding-top: 0;
padding-bottom: var(--spacing-4);
font-size: var(--text-caption);
line-height: 1.5;
}
.m02-labrow__opt {
max-width: 0;
overflow: hidden;
color: var(--color-text-secondary);
white-space: nowrap;
text-overflow: ellipsis;
}
.m02-labrow__row:has(+ .m02-labrow__optrow) td {
border-bottom: 0;
}
.m02-labrow__optrow.is-open {
cursor: pointer;
}
.m02-labrow__optrow.is-open:hover,
.m02-labrow__row.is-open:hover + .m02-labrow__optrow {
background: var(--color-mist-violet);
}
/* 별칭 칸(44-6) — 칸 안 글상자 · 누르면 바로 고침 */
.m02-labrow__alias {
min-width: 7rem;
}
.m02-labrow__aliasin {
width: 100%;
min-width: 6rem;
padding: 2px var(--spacing-4);
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm, 4px);
background: transparent;
color: inherit;
font: inherit;
}
.m02-labrow__aliasin:hover,
.m02-labrow__aliasin:focus {
border-color: var(--color-primary);
border-style: solid;
}
/* 총합계 줄 전체 합계 칸(44-6) — 테두리 · 색 */
.m02-labrow__sum .m02-labrow__total {
border: 2px solid var(--color-primary);
background: var(--color-mist-violet);
color: var(--color-primary);
}
/* 입력 모달 */
.m02-labin {
width: min(40rem, calc(100vw - 32px));
@@ -234,6 +292,17 @@
order: 3;
text-align: right;
}
/* 옵션 값 줄 — 한 줄 통째 */
.m02-labrow__optrow .m02-labrow__no,
.m02-labrow__sum .m02-labrow__alias {
display: none;
}
.m02-labrow .m02-labrow__opt {
grid-column: 1 / -1;
max-width: none;
}
}
/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */
@@ -121,6 +121,8 @@ export type LaborQty = { 손: number } | null;
/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */
export interface LaborRow {
로직: string;
/** 사용자가 지은 이름(44-6 · 빈 것 = null) */
별칭?: string | null;
메모: string | null;
입력값: Record<string, string | number>;
수량: LaborQty;
@@ -153,6 +155,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
단위: d.단위 ?? null,
담은로직: (d.담은로직 ?? []).map((r) => ({
로직: r.로직,
별칭: r.별칭 ?? null,
메모: r.메모 ?? null,
입력값: r.입력값 ?? {},
수량: r.수량 ?? null,
@@ -43,6 +43,7 @@ import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { keepKnown } from "./M02_Labor_Values_Model";
import { pickMaterial } from "./M02_Material_Pick";
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
import "@ui/ui_template_general_layout.css";
@@ -135,7 +136,13 @@ export function mountLaborDetail(
{ rows: cur.담은로직, logics, calc, fail, cells: cells() },
// 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠)
edit
? { open: openRow, qty: openQty, ...(opt.card ? {} : { move }), remove: removeRow }
? {
open: openRow,
qty: openQty,
...(opt.card ? {} : { move }),
remove: removeRow,
alias: setAlias,
}
: { open: openRow },
),
);
@@ -262,6 +269,11 @@ export function mountLaborDetail(
cur.담은로직.splice(i, 1);
changed();
}
/** 별칭(44-6) — 화면 캐시만(저장은 [저장]) · 금액과 무관이라 다시 셈 · 다시 그림 없음 */
function setAlias(i: number, value: string | null): void {
cur.담은로직[i].별칭 = value;
opt.onChanged(dirty());
}
function removeMat(i: number): void {
cur.자재.splice(i, 1);
changed();
@@ -311,7 +323,7 @@ export function mountLaborDetail(
openLogicPicker((x) => {
if (cur.담은로직.some((r) => r.로직 === x.키))
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null });
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null });
void loadLogics().then(() => changed());
}),
});
@@ -330,15 +342,27 @@ export function mountLaborDetail(
}),
});
const unit = createInputField({ type: "text", label: tc("Labor_Unit") });
unit.input.value = cur.단위 ?? "";
unit.input.readOnly = !edit;
unit.input.maxLength = 20;
unit.input.setAttribute("data-labor-field", "단위");
unit.input.addEventListener("input", () => {
cur.단위 = unit.input.value.trim() || null;
opt.onChanged(dirty());
});
// 단위 = 표준 단위 목록에서 고르기(지금 값이 목록에 없어도 선택지로 둠) · 저장은 [저장]
const unit = {
root: el("div", { className: "ui-field", attrs: { "data-labor-field": "단위" } }),
};
const unit0 = cur.단위;
const drawUnit = (): void =>
unit.root.replaceChildren(
el("label", { className: "ui-field__label", text: tc("Labor_Unit") }),
createUnitPick({
value: cur.단위 || NO_UNIT,
extra: [unit0, cur.단위].filter((u): u is string => !!u),
readOnly: !edit,
ariaLabel: tc("Labor_Unit"),
onPick: (u) => {
cur.단위 = u === NO_UNIT ? null : u;
opt.onChanged(dirty());
drawUnit();
},
}),
);
drawUnit();
const origin = createInputField({ type: "text", label: tc("Labor_Origin") });
origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self");
origin.input.readOnly = true;
@@ -1,8 +1,9 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Rows.ts
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
* 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) ·
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄.
* 담은 로직 = 차례 · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) ·
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) ·
* 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조).
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
* 금액은 calc 답(없으면 「…」).
* 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음).
@@ -31,11 +32,14 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
계: "Prev_Sum",
};
/** 옵션 값 줄이 걸치는 칸 수 — 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */
const SPAN = 4 + COSTS.length;
const num = (v: number): string =>
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) ·
* 이은 칸이 비었으면 「수량 A1 = 빈 칸」(수량 없음 · 서버와 같음) · 아니면 빈 글 */
/** 수량 글 — 「수량 손 3 개소」 · 「수량 12.5 ㎡(B6)」(칸 = 그 알약을 이은 면적산출 칸 · 44-6) ·
* 이은 칸이 비었으면 「수량 빈 칸(A1)」(수량 없음 · 서버와 같음) · 아니면 빈 글 */
export function qtyText(
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
@@ -44,12 +48,12 @@ export function qtyText(
if (!line) return "";
if (line.수량 === null || line.수량 === undefined) {
const at = line.알약 ? cells.get(line.알약) : undefined;
return at ? `${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}` : "";
return at ? `${tc("Labor_Qty")} ${tc("In_QtyEmpty")}(${at})` : "";
}
const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`;
if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`;
const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell");
return `${tc("Labor_Qty")} ${cell} = ${v}`;
return `${tc("Labor_Qty")} ${v}(${cell})`;
}
/** 결과단위 기준 수량 — 분모 앞 수 · 분모(「10 ㎡」 · 「1 ㎥」) · 분모 없으면 빈 글 */
@@ -60,7 +64,7 @@ export function baseQtyText(
return `${num(line.배 || 1)} ${line.분모}`;
}
/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「B6 = 12.5」 · 「B6 = 빈 칸」 · 없으면 「—」 */
/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「12.5(B6)」 · 「빈 칸(B6)」 · 없으면 「—」 */
export function qtyShort(
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
@@ -68,13 +72,13 @@ export function qtyShort(
): string {
const at = line?.알약 ? cells.get(line.알약) : undefined;
if (!line || line.수량 === null || line.수량 === undefined)
return at ? `${at} = ${tc("In_QtyEmpty")}` : "—";
return at ? `${tc("In_QtyEmpty")}(${at})` : "—";
if (line.수량출처 === "손")
return `${tc("Labor_Hand")} ${num(line.수량)}${unit ? ` ${unit}` : ""}`;
return `${at ?? tc("In_QtyCell")} = ${num(line.수량)}`;
return `${num(line.수량)}(${at ?? tc("In_QtyCell")})`;
}
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「B6 = 12.5」 · 「—」) ·
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「12.5(B6)」 · 「—」) ·
* `open` 이 있으면 단추(줄 누름과 따로) */
function qtyCell(
line: LaborCalcLine | undefined,
@@ -155,6 +159,42 @@ export interface RowHandlers {
qty?: (i: number) => void;
move?: (i: number, d: number) => void;
remove?: (i: number) => void;
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
alias?: (i: number, value: string | null) => void;
}
/** 별칭 칸(44-6) — 고칠 수 있으면 칸 안 글상자(누르면 바로 고침 · 줄 누름과 따로) · 아니면 글 · 비면 빈 칸 */
function aliasCell(
value: string | null | undefined,
set: ((v: string | null) => void) | null,
): HTMLElement {
if (!set)
return el("td", {
className: "m02-labrow__alias",
text: value ?? "",
attrs: { "data-label": tc("Col_Alias") },
});
const input = el("input", {
className: "m02-labrow__aliasin",
attrs: {
type: "text",
maxlength: "40", // 서버 `ALIAS_MAX` 와 같음
title: tc("Alias_Edit"),
"aria-label": tc("Col_Alias"),
"data-labor-alias": "",
},
});
input.value = value ?? "";
input.addEventListener("click", (e) => e.stopPropagation()); // 줄 누름(입력 모달)과 따로
input.addEventListener("input", () => set(input.value.trim() || null));
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") input.blur();
});
return el("td", {
className: "m02-labrow__alias",
attrs: { "data-label": tc("Col_Alias") },
children: [input],
});
}
export interface LogicTableData {
@@ -175,17 +215,21 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
const tools = !!(move || remove);
const lineOf = (key: string): LaborCalcLine | undefined =>
data.calc?.줄.find((l) => l.로직 === key);
const trs = data.rows.map((row, i) => {
const trs = data.rows.flatMap((row, i) => {
const logic = data.logics.get(row.로직);
const line = lineOf(row.로직);
const unit = logic?.결과단위 ?? "";
const values = valueLine(logic?.입력 ?? [], row.입력값);
const note = values.text;
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
const small = (label: string, run: () => void): HTMLElement =>
const small = (
label: string,
run: () => void,
variant: "ghost" | "danger" = "ghost",
): HTMLElement =>
createButton({
label,
variant: "ghost",
variant,
onClick: (e) => {
e.stopPropagation(); // 줄 누름(입력 모달)과 따로
run();
@@ -201,7 +245,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
children: [
el("span", { className: "m02-labrow__key", text: row.로직 }),
el("strong", { text: logic?.이름 ?? row.로직 }),
...(note ? [el("small", { className: "m02-labrow__note", text: note })] : []),
...(values.empty
? [
el("small", {
@@ -221,6 +264,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
: []),
],
}),
aliasCell(row.별칭, on?.alias ? (v) => on.alias?.(i, v) : null),
qtyCell(line, data.cells, on?.qty ? () => on.qty?.(i) : null),
el("td", { className: "m02-labrow__unit", text: unit }),
...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait, tc(HEAD[c]))),
@@ -232,7 +276,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
...(move
? [small(tc("Up"), () => move(i, -1)), small(tc("Down"), () => move(i, 1))]
: []),
...(remove ? [small(tc("Remove"), () => remove(i))] : []),
...(remove ? [small(tc("Remove"), () => remove(i), "danger")] : []),
],
}),
]
@@ -240,7 +284,23 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
],
});
if (on) tr.addEventListener("click", () => on.open(i));
return tr;
if (!note) return [tr];
// 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐
const opt = el("tr", {
className: on ? "m02-labrow__optrow is-open" : "m02-labrow__optrow",
attrs: { "data-labor-opt": row.로직 },
children: [
el("td", { className: "m02-labrow__no", text: "" }),
el("td", {
className: "m02-labrow__opt",
text: note,
attrs: { colspan: String(SPAN), title: note },
}),
...(tools ? [el("td", { text: "" })] : []),
],
});
if (on) opt.addEventListener("click", () => on.open(i));
return [tr, opt];
});
const sum = (part: Partial<LaborCost> | undefined, c: (typeof COSTS)[number]): number | null =>
part?.[c] ?? null;
@@ -251,11 +311,19 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
children: [
el("td", { className: "m02-labrow__no", text: "" }),
el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }),
el("td", { className: "m02-labrow__alias", text: "" }),
el("td", { className: "m02-labrow__qty", text: "" }),
el("td", { className: "m02-labrow__unit", text: "" }),
...COSTS.map((c) =>
moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait, tc(HEAD[c])),
),
...COSTS.map((c) => {
const td = moneyCell(
sum(data.calc?.기준합, c),
sum(data.calc?.반영합, c),
wait,
tc(HEAD[c]),
);
if (c === "계") td.classList.add("m02-labrow__total"); // 전체 합계 강조(44-6)
return td;
}),
...(tools ? [el("td", { text: "" })] : []),
],
}),
@@ -264,6 +332,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
const heads = [
tc("Col_Order"),
tc("Col_Name"),
tc("Col_Alias"),
tc("Labor_QtyCol"),
tc("Col_Unit"),
...COSTS.map((c) => tc(HEAD[c])),
@@ -338,7 +407,7 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
children: [
createButton({
label: tc("Remove"),
variant: "ghost",
variant: "danger",
onClick: (e) => {
e.stopPropagation();
remove(i);
@@ -27,6 +27,8 @@ const TEXT = {
Logics_Title: ["단가산출 로직", "Unit-price logics"],
Col_Order: ["차례", "No."],
Col_Name: ["이름", "Name"],
Col_Alias: ["별칭", "Alias"],
Alias_Edit: ["눌러서 별칭 고침", "Click to edit alias"],
Col_Unit: ["결과 단위", "Result unit"],
Col_Move: ["순서", "Move"],
Up: ["올리기", "Up"],
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_Layer.ts
* M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시
* `#/m02-master-template?open=구조물조합|일위대가|옵션|도면&root=master|company|personal&owner=…&company=…`
* `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…`
* (옛 `?layer=` 도 root 로 읽음) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) ·
* 회사 없음 안내 · 회사용 읽기만일 때 [개인용으로 복사] 막대.
* ========================================================================== */
@@ -16,7 +16,7 @@ import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_A
export type ManageLayer = "system" | "company" | "personal";
/** 해시 · 화면의 뿌리 이름 — 서버 층 이름은 `layerOfRoot` */
export type Root = "master" | "company" | "personal";
export type OpenTarget = "구조물조합" | "일위대가" | "옵션" | "도면";
export type OpenTarget = "구조물조합" | "옵션" | "도면";
export interface Entry {
root: Root;
@@ -27,7 +27,7 @@ export interface Entry {
company: string | null;
}
const OPENS: OpenTarget[] = ["구조물조합", "일위대가", "옵션", "도면"];
const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면"];
export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root);
@@ -1,220 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Options.ts
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도) — 누르면 메인 칸에
* 종류 목록(군별) → 고른 종류의 옵션 체크 목록(라벨 · 키 작은 글 · B06 전용 · 필수 표시) ·
* [저장] · [모두 보임]. 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
* 서버 = `M02_MasterTemplete_Options.py`(마스터 템플릿 `구조물옵션표시.json` · 숨긴 것만 저장).
* ========================================================================== */
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
import {
fetchStructureOptions,
saveStructureOptions,
StaleError,
type OptionKind,
type OptionsDoc,
} from "./M02_MasterTemplete_Api_Fetch";
import { to } from "./M02_MasterTemplete_Options_Text";
export interface OptionsSectionOptions {
isAdmin: boolean;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
onOpen: () => void;
}
export interface OptionsSection {
title: string;
body: HTMLElement;
/** 다른 화면을 열었음 — 고름 표시만 지움 */
clear: () => void;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection {
const row = el("button", {
className: "m02-side__item ui-sidebar-row",
text: to("Row"),
attrs: { type: "button", "data-options-row": "" },
});
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
row.addEventListener("click", () => void open());
/** 이 화면이 열려 있는 동안 참 — 다른 것이 열리면 `clear` 가 내림 */
let opened = false;
const mark = (on: boolean): void => {
opened = on;
row.classList.toggle("is-active", on);
};
async function open(): Promise<void> {
if (opened || !(await opt.confirmLeave())) return;
let doc: OptionsDoc;
try {
doc = await fetchStructureOptions();
} catch (error) {
return showToast(to("Load_Failed", why(error)), "error");
}
draw(doc);
}
/** 메인 칸 화면 — `doc.표시` 를 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */
function draw(doc: OptionsDoc): void {
let version = doc.판;
const edit = opt.isAdmin;
/** 숨긴 옵션 — 종류 → 키 집합 */
const hidden = new Map<string, Set<string>>();
const load = (src: OptionsDoc["표시"]): void => {
hidden.clear();
for (const [id, keys] of Object.entries(src)) hidden.set(id, new Set(Object.keys(keys)));
};
load(doc.표시);
let kinds = doc.종류;
let current: OptionKind | null = null;
const kindList = el("div", { className: "m02-opts__kinds" });
const optionList = el("div", { className: "m02-opts__list" });
const off = (id: string): number => hidden.get(id)?.size ?? 0;
const paintKinds = (): void => {
const parts: HTMLElement[] = [];
let group = "";
for (const k of kinds) {
if (k.group !== group) {
group = k.group;
parts.push(
el("h4", {
className: "m02-opts__group",
text: group.length === 1 ? to("Group", group) : group,
}),
);
}
const n = off(k.type_id);
const b = el("button", {
className: `m02-opts__kind${k.type_id === current?.type_id ? " is-active" : ""}`,
attrs: { type: "button", "data-options-kind": k.type_id },
children: [
el("span", { text: k.name }),
...(n ? [createTag(to("Hidden", String(n)), "warning")] : []),
...(k.enabled ? [] : [createTag(to("Off"), "neutral")]),
],
});
b.addEventListener("click", () => {
current = k;
paintKinds();
paintOptions();
});
parts.push(b);
}
kindList.replaceChildren(...parts);
};
const paintOptions = (): void => {
if (!current) {
optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
return;
}
const kind = current;
optionList.replaceChildren(
el("h3", { className: "m02-options__head", text: kind.name }),
...kind.options.map((o) => {
const box = el("input", {
attrs: { type: "checkbox", "data-options-key": o.key },
}) as HTMLInputElement;
box.checked = !hidden.get(kind.type_id)?.has(o.key);
box.disabled = !edit;
box.addEventListener("change", () => {
const set = hidden.get(kind.type_id) ?? new Set<string>();
if (box.checked) set.delete(o.key);
else set.add(o.key);
hidden.set(kind.type_id, set);
setDirty(true);
paintKinds();
});
const tags: HTMLElement[] = [];
if (o.phase === "detail") tags.push(createTag(to("Detail"), "accent"));
if (o.required && o.phase === "b05") {
const tag = createTag(to("Required"), "danger");
tag.title = to("RequiredTip");
tags.push(tag);
}
return el("label", {
className: "m02-opts__option",
children: [
box,
el("span", { text: o.label }),
el("small", { className: "m02-opts__key", text: o.key }),
...tags,
],
});
}),
);
};
const save = createButton({ label: to("Save"), variant: "filled" });
const all = createButton({ label: to("ShowAll"), variant: "ghost" });
all.addEventListener("click", () => {
if (![...hidden.values()].some((s) => s.size)) return;
hidden.clear();
setDirty(true);
paintKinds();
paintOptions();
});
save.addEventListener("click", async () => {
const 표시: OptionsDoc["표시"] = {};
for (const [id, keys] of hidden)
if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const]));
save.disabled = true; // 연달아 눌러도 한 번만
try {
const done = await saveStructureOptions(version, 표시);
version = done.판;
load(done.표시);
setDirty(false);
showToast(to("Saved"), "success");
paintKinds();
paintOptions();
} catch (error) {
if (error instanceof StaleError) {
showToast(to("Stale"), "error");
try {
const fresh = await fetchStructureOptions();
version = fresh.판;
kinds = fresh.종류;
load(fresh.표시);
setDirty(false);
current = kinds.find((k) => k.type_id === current?.type_id) ?? null;
paintKinds();
paintOptions();
} catch (again) {
showToast(to("Load_Failed", why(again)), "error");
}
} else showToast(to("Failed", why(error)), "error");
} finally {
save.disabled = false;
}
});
const node = el("div", {
className: "m02-opts",
children: [
el("p", { className: "m02-side__empty", text: to("Note") }),
el("div", {
className: "m02-opts__panes",
children: [kindList, optionList],
}),
...(edit ? [el("div", { className: "m02-side__row", children: [save, all] })] : []),
],
});
const setDirty = opt.show(to("Screen"), node);
paintKinds();
paintOptions();
opt.onOpen();
mark(true);
}
return { title: to("Title"), body, clear: () => mark(false) };
}
@@ -1,43 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Options_Text.ts
* 좌측 「구조물 옵션 보이기」 줄 · 상세 화면 글자 — [한국어, 영어]
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["구조물 옵션 보이기", "Structure options shown"],
Row: ["종류마다 보일 옵션 고르기", "Choose options per type"],
Screen: ["구조물 옵션 보이기", "Structure options shown"],
Note: [
"체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임",
"Only checked options appear in the B05 · B06 forms — definitions and stored values stay",
],
Group: ["{value}군", "Group {value}"],
Hidden: ["숨김 {value}", "{value} hidden"],
Off: ["안 쓰는 종류", "Not offered"],
NoOption: ["옵션 없음", "No options"],
Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"],
Detail: ["B06 전용", "B06 only"],
Required: ["필수", "Required"],
RequiredTip: [
"필수 옵션 — 숨기면 폼에 안 나오고 필수로도 안 침(저장이 안 막힘)",
"Required — when hidden it is not shown and not enforced",
],
Save: ["저장", "Save"],
ShowAll: ["모두 보임", "Show all"],
Saved: ["저장함", "Saved"],
Stale: [
"그 사이 옵션 표시가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
"Changed meanwhile — reloaded, please edit again",
],
Load_Failed: ["옵션 표시를 못 받음 — {value}", "Could not load option display — {value}"],
Failed: ["옵션 표시를 못 저장함 — {value}", "Could not save option display — {value}"],
} as const satisfies Record<string, readonly [string, string]>;
export type OptionsTextKey = keyof typeof TEXT;
export function to(key: OptionsTextKey, value = ""): string {
const entry = TEXT[key];
return (entry[currentLanguageIndex as 0 | 1] ?? entry[0]).replace("{value}", value);
}
@@ -10,7 +10,6 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from M02_MasterTemplete import M02_MasterTemplete_Options as options
from M02_MasterTemplete import M02_MasterTemplete_Store as store
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
@@ -21,11 +20,6 @@ class SaveBody(BaseModel):
문서: Any
class OptionsBody(BaseModel):
판: str = "" # 옵션 표시 표 판 — 어긋나면 409
표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김
def _call(fn, *args):
try:
return fn(*args)
@@ -55,18 +49,6 @@ def post_structure(column: str) -> dict:
return _call(store.create_structure, column)
@router.get("/structure-options")
def get_structure_options() -> dict:
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션 있는 종류와 그 옵션."""
return _call(options.listing)
@router.put("/structure-options")
def put_structure_options(body: OptionsBody) -> dict:
"""고쳐 씀(낡은 판이면 409 · 없는 종류 · 키는 422) — 돌려주는 것 = 새 `{판, 표시, 종류}`."""
return _call(options.save, body.표시, body.판)
# 복사 가져오기 — 다른 열(`from`) 내용을 받는 열로 · 번호 · 열은 받는 쪽 그대로
@router.post("/structures/{column}/copy")
def post_structure_copy(column: str, source: str = Query(alias="from")) -> dict:
@@ -288,14 +288,6 @@ def get_numbers(layer: Layer, owner: str | None = None, session: Session = Depen
return _call(_root(session, layer, owner), store.structure_numbers)
@router.get("/layers/system/structure-options")
def get_structure_options(session: Session = Depends(verify_session)) -> dict:
"""구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 옛 길 시스템 관리자만)."""
from M02_MasterTemplete import M02_MasterTemplete_Options as options
return _call(_root(session, "system"), options.listing)
@router.get("/layers/{layer}/structure-combos")
def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
return _call(_root(session, layer, owner), store.read_combos)
@@ -18,9 +18,7 @@
flex: none;
grid-auto-rows: var(--m02-struct-card-h);
grid-template-columns: repeat(3, minmax(0, 1fr)); /* 도면 · 일위대가 · 면적산출(40-2) */
padding-top: var(--spacing-12);
border-top: 1px solid var(--color-border);
min-width: 0;
min-width: 0; /* 표와 사이 = 메인 칸 간격 하나 · 구분선 없음(45-5) */
}
/* 상세 칸이 좁으면(카드 하나 300px 아래) 1열 — 좌측 패널을 접어도 맞게 상세 칸 폭으로 잼
@@ -46,9 +44,18 @@
overflow-y: auto;
}
/* 표는 줄어들지 않음(줄면 표가 카드 위로 넘침) */
/* 표는 줄어들지 않음(줄면 표가 카드 위로 넘침) · 표 높이만큼만(빈 칸 최소 높이 없음 · 45-5) */
.m02-main:has(> .m02-main__extra > .m02-struct:not([hidden])) > .m02-main__host {
flex-shrink: 0;
min-height: 0;
}
/* 분류 표 틀 아래 줄 없음 — 마지막 줄 칸 선이 표를 닫음(45-5) */
.m02-main:has(> .m02-main__extra > .m02-struct:not([hidden]))
> .m02-main__host
> .ui-sheet--combo
> .ui-sheet__scroll {
border-bottom: 0;
}
/* 카드 = 제목 + 머리 + 본문 — 본문이 남는 높이를 채움 · 넘치면 카드 안 스크롤 */
@@ -143,10 +143,6 @@ export function headRenameEdit(col: StructureColumn, level: number, name: string
export const columnHead = (col: StructureColumn): string =>
col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" ") || col.id;
/** 번호가 맨 앞 — 「SD010101 돌기슭막이 H=1.0」(번호가 없으면 이름만) */
export const structureLabel = (number: string, col: StructureColumn): string =>
[number, columnHead(col)].filter(Boolean).join(" ");
/** 면적산출 편집 화면 아래 알약 막대 — 그 열 일위대가의 로직 물량 「이름|단위」 + 자재 「자재|MT키」
* (서버 pills 길 · 39-4 · 다 `고정` — 더하기 · 빼기는 일위대가 쪽).
* 알약이 없으면 까닭 글(`notice`)을 같이 돌려줌 */
@@ -42,28 +42,6 @@
gap: var(--spacing-4);
}
.m02-layers__btn {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-size: var(--text-body-sm);
cursor: pointer;
}
.m02-layers__btn:hover {
background: var(--color-mist-violet);
}
.m02-layers__btn.is-active {
border-color: var(--color-accent);
background: var(--color-mist-violet);
color: var(--color-accent);
font-weight: 600;
}
/* 층 단추 줄 오른쪽 끝 — 「N개 고름」 · 「+」(PLAN 30-2) */
.m02-tree__tools {
display: inline-flex;
@@ -94,28 +72,6 @@
background: var(--color-mist-violet);
}
/* 층 단추 옆 「+」 — 나무 맨 위 폴더 · 항목 더하기(PLAN 29-6) */
.m02-tree__add {
min-width: 1.75rem;
padding: var(--spacing-4) var(--spacing-8);
border: 1px dashed var(--color-border);
border-radius: var(--radius-buttons);
background: none;
color: var(--color-text-secondary);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.m02-tree__add:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.m02-tree__add[hidden] {
display: none;
}
/* 남의 회사 「개인」 사람 나무 — 고른 사람 줄 아래 그 사람 라이브러리(PLAN 29-6) */
.m02-people__panel {
padding-left: var(--spacing-16);
@@ -10,7 +10,13 @@
* ========================================================================== */
import "./M02_MasterTemplete_TreePanel.css";
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { t as L } from "@ui/ui_template_locale";
import {
@@ -141,13 +147,15 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
const root = el("div", { className: "m02-tree", children: [body] });
search.root.classList.add("m02-tree__search");
// 층 단추 옆 「+」 — 맨 위에 폴더 · 항목 더하기(뿌리 줄 오른쪽 클릭을 갈음)
const add = el("button", {
className: "m02-tree__add",
text: "+",
attrs: { type: "button", title: L("M02_TreeAddTop"), "aria-label": L("M02_TreeAddTop") },
const add = createButton({
label: "+",
variant: "ghost",
onClick: (event) => openTreeMenu(event, topMenu()),
});
add.classList.add("m02-tree__add");
add.title = L("M02_TreeAddTop");
add.setAttribute("aria-label", L("M02_TreeAddTop"));
add.hidden = !opt.canEdit;
add.addEventListener("click", (event) => openTreeMenu(event, topMenu()));
// 「N개 고름」 · 「+」 — 층 단추 줄 오른쪽 끝(30-2 · 부르는 쪽이 붙임)
const status = el("span", { className: "m02-tree__picked", attrs: { hidden: "" } });
const tools = el("span", { className: "m02-tree__tools", children: [status, add] });
@@ -9,7 +9,7 @@
* 누르면 `go`(부르는 쪽이 해시로 다시 엶).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createButton, el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createTreeRow } from "@ui/ui_template_tree";
import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch";
@@ -67,15 +67,17 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement {
: "M02_PickerPersonal",
);
const current = root === active.root && (root !== "personal" || people || !active.owner);
const b = el("button", {
className: `m02-layers__btn${current ? " is-active" : ""}`,
text: readOnly ? `${name} · ${L("M02_RootReadOnly")}` : name,
attrs: { type: "button", "data-root": root, "aria-pressed": String(current) },
});
b.addEventListener("click", () => {
if (!current) opt.go(root, owner);
else opt.reselect?.();
const b = createButton({
label: readOnly ? `${name} · ${L("M02_RootReadOnly")}` : name,
variant: current ? "filled" : "ghost",
onClick: () => {
if (!current) opt.go(root, owner);
else opt.reselect?.();
},
});
b.classList.add("m02-layers__btn");
b.dataset.root = root;
b.setAttribute("aria-pressed", String(current));
return b;
}
@@ -15,7 +15,7 @@ import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { navigateTo, type MountedPage } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { fetchOwners, setActiveLayer, type Kind } from "./M02_MasterTemplete_Api_Fetch";
import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
import {
accessFor,
buildReadOnlyBar,
@@ -40,8 +40,7 @@ import {
seesOthers,
} from "./M02_MasterTemplete_TreePanel_Roots";
import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main";
import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side";
import { buildAllItems } from "./M02_MasterTemplete_ItemList";
import { buildSide } from "./M02_MasterTemplete_UI_Side";
import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick";
import "./M02_MasterTemplete_UI_Style.css";
@@ -49,13 +48,6 @@ const NEW_NOTE = "새 항목이 들어갈 자리 — {value} · 나무에서 폴
const NEW_ASK =
"{from} 「{name}」 을(를) 복사해 「{to}」 에 새 항목으로 만듦 — 새 번호 · 원래 이름";
/** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */
const ALL_TITLE = {
structure: "M02_KindStructure",
labor: "M02_KindLabor",
area: "M02_KindArea",
} as const satisfies Record<AllKind, string>;
export async function renderM02MasterTemplate(root: HTMLElement): Promise<MountedPage> {
const user = await fetchSessionUser().catch(() => null);
const entry = entryFromHash();
@@ -97,9 +89,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
canEdit: canEdit(layer, isAdmin),
onPick: (item) => void main.openTable(layer, item.열),
settle: () => main.settle(),
// 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표 + 좌측 하위 열 줄 · 번호
// 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표
onChanged: async (colId) => {
await Promise.all([main.openTable(layer, colId, true), side.refresh()]);
await main.openTable(layer, colId, true);
},
});
const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise<void> => {
@@ -110,7 +102,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
entryQuery({ ...entry, open, root: next, owner: nextOwner }),
);
};
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌(하위 도면 · 일위대가 · 면적산출도 이 층)
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 집계표)
const layerBar = (open: OpenTarget, add: HTMLElement, reselect: () => void): HTMLElement =>
@@ -193,20 +185,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
peopleTree("구조물조합", tree.root),
],
// 「전체 항목」(39-5) — 메인 칸에 그 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄과 같은 `open`)
onAll: (kind) =>
void main.showView(
`${L(ALL_TITLE[kind])} · ${L("M02_ItemsAll")}`,
buildAllItems(kind, layer, owner, (row) => {
void main.open({
layer,
projectId: null,
kind: kind as Kind,
name: row.열,
label: row.이름,
});
}),
),
});
const layout = el("div", { className: "ui-workflow-layout m02-master" });
@@ -226,7 +204,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
root.innerHTML = "";
root.append(layout);
void main.open(home);
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 · 일위대가는 구조물 조합 하위(세 층) ·
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 ·
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
if (entry.open !== "구조물조합") side.reveal(entry.open);
void tree.refresh();
+16 -128
View File
@@ -1,11 +1,8 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무 → 수평선 → 하위 구조물 도면 · 일위대가 ·
* 면적산출 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기).
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「도면 양식」(새로 · 본떠 · 지우기).
* 서버 길은 계약 `6_계약.md`.
* 구조물 도면 · 일위대가 · 면적산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
* 서버가 미리 만들어 둠(도번 SD · 일위대가번 SU · 면적번 SA + 같은 뒷자리) ·
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」).
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
* 구조물 라이브러리는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
* ========================================================================== */
@@ -22,24 +19,16 @@ import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import {
deleteTemplate,
fetchStructureNumbers,
listTemplates,
readTemplate,
saveTemplate,
StaleError,
type Kind,
type Layer,
type StructureNumbers,
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
import {
STRUCTURE_TABLE,
columnHead,
isStructureColumn,
structureLabel,
type StructureColumn,
} from "./M02_MasterTemplete_Structure";
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { buildOptionsSection } from "./StructOptions/M02_SO_Options";
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
@@ -47,21 +36,6 @@ const KINDS: { kind: Kind; label: () => string }[] = [
{ kind: "table", label: () => L("M02_KindTable") },
{ kind: "drawing", label: () => L("M02_KindDrawing") },
];
/** 구조물 열마다 한 줄씩인 하위 목록 — 종류(편집 칸이 여는 `Kind`) · 번호 칸 · 제목 ·
* `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가(SU) → 면적산출 */
type ColumnKind = "structure" | "labor" | "area";
const COLUMN_KINDS: {
kind: ColumnKind;
key: keyof StructureNumbers;
label: () => string;
all: boolean;
}[] = [
{ kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true },
{ kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true },
{ kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true },
];
/** 「전체 항목」 줄이 있는 하위 — 누르면 H 목록 화면(`onAll`) */
export type AllKind = "structure" | "labor" | "area";
/** 새 도면 양식의 첫 문서 — 빈 웹캐드 문서 */
const STUB_DRAWING = {
format: 6,
@@ -84,8 +58,6 @@ export interface SideOptions {
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
tree?: HTMLElement[];
/** 하위 맨 위 「전체 항목」 줄 — 메인 칸에 그 하위 목록(화면 = `ItemList` · 페이지가 이음) */
onAll?: (kind: AllKind) => void;
}
export interface SideHandle {
@@ -94,7 +66,7 @@ export interface SideHandle {
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
paint: () => void;
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */
reveal: (target: "옵션" | "도면" | "일위대가") => void;
reveal: (target: "옵션" | "도면") => void;
}
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
@@ -111,10 +83,6 @@ const failed = (error: unknown): void =>
export function buildSide(opt: SideOptions): SideHandle {
const layer = opt.layer;
let items: TemplateInfo[] = [];
let columns: StructureColumn[] = [];
let numbers: Record<string, StructureNumbers> = {};
/** 지금 연 「전체 항목」 — 양식 · 다른 보기 화면이 열리면 지움 */
let allPicked: AllKind | null = null;
let seq = 0;
const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement =>
@@ -125,9 +93,6 @@ export function buildSide(opt: SideOptions): SideHandle {
const bodies = new Map<Kind, HTMLElement>(
KINDS.map((k) => [k.kind, el("div", { className: "m02-side__items ui-sidebar-list" })]),
);
const columnBodies = COLUMN_KINDS.map(() =>
el("div", { className: "m02-side__items ui-sidebar-list" }),
);
/** 새로 · 본떠 만들기 · 지우기 — 도면 양식 컨테이너 안(구조물 집계 양식은 집계표 하나라 없음) · 관리자만 */
const editButtons = new Map<Kind, { copy: HTMLButtonElement; del: HTMLButtonElement }>();
const actionRow = (kind: Kind): HTMLElement[] => {
@@ -146,7 +111,7 @@ export function buildSide(opt: SideOptions): SideHandle {
isAdmin: canEdit(layer, opt.isAdmin),
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (practice?.clear(), (allPicked = null), paint()),
onOpen: () => (practice?.clear(), paint()),
});
/** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */
const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => {
@@ -155,25 +120,21 @@ export function buildSide(opt: SideOptions): SideHandle {
});
return sec;
};
const columnSections = COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i]));
// 구조물 집계 양식 — 층 단추 · 나무 → 수평선 → 하위 컨테이너(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) ·
// 구조물 집계 양식 — 층 단추 · 나무 · 처음부터 펼침(집계표가 열려 있음) ·
// 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6)
const summary = section(
L("M02_KindTable"),
el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }),
el("hr", { className: "m02-side__rule" }),
...columnSections,
);
summary.classList.remove("is-collapsed");
summary.classList.add("m02-side__summary");
openOnExpand(summary, async () => {
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
});
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 옵션 보이기(그룹)는 시스템 관리자만(PLAN 46)
const extras =
layer === "system"
? {
옵션: section(options.title, options.body),
옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null,
도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
}
: null;
@@ -183,14 +144,14 @@ export function buildSide(opt: SideOptions): SideHandle {
? buildPracticeSection({
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (options.clear(), (allPicked = null), paint()),
onOpen: () => (options.clear(), paint()),
})
: null;
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [
summary,
...(extras ? [extras.옵션, extras.도면] : []),
...(extras ? [extras.옵션, extras.도면].filter((s): s is HTMLElement => !!s) : []),
...(practice ? [section(practice.title, practice.body)] : []),
],
});
@@ -234,94 +195,27 @@ export function buildSide(opt: SideOptions): SideHandle {
}
}
/** 맨 위 「전체 항목」 줄(PLAN 39-2) — 누르면 그 하위 목록(`onAll`) */
function allRow(kind: AllKind): HTMLElement {
const b = el("button", {
className: `m02-side__item m02-side__all ui-sidebar-row${allPicked === kind ? " is-active" : ""}`,
text: L("M02_ItemsAll"),
attrs: { type: "button", "data-all": kind },
});
b.addEventListener("click", () => void selectAll(kind));
return b;
}
/** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */
function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void {
const { kind, key } = k;
const head = k.all ? [allRow(kind as AllKind)] : [];
body.replaceChildren(
...head,
...(columns.length
? columns.map((col) => {
const num = numbers[col.id]?.[key] || L("M02_StructureNone");
const b = el("button", {
className: `m02-side__item ui-sidebar-row${isPicked(kind, col.id) ? " is-active" : ""}`,
text: structureLabel(num, col),
attrs: { type: "button" },
});
b.addEventListener("click", () => void selectColumn(col, kind));
return b;
})
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
);
}
const paintColumns = (): void => COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k));
/** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */
function paintButtons(): void {
const cur = currentInfo();
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
}
/** 고름 표시는 여기서만 — 목록 · 열 줄 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음
/** 고름 표시는 여기서만 — 목록 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음
* (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */
function paint(): void {
if (opt.getOpen()) allPicked = null;
paintList();
paintColumns();
paintButtons();
if (opt.getOpen()) options.clear();
if (opt.getOpen()) practice?.clear();
}
async function selectAll(kind: AllKind): Promise<void> {
if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;
options.clear();
practice?.clear();
allPicked = kind;
opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림
paint();
}
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
async function select(info: TemplateInfo): Promise<void> {
if (!isOpen(info) && !(await opt.confirmLeave())) return;
opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 });
}
async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise<void> {
if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return;
// 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」)
opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) });
}
async function refreshStructure(): Promise<void> {
try {
const [table, nums] = await Promise.all([
readTemplate(layer, "table", STRUCTURE_TABLE, null),
fetchStructureNumbers(),
]);
const all = ((table.문서 as { 열?: StructureColumn[] }).열 ?? []) as StructureColumn[];
columns = all.filter(isStructureColumn);
numbers = nums;
} catch (error) {
columns = [];
numbers = {};
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
}
paintColumns();
}
async function refresh(): Promise<void> {
const mine = ++seq;
paintButtons();
@@ -334,7 +228,6 @@ export function buildSide(opt: SideOptions): SideHandle {
if (mine !== seq) return;
paintList();
paintButtons();
await refreshStructure();
}
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
@@ -414,15 +307,10 @@ export function buildSide(opt: SideOptions): SideHandle {
}
}
const reveal = (target: "옵션" | "도면" | "일위대가"): void => {
// 일위대가 = 구조물 조합(처음부터 펼침) 안 일위대가 하위를 펼치고 「전체 항목」
if (target === "일위대가") {
columnSections[COLUMN_KINDS.findIndex((k) => k.kind === "labor")].classList.remove(
"is-collapsed",
);
return void selectAll("labor");
}
extras?.[target].classList.remove("is-collapsed");
const reveal = (target: "옵션" | "도면"): void => {
const sec = extras?.[target];
if (!sec) return; // 옵션 보이기는 시스템 관리자만
sec.classList.remove("is-collapsed");
if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();
};
@@ -10,18 +10,6 @@
font-size: var(--text-body-sm);
}
/* 구조물 집계 양식 하위 컨테이너(구조물 도면 · 면적산출 · 재료산출 · 자재 조합) — 들여 씀 */
.m02-side__summary > .ui-sidebar-card {
margin-left: var(--spacing-8);
}
/* 구조물 조합 안 층 단추 · 나무와 하위 컨테이너 사이 수평선(PLAN 39-2) */
.m02-side__rule {
margin: var(--spacing-8) 0;
border: 0;
border-top: 1px solid var(--color-border);
}
/* 실무문서 화면(PLAN 39-2) — 위 막대(개수 · 통째 지우기 / 목록으로 · 이름 · 정보) + 표 */
.m02-practice {
display: flex;
@@ -155,13 +143,6 @@
gap: var(--spacing-4);
}
/* 구조물 옵션 보이기 — 종류 제목 */
.m02-options__head {
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
}
/* 층 화면 — 회사용 읽기만 안내 막대 · 회사 없음 안내 */
.m02-layer-bar {
display: flex;
@@ -184,76 +165,6 @@
color: var(--color-text);
}
/* 좌측 「구조물 옵션 보이기」 화면(메인 칸) — 왼쪽 종류(군별) · 오른쪽 옵션 체크 */
.m02-opts {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
max-width: 64rem;
font-size: var(--text-body-sm);
}
.m02-opts__panes {
display: grid;
grid-template-columns: minmax(14rem, 20rem) 1fr;
gap: var(--spacing-16);
align-items: start;
}
.m02-opts__kinds,
.m02-opts__list {
max-height: calc(100vh - 20rem);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-opts__group {
margin: var(--spacing-8) 0 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-opts__kind {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.m02-opts__kind:hover {
background: var(--color-mist-violet);
}
.m02-opts__kind.is-active {
border-color: var(--color-accent);
background: var(--color-mist-violet);
}
.m02-opts__option {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-8);
padding: var(--spacing-4) 0;
cursor: pointer;
}
.m02-opts__key {
color: var(--color-text-muted);
font-size: 11px;
}
.m02-main__head + .m02-tree__search {
margin-bottom: var(--spacing-8);
}
+27 -1
View File
@@ -110,6 +110,7 @@ def collect_items(
"options": _with_defaults(definition, dict(item.options or {})),
"summary_item": item.summary_item,
"복사본": item.structure_id or f"#{index}",
"group_items": item.group_items,
}
)
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))):
@@ -126,9 +127,34 @@ def collect_items(
"관연장": _nearest(lengths, chainage) if type_id == "pipe" else None,
"summary_item": point.summary_item,
"복사본": f"pipe@{round(chainage, 3)}",
"group_items": point.group_items,
}
)
return items
return _with_group_items(items)
def _with_group_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""옵션 그룹(M02 PLAN 46)마다 한 건 더 — 같은 구조물 · 옵션 · 자리에 그 그룹이 고른 항목 ·
복사본 `{구조물 id}__{그룹 id}`. 정의된 그룹만(미지정 그룹 = 「미지정」 알림에 셈) · 정의가 사라진
그룹 연결은 기록에만 남고 표에 안 들어감."""
from common_util.common_util_summary_item import GROUP_JOIN
from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of
defined = groups_of()
out: list[dict[str, Any]] = []
for item in items:
links = item.pop("group_items", None) or {}
out.append(item)
for group in defined.get(item["type_id"], []):
out.append(
{
**item,
"summary_item": links.get(group["id"]),
"복사본": f"{item['복사본']}{GROUP_JOIN}{group['id']}",
"그룹": group["name"],
}
)
return out
# ── 값 · 글 ───────────────────────────────────────────
@@ -0,0 +1,56 @@
/* =============================================================================
* M02_SO_Api.ts
* 구조물 옵션 보이기 · 그룹 서버 호출 — 서버 = `M02_SO_Router.py`
* (마스터 템플릿 `구조물옵션표시.json` · `구조물옵션그룹.json`)
* ========================================================================== */
import { call } from "../M02_MasterTemplete_Api_Fetch";
export interface OptionKind {
type_id: string;
group: string;
name: string;
/** 거짓이면 B05 선택지에 안 나오는 종류 */
enabled: boolean;
options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[];
}
/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */
export interface OptionsDoc {
판: string;
표시: Record<string, Record<string, false>>;
종류: OptionKind[];
}
export const fetchStructureOptions = (): Promise<OptionsDoc> =>
call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 시스템 관리자
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
/* --- 옵션 그룹(PLAN 46-2 · 마스터 템플릿 `구조물옵션그룹.json` · 시스템 관리자만) --- */
/** 그룹 하나 = 프로젝트 폼의 보조 컨테이너 하나(제목 = 이름) · 옵션 0 도 됨 */
export interface OptionGroupDef {
id: string;
이름: string;
옵션: string[];
}
/** 판 · 그룹(`{종류: 그룹[]}`) · 종류(레지스트리 전부 — 옵션 없는 종류도) */
export interface GroupsDoc {
판: string;
그룹: Record<string, OptionGroupDef[]>;
종류: OptionKind[];
}
export const fetchStructureOptionGroups = (): Promise<GroupsDoc> =>
call("/structure-option-groups");
/** 고쳐 씀 — 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
export const saveStructureOptionGroups = (
판: string,
그룹: GroupsDoc["그룹"],
): Promise<GroupsDoc> =>
call("/structure-option-groups", { method: "PUT", body: JSON.stringify({ 판, 그룹 }) });
@@ -0,0 +1,148 @@
"""M02 구조물 옵션 그룹 — 구조물 종류 안에 이름 붙은 묶음(PLAN 46-2).
정본 = `resources/master_template/구조물옵션그룹.json` =
`{type_id: [{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": ["inlet_revet_form", …]}]}`
— 없는 종류 = 그룹 없음(파일이 없어도 됨) · 빈 그룹(옵션 0)도 그룹.
그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 이름) — 그 안에서 구조물 조합
항목 하나를 지정(구조물 기록 `group_items[그룹 id]`). 그룹에 안 넣은 옵션 = 본체 칸(`summary_item`).
옵션 하나는 한 종류 안에서 그룹 하나에만. 시스템 공통 · 고쳐 씀은 시스템 관리자(길이 막음).
판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store
FILE = "구조물옵션그룹.json"
GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$")
NAME_MAX = 40
logger = logging.getLogger(__name__)
def _load() -> tuple[dict[str, Any], str]:
"""(그룹 표 · 판) — 파일이 없으면 빈 표 · 판 빈 글."""
path = store.FOLDER / FILE
if not path.is_file():
return {}, ""
raw = path.read_bytes()
try:
doc = json.loads(raw)
except ValueError as e:
raise store.StoreError(500, f"{FILE} 을 못 읽음 — {e}") from e
if not isinstance(doc, dict):
raise store.StoreError(500, f"{FILE} 은 종류별 묶음이어야 함")
return doc, store.version_of(raw)
def groups_of() -> dict[str, list[dict[str, Any]]]:
"""종류 → `[{id, name, options}]` — 폼 응답용 · 깨진 표 · 모르는 종류는 빼고 로그만."""
try:
doc = _load()[0]
except store.StoreError as e:
logger.warning("구조물 옵션 그룹 표를 못 읽어 그룹 없음으로 봄 — %s", e.detail)
return {}
known = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
out: dict[str, list[dict[str, Any]]] = {}
for type_id, rows in doc.items():
if type_id not in known or not isinstance(rows, list):
continue
for row in rows:
if not isinstance(row, dict) or not GROUP_ID.match(str(row.get("id", ""))):
continue
keys = [k for k in row.get("옵션") or [] if k in known[type_id]]
out.setdefault(type_id, []).append(
{"id": row["id"], "name": str(row.get("이름") or row["id"]), "options": keys}
)
return out
def listing() -> dict[str, Any]:
"""`{판, 그룹, 종류}` — 종류 = 레지스트리 전부(옵션 없는 종류도 · 빈 그룹을 둘 수 있음)."""
doc, version = _load()
kinds = [
{
"type_id": t.type_id,
"group": t.group,
"name": t.name,
"enabled": t.enabled,
"options": [
{"key": o.key, "label": o.label, "phase": o.phase, "required": o.required}
for o in t.options
],
}
for t in load_structure_types()
]
return {"판": version, "그룹": doc, "종류": kinds}
def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
"""받은 표 검사 — 없는 종류 · 키 · 겹친 id · 이름 · 옵션 두 그룹은 422 · 빈 종류는 뺌."""
if not isinstance(groups, dict):
raise store.StoreError(400, "그룹은 종류별 묶음이어야 함")
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
errors: list[str] = []
out: dict[str, list[dict[str, Any]]] = {}
for type_id, rows in groups.items():
if type_id not in keys:
errors.append(f"없는 구조물 종류 「{type_id}」")
continue
if not isinstance(rows, list):
errors.append(f"「{type_id}」 는 그룹 목록이어야 함")
continue
ids: set[str] = set()
names: set[str] = set()
owner: dict[str, str] = {}
clean: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
errors.append(f"「{type_id}」 그룹은 묶음이어야 함")
continue
gid = str(row.get("id", ""))
name = str(row.get("이름", "")).strip()
opts = row.get("옵션") or []
if not GROUP_ID.match(gid) or gid in ids:
errors.append(f"「{type_id}」 그룹 id 「{gid}」 가 틀리거나 겹침")
if not name or len(name) > NAME_MAX or name in names:
errors.append(
f"「{type_id}」 그룹 이름 「{name}」 은 1~{NAME_MAX}자 · 겹치면 안 됨"
)
if not isinstance(opts, list):
errors.append(f"「{type_id}.{name}」 옵션은 목록이어야 함")
opts = []
for key in opts:
if key not in keys[type_id]:
errors.append(f"「{type_id}」 에 없는 옵션 「{key}」")
elif key in owner:
errors.append(
f"「{type_id}.{key}」 는 「{owner[key]}」 · 「{name}」 두 그룹에 있음"
)
else:
owner[key] = name
ids.add(gid)
names.add(name)
clean.append({"id": gid, "이름": name, "옵션": [k for k in opts if k in keys[type_id]]})
if clean:
out[type_id] = clean
if errors:
raise store.StoreError(422, {"errors": errors})
return out
def save(groups: Any, version: str) -> dict[str, Any]:
"""고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`."""
fixed = _clean(groups)
path = store.FOLDER / FILE
with store._LOCK:
have = store.version_of(path.read_bytes()) if path.is_file() else ""
if have != (version or ""):
raise store.StoreError(409, {"stale": [FILE], "판": have})
atomic_write_json(path, fixed)
return listing()
@@ -0,0 +1,141 @@
/* =============================================================================
* M02_SO_Groups.ts
* 「구조물 옵션 보이기」 화면의 **옵션 그룹** 부품(PLAN 46-2) — 고른 종류의 그룹 목록
* (이름 · 위로 · 아래로 · 지우기 · [그룹 추가]) · 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹).
* 값은 화면이 쥔 고칠 사본(종류 → 그룹[])에만 — 저장은 화면 [저장](`M02_SO_Options.ts`).
* 그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나 · 옵션이 없어도 됨(서버 `M02_SO_Groups.py`).
* ========================================================================== */
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import type { OptionGroupDef } from "./M02_SO_Api";
import { to } from "./M02_SO_Text";
const NAME_MAX = 40;
/** 새 그룹 id — 서버 규칙 `g[0-9a-z]{1,16}` · 같은 종류 안에서 안 겹치게 */
export function newGroupId(list: readonly OptionGroupDef[]): string {
const taken = new Set(list.map((g) => g.id));
for (;;) {
const id = `g${Math.random().toString(36).slice(2, 8)}`;
if (/^g[0-9a-z]{1,16}$/.test(id) && !taken.has(id)) return id;
}
}
/** 그 옵션이 든 그룹 id — 없으면 null(본체) */
export function groupOf(list: readonly OptionGroupDef[], key: string): string | null {
return list.find((g) => g.옵션.includes(key))?.id ?? null;
}
/** 옵션을 그룹(null = 본체)으로 옮김 — 한 옵션은 한 그룹에만 */
export function moveOption(list: OptionGroupDef[], key: string, gid: string | null): void {
for (const g of list) g.옵션 = g.옵션.filter((k) => k !== key);
const target = gid ? list.find((g) => g.id === gid) : null;
if (target) target.옵션.push(key);
}
/** 이름이 다 괜찮은가 — 1~40자 · 같은 종류 안에서 안 겹침(서버도 같은 검사) */
export function namesOk(groups: Map<string, OptionGroupDef[]>): boolean {
for (const list of groups.values()) {
const names = list.map((g) => g.이름.trim());
if (names.some((n) => !n || n.length > NAME_MAX)) return false;
if (new Set(names).size !== names.length) return false;
}
return true;
}
export interface GroupBlockOptions {
/** 고른 종류의 그룹(고칠 사본 — 이 부품이 직접 고침) */
list: OptionGroupDef[];
edit: boolean;
/** 고쳤음 — 화면이 고침 표시 · 다시 그리기 */
changed: () => void;
}
/** 그룹 목록 칸 — 제목 · 안내 · 그룹 줄 · [그룹 추가] */
export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
const { list } = opt;
const rows = list.map((group, index) => {
const name = el("input", {
className: "ui-input ui-input--sm m02-sog__name",
attrs: { type: "text", maxlength: String(NAME_MAX), "data-sog-name": group.id },
}) as HTMLInputElement;
name.value = group.이름;
name.placeholder = to("GroupName");
name.disabled = !opt.edit;
// 글자마다 사본만 고침(다시 그리면 초점이 끊김) · 칸을 떠날 때 다시 그림(선택지 이름)
name.addEventListener("input", () => {
group.이름 = name.value;
name.classList.toggle("is-invalid", !name.value.trim());
});
name.addEventListener("change", () => opt.changed());
const step = (delta: number): void => {
const at = index + delta;
if (at < 0 || at >= list.length) return;
[list[index], list[at]] = [list[at], list[index]];
opt.changed();
};
const up = createButton({ label: to("GroupUp"), variant: "ghost", size: "sm" });
const down = createButton({ label: to("GroupDown"), variant: "ghost", size: "sm" });
const del = createButton({ label: to("GroupDel"), variant: "danger", size: "sm" });
up.disabled = !opt.edit || index === 0;
down.disabled = !opt.edit || index === list.length - 1;
del.disabled = !opt.edit;
up.addEventListener("click", () => step(-1));
down.addEventListener("click", () => step(1));
del.addEventListener("click", async () => {
if (!(await showConfirmDialog(to("GroupDelAsk", group.이름), to("GroupDel")))) return;
list.splice(list.indexOf(group), 1);
opt.changed();
});
return el("div", {
className: "m02-sog__row",
attrs: { "data-sog-group": group.id },
children: [
name,
el("small", {
className: "m02-opts__key",
text: to("GroupCount", String(group.옵션.length)),
}),
...(opt.edit ? [up, down, del] : []),
],
});
});
const add = createButton({ label: to("GroupAdd"), variant: "ghost", size: "sm" });
add.setAttribute("data-sog-add", "");
add.addEventListener("click", () => {
let n = list.length + 1;
while (list.some((g) => g.이름 === to("GroupNew", String(n)))) n += 1;
list.push({ id: newGroupId(list), 이름: to("GroupNew", String(n)), 옵션: [] });
opt.changed();
});
return el("div", {
className: "m02-sog",
children: [
el("h4", { className: "m02-opts__group", text: to("GroupHead") }),
el("p", { className: "m02-side__empty", text: to("GroupNote") }),
...(rows.length ? rows : [el("p", { className: "m02-side__empty", text: to("GroupNone") })]),
...(opt.edit ? [el("div", { className: "m02-side__row", children: [add] })] : []),
],
});
}
/** 옵션 줄의 「들어갈 자리」 고르기 — 본체 · 그룹 이름들 */
export function buildPlaceSelect(
list: OptionGroupDef[],
key: string,
edit: boolean,
changed: () => void,
): HTMLSelectElement {
const select = el("select", {
className: "ui-input ui-input--sm m02-sog__place",
attrs: { "data-sog-place": key },
}) as HTMLSelectElement;
select.append(new Option(to("Main"), ""), ...list.map((g) => new Option(g.이름 || g.id, g.id)));
select.value = groupOf(list, key) ?? "";
select.disabled = !edit || !list.length;
select.addEventListener("change", () => {
moveOption(list, key, select.value || null);
changed();
});
return select;
}
@@ -13,7 +13,7 @@ import json
import logging
from typing import Any
from pydantic import Field
from pydantic import BaseModel, Field
from B05_Profile.B05_Profile_Structures_Schema import (
StructureOptionField,
@@ -24,6 +24,7 @@ from B05_Profile.B05_Profile_Structures_Schema import (
)
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of
FILE = "구조물옵션표시.json"
logger = logging.getLogger(__name__)
@@ -35,12 +36,21 @@ class OptionWithVisible(StructureOptionField):
visible: bool = True
class OptionGroup(BaseModel):
"""옵션 그룹 한 개(PLAN 46-2 · `M02_SO_Groups`) — 폼의 보조 컨테이너 하나 · 빈 옵션도 됨."""
id: str
name: str
options: list[str] = Field(default_factory=list)
class TypeWithVisible(StructureType):
options: list[OptionWithVisible] = Field(default_factory=list)
groups: list[OptionGroup] = Field(default_factory=list)
class TypesWithVisible(StructureTypesResponse):
"""`GET /structure-types` 응답 — 옵션마다 `visible` 칸."""
"""`GET /structure-types` 응답 — 옵션마다 `visible` 칸 · 종류마다 `groups`."""
types: list[TypeWithVisible]
@@ -86,12 +96,14 @@ def with_visible() -> TypesWithVisible:
except store.StoreError as e:
logger.warning("구조물 옵션 표시 표를 못 읽어 모두 보임으로 냄 — %s", e.detail)
doc = {}
groups = groups_of()
types = [
{
**t.model_dump(),
"options": [
{**o.model_dump(), "visible": _shown(doc, t.type_id, o.key)} for o in t.options
],
"groups": groups.get(t.type_id, []),
}
for t in load_structure_types()
]
@@ -0,0 +1,297 @@
/* =============================================================================
* M02_SO_Options.ts
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도 · 시스템 관리자만) — 누르면 메인 칸에
* 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄
* (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임].
* 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
* 서버 = `M02_SO_Options.py`(`구조물옵션표시.json` · 숨긴 것만) · `M02_SO_Groups.py`(`구조물옵션그룹.json`).
* ========================================================================== */
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
import { StaleError } from "../M02_MasterTemplete_Api_Fetch";
import {
fetchStructureOptionGroups,
fetchStructureOptions,
saveStructureOptionGroups,
saveStructureOptions,
type GroupsDoc,
type OptionGroupDef,
type OptionKind,
type OptionsDoc,
} from "./M02_SO_Api";
import { buildGroupBlock, buildPlaceSelect, namesOk } from "./M02_SO_Groups";
import "./M02_SO_Style.css";
import { to } from "./M02_SO_Text";
export interface OptionsSectionOptions {
isAdmin: boolean;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
onOpen: () => void;
}
export interface OptionsSection {
title: string;
body: HTMLElement;
/** 다른 화면을 열었음 — 고름 표시만 지움 */
clear: () => void;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection {
const row = el("button", {
className: "m02-side__item ui-sidebar-row",
text: to("Row"),
attrs: { type: "button", "data-options-row": "" },
});
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
row.addEventListener("click", () => void open());
/** 이 화면이 열려 있는 동안 참 — 다른 것이 열리면 `clear` 가 내림 */
let opened = false;
const mark = (on: boolean): void => {
opened = on;
row.classList.toggle("is-active", on);
};
async function open(): Promise<void> {
if (opened || !(await opt.confirmLeave())) return;
let doc: OptionsDoc;
let groups: GroupsDoc;
try {
[doc, groups] = await Promise.all([fetchStructureOptions(), fetchStructureOptionGroups()]);
} catch (error) {
return showToast(to("Load_Failed", why(error)), "error");
}
draw(doc, groups);
}
/** 메인 칸 화면 — `doc.표시` · `groups.그룹` 을 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */
function draw(doc: OptionsDoc, groupsDoc: GroupsDoc): void {
let version = doc.판;
let groupVersion = groupsDoc.판;
const edit = opt.isAdmin;
/** 숨긴 옵션 — 종류 → 키 집합 */
const hidden = new Map<string, Set<string>>();
const load = (src: OptionsDoc["표시"]): void => {
hidden.clear();
for (const [id, keys] of Object.entries(src)) hidden.set(id, new Set(Object.keys(keys)));
};
/** 그룹 — 종류 → 그룹[](고칠 사본) */
const groups = new Map<string, OptionGroupDef[]>();
const loadGroups = (src: GroupsDoc["그룹"]): void => {
groups.clear();
for (const [id, list] of Object.entries(src))
groups.set(
id,
list.map((g) => ({ ...g, 옵션: [...g.옵션] })),
);
};
load(doc.표시);
loadGroups(groupsDoc.그룹);
/** 종류 = 레지스트리 전부(옵션 없는 종류도 그룹을 둘 수 있음) */
let kinds = groupsDoc.종류;
let current: OptionKind | null = null;
let shownDirty = false;
let groupDirty = false;
const kindList = el("div", { className: "m02-opts__kinds" });
const optionList = el("div", { className: "m02-opts__list" });
const off = (id: string): number => hidden.get(id)?.size ?? 0;
const groupList = (id: string): OptionGroupDef[] => {
if (!groups.has(id)) groups.set(id, []);
return groups.get(id)!;
};
const paintKinds = (): void => {
const parts: HTMLElement[] = [];
let group = "";
for (const k of kinds) {
if (k.group !== group) {
group = k.group;
parts.push(
el("h4", {
className: "m02-opts__group",
text: group.length === 1 ? to("Group", group) : group,
}),
);
}
const n = off(k.type_id);
const g = groups.get(k.type_id)?.length ?? 0;
const b = el("button", {
className: `m02-opts__kind${k.type_id === current?.type_id ? " is-active" : ""}`,
attrs: { type: "button", "data-options-kind": k.type_id },
children: [
el("span", { text: k.name }),
...(g ? [createTag(to("GroupTag", String(g)), "accent")] : []),
...(n ? [createTag(to("Hidden", String(n)), "warning")] : []),
...(k.enabled ? [] : [createTag(to("Off"), "neutral")]),
],
});
b.addEventListener("click", () => {
current = k;
paintKinds();
paintOptions();
});
parts.push(b);
}
kindList.replaceChildren(...parts);
};
/** 그룹을 고쳤음 — 고침 표시 · 종류 목록(그룹 수) · 오른쪽 다시 그림 */
const groupsChanged = (): void => {
groupDirty = true;
sync();
paintKinds();
paintOptions();
};
const paintOptions = (): void => {
if (!current) {
optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
return;
}
const kind = current;
const list = groupList(kind.type_id);
const rows = kind.options.map((o) => {
const box = el("input", {
attrs: { type: "checkbox", "data-options-key": o.key },
}) as HTMLInputElement;
box.checked = !hidden.get(kind.type_id)?.has(o.key);
box.disabled = !edit;
box.addEventListener("change", () => {
const set = hidden.get(kind.type_id) ?? new Set<string>();
if (box.checked) set.delete(o.key);
else set.add(o.key);
hidden.set(kind.type_id, set);
shownDirty = true;
sync();
paintKinds();
});
const tags: HTMLElement[] = [];
if (o.phase === "detail") tags.push(createTag(to("Detail"), "accent"));
if (o.required && o.phase === "b05") {
const tag = createTag(to("Required"), "danger");
tag.title = to("RequiredTip");
tags.push(tag);
}
return el("div", {
className: "m02-opts__option",
children: [
el("label", {
className: "m02-opts__check",
children: [
box,
el("span", { text: o.label }),
el("small", { className: "m02-opts__key", text: o.key }),
...tags,
],
}),
buildPlaceSelect(list, o.key, edit, groupsChanged),
],
});
});
optionList.replaceChildren(
el("h3", { className: "m02-options__head", text: kind.name }),
buildGroupBlock({ list, edit, changed: groupsChanged }),
el("h4", { className: "m02-opts__group", text: to("OptionHead") }),
...(rows.length
? rows
: [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]),
);
};
const save = createButton({ label: to("Save"), variant: "filled" });
const all = createButton({ label: to("ShowAll"), variant: "ghost" });
all.addEventListener("click", () => {
if (![...hidden.values()].some((s) => s.size)) return;
hidden.clear();
shownDirty = true;
sync();
paintKinds();
paintOptions();
});
/** 다시 받기 — 낡은 판으로 막혔을 때 둘 다 새로 */
const reload = async (): Promise<void> => {
try {
const [fresh, freshGroups] = await Promise.all([
fetchStructureOptions(),
fetchStructureOptionGroups(),
]);
version = fresh.판;
groupVersion = freshGroups.판;
kinds = freshGroups.종류;
load(fresh.표시);
loadGroups(freshGroups.그룹);
shownDirty = groupDirty = false;
sync();
current = kinds.find((k) => k.type_id === current?.type_id) ?? null;
paintKinds();
paintOptions();
} catch (again) {
showToast(to("Load_Failed", why(again)), "error");
}
};
save.addEventListener("click", async () => {
if (groupDirty && !namesOk(groups)) return showToast(to("NameBad"), "error");
save.disabled = true; // 연달아 눌러도 한 번만
try {
if (shownDirty) {
const 표시: OptionsDoc["표시"] = {};
for (const [id, keys] of hidden)
if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const]));
const done = await saveStructureOptions(version, 표시);
version = done.판;
load(done.표시);
shownDirty = false;
}
if (groupDirty) {
const 그룹: GroupsDoc["그룹"] = {};
for (const [id, list] of groups)
if (list.length) 그룹[id] = list.map((g) => ({ ...g, 이름: g.이름.trim() }));
const done = await saveStructureOptionGroups(groupVersion, 그룹);
groupVersion = done.판;
loadGroups(done.그룹);
groupDirty = false;
}
sync();
showToast(to("Saved"), "success");
paintKinds();
paintOptions();
} catch (error) {
if (error instanceof StaleError) {
showToast(to("Stale"), "error");
await reload();
} else showToast(to("Failed", why(error)), "error");
} finally {
save.disabled = false;
}
});
const node = el("div", {
className: "m02-opts",
children: [
el("p", { className: "m02-side__empty", text: to("Note") }),
el("div", {
className: "m02-opts__panes",
children: [kindList, optionList],
}),
...(edit ? [el("div", { className: "m02-side__row", children: [save, all] })] : []),
],
});
const setDirty = opt.show(to("Screen"), node);
const sync = (): void => setDirty(shownDirty || groupDirty);
paintKinds();
paintOptions();
opt.onOpen();
mark(true);
}
return { title: to("Title"), body, clear: () => mark(false) };
}
@@ -0,0 +1,70 @@
"""M02 구조물 옵션 보이기 · 그룹 길 — 정본 `M02_SO_Options.py` · `M02_SO_Groups.py`.
`router` = 고쳐 씀 · 관리 읽기(`main.py` 가 `system_admin_only` 로 붙임) ·
`read_router` = 읽기만(로그인 누구나 · `main.py` 가 `protected` 로 붙임).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
from M02_MasterTemplete.StructOptions import M02_SO_Options as options
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
read_router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
class OptionsBody(BaseModel):
판: str = "" # 옵션 표시 표 판 — 어긋나면 409
표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김
class GroupsBody(BaseModel):
판: str = "" # 그룹 표 판 — 어긋나면 409
그룹: dict[str, Any] # {종류: [{id, 이름, 옵션[]}]}
def _call(fn, *args):
with store.at(store.FOLDER):
try:
return fn(*args)
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
@router.get("/structure-options")
def get_structure_options() -> dict:
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션 있는 종류와 그 옵션."""
return _call(options.listing)
@router.put("/structure-options")
def put_structure_options(body: OptionsBody) -> dict:
"""고쳐 씀(낡은 판이면 409 · 없는 종류 · 키는 422) — 돌려주는 것 = 새 `{판, 표시, 종류}`."""
return _call(options.save, body.표시, body.판)
@router.get("/structure-option-groups")
def get_structure_option_groups() -> dict:
"""`{판, 그룹, 종류}` — 그룹 = `{type_id: [{id, 이름, 옵션}]}` · 종류 = 레지스트리 전부."""
return _call(groups.listing)
@router.put("/structure-option-groups")
def put_structure_option_groups(body: GroupsBody) -> dict:
"""고쳐 씀(낡은 판 409 · 없는 종류 · 키 · 겹침 422) — 돌려주는 것 = 새 `{판, 그룹, 종류}`."""
return _call(groups.save, body.그룹, body.판)
@read_router.get("/layers/system/structure-options")
def get_structure_options_read(session=Depends(verify_session)) -> dict:
"""구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 시스템 관리자만)."""
access.require(session, "system", None)
return _call(options.listing)
@@ -0,0 +1,114 @@
/* 좌측 「구조물 옵션 보이기」 화면(메인 칸) — 왼쪽 종류(군별) · 오른쪽 옵션 체크 */
.m02-opts {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
max-width: 64rem;
font-size: var(--text-body-sm);
}
/* 종류 제목 */
.m02-options__head {
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
}
.m02-opts__panes {
display: grid;
grid-template-columns: minmax(14rem, 20rem) 1fr;
gap: var(--spacing-16);
align-items: start;
}
.m02-opts__kinds,
.m02-opts__list {
max-height: calc(100vh - 20rem);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-opts__group {
margin: var(--spacing-8) 0 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-opts__kind {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.m02-opts__kind:hover {
background: var(--color-mist-violet);
}
.m02-opts__kind.is-active {
border-color: var(--color-accent);
background: var(--color-mist-violet);
}
/* 옵션 줄 = 왼쪽 보이기 체크 · 라벨 · 키 · 표시 / 오른쪽 들어갈 자리(본체 · 그룹) */
.m02-opts__option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
padding: var(--spacing-4) 0;
border-bottom: 1px solid var(--color-border);
}
.m02-opts__check {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-8);
min-width: 0;
cursor: pointer;
}
/* 옵션 그룹(PLAN 46-2) — 그룹 줄 = 이름 · 옵션 수 · 위로 · 아래로 · 지우기 */
.m02-sog {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
}
.m02-sog__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-sog__name {
flex: 1 1 12rem;
min-width: 0;
}
.m02-sog__place {
flex: none;
width: 11rem;
}
.m02-opts__key {
color: var(--color-text-muted);
font-size: 11px;
}
@@ -0,0 +1,74 @@
/* =============================================================================
* M02_SO_Text.ts
* 좌측 「구조물 옵션 보이기」 줄 · 상세 화면(보이기 · 그룹) 글자 — [한국어, 영어]
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["구조물 옵션 보이기", "Structure options shown"],
Row: ["종류마다 그룹 · 보일 옵션 정하기", "Groups and shown options per type"],
Screen: ["구조물 옵션 보이기 · 그룹", "Structure options · groups"],
Note: [
"체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임",
"Only checked options appear in the B05 · B06 forms — definitions and stored values stay",
],
GroupHead: ["그룹", "Groups"],
GroupNote: [
"그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 그룹 이름) · 그 안에서 구조물 조합 항목을 따로 지정 · 옵션이 없어도 연결됨 · 그룹에 안 넣은 옵션은 본체",
"One group = one sub-container in the B05 · B06 form (title = group name) with its own structure combo · works without options · ungrouped options stay in the main body",
],
GroupAdd: ["그룹 추가", "Add group"],
GroupName: ["그룹 이름", "Group name"],
GroupNew: ["새 그룹 {value}", "New group {value}"],
GroupUp: ["위로", "Up"],
GroupDown: ["아래로", "Down"],
GroupDel: ["지우기", "Delete"],
GroupDelAsk: [
"그룹 「{value}」 을 지움 — 그 옵션은 본체로 돌아감 · 프로젝트에 이미 지정한 조합은 값만 남음",
"Delete group “{value}” — its options return to the main body; project links keep their values",
],
GroupCount: ["옵션 {value}", "{value} options"],
GroupNone: [
"그룹 없음 — 모든 옵션이 본체 · 집계 항목 하나",
"No groups — all options in the main body",
],
GroupTag: ["그룹 {value}", "{value} groups"],
Main: ["본체", "Main body"],
OptionHead: ["옵션 — 보이기 · 들어갈 자리", "Options — shown · container"],
NoOptionKind: [
"이 종류는 옵션이 없음 — 그룹만 만들 수 있음(그룹마다 조합 연결)",
"No options — groups only (each links a combo)",
],
NameBad: [
"그룹 이름은 1~40자 · 같은 종류 안에서 겹치면 안 됨",
"Group names: 1–40 chars, unique",
],
Group: ["{value}군", "Group {value}"],
Hidden: ["숨김 {value}", "{value} hidden"],
Off: ["안 쓰는 종류", "Not offered"],
NoOption: ["옵션 없음", "No options"],
Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"],
Detail: ["B06 전용", "B06 only"],
Required: ["필수", "Required"],
RequiredTip: [
"필수 옵션 — 숨기면 폼에 안 나오고 필수로도 안 침(저장이 안 막힘)",
"Required — when hidden it is not shown and not enforced",
],
Save: ["저장", "Save"],
ShowAll: ["모두 보임", "Show all"],
Saved: ["저장함", "Saved"],
Stale: [
"그 사이 옵션 표시가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
"Changed meanwhile — reloaded, please edit again",
],
Load_Failed: ["옵션 표시를 못 받음 — {value}", "Could not load option display — {value}"],
Failed: ["옵션 표시를 못 저장함 — {value}", "Could not save option display — {value}"],
} as const satisfies Record<string, readonly [string, string]>;
export type OptionsTextKey = keyof typeof TEXT;
export function to(key: OptionsTextKey, value = ""): string {
const entry = TEXT[key];
return (entry[currentLanguageIndex as 0 | 1] ?? entry[0]).replace("{value}", value);
}
+7 -1
View File
@@ -35,7 +35,7 @@ from shapely.geometry import LineString, Point
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
from common_util.common_util_json import atomic_write_json
from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex
from common_util.common_util_summary_item import parse_summary_item
from common_util.common_util_summary_item import parse_group_items, parse_summary_item
from config.config_system import (
DRAINAGE_CACHE_DIRNAME,
DRAINAGE_DETAIL_FILENAME,
@@ -92,6 +92,8 @@ class PipePoint:
y: float | None = None
# 구조물 집계표 항목(PLAN 21-1 · options 밖) — 없으면 None · 모양은 `common_util_summary_item`
summary_item: dict[str, Any] | None = None
# 옵션 그룹(M02 PLAN 46)마다 고른 집계 항목 `{그룹 id: summary_item}` — 본체는 `summary_item`
group_items: dict[str, Any] | None = None
def as_dict(self) -> dict[str, Any]:
# 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다.
@@ -115,6 +117,8 @@ class PipePoint:
payload["y"] = round(float(self.y), 3)
if self.summary_item:
payload["summary_item"] = self.summary_item
if self.group_items:
payload["group_items"] = self.group_items
return payload
@@ -355,6 +359,7 @@ def parse_pipe_points(values: Any) -> list[PipePoint]:
else None
),
summary_item=parse_summary_item(item.get("summary_item")),
group_items=parse_group_items(item.get("group_items")),
)
)
points.sort(key=lambda point: point.chainage_m)
@@ -406,6 +411,7 @@ def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint])
point.end_m = ref.end_m
point.options = ref.options
point.summary_item = ref.summary_item
point.group_items = ref.group_items
return base
+23
View File
@@ -62,6 +62,29 @@ def parse_summary_item(value: Any) -> dict[str, Any] | None:
return {"layer": layer, "owner": owner, "key": key, "name": name}
#: 옵션 그룹 id(PLAN 46 · `M02_SO_Groups.GROUP_ID` 와 같은 규칙)
_GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$")
#: 그룹 복사본 이름 잇개 — `{구조물 id}__{그룹 id}`(구조물 id 는 hex · 관은 `pipe@측점`)
GROUP_JOIN = "__"
def parse_group_items(value: Any) -> dict[str, dict[str, Any]] | None:
"""그룹별 집계 항목 `{그룹 id: summary_item}`(PLAN 46-3) — 빈 값 · 빈 묶음은 None ·
null 항목은 뺌 · 그룹 id · 항목 모양이 틀리면 ValueError."""
if value is None:
return None
if not isinstance(value, dict):
raise ValueError("그룹 집계 항목(group_items)은 묶음이어야 합니다.")
out: dict[str, dict[str, Any]] = {}
for gid, item in value.items():
if not isinstance(gid, str) or not _GROUP_ID.match(gid):
raise ValueError(f"그룹 id 모양이 아닙니다: {gid}")
parsed = parse_summary_item(item)
if parsed:
out[gid] = parsed
return out or None
def _key_ok(layer: str, owner: Any, key: str) -> bool:
"""옛 · 마스터 SS 키는 어느 층이든 · SC · SP 는 그 층에서 머리 번호가 owner 와 같을 때만."""
if _KEY.match(key):
+35
View File
@@ -45,6 +45,41 @@ export function readSummaryItem(value: unknown): SummaryItem | null {
return isSummaryItem(value) ? value : null;
}
/** 옵션 그룹(M02 PLAN 46)마다 고른 항목 `{그룹 id: 참조}` — 본체는 `summary_item`. */
export type GroupItems = Record<string, SummaryItem>;
/** 그룹 복사본 id 잇개 — `{구조물 id}__{그룹 id}`(서버 `GROUP_JOIN` 과 짝). */
export const GROUP_JOIN = "__";
/** 서버 · 세션 값을 그룹 참조 묶음으로 — 모양이 틀린 항목은 뺌 · 없으면 빈 묶음. */
export function readGroupItems(value: unknown): GroupItems {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).filter(
(entry): entry is [string, SummaryItem] =>
/^g[0-9a-z]{1,16}$/.test(entry[0]) && isSummaryItem(entry[1]),
),
);
}
/** 저장 · 이음에 실을 연결 칸 — 지정한 것만(본체 `summary_item` · 그룹 `group_items`). */
export function linkFields(source: { summary_item?: unknown; group_items?: unknown }): {
summary_item?: SummaryItem;
group_items?: GroupItems;
} {
const summary = readSummaryItem(source.summary_item);
const groups = readGroupItems(source.group_items);
return {
...(summary ? { summary_item: summary } : {}),
...(Object.keys(groups).length ? { group_items: groups } : {}),
};
}
/** 연결이 하나라도 있나 — 본체 또는 그룹. */
export function hasLinks(source: { summary_item?: unknown; group_items?: unknown }): boolean {
return Object.keys(linkFields(source)).length > 0;
}
/** 표시 글 — 이름(없으면 「미지정」). 키는 앞에 내세우지 않는다(PLAN 21-0). */
export function summaryLabel(item: SummaryItem | null): string {
return item ? item.name || item.key : "미지정";
+5
View File
@@ -68,6 +68,8 @@ from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import router as m02_d
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import router as m02_items_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Layers import router as m02_layers_router
from M02_MasterTemplete.M02_Project_Items_Router import router as m02_project_items_router
from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router as m02_so_read_router
from M02_MasterTemplete.StructOptions.M02_SO_Router import router as m02_so_router
from common_util.common_util_audit import note_api_call, record_call_burst
from common_util.common_util_auth import (
require_company,
@@ -646,6 +648,9 @@ app.include_router(
)
# 마스터 템플릿 — 시스템 층은 시스템 관리자만 · 층(회사·개인·프로젝트)·도면 길은 로그인만(권한은 길 안에서).
app.include_router(m02_master_template_router, dependencies=system_admin_only)
# 구조물 옵션 보이기 · 그룹(PLAN 46) — 고쳐 씀은 시스템 관리자 · 읽기는 로그인만
app.include_router(m02_so_router, dependencies=system_admin_only)
app.include_router(m02_so_read_router, dependencies=protected)
app.include_router(m02_layers_router, dependencies=protected)
app.include_router(m02_items_router, dependencies=protected)
app.include_router(m02_combos_router, dependencies=protected)
@@ -25,11 +25,9 @@
"결과단위": "원/km",
"출처": "산림품셈 3-3",
"소유": "공용",
"비고": "할인·할증 못 넣음 — 1-4-5 경사도(값 칸 「할인·할증률」) · 1-4-9 제거대상 식생(조건 「제거대상 식생」 빈칸) — 칸 이름 · 범위규칙 문법 · 체인톱 가격 · 체인오일 단가 원문에 없음(시중가격) — 설계자 입력 · 상층목은 솎아베기 본수에 포함(주③⑥)",
"비고": "할인·할증 못 넣음 — 1-4-5 경사도(값 칸 「할인·할증률」) · 1-4-9 제거대상 식생(조건 「제거대상 식생」 빈칸) — 칸 이름 · 범위규칙 문법 · 체인톱 가격 = 자재품목 「동력체인톱」(기본 = 첫 규격 · 고를 수 있음) · 체인오일 = 자재품목 「작동유」 · 상층목은 솎아베기 본수에 포함(주③⑥)",
"입력": [
{ "이름": "구분", "고르기": ["소작업로", "대작업로"] },
{ "이름": "체인톱가격", "단위": "원" },
{ "이름": "체인오일단가", "단위": "원/ℓ" },
{
"이름": "지역",
"고르기": [
@@ -86,22 +84,33 @@
"수량": "체인톱대수 * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').주연료",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주③"
},
{
"종류": "재료",
"요소": { "구분": "기계·설비", "상세구분": "기계류", "검색어": "작동유" },
"이름": "체인오일",
"단위": "ℓ",
"수량": "체인톱대수 * 찾기(QF000001, 기계명='체인톱', 재료명='체인오일(일반오일)').주연료",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주④"
},
{
"종류": "재료",
"요소": { "구분": "기계·설비", "상세구분": "기계류", "검색어": "동력체인톱" },
"이름": "체인톱 손료",
"단위": "대",
"수량": "체인톱대수 * EQ000620.손료계수",
"비목": "경비",
"출처": "산림품셈 2-2-1"
}
],
"덧줄": [
{
"이름": "잡품",
"식": "재료비 * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').잡품 / 100",
"식": "줄.'휘발유(체인톱)' * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').잡품 / 100",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주⑤"
},
{
"이름": "체인오일",
"식": "체인톱대수 * 찾기(QF000001, 기계명='체인톱', 재료명='체인오일(일반오일)').주연료 * 체인오일단가",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주④"
},
{ "이름": "체인톱 손료", "식": "체인톱대수 * EQ000620.손료계수 * 체인톱가격", "비목": "경비", "출처": "산림품셈 2-2-1" }
}
],
"끝수": null
},
@@ -363,12 +363,10 @@
"결과단위": "원/ha",
"출처": "산림품셈 6-5",
"소유": "공용",
"비고": "소작업로 = ha당 소작업로 길이(없으면 0 · 인력구성 특별 1.0 · 보통 1.0 — 소요인력 산출 1) · 해석: 체인톱 대수 = 특별인부 100%(2-1-1 비고) · 체인톱 가격 · 체인오일 단가 원문에 없음(시중가격) — 설계자 입력 · 덩굴걷기 추가는 6-4-1 따로(주③) · 할인·할증 못 넣음 — 1-4-1 작업시기(범위규칙이 글) · 1-4-3 집단화정도(조건 「개소당 평균면적」 빈칸 · 범위규칙이 글) · 1-4-5 경사도(값 칸 「할인·할증률」) · 1-4-12 제거대상 피복도(조건 빈칸 · 값 칸 「할인·할증률」) — 칸 이름 · 범위규칙 문법",
"비고": "소작업로 = ha당 소작업로 길이(없으면 0 · 인력구성 특별 1.0 · 보통 1.0 — 소요인력 산출 1) · 해석: 체인톱 대수 = 특별인부 100%(2-1-1 비고) · 체인톱 가격 = 자재품목 「동력체인톱」(기본 = 첫 규격 · 고를 수 있음) · 체인오일 = 자재품목 「작동유」 · 덩굴걷기 추가는 6-4-1 따로(주③) · 할인·할증 못 넣음 — 1-4-1 작업시기(범위규칙이 글) · 1-4-3 집단화정도(조건 「개소당 평균면적」 빈칸 · 범위규칙이 글) · 1-4-5 경사도(값 칸 「할인·할증률」) · 1-4-12 제거대상 피복도(조건 빈칸 · 값 칸 「할인·할증률」) — 칸 이름 · 범위규칙 문법",
"입력": [
{ "이름": "대상지유형", "고르기": ["치수림 단계", "유령림 단계"] },
{ "이름": "소작업로", "단위": "km/ha" },
{ "이름": "체인톱가격", "단위": "원" },
{ "이름": "체인오일단가", "단위": "원/ℓ" },
{
"이름": "지역",
"고르기": [
@@ -425,22 +423,33 @@
"수량": "특별 * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').주연료",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주③"
},
{
"종류": "재료",
"요소": { "구분": "기계·설비", "상세구분": "기계류", "검색어": "작동유" },
"이름": "체인오일",
"단위": "ℓ",
"수량": "특별 * 찾기(QF000001, 기계명='체인톱', 재료명='체인오일(일반오일)').주연료",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주④"
},
{
"종류": "재료",
"요소": { "구분": "기계·설비", "상세구분": "기계류", "검색어": "동력체인톱" },
"이름": "체인톱 손료",
"단위": "대",
"수량": "특별 * EQ000620.손료계수",
"비목": "경비",
"출처": "산림품셈 2-2-1"
}
],
"덧줄": [
{
"이름": "잡품",
"식": "재료비 * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').잡품 / 100",
"식": "줄.'휘발유(체인톱)' * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').잡품 / 100",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주⑤"
},
{
"이름": "체인오일",
"식": "특별 * 찾기(QF000001, 기계명='체인톱', 재료명='체인오일(일반오일)').주연료 * 체인오일단가",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주④"
},
{ "이름": "체인톱 손료", "식": "특별 * EQ000620.손료계수 * 체인톱가격", "비목": "경비", "출처": "산림품셈 2-2-1" }
}
],
"끝수": null
},
@@ -50,7 +50,7 @@
"결과단위": "원/㎥",
"출처": "산림품셈 8-3",
"소유": "공용",
"비고": "경급 52㎝ 이상은 50(주③) · 해석: 체인톱 대수 = 벌목부 100%(2-1-1 비고) · 체인톱 가격 · 체인오일 단가 원문에 없음(시중가격) — 설계자 입력 · 할인·할증 못 넣음 — 1-4-8 장애물의 정도(조건 「장애물의 정도」 빈칸) · 1-4-17 방제대상목 분포(조건 빈칸 · 값 칸 「할인·할증률」) — 칸 이름 · 범위규칙 문법",
"비고": "경급 52㎝ 이상은 50(주③) · 해석: 체인톱 대수 = 벌목부 100%(2-1-1 비고) · 체인톱 가격 = 자재품목 「동력체인톱」(기본 = 첫 규격 · 고를 수 있음) · 체인오일 = 자재품목 「작동유」 · 할인·할증 못 넣음 — 1-4-8 장애물의 정도(조건 「장애물의 정도」 빈칸) · 1-4-17 방제대상목 분포(조건 빈칸 · 값 칸 「할인·할증률」) — 칸 이름 · 범위규칙 문법",
"입력": [
{
"이름": "경급",
@@ -59,8 +59,6 @@
},
{ "이름": "소군락", "고르기": ["소군락 모두베기", "해당 없음"] },
{ "이름": "방제수종", "고르기": ["잣나무", "소나무, 해송"] },
{ "이름": "체인톱가격", "단위": "원" },
{ "이름": "체인오일단가", "단위": "원/ℓ" },
{
"이름": "지역",
"고르기": [
@@ -122,22 +120,33 @@
"수량": "벌목부수 * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').주연료",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주③"
},
{
"종류": "재료",
"요소": { "구분": "기계·설비", "상세구분": "기계류", "검색어": "작동유" },
"이름": "체인오일",
"단위": "ℓ",
"수량": "벌목부수 * 찾기(QF000001, 기계명='체인톱', 재료명='체인오일(일반오일)').주연료",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주④"
},
{
"종류": "재료",
"요소": { "구분": "기계·설비", "상세구분": "기계류", "검색어": "동력체인톱" },
"이름": "체인톱 손료",
"단위": "대",
"수량": "벌목부수 * EQ000620.손료계수",
"비목": "경비",
"출처": "산림품셈 2-2-1"
}
],
"덧줄": [
{
"이름": "잡품",
"식": "재료비 * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').잡품 / 100",
"식": "줄.'휘발유(체인톱)' * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').잡품 / 100",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주⑤"
},
{
"이름": "체인오일",
"식": "벌목부수 * 찾기(QF000001, 기계명='체인톱', 재료명='체인오일(일반오일)').주연료 * 체인오일단가",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주④"
},
{ "이름": "체인톱 손료", "식": "벌목부수 * EQ000620.손료계수 * 체인톱가격", "비목": "경비", "출처": "산림품셈 2-2-1" }
}
],
"끝수": null
},
+103 -18
View File
@@ -9,27 +9,112 @@
{
"id": "s1",
"이름": "면적산출",
"칸": {},
"연결": [
{
"키": "콘크리트 표층 인력포설|㎥",
"이름": "콘크리트 표층 인력포설",
"칸": "B6",
"단위": "㎥"
"칸": {
"E7": {
"값": "구 분"
},
{
"키": "와이어메시 바닥깔기|㎡",
"이름": "와이어메시 바닥깔기",
"칸": "D15",
"단위": "㎡"
"F7": {
"값": "규 격"
},
{
"키": "기계비빔타설|㎥",
"이름": "기계비빔타설",
"칸": "F14",
"단위": "㎥"
"G7": {
"값": "수 량"
},
"H7": {
"값": "단 위"
},
"I7": {
"값": "산 출 근 거"
},
"J7": {
"값": "비 고"
},
"E8": {
"값": "야면석"
},
"E9": {
"값": "고임돌량"
},
"E10": {
"값": "뒤채움돌"
},
"E11": {
"값": "구조물터파기량"
},
"E12": {
"값": "되메우기량"
},
"E13": {
"값": "잔토량"
},
"F8": {
"값": "뒤길이35cm"
},
"F10": {
"값": "9~15cm"
},
"G8": {
"값": 2.65
},
"G9": {
"값": 0.24
},
"G10": {
"값": 0.94
},
"G11": {
"값": 0.38
},
"G12": {
"값": 0.11
},
"G13": {
"값": 0.26
},
"H8": {
"값": "㎡"
},
"H9": {
"값": "㎥"
},
"H10": {
"값": "㎥"
},
"H11": {
"값": "㎥"
},
"H12": {
"값": "㎥"
},
"H13": {
"값": "㎥"
},
"I8": {
"값": "2.5m * 1.059m / 1.0 m * 1.0 m = 2.6475m"
},
"I9": {
"값": "2.6475m * 0.09m /1.0 m = 0.2383㎥"
},
"I10": {
"값": "(0.45 + 0.3)/2 * 2.5 * 1.0 = 0.9375"
},
"I11": {
"값": "(1.1+1.4)/2 * 0.3 * 1.0 = 0.375"
},
"I12": {
"값": "(0.3+0.45)/2 * 0.3 * 1.0 = 0.1125"
},
"I13": {
"값": "0.375 - 0.1125 = 0.2625"
}
]
},
"열": {
"E": {
"폭": 14.460318429129464
},
"I": {
"폭": 39.60316685267857
}
}
}
],
"활성": "s1",
@@ -11,84 +11,98 @@
"담은로직": [
{
"로직": "GC000198",
"별칭": null,
"메모": "레미콘타설(장비) 무근,소형",
"입력값": {},
"수량": null
},
{
"로직": "GC000214",
"별칭": null,
"메모": "유로폼 설치 및 해체 보통",
"입력값": {},
"수량": null
},
{
"로직": "GC000206",
"별칭": null,
"메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립",
"입력값": {},
"수량": null
},
{
"로직": "GC000207",
"별칭": null,
"메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립",
"입력값": {},
"수량": null
},
{
"로직": "GF000218",
"별칭": null,
"메모": "기초다짐 및 뒤채움 소형 장비사용 시공 (실무 수는 옛 판 값)",
"입력값": {},
"수량": null
},
{
"로직": "GF000225",
"별칭": null,
"메모": "깬잡석찰붙임 L3=30cm내외",
"입력값": {},
"수량": null
},
{
"로직": "GF000156",
"별칭": null,
"메모": "콘크리트(기계비빔) 무근구조물 (실무 수는 옛 판 값)",
"입력값": {},
"수량": null
},
{
"로직": "GF000226",
"별칭": null,
"메모": "큰돌메쌓기 뒷길이 0.6~0.8m",
"입력값": {},
"수량": null
},
{
"로직": "GF000214",
"별칭": null,
"메모": "야면석채집 L3=45cm내외 (실무 수는 옛 판 값)",
"입력값": {},
"수량": null
},
{
"로직": "GF000222",
"별칭": null,
"메모": "찰쌓기(켜쌓기) L3=55cm이하 (실무 수는 옛 판 값)",
"입력값": {},
"수량": null
},
{
"로직": "GC000677",
"별칭": null,
"메모": "모르터바르기 1:3",
"입력값": {},
"수량": null
},
{
"로직": "GF000221",
"별칭": null,
"메모": "돌쌓기 찰(켜)쌓기,L3=55cm이하 (실무 수는 옛 판 값)",
"입력값": {},
"수량": null
},
{
"로직": "GC000088",
"별칭": null,
"메모": "구조물터파기(토사) 굴착기0.6m3",
"입력값": {},
"수량": null
},
{
"로직": "GF000113",
"별칭": null,
"메모": "구조물되메우기 굴착기0.6m3 (실무 수는 옛 판 값)",
"입력값": {},
"수량": null
@@ -0,0 +1 @@
{}
@@ -355,6 +355,8 @@ async function saveWith({
setTempPipeChainage() {},
summary: () => summaryValue,
setSummary: (value) => (summaryValue = value),
groupItems: () => ({}), // 옵션 그룹(PLAN 46-3) — 이 시험은 그룹 없음
setGroupItems() {},
};
if (summaryOnly) {
const done = commitModule.commitSummaryOnly(ctx);
+3 -6
View File
@@ -70,31 +70,28 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite")
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_시스템_관리자_카드_다섯_고른_회사_해시():
def test_시스템_관리자_카드_넷_고른_회사_해시():
m = "B01_Dashboard_PermMasterWrite"
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [
["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"],
["B01_Dashboard_CardUnitCombo", m, f"{M02}?open=일위대가&root=master&company=3"],
["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master&company=3"],
["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"],
["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"],
]
# 회사가 없으면 해시에 안 실음
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[1][2] == (
f"{M02}?open=일위대가&root=master"
f"{M02}?open=구조물조합&root=master"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_회사_관리자_개인_카드_둘():
def test_회사_관리자_개인_카드_하나():
company = [
["B01_Dashboard_CardUnitCombo", WRITE_CO, f"{M02}?open=일위대가&root=company"],
["B01_Dashboard_CardStructCombo", WRITE_CO, f"{M02}?open=구조물조합&root=company"],
]
assert _cards({"role": "ADMIN", "is_master": False}, 3) == company # 회사 인자 안 실음
assert _cards({"role": "USER", "is_master": True}) == company # 회사 마스터 = 회사 수정
assert _cards({"role": "USER", "is_master": False}) == [
["B01_Dashboard_CardUnitCombo", READ_CO, f"{M02}?open=일위대가&root=personal"],
["B01_Dashboard_CardStructCombo", READ_CO, f"{M02}?open=구조물조합&root=personal"],
]
+4 -1
View File
@@ -85,8 +85,11 @@ def test_화면과_서버가_같은_답():
def test_화면_보관소_끌기_되돌리기_때_속성이_따라감():
"""BOX(100) · 옵션 없는 관(80) — 관을 끌거나 되돌려도 BOX 속성은 BOX 관에만."""
body = FACILITY.read_text(encoding="utf-8")
# 연결 칸 도우미(`linkFields` · `hasLinks` · PLAN 46-3) — import 없는 파일이라 통째로 붙임
links = (ROOT / "common_util" / "common_util_summary_item.ts").read_text(encoding="utf-8")
script = (
"type PipeFacility = string; type PipeSource = string; type DetailPipeInput = any;\n"
links
+ "\ntype PipeFacility = string; type PipeSource = string; type DetailPipeInput = any;\n"
"interface FacilityAttributes { facility: string; start_m?: number; end_m?: number;"
" options?: Record<string, string | number> }\n"
+ "\n".join(
@@ -0,0 +1,86 @@
"""새 구조물 기본값 — 「제안값 넣기」 걷음 · 생길 때 등록부 기본값을 칸에 실제 값으로 (PLAN 46-7).
못박는 것
- 자동 배치로 새로 놓인 관 = 등록부 기본값을 빈 키에 채움 · 추천 관경이 기본값을 이김.
- 이미 있던 관(원본에 있던 자리)의 빈 칸은 그대로(옛 프로젝트 빈칸 = 미확정 유지).
- 월류 폭 기본값(세월교 10 · 물넘이 5)은 등록부에 · 엔진 상수와 같음.
- 화면: 제안값 단추 · 「제안 …」 글이 없음 · 값 없이 그리면 등록부 기본값(`newDefaults`).
"""
from __future__ import annotations
from pathlib import Path
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import (
FORD_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
)
from common_util.common_util_drainage_pipes import PipePoint
ROOT = Path(__file__).resolve().parents[2]
def _defaults(type_id: str) -> dict:
return {
o.key: o.default
for o in structure_type_map()[type_id].options
if o.default not in (None, "")
}
def test_새_관만_기본값_빈_키에만():
fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 800})
basins._apply_new_defaults([fresh])
expected = {**_defaults("pipe"), "pipe_diameter_mm": 800} # 추천(먼저 들어간 값)이 이김
assert fresh.options == expected
assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "기슭막이"
box = PipePoint(chainage_m=20.0, source="stream", facility="box_culvert")
basins._apply_new_defaults([box])
assert box.options["body_width_m"] == 2.0 and box.options["wing_in"] == "있음"
def test_원본에_있던_관은_빈칸_그대로(monkeypatch):
"""`_build` — 원본 목록에 있던 자리는 기본값을 안 받고, 새 자리만 받음."""
class Pipe:
def __init__(self, chainage_m: float, reason: str) -> None:
self.chainage_m, self.reason = chainage_m, reason
class Detail:
pipes = [Pipe(100.0, "stream"), Pipe(300.0, "spacing")]
basins: list = []
monkeypatch.setattr(basins, "build_detail", lambda *_: Detail())
monkeypatch.setattr(basins, "drainage_dir", lambda _: "x")
class Ctx:
vertices: list = []
old = PipePoint(chainage_m=100.0, source="stream") # 옛 관 — 옵션 없음
_, points = basins._build("p", Ctx(), [old])
by = {p.chainage_m: p for p in points}
assert by[100.0].options is None # 옛 빈칸 그대로
assert by[300.0].options == _defaults("pipe") # 새로 보충된 관 = 기본값
def test_월류_폭_기본값은_등록부_엔진과_같음():
assert _defaults("ford_bridge")["ford_width_m"] == FORD_DEFAULT_WIDTH_M
assert _defaults("ford_pavement")["ford_width_m"] == FORD_PAVEMENT_DEFAULT_WIDTH_M
def test_화면에_제안값_단추_제안_글이_없음():
files = [
"B05_Profile/B05_Profile_UI_Drainage_Facility.ts",
"B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts",
"B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts",
"B05_Profile/B05_Profile_UI_Structures_Fields.ts",
"B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts",
]
for name in files:
text = (ROOT / name).read_text(encoding="utf-8")
assert "제안값 넣기" not in text and "b05-structure__suggest" not in text, name
assert "`제안 " not in text and '"제안 ' not in text and "(제안 " not in text, name
panel = (ROOT / "B05_Profile/B05_Profile_UI_Structures_Panel.ts").read_text(encoding="utf-8")
assert "values ?? newDefaults()" in panel and "options ?? newDefaults()" in panel
@@ -42,5 +42,6 @@ def test_TS_가_쓰는_칸이_파일에_있다() -> None:
def test_B05_높이_칸은_제안_없이_설계자_입력() -> None:
assert 'height: "",' in FIELDS and '"설계자 입력"' in FIELDS
# 독립 기슭막이 높이 = 기본값 없음 · 「설계자 입력」 안내(제안값 단추 걷음 · PLAN 46-7)
assert 'createRevetmentFields(keys, { height: "" })' in FIELDS and '"설계자 입력"' in FIELDS
assert "기슭막이 높이 없음 — 근입만 그림" in CHROME
@@ -24,6 +24,7 @@ def out():
["node", os.path.join(HERE, "helper_b05_structures_form_summary.cjs")],
capture_output=True,
text=True,
encoding="utf-8", # 한글 출력 — Windows 기본(cp949)으로 읽으면 깨짐
timeout=60,
)
assert proc.returncode == 0, proc.stderr
@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""B05 · B06 좌측 패널 아래 고정(계획서 44-3) — 단추 묶음이 패널 바닥에 붙고 위 칸만 스크롤.
공용 쪼개기(`splitSidebarActions`)가 [스크롤 칸][아래 고정 단추] 로 나누는데, 한 페이지(design_page)는
좌측 자리를 빈 채 틀에 넘겨 쪼개기가 지나가고, 보기 전환이 패널 클래스를 통째 덮어 `ui-sidebar-fill` 을
지웠다. 세 자리가 다시 어긋나면 빨강. 화면 수치는 `ui_checks/44-3_좌측패널_하단고정.js`.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OVERLAY = ROOT / "ui_template" / "ui_template_overlay.ts"
PANEL = ROOT / "A00_Common" / "design" / "design_panel.ts"
SHARED = ROOT / "A00_Common" / "design" / "design_shared.ts"
def read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def test_split_is_exported_and_idempotent():
src = read(OVERLAY)
assert "export function splitSidebarActions(" in src
assert 'if (body.classList.contains("ui-sidebar-split")) return;' in src
def test_shared_panel_splits_after_attach():
src = read(SHARED)
attach = src.index("shared.left.append(panel.root);")
assert "splitSidebarActions(body)" in src[attach : attach + 400]
def test_panel_keeps_fill_class_and_scroll_host():
src = read(PANEL)
# 보기 전환이 클래스를 통째 덮으면 ui-sidebar-fill 이 지워져 단추 묶음이 바닥에서 뜬다.
assert "root.className = next" not in src
# 늦게 서는 칸은 스크롤 칸 안으로 — 패널 루트에 바로 끼우면 스크롤 밖에 남는다.
assert ":scope > .ui-sidebar-scroll" in src
+7 -1
View File
@@ -225,7 +225,13 @@ def test_나무_오른쪽_클릭은_메뉴_항목을_밖에서_주는_자리():
def test_B05_구조물_폼이_구조물_id_를_모달에_넘김():
# 배선은 `_Structures_Groups.bindSummaryPickers`(PLAN 46-3) — 그룹은 `{id}__{그룹 id}`
picks = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Groups.ts").read_text(
encoding="utf-8"
)
assert "opts.editingId() ?? (pipe !== null ? pipeStructureId(pipe) : null)" in picks
assert "`${id}${GROUP_JOIN}${groupId}`" in picks
panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text(
encoding="utf-8"
)
assert "pipeStructureId(selectedPipeChainage)" in panel and "editingId ??" in panel
assert "bindSummaryPickers({" in panel

Some files were not shown because too many files have changed in this diff Show More