Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_7
# Conflicts: # B05_Profile/B05_Profile_UI_Structures_List.ts # B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts
This commit is contained in:
@@ -17,6 +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";
|
||||
|
||||
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
|
||||
export interface SurfaceAnalyzeRequest {
|
||||
@@ -491,6 +492,8 @@ export interface DetailPipePoint {
|
||||
end_m?: number;
|
||||
/** 유무·종류 수준의 시설 옵션(예: 세월교 관 종류/크기/수량). 상세 치수는 B06/B07. */
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖 칸) — 없으면 null. */
|
||||
summary_item?: SummaryItem | null;
|
||||
}
|
||||
|
||||
/** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import type { SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
|
||||
/** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */
|
||||
@@ -42,6 +43,9 @@ export interface StructureOptionField {
|
||||
not_in_table?: string | null;
|
||||
/** 기본값의 뜻(도메인 확정값이 아닐 때) — 칸 밑 근거 한 줄. */
|
||||
default_basis?: string | null;
|
||||
/** 폼에 보일지 — 서버가 「구조물 옵션 보이기」(21-8) 로 채움. false 면 폼에서 숨김(정의 · 옛 값은
|
||||
* 그대로) · 없으면 보임. */
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
|
||||
@@ -86,6 +90,8 @@ export interface StructureInstance {
|
||||
status: "draft" | "confirmed";
|
||||
revision: number;
|
||||
geometry: Record<string, unknown> | null;
|
||||
/** 이 구조물이 뜻하는 구조물 집계표 항목(21-1) — 없으면 null(미지정 · 집계표에 안 들어감). */
|
||||
summary_item?: SummaryItem | null;
|
||||
}
|
||||
|
||||
interface StructureTypesResponse {
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
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 { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford";
|
||||
|
||||
export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로
|
||||
@@ -69,10 +70,13 @@ export interface FacilityAttributes {
|
||||
start_m?: number;
|
||||
end_m?: number;
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖) — null/없음 = 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
}
|
||||
|
||||
/** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */
|
||||
function hasExtras(attributes: FacilityAttributes): boolean {
|
||||
if (attributes.summary_item) 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;
|
||||
@@ -115,18 +119,30 @@ export function createFacilityStore() {
|
||||
start_m?: number;
|
||||
end_m?: number;
|
||||
options?: Record<string, string | number>;
|
||||
summary_item?: SummaryItem | null;
|
||||
}>,
|
||||
): void {
|
||||
const before = entries;
|
||||
entries = points
|
||||
.map((point) => ({
|
||||
chainage: point.chainage_m,
|
||||
attributes: {
|
||||
facility: (point.facility ?? "pipe") as PipeFacility,
|
||||
start_m: point.start_m,
|
||||
end_m: point.end_m,
|
||||
options: point.options,
|
||||
},
|
||||
}))
|
||||
.map((point) => {
|
||||
// 서버가 집계 항목 칸을 아직 안 돌려주면(칸 없음) 화면이 쥔 값을 같은 측점에서 이어받는다 —
|
||||
// 칸이 있으면(null 포함) 응답이 정본이다.
|
||||
const summary =
|
||||
point.summary_item === undefined
|
||||
? (before.find((entry) => Math.abs(entry.chainage - point.chainage_m) <= 0.005)
|
||||
?.attributes.summary_item ?? null)
|
||||
: point.summary_item;
|
||||
return {
|
||||
chainage: point.chainage_m,
|
||||
attributes: {
|
||||
facility: (point.facility ?? "pipe") as PipeFacility,
|
||||
start_m: point.start_m,
|
||||
end_m: point.end_m,
|
||||
options: point.options,
|
||||
...(summary ? { summary_item: summary } : {}),
|
||||
} as FacilityAttributes,
|
||||
};
|
||||
})
|
||||
.filter((entry) => hasExtras(entry.attributes));
|
||||
},
|
||||
/** 한 지점의 시설을 바꾼다(맨 배관으로 되돌리면 엔트리 삭제). */
|
||||
@@ -162,7 +178,10 @@ export function createFacilityStore() {
|
||||
);
|
||||
return points.map((point, index) => {
|
||||
const pick = picks[index];
|
||||
return pick === null ? { ...point } : { ...point, ...entries[pick].attributes };
|
||||
if (pick === null) return { ...point };
|
||||
const { summary_item: summary, ...rest } = entries[pick].attributes;
|
||||
// 미지정(null)은 칸을 안 싣는다 — 서버가 모르는 칸을 거절하는 동안에도 계산이 안 깨짐.
|
||||
return { ...point, ...rest, ...(summary ? { summary_item: summary } : {}) };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,6 +58,8 @@ interface MergeableAttributes {
|
||||
start_m?: number;
|
||||
end_m?: number;
|
||||
options?: Record<string, string | number>;
|
||||
/** 집계 항목(options 밖) — 다음 값에 칸이 없으면(undefined) 옛 지정을 그대로 잇는다. */
|
||||
summary_item?: unknown;
|
||||
}
|
||||
|
||||
/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */
|
||||
@@ -69,5 +71,9 @@ export function mergeFacilityOptions<T extends MergeableAttributes>(
|
||||
const managed = new Set(FORM_OPTION_KEYS[next.facility] ?? []);
|
||||
const kept = Object.entries(previous.options ?? {}).filter(([key]) => !managed.has(key));
|
||||
const options = { ...Object.fromEntries(kept), ...(next.options ?? {}) };
|
||||
return { ...next, options: Object.keys(options).length ? options : undefined };
|
||||
const summary =
|
||||
next.summary_item === undefined && previous.summary_item !== undefined
|
||||
? { summary_item: previous.summary_item }
|
||||
: {};
|
||||
return { ...next, ...summary, options: Object.keys(options).length ? options : undefined };
|
||||
}
|
||||
|
||||
@@ -376,6 +376,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
end_m: attributes?.end_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
options: attributes?.options,
|
||||
summary_item: attributes?.summary_item,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -628,6 +629,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
if (index >= 0) pipeEditor.moveTo(index, toChainageM);
|
||||
else void analyze();
|
||||
} else {
|
||||
// 세션 초안에도 담는다 — 집계 항목 · 옵션만 바꿔도 B06 이 같은 값을 보게(옮긴 경우는
|
||||
// 위 moveTo 의 onCommit 이 담는다).
|
||||
rememberPipes();
|
||||
void analyze(); // 위치는 그대로 — 옵션·구간만 정본 경로로 재반영.
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 그대로 다시 내보내므로 기존 import 경로는 유효하다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { 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";
|
||||
@@ -65,6 +66,8 @@ export interface DrainagePanelCallbacks {
|
||||
end_m?: number;
|
||||
source?: PipeSource;
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
}>,
|
||||
) => void;
|
||||
/** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store";
|
||||
import { migrateLegacyStations } from "./B05_Profile_Api_Structures";
|
||||
import { pipeToFacilityItem } from "./B05_Profile_UI_Structures_Panel_Types";
|
||||
import {
|
||||
loadCorridorIfFresh,
|
||||
refreshCorridor,
|
||||
@@ -116,17 +117,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
// 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅
|
||||
// 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다.
|
||||
bridge.setPipes(pipes);
|
||||
panel.structures.setPipeFacilities(
|
||||
pipes.map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
facility: pipe.facility,
|
||||
start_m: pipe.start_m,
|
||||
end_m: pipe.end_m,
|
||||
source: pipe.source,
|
||||
design_flow_m3s: pipe.design_flow_m3s,
|
||||
options: pipe.options,
|
||||
})),
|
||||
);
|
||||
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
|
||||
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
|
||||
},
|
||||
// 그래프 측점선은 이제 배관(계곡 통과 시설) 투영뿐이다 — 수동 구조물은
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 이름·필드·주석은 그대로이고, 패널이 다시 `export` 해 옛 임포트 경로도 그대로 동작한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { 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";
|
||||
|
||||
@@ -24,6 +25,8 @@ export interface RouteProfilePanelCallbacks {
|
||||
end_m?: number;
|
||||
source?: PipeSource;
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
}>,
|
||||
) => void;
|
||||
/** 테이블에서 구조물 라인을 끌어 옮김. */
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
stationFields,
|
||||
} 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";
|
||||
|
||||
export interface StructuresFormElements {
|
||||
root: HTMLElement;
|
||||
@@ -49,6 +50,8 @@ export interface StructuresFormElements {
|
||||
resetButton: HTMLButtonElement;
|
||||
/** 하단 고정 영역에 패널이 따로 붙이는 구조물 목록. */
|
||||
list: HTMLElement;
|
||||
/** 옵션 · 시설 서브폼 아래 「집계 항목」 줄(PLAN 21-4) — 이름 · [고르기] · [해제]. */
|
||||
summary: SummaryRow;
|
||||
}
|
||||
|
||||
/** 폼 뼈대를 세운다. 값 채우기·이벤트 배선은 호출한 쪽(패널)이 한다. */
|
||||
@@ -138,6 +141,10 @@ export function buildStructuresForm(options: {
|
||||
const positionDivider = document.createElement("hr");
|
||||
positionDivider.className = "b05-structure__divider";
|
||||
|
||||
// 집계 항목 줄 — 종류를 고른 뒤에만 보인다(패널이 `syncPlacementFields` 에서 켠다).
|
||||
const summary = buildSummaryRow();
|
||||
summary.root.hidden = true;
|
||||
|
||||
body.append(
|
||||
typeRow,
|
||||
ownerNote,
|
||||
@@ -145,6 +152,7 @@ export function buildStructuresForm(options: {
|
||||
positionDivider,
|
||||
optionRow,
|
||||
facilityOptions.root,
|
||||
summary.root,
|
||||
actions,
|
||||
);
|
||||
|
||||
@@ -169,5 +177,6 @@ export function buildStructuresForm(options: {
|
||||
removeButton,
|
||||
resetButton,
|
||||
list,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
import { readSummaryItem, summaryTooltip } from "../common_util/common_util_summary_item";
|
||||
|
||||
export interface StructureListParams {
|
||||
/** 목록을 담을 <ul>. 그릴 때마다 통째로 갈아 끼운다. */
|
||||
@@ -98,6 +99,12 @@ export function renderStructureList(params: StructureListParams): void {
|
||||
const station = document.createElement("strong");
|
||||
const name = document.createElement("span");
|
||||
name.textContent = displayNames[index];
|
||||
// 집계 항목이 지정된 줄은 색으로(`.is-assigned`) · 툴팁에 항목 이름(PLAN 21-4) — B05 · B06 공통.
|
||||
const assigned = readSummaryItem(
|
||||
row.kind === "structure" ? row.structure.summary_item : row.pipe.summary_item,
|
||||
);
|
||||
item.classList.toggle("is-assigned", assigned !== null);
|
||||
if (assigned) item.title = summaryTooltip(assigned); // 미지정 줄은 지금 그대로
|
||||
if (row.kind === "structure") {
|
||||
const { structure } = row;
|
||||
item.classList.toggle("is-selected", structure.structure_id === editingId);
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
isB05Option,
|
||||
structureAnchorM,
|
||||
type StructureInstance,
|
||||
type StructuresSectionOptions,
|
||||
@@ -23,7 +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, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { field } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options";
|
||||
import { openItemPicker } from "./B05_Profile_UI_Structures_Summary_Picker";
|
||||
import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form";
|
||||
import {
|
||||
bindStructuresEvents,
|
||||
@@ -85,6 +86,7 @@ export function createStructuresSection(
|
||||
removeButton,
|
||||
resetButton,
|
||||
list,
|
||||
summary,
|
||||
} = form;
|
||||
|
||||
/** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신
|
||||
@@ -166,6 +168,18 @@ export function createStructuresSection(
|
||||
read: () => string | number;
|
||||
isEmpty: () => boolean;
|
||||
}> = [];
|
||||
const optionContext = {
|
||||
optionInputs,
|
||||
optionRow,
|
||||
includeDetail: sectionOptions.includeDetail === true,
|
||||
currentType: () => currentType(),
|
||||
facilityFormKind: (type: StructureType | null) => facilityFormKind(type),
|
||||
liveCommit: () => liveCommit(),
|
||||
syncOptionLock: () => syncOptionLock(),
|
||||
syncRangeDisplay: () => syncRangeDisplay(),
|
||||
syncSplitInputs: (source: "length" | "before_m" | "after_m", normalizeSource: boolean) =>
|
||||
syncSplitInputs(source, normalizeSource),
|
||||
};
|
||||
|
||||
function typeMap(): Map<string, StructureType> {
|
||||
return new Map(types.map((type) => [type.type_id, type]));
|
||||
@@ -192,6 +206,7 @@ export function createStructuresSection(
|
||||
!!type && !managed && type.placement === "interval" && !hasComputedRange(type);
|
||||
positionRow.hidden = !type;
|
||||
positionDivider.hidden = !type;
|
||||
summary.root.hidden = !type;
|
||||
startFields.wrap.hidden = !isInterval;
|
||||
endFields.wrap.hidden = !isInterval;
|
||||
primary.disabled = !type;
|
||||
@@ -280,85 +295,9 @@ export function createStructuresSection(
|
||||
syncRangeDisplay();
|
||||
}
|
||||
|
||||
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다. 상세(detail)는 `includeDetail` 인 화면
|
||||
* (B06/B07)에서만 — 안 받으면 뒷길이·돌규격·형식이 비어 **수량이 갈래를 못 고른다**. */
|
||||
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */
|
||||
function renderOptionFields(values: Record<string, string | number> = {}): void {
|
||||
const type = currentType();
|
||||
// 여기도 배열을 갈아끼우지 않는다 — 조각들이 참조로 받아 두므로 새 배열로
|
||||
// 바꾸면 옛 칸 목록을 읽어 저장값이 어긋난다(2026-09-04).
|
||||
optionInputs.length = 0;
|
||||
optionRow.replaceChildren();
|
||||
// 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다.
|
||||
const all = sectionOptions.includeDetail === true;
|
||||
const visible =
|
||||
type && !facilityFormKind(type) ? type.options.filter((o) => all || isB05Option(o)) : [];
|
||||
if (!visible.length) {
|
||||
optionRow.hidden = true;
|
||||
syncOptionLock();
|
||||
return;
|
||||
}
|
||||
optionRow.hidden = false;
|
||||
const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> =
|
||||
[];
|
||||
visible.forEach((option) => {
|
||||
// ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은
|
||||
// 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석).
|
||||
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` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다
|
||||
// (고를 수 없는 칸이라 제안이 아니라 프로그램 값).
|
||||
const locked = option.enabled === false;
|
||||
if (locked && !input.value) input.value = String(option.default ?? "");
|
||||
if (locked) {
|
||||
input.disabled = true;
|
||||
input.classList.add("is-locked");
|
||||
input.title = "지금은 고를 수 없는 항목입니다.";
|
||||
}
|
||||
optionRow.append(field(label, input, option.default_basis));
|
||||
input.addEventListener("change", () => liveCommit());
|
||||
optionInputs.push({
|
||||
key: option.key,
|
||||
required: !locked && !!option.required,
|
||||
input,
|
||||
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
|
||||
isEmpty: () => input.value.trim() === "",
|
||||
});
|
||||
// 전면 기울기는 품셈 표준경사 표(0.20~0.50) 밖 값도 받는다 — 실무 도면이 S0.7·S0.8 을
|
||||
// 쓴다(2026-09-09 실무 DWG 확인). **막지 않고 알리기만** 한다: 막으면 실물이 안 들어간다.
|
||||
if (option.key === "face_slope_ratio") {
|
||||
const warn = (): void => {
|
||||
const value = Number(input.value);
|
||||
const outside =
|
||||
input.value.trim() !== "" && Number.isFinite(value) && (value < 0.2 || value > 0.5);
|
||||
input.title = outside
|
||||
? "품셈 표준경사 표 범위(0.20~0.50) 밖 값입니다 — 근거를 적어 두시기 바랍니다."
|
||||
: "비워 두면 품셈 표대로 자동 판정합니다.";
|
||||
input.classList.toggle("is-outside-standard", outside);
|
||||
};
|
||||
warn();
|
||||
input.addEventListener("input", warn);
|
||||
input.addEventListener("change", warn);
|
||||
}
|
||||
// 길이·기준측점 전/후가 바뀌면 서로 연동하고 측점 범위 표시가 즉시 따라온다
|
||||
// (범위 계산 타입, 2026-08-19). 전·후는 **나중에 고친 쪽**이 살아남고 반대쪽이
|
||||
// 길이 − 그 값으로 맞춰진다. 길이를 고치면 나중에 고쳤던 쪽을 지키며 재배분.
|
||||
if (option.key === "length_m" || option.key === "before_m" || option.key === "after_m") {
|
||||
const source = (option.key === "length_m" ? "length" : option.key) as
|
||||
"length" | "before_m" | "after_m";
|
||||
const refresh = (normalize: boolean): void => {
|
||||
input.classList.remove("is-invalid");
|
||||
syncSplitInputs(source, normalize);
|
||||
};
|
||||
input.addEventListener("input", () => refresh(false));
|
||||
input.addEventListener("change", () => refresh(true));
|
||||
}
|
||||
});
|
||||
if (suggestions.some((entry) => entry.suggested)) {
|
||||
optionRow.append(suggestButton(suggestions));
|
||||
}
|
||||
syncOptionLock();
|
||||
syncRangeDisplay();
|
||||
renderOptionFieldsInto(optionContext, values);
|
||||
}
|
||||
|
||||
/** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도
|
||||
@@ -527,8 +466,25 @@ export function createStructuresSection(
|
||||
setPositionConfirmed: (value) => {
|
||||
positionConfirmed = value;
|
||||
},
|
||||
summary: () => summary.get(),
|
||||
setSummary: (value) => summary.set(value),
|
||||
};
|
||||
bindStructuresEvents(panelContext);
|
||||
// 집계 항목 — [고르기]는 21-5 모달, 결과는 폼 값으로만 두고 고치는 중이면 곧바로 반영한다
|
||||
// (새 항목은 종전대로 [추가]가 확정). 안 고른 채 닫으면 그대로.
|
||||
summary.bind({
|
||||
pick: () => {
|
||||
void openItemPicker({ current: summary.get() }).then((result) => {
|
||||
if (!result) return;
|
||||
summary.set("item" in result ? result.item : null);
|
||||
liveCommit();
|
||||
});
|
||||
},
|
||||
clear: () => {
|
||||
summary.set(null);
|
||||
liveCommit();
|
||||
},
|
||||
});
|
||||
body.insertBefore(field("메모", memoField), actions);
|
||||
syncButtons();
|
||||
renderList();
|
||||
|
||||
@@ -17,6 +17,8 @@ import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility";
|
||||
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 type {
|
||||
PipeFacilityItem,
|
||||
StructuresCallbacks,
|
||||
@@ -64,6 +66,9 @@ export interface StructuresCommitContext {
|
||||
setPositionConfirmed: (value: boolean) => void;
|
||||
tempPipeChainage: () => number | null;
|
||||
setTempPipeChainage: (value: number | null) => void;
|
||||
/** 폼의 「집계 항목」 값(PLAN 21-4) — 저장은 옵션 밖 `summary_item` 칸으로 나간다. */
|
||||
summary: () => SummaryItem | null;
|
||||
setSummary: (value: SummaryItem | null) => void;
|
||||
}
|
||||
|
||||
export function loadForm(ctx: StructuresCommitContext, target: StructureInstance | null): void {
|
||||
@@ -85,6 +90,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
|
||||
ctx.endFields.write(target.end_m ?? null, step);
|
||||
ctx.memoField.value = target.memo ?? "";
|
||||
ctx.renderOptionFields(target.options);
|
||||
ctx.setSummary(readSummaryItem(target.summary_item));
|
||||
// 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된
|
||||
// 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19).
|
||||
if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) {
|
||||
@@ -112,6 +118,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
|
||||
ctx.endFields.write(null, step);
|
||||
ctx.memoField.value = "";
|
||||
ctx.renderOptionFields();
|
||||
ctx.setSummary(null);
|
||||
ctx.syncFacilityForm();
|
||||
}
|
||||
[ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid());
|
||||
@@ -158,6 +165,7 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte
|
||||
ctx.endFields.write(null, step);
|
||||
ctx.memoField.value = "";
|
||||
ctx.syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null);
|
||||
ctx.setSummary(readSummaryItem(pipe.summary_item));
|
||||
[ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid());
|
||||
ctx.syncPlacementFields();
|
||||
ctx.syncButtons();
|
||||
@@ -199,6 +207,8 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility };
|
||||
const options = ctx.facilityOptions.readOptions();
|
||||
if (Object.keys(options).length) attributes.options = options;
|
||||
// 집계 항목 — 미지정(null)도 그대로 실어 옛 지정을 풀 수 있게 한다.
|
||||
attributes.summary_item = ctx.summary();
|
||||
if (ctx.tempPipeChainage() !== null) {
|
||||
// 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영.
|
||||
const from = ctx.tempPipeChainage() as number;
|
||||
@@ -271,6 +281,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = ctx.summary();
|
||||
const base = {
|
||||
type_id: type.type_id,
|
||||
placement,
|
||||
@@ -278,6 +289,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
start_m: start,
|
||||
end_m: end,
|
||||
options: readOptions(ctx),
|
||||
...(summary ? { summary_item: summary } : {}),
|
||||
memo: ctx.memoField.value.trim(),
|
||||
placement_source: "manual" as const,
|
||||
status: "draft" as const,
|
||||
@@ -304,7 +316,23 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
|
||||
if (ctx.editingId()) {
|
||||
const index = ctx.structures.findIndex((entry) => entry.structure_id === ctx.editingId());
|
||||
if (index >= 0) ctx.structures[index] = { ...ctx.structures[index], ...records[0] };
|
||||
if (index >= 0) {
|
||||
// [수정] = 폼이 그린 칸만 갈아 끼우고 나머지(안 보이는 칸 · 상세 칸)는 옛 값 보존.
|
||||
const previous = ctx.structures[index];
|
||||
const next: StructureInstance = {
|
||||
...previous,
|
||||
...records[0],
|
||||
options: mergeStructureOptions(
|
||||
previous,
|
||||
type.type_id,
|
||||
ctx.optionInputs.map((entry) => entry.key),
|
||||
records[0].options,
|
||||
),
|
||||
};
|
||||
// 해제 = 칸을 지움(옛 지정이 `...previous` 로 살아 오지 않게)
|
||||
if (!summary) delete next.summary_item;
|
||||
ctx.structures[index] = next;
|
||||
}
|
||||
records.slice(1).forEach((record) => ctx.structures.push({ ...record, structure_id: null }));
|
||||
} else {
|
||||
records.forEach((record) => ctx.structures.push({ ...record, structure_id: null }));
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Structures_Panel_Options.ts
|
||||
* 구조물 배치 패널의 **옵션 칸 그리기** — 타입의 옵션 스키마대로 입력 칸을 다시 그린다.
|
||||
*
|
||||
* `B05_Profile_UI_Structures_Panel` 이 700줄을 넘겨 떼어낸 조각이다(2026-09-29 · PLAN 21-4).
|
||||
* 그리는 규칙은 옮기기 전 그대로이고, 패널 클로저가 쥐던 값만 `ctx` 로 받는다.
|
||||
* 새로 얹은 것 하나 — 서버가 `visible: false` 로 채운 옵션은 그리지 않는다(정의 · 옛 값은 그대로,
|
||||
* 저장 때 안 그린 칸은 보존한다 → `B05_Profile_UI_Structures_Summary.mergeStructureOptions`).
|
||||
* ========================================================================== */
|
||||
|
||||
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 { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
|
||||
|
||||
/** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */
|
||||
export interface OptionRenderContext {
|
||||
optionInputs: Array<{
|
||||
key: string;
|
||||
required: boolean;
|
||||
input: HTMLInputElement | HTMLSelectElement;
|
||||
read: () => string | number;
|
||||
isEmpty: () => boolean;
|
||||
}>;
|
||||
optionRow: HTMLElement;
|
||||
/** B06/B07 은 상세(detail) 옵션까지 그린다. */
|
||||
includeDetail: boolean;
|
||||
currentType: () => StructureType | null;
|
||||
facilityFormKind: (type: StructureType | null) => PipeFacility | null;
|
||||
liveCommit: () => void;
|
||||
syncOptionLock: () => void;
|
||||
syncRangeDisplay: () => void;
|
||||
syncSplitInputs: (source: "length" | "before_m" | "after_m", normalizeSource: boolean) => void;
|
||||
}
|
||||
|
||||
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다. 상세(detail)는 `includeDetail` 인 화면
|
||||
* (B06/B07)에서만 — 안 받으면 뒷길이·돌규격·형식이 비어 **수량이 갈래를 못 고른다**. */
|
||||
export function renderOptionFields(
|
||||
ctx: OptionRenderContext,
|
||||
values: Record<string, string | number> = {},
|
||||
): void {
|
||||
const { optionInputs, optionRow } = ctx;
|
||||
const type = ctx.currentType();
|
||||
// 여기도 배열을 갈아끼우지 않는다 — 조각들이 참조로 받아 두므로 새 배열로
|
||||
// 바꾸면 옛 칸 목록을 읽어 저장값이 어긋난다(2026-09-04).
|
||||
optionInputs.length = 0;
|
||||
optionRow.replaceChildren();
|
||||
// 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다.
|
||||
const shown =
|
||||
type && !ctx.facilityFormKind(type)
|
||||
? type.options.filter((o) => (ctx.includeDetail || isB05Option(o)) && isOptionVisible(o))
|
||||
: [];
|
||||
if (!shown.length) {
|
||||
optionRow.hidden = true;
|
||||
ctx.syncOptionLock();
|
||||
return;
|
||||
}
|
||||
optionRow.hidden = false;
|
||||
const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> = [];
|
||||
shown.forEach((option) => {
|
||||
// ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은
|
||||
// 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석).
|
||||
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` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다
|
||||
// (고를 수 없는 칸이라 제안이 아니라 프로그램 값).
|
||||
const locked = option.enabled === false;
|
||||
if (locked && !input.value) input.value = String(option.default ?? "");
|
||||
if (locked) {
|
||||
input.disabled = true;
|
||||
input.classList.add("is-locked");
|
||||
input.title = "지금은 고를 수 없는 항목입니다.";
|
||||
}
|
||||
optionRow.append(field(label, input, option.default_basis));
|
||||
input.addEventListener("change", () => ctx.liveCommit());
|
||||
optionInputs.push({
|
||||
key: option.key,
|
||||
required: !locked && !!option.required,
|
||||
input,
|
||||
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
|
||||
isEmpty: () => input.value.trim() === "",
|
||||
});
|
||||
// 전면 기울기는 품셈 표준경사 표(0.20~0.50) 밖 값도 받는다 — 실무 도면이 S0.7·S0.8 을
|
||||
// 쓴다(2026-09-09 실무 DWG 확인). **막지 않고 알리기만** 한다: 막으면 실물이 안 들어간다.
|
||||
if (option.key === "face_slope_ratio") {
|
||||
const warn = (): void => {
|
||||
const value = Number(input.value);
|
||||
const outside =
|
||||
input.value.trim() !== "" && Number.isFinite(value) && (value < 0.2 || value > 0.5);
|
||||
input.title = outside
|
||||
? "품셈 표준경사 표 범위(0.20~0.50) 밖 값입니다 — 근거를 적어 두시기 바랍니다."
|
||||
: "비워 두면 품셈 표대로 자동 판정합니다.";
|
||||
input.classList.toggle("is-outside-standard", outside);
|
||||
};
|
||||
warn();
|
||||
input.addEventListener("input", warn);
|
||||
input.addEventListener("change", warn);
|
||||
}
|
||||
// 길이·기준측점 전/후가 바뀌면 서로 연동하고 측점 범위 표시가 즉시 따라온다
|
||||
// (범위 계산 타입, 2026-08-19). 전·후는 **나중에 고친 쪽**이 살아남고 반대쪽이
|
||||
// 길이 − 그 값으로 맞춰진다. 길이를 고치면 나중에 고쳤던 쪽을 지키며 재배분.
|
||||
if (option.key === "length_m" || option.key === "before_m" || option.key === "after_m") {
|
||||
const source = (option.key === "length_m" ? "length" : option.key) as
|
||||
"length" | "before_m" | "after_m";
|
||||
const refresh = (normalize: boolean): void => {
|
||||
input.classList.remove("is-invalid");
|
||||
ctx.syncSplitInputs(source, normalize);
|
||||
};
|
||||
input.addEventListener("input", () => refresh(false));
|
||||
input.addEventListener("change", () => refresh(true));
|
||||
}
|
||||
});
|
||||
if (suggestions.some((entry) => entry.suggested)) {
|
||||
optionRow.append(suggestButton(suggestions));
|
||||
}
|
||||
ctx.syncOptionLock();
|
||||
ctx.syncRangeDisplay();
|
||||
}
|
||||
@@ -7,13 +7,18 @@
|
||||
* 그대로 동작한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type {
|
||||
DetailPipePoint,
|
||||
PipeFacility,
|
||||
PipeSource,
|
||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import {
|
||||
isB05Option,
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import type { SummaryItem } from "../common_util/common_util_summary_item";
|
||||
|
||||
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
|
||||
* 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */
|
||||
@@ -43,6 +48,27 @@ export interface PipeFacilityItem {
|
||||
design_flow_m3s?: number | null;
|
||||
/** 부속 옵션(유형·집수정·기슭막이·돌붙임·날개벽·세월교 관 등). */
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
}
|
||||
|
||||
/** 관 지점 정본 한 건 → 목록·폼 항목. 집계 항목(21-1)도 옮긴다 — 안 옮기면 목록 색 · 폼이 잃는다. */
|
||||
export function pipeToFacilityItem(
|
||||
pipe: Omit<DetailPipePoint, "lonlat" | "source"> & {
|
||||
source?: PipeSource;
|
||||
design_flow_m3s?: number | null;
|
||||
},
|
||||
): PipeFacilityItem {
|
||||
return {
|
||||
chainage_m: pipe.chainage_m,
|
||||
facility: pipe.facility ?? "pipe",
|
||||
start_m: pipe.start_m,
|
||||
end_m: pipe.end_m,
|
||||
source: pipe.source,
|
||||
design_flow_m3s: pipe.design_flow_m3s,
|
||||
options: pipe.options,
|
||||
summary_item: pipe.summary_item,
|
||||
};
|
||||
}
|
||||
|
||||
export interface StructuresSection {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Structures_Summary.ts
|
||||
* 구조물 폼의 **집계 항목** 쪽 순수 규칙(PLAN 21-4) — 옵션 보이기 · [수정] 옵션 보존.
|
||||
*
|
||||
* ⚠ [수정]이 옵션을 통째로 덮어쓰던 흠(2026-09-29 조사: B05 폼이 안 그리는 상세(detail) 옵션이
|
||||
* 저장 때 지워져 B06 상세 값이 사라졌음)을 여기서 막는다 — **폼이 그린 칸만 갈아 끼우고
|
||||
* 나머지(안 보이는 칸 · 상세 칸)는 옛 값 그대로**. 관 계열(`mergeFacilityOptions`)과 같은 길.
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 옵션 보이기 — 서버가 채운 `visible`(21-8)이 false 일 때만 숨김. 없으면 보임. */
|
||||
export function isOptionVisible(option: { visible?: boolean }): boolean {
|
||||
return option.visible !== false;
|
||||
}
|
||||
|
||||
/** 저장할 옵션 — 같은 종류를 고치는 중이면 폼이 안 그린 옛 칸을 이어 붙임.
|
||||
* `rendered` = 폼이 그린 칸의 키 · `next` = 폼이 읽은 값(빈 칸은 이미 빠져 있음).
|
||||
* 폼이 그린 칸을 폼이 비웠으면 그 칸은 지워짐(사용자가 한 일). 종류를 바꾸면 통째로 새 값. */
|
||||
export function mergeStructureOptions(
|
||||
previous: { type_id: string; options?: Record<string, string | number> } | null | undefined,
|
||||
typeId: string,
|
||||
rendered: Iterable<string>,
|
||||
next: Record<string, string | number>,
|
||||
): Record<string, string | number> {
|
||||
if (!previous || previous.type_id !== typeId) return next;
|
||||
const drawn = new Set(rendered);
|
||||
const kept = Object.entries(previous.options ?? {}).filter(([key]) => !drawn.has(key));
|
||||
return { ...Object.fromEntries(kept), ...next };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Structures_Summary_Picker.ts
|
||||
* ⚠ **임시(가짜) 항목 고르기 모달** — 21-5(sub1) `ui_template/ui_template_item_picker.ts` 의
|
||||
* `openItemPicker()` 가 오면 이 파일을 지우고 `Summary_Row` 를 쓰는 패널의 import 한 줄만 바꾼다.
|
||||
*
|
||||
* 이름 · 돌려주는 값은 21-5 와 맞춘 자리다:
|
||||
* openItemPicker({ current }) → Promise<{ item } | { clear: true } | null>
|
||||
* (item = 21-1 `summary_item` · clear = [해제] · null = 닫음)
|
||||
* 목록은 `GET /api/m02/items?all=1`(21-2) 을 읽고, 아직 없으면(404 등) 같은 21-1 모양의 가짜 세 줄.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
sameSummaryItem,
|
||||
summaryLabel,
|
||||
type SummaryItem,
|
||||
type SummaryLayer,
|
||||
} from "../common_util/common_util_summary_item";
|
||||
|
||||
export type ItemPickResult = { item: SummaryItem } | { clear: true } | null;
|
||||
|
||||
interface ItemRow {
|
||||
key: string;
|
||||
이름?: string;
|
||||
종류?: string;
|
||||
분류1?: string;
|
||||
layer?: SummaryLayer;
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
/** 21-2 서버가 없을 때의 가짜 응답 — 21-1 모양. */
|
||||
const FAKE_ITEMS: SummaryItem[] = [
|
||||
{ layer: "system", owner: null, key: "SS000001", name: "돌기슭막이 메쌓기(가짜)" },
|
||||
{ layer: "company", owner: "가짜회사", key: "SS000002", name: "옹벽 콘크리트(가짜)" },
|
||||
{ layer: "personal", owner: "가짜회사/가짜사용자", key: "SS000003", name: "돌쌓기 찰쌓기(가짜)" },
|
||||
];
|
||||
|
||||
async function loadItems(): Promise<SummaryItem[]> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/m02/items?all=1`, { credentials: "include" });
|
||||
if (!response.ok) return FAKE_ITEMS;
|
||||
const rows = (await response.json()) as ItemRow[];
|
||||
if (!Array.isArray(rows) || !rows.length) return FAKE_ITEMS;
|
||||
return rows.map((row) => ({
|
||||
layer: row.layer ?? "system",
|
||||
owner: row.owner ?? null,
|
||||
key: row.key,
|
||||
name: row.분류1 ? `${row.종류 ?? ""} · ${row.분류1} · ${row.key}` : (row.종류 ?? row.key),
|
||||
}));
|
||||
} catch {
|
||||
return FAKE_ITEMS;
|
||||
}
|
||||
}
|
||||
|
||||
export function openItemPicker(options: { current: SummaryItem | null }): Promise<ItemPickResult> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: ItemPickResult, close?: () => void): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
close?.();
|
||||
resolve(result);
|
||||
};
|
||||
const modal = openModal({
|
||||
title: "집계 항목 고르기(임시)",
|
||||
closeLabel: "닫기",
|
||||
mount: (body, close) => {
|
||||
const list = el("div", { className: "b05-structure__pick-list" });
|
||||
const apply = createButton({ label: "적용", variant: "filled" });
|
||||
apply.disabled = true;
|
||||
let chosen: SummaryItem | null = null;
|
||||
void loadItems().then((items) => {
|
||||
items.forEach((item) => {
|
||||
const row = createButton({ label: summaryLabel(item), variant: "ghost" });
|
||||
row.dataset.b05PickItem = item.key;
|
||||
// 지금 지정된 항목은 미리 표시(눌러서 바로 [적용] 가능)
|
||||
if (sameSummaryItem(item, options.current)) {
|
||||
chosen = item;
|
||||
apply.disabled = false;
|
||||
row.classList.add("is-picked");
|
||||
}
|
||||
row.addEventListener("click", () => {
|
||||
chosen = item;
|
||||
apply.disabled = false;
|
||||
list
|
||||
.querySelectorAll(".is-picked")
|
||||
.forEach((node) => node.classList.remove("is-picked"));
|
||||
row.classList.add("is-picked");
|
||||
});
|
||||
list.append(row);
|
||||
});
|
||||
});
|
||||
apply.addEventListener("click", () => chosen && finish({ item: chosen }, close));
|
||||
const clear = createButton({ label: "해제", variant: "ghost" });
|
||||
clear.addEventListener("click", () => finish({ clear: true }, close));
|
||||
body.append(list, el("div", { children: [apply, clear] }));
|
||||
},
|
||||
beforeClose: () => {
|
||||
finish(null);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
void modal;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Structures_Summary_Row.ts
|
||||
* 구조물 폼의 「집계 항목」 줄 — 이름(없으면 「미지정」) · [고르기] · [해제] (PLAN 21-4).
|
||||
*
|
||||
* B05 · B06 · 관 계열 하위 폼이 같은 줄을 쓴다(옵션 · 서브폼 아래 한 줄). 줄은 값만 들고 있고
|
||||
* 저장은 패널이 한다 — [고르기]가 모달(21-5 `openItemPicker`)을 부르고 돌려받은 참조를 `set` 한다.
|
||||
* 키(SS…)는 앞에 내세우지 않고 툴팁에만 둔다(PLAN 21-0).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
summaryLabel,
|
||||
summaryTooltip,
|
||||
type SummaryItem,
|
||||
} from "../common_util/common_util_summary_item";
|
||||
|
||||
export interface SummaryRow {
|
||||
root: HTMLElement;
|
||||
get: () => SummaryItem | null;
|
||||
/** 값만 바꿔 그림 — 저장 · 알림은 안 함(폼 불러오기 · 모달 결과가 씀). */
|
||||
set: (item: SummaryItem | null) => void;
|
||||
/** [고르기] · [해제] 배선 — 패널이 한 번 부른다. */
|
||||
bind: (handlers: { pick: () => void; clear: () => void }) => void;
|
||||
}
|
||||
|
||||
export function buildSummaryRow(): SummaryRow {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-structure__summary";
|
||||
root.dataset.b05Summary = "row";
|
||||
const label = document.createElement("span");
|
||||
label.className = "b05-structure__summary-label";
|
||||
label.textContent = "집계 항목";
|
||||
const name = document.createElement("span");
|
||||
name.className = "b05-structure__summary-name";
|
||||
const pick = document.createElement("button");
|
||||
pick.type = "button";
|
||||
pick.className = "b05-route__irregular-btn";
|
||||
pick.textContent = "고르기";
|
||||
pick.dataset.b05Summary = "pick";
|
||||
const clear = document.createElement("button");
|
||||
clear.type = "button";
|
||||
clear.className = "b05-route__irregular-btn";
|
||||
clear.textContent = "해제";
|
||||
clear.dataset.b05Summary = "clear";
|
||||
root.append(label, name, pick, clear);
|
||||
|
||||
let current: SummaryItem | null = null;
|
||||
const set = (item: SummaryItem | null): void => {
|
||||
current = item;
|
||||
name.textContent = summaryLabel(item);
|
||||
name.title = summaryTooltip(item);
|
||||
name.classList.toggle("is-assigned", item !== null);
|
||||
clear.disabled = item === null;
|
||||
};
|
||||
set(null);
|
||||
|
||||
return {
|
||||
root,
|
||||
get: () => current,
|
||||
set,
|
||||
bind(handlers) {
|
||||
pick.addEventListener("click", () => handlers.pick());
|
||||
clear.addEventListener("click", () => handlers.clear());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -437,3 +437,62 @@
|
||||
.is-outside-standard {
|
||||
border-color: var(--color-warning, #c98a00);
|
||||
}
|
||||
|
||||
/* 집계 항목 줄(PLAN 21-4) — 옵션 · 시설 서브폼 아래 [이름][고르기][해제] 한 줄. 이름이 남는 폭을
|
||||
* 먹고 말줄임 · 키는 툴팁에만. 지정되면 성공색(`.is-assigned`) — 목록 줄과 같은 색. */
|
||||
.b05-structure__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b05-structure__summary[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-structure__summary-label {
|
||||
flex: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-structure__summary-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 글자는 성공색에 본문색을 섞어 대비 4.5 를 넘김(성공색 그대로는 밝은 테마에서 4.4). */
|
||||
.b05-structure__summary-name.is-assigned {
|
||||
color: color-mix(in srgb, var(--color-success) 65%, var(--color-text));
|
||||
}
|
||||
|
||||
.b05-structure__summary .b05-route__irregular-btn {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* 목록 줄 — 집계 항목이 지정된 구조물 · 관은 왼쪽 선 + 이름 색으로(밝음 · 어두움 같은 토큰). */
|
||||
.b05-route__irregular-item.is-assigned {
|
||||
border-left: 3px solid var(--color-success);
|
||||
}
|
||||
|
||||
.b05-route__irregular-item.is-assigned span {
|
||||
color: color-mix(in srgb, var(--color-success) 65%, var(--color-text));
|
||||
}
|
||||
|
||||
/* 임시 항목 고르기 모달(21-5 가 오면 사라짐) */
|
||||
.b05-structure__pick-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-structure__pick-list .is-picked {
|
||||
outline: 2px solid var(--color-success);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { readSummaryItem } from "../common_util/common_util_summary_item";
|
||||
import {
|
||||
readStructurePick,
|
||||
writeStructurePick,
|
||||
@@ -323,6 +324,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
facility: attributes.facility ?? "pipe",
|
||||
source: "user",
|
||||
options: attributes.options ?? {},
|
||||
summary_item: attributes.summary_item ?? null,
|
||||
});
|
||||
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
|
||||
currentChainageM = at;
|
||||
@@ -340,6 +342,18 @@ 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) {
|
||||
// 옮긴 뒤 자리(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) {
|
||||
mine.summary_item = attributes.summary_item;
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
deps.onStructuresChanged?.();
|
||||
}
|
||||
}
|
||||
const patch = attributes.options;
|
||||
if (!patch || !Object.keys(patch).length) return;
|
||||
if (!deps.queuePipeOptions) {
|
||||
@@ -418,6 +432,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
start_m: pipe.start_m,
|
||||
end_m: pipe.end_m,
|
||||
source: pipe.source,
|
||||
summary_item: readSummaryItem(pipe.summary_item),
|
||||
// 폼 기본값 = 조정창이 쓰던 값(횡단 스펙). 저장된 옵션이 있으면 그쪽이 이긴다.
|
||||
options: withSpecDefaults(
|
||||
pipe.options,
|
||||
@@ -491,6 +506,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 } : {}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* =============================================================================
|
||||
* common_util_summary_item.ts
|
||||
* 구조물 집계표 항목 참조(PLAN 21-1) — 구조물 · 관 지점이 「어느 집계표 항목」인지 가리키는 값.
|
||||
*
|
||||
* summary_item: { layer: "system"|"company"|"personal", owner: null|"{회사}"|"{회사}/{사용자}",
|
||||
* key: "SS000004", name: "돌기슭막이 메쌓기 1.2m" }
|
||||
*
|
||||
* 없으면 null(= 미지정 · 집계표에 안 들어감). 옵션(options) 밖의 칸이다.
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
export type SummaryLayer = "system" | "company" | "personal";
|
||||
|
||||
export interface SummaryItem {
|
||||
layer: SummaryLayer;
|
||||
owner: string | null;
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const LAYERS: readonly string[] = ["system", "company", "personal"];
|
||||
|
||||
/** 참조 모양이 맞는가 — 서버 응답 · 세션 초안을 읽을 때 걸러 쓴다. */
|
||||
export function isSummaryItem(value: unknown): value is SummaryItem {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const item = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof item.layer === "string" &&
|
||||
LAYERS.includes(item.layer) &&
|
||||
(item.owner === null || typeof item.owner === "string") &&
|
||||
typeof item.key === "string" &&
|
||||
item.key !== "" &&
|
||||
typeof item.name === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/** 같은 항목인가 — 층 · 주인 · 키가 같으면 같음(이름은 바뀔 수 있어 안 봄). */
|
||||
export function sameSummaryItem(left: SummaryItem | null, right: SummaryItem | null): boolean {
|
||||
if (!left || !right) return left === right;
|
||||
return left.layer === right.layer && left.owner === right.owner && left.key === right.key;
|
||||
}
|
||||
|
||||
/** 서버 · 세션에서 온 값을 참조 또는 null 로 — 모양이 틀리면 null(미지정). */
|
||||
export function readSummaryItem(value: unknown): SummaryItem | null {
|
||||
return isSummaryItem(value) ? value : null;
|
||||
}
|
||||
|
||||
/** 표시 글 — 이름(없으면 「미지정」). 키는 앞에 내세우지 않는다(PLAN 21-0). */
|
||||
export function summaryLabel(item: SummaryItem | null): string {
|
||||
return item ? item.name || item.key : "미지정";
|
||||
}
|
||||
|
||||
/** 툴팁 — 이름 + 어느 그룹 것인지 · 키는 작은 글로만. */
|
||||
export function summaryTooltip(item: SummaryItem | null): string {
|
||||
if (!item) return "집계 항목 미지정 — 집계표에 안 들어감";
|
||||
const group = { system: "마스터", company: "회사용", personal: "개인용" }[item.layer];
|
||||
return `${item.name} · ${group} · ${item.key}`;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/* 구조물 폼 「집계 항목」 단위검증 헬퍼(PLAN 21-4) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
|
||||
* 화면 그릇(DOM)은 시험에 필요한 만큼만 흉내낸다. 결과는 표준출력에 JSON 한 벌.
|
||||
*
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
// ── 화면 그릇 흉내 — 요소 · 클래스 · 이벤트 · 글자만 ─────────────────────────
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = tag;
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.style = {};
|
||||
this.listeners = {};
|
||||
this._text = "";
|
||||
this._classes = new Set();
|
||||
this.title = "";
|
||||
this.hidden = false;
|
||||
this.disabled = false;
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...names) => names.forEach((n) => self._classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
|
||||
toggle: (name, force) => {
|
||||
const on = force === undefined ? !self._classes.has(name) : !!force;
|
||||
on ? self._classes.add(name) : self._classes.delete(name);
|
||||
return on;
|
||||
},
|
||||
contains: (name) => self._classes.has(name),
|
||||
};
|
||||
}
|
||||
set className(value) {
|
||||
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
get className() {
|
||||
return [...this._classes].join(" ");
|
||||
}
|
||||
set textContent(value) {
|
||||
this._text = String(value);
|
||||
this.children = [];
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((k) => this.children.push(typeof k === "string" ? Object.assign(new FakeNode("#text"), { _text: k }) : k));
|
||||
}
|
||||
replaceChildren(...kids) {
|
||||
this.children = [];
|
||||
this.append(...kids);
|
||||
}
|
||||
addEventListener(type, handler) {
|
||||
(this.listeners[type] ||= []).push(handler);
|
||||
}
|
||||
click() {
|
||||
(this.listeners.click || []).forEach((h) => h({ target: this }));
|
||||
}
|
||||
scrollIntoView() {}
|
||||
}
|
||||
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
|
||||
globalThis.Option = function Option(text, value) {
|
||||
const node = new FakeNode("option");
|
||||
node._text = String(text);
|
||||
node.value = value;
|
||||
return node;
|
||||
};
|
||||
globalThis.requestAnimationFrame = (fn) => fn();
|
||||
const session = new Map();
|
||||
globalThis.window = { sessionStorage: { getItem: (k) => session.get(k) ?? null, setItem: (k, v) => session.set(k, String(v)), removeItem: (k) => session.delete(k) } };
|
||||
globalThis.sessionStorage = globalThis.window.sessionStorage;
|
||||
|
||||
const STUBS = {
|
||||
"@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 },
|
||||
};
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
// 화면 공용 부품(@ui/…) — 이 시험이 안 쓰는 것이라 뭘 불러도 아무 일 안 하는 함수로 막아 둠
|
||||
if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} });
|
||||
return request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const B05 = (name) => loadTs(path.join(ROOT, "B05_Profile", `${name}.ts`));
|
||||
const item = loadTs(path.join(ROOT, "common_util", "common_util_summary_item.ts"));
|
||||
const logic = B05("B05_Profile_UI_Structures_Summary");
|
||||
const merge = B05("B05_Profile_UI_Drainage_Facility_Merge");
|
||||
const { buildSummaryRow } = B05("B05_Profile_UI_Structures_Summary_Row");
|
||||
const { renderStructureList } = B05("B05_Profile_UI_Structures_List");
|
||||
const { renderOptionFields } = B05("B05_Profile_UI_Structures_Panel_Options");
|
||||
const commitModule = B05("B05_Profile_UI_Structures_Panel_Commit");
|
||||
|
||||
const out = {};
|
||||
const A = { layer: "personal", owner: "삼성/홍길동", key: "SS000007", name: "돌기슭막이 메쌓기 1.2m" };
|
||||
const B = { layer: "system", owner: null, key: "SS000004", name: "옹벽 콘크리트" };
|
||||
|
||||
// ── 순수 규칙 ───────────────────────────────────────────────────────────
|
||||
out.visible = [logic.isOptionVisible({}), logic.isOptionVisible({ visible: true }), logic.isOptionVisible({ visible: false })];
|
||||
const before = { type_id: "wall", options: { H: 2, stone_kind: "화강암", back_length_m: 0.6, memo_only: "x" } };
|
||||
out.mergeSame = logic.mergeStructureOptions(before, "wall", ["H", "stone_kind"], { H: 3 });
|
||||
out.mergeCleared = logic.mergeStructureOptions(before, "wall", ["H", "stone_kind"], {});
|
||||
out.mergeTypeChange = logic.mergeStructureOptions(before, "fence", ["H"], { H: 1 });
|
||||
out.mergeNoPrev = logic.mergeStructureOptions(null, "wall", ["H"], { H: 9 });
|
||||
out.mergePrevNoOptions = logic.mergeStructureOptions({ type_id: "wall" }, "wall", ["H"], { H: 9 });
|
||||
|
||||
out.isItem = [item.isSummaryItem(A), item.isSummaryItem({ layer: "x", owner: null, key: "K", name: "n" }), item.isSummaryItem(null), item.isSummaryItem({ ...A, key: "" })];
|
||||
out.readItem = [item.readSummaryItem(A)?.key ?? null, item.readSummaryItem("nope")];
|
||||
out.same = [item.sameSummaryItem(A, { ...A, name: "이름만 바뀜" }), item.sameSummaryItem(A, B), item.sameSummaryItem(null, null), item.sameSummaryItem(A, null)];
|
||||
out.label = [item.summaryLabel(null), item.summaryLabel(A)];
|
||||
out.tip = [item.summaryTooltip(null), item.summaryTooltip(A)];
|
||||
|
||||
// 관 계열 merge — 집계 항목 이어받기 · 지우기
|
||||
const pipePrev = { facility: "pipe", options: { pipe_kind: "흄관", extra: 1 }, summary_item: A };
|
||||
out.pipeKeep = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: { pipe_kind: "VR관" } }).summary_item ?? null;
|
||||
out.pipeClear = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: {}, summary_item: null }).summary_item;
|
||||
out.pipeSet = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: {}, summary_item: B }).summary_item.key;
|
||||
|
||||
// 집계 항목 줄
|
||||
const row = buildSummaryRow();
|
||||
const clicks = [];
|
||||
row.bind({ pick: () => clicks.push("pick"), clear: () => clicks.push("clear") });
|
||||
const [labelNode, nameNode, pickBtn, clearBtn] = row.root.children;
|
||||
out.rowEmpty = { name: nameNode.textContent, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, get: row.get(), label: labelNode.textContent, pick: pickBtn.textContent, clear: clearBtn.textContent };
|
||||
row.set(A);
|
||||
out.rowSet = { name: nameNode.textContent, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, title: nameNode.title, key: row.get().key, nameShowsKey: nameNode.textContent.includes("SS000007") };
|
||||
pickBtn.click();
|
||||
clearBtn.click();
|
||||
out.clicks = clicks;
|
||||
row.set(null);
|
||||
out.rowCleared = { name: nameNode.textContent, clearDisabled: clearBtn.disabled };
|
||||
|
||||
// 목록 줄 — 지정 색 · 툴팁
|
||||
const list = new FakeNode("ul");
|
||||
const types = new Map([["wall", { name: "옹벽" }], ["fence", { name: "울타리" }], ["pipe", { name: "배수관" }]]);
|
||||
renderStructureList({
|
||||
list,
|
||||
structures: [
|
||||
{ structure_id: "s1", type_id: "wall", placement: "point", chainage_m: 100, options: {}, summary_item: A },
|
||||
{ structure_id: "s2", type_id: "fence", placement: "point", chainage_m: 200, options: {} },
|
||||
],
|
||||
pipeFacilities: [
|
||||
{ chainage_m: 300, facility: "pipe", summary_item: B },
|
||||
{ chainage_m: 400, facility: "pipe" },
|
||||
],
|
||||
typeMap: types,
|
||||
intervalM: 20,
|
||||
editingId: null,
|
||||
selectedPipeChainage: null,
|
||||
onSelectStructure() {},
|
||||
onSelectPipe() {},
|
||||
});
|
||||
out.list = list.children.map((li) => ({ assigned: li.classList.contains("is-assigned"), title: li.title }));
|
||||
|
||||
// 옵션 칸 그리기 — visible:false 숨김 · 상세(detail) 는 includeDetail 일 때만 · 정의는 그대로
|
||||
const opt = (key, extra = {}) => ({ key, label: key, input: "number", choices: [], unit: null, default: null, ...extra });
|
||||
const wallType = {
|
||||
type_id: "wall",
|
||||
options: [
|
||||
opt("height_m"),
|
||||
opt("length_m", { visible: false }),
|
||||
opt("stone_kind", { phase: "detail", input: "select", choices: ["a", "b"] }),
|
||||
opt("back_len_cm", { phase: "detail", visible: false }),
|
||||
],
|
||||
};
|
||||
const draw = (includeDetail) => {
|
||||
const optionInputs = [];
|
||||
const optionRow = new FakeNode("div");
|
||||
renderOptionFields(
|
||||
{
|
||||
optionInputs,
|
||||
optionRow,
|
||||
includeDetail,
|
||||
currentType: () => wallType,
|
||||
facilityFormKind: () => null,
|
||||
liveCommit() {},
|
||||
syncOptionLock() {},
|
||||
syncRangeDisplay() {},
|
||||
syncSplitInputs() {},
|
||||
},
|
||||
{ height_m: 2 },
|
||||
);
|
||||
return { keys: optionInputs.map((e) => e.key), hidden: optionRow.hidden };
|
||||
};
|
||||
out.drawB05 = draw(false); // B05 — 상세 칸 없음 + visible:false 숨김
|
||||
out.drawB06 = draw(true); // B06 — 상세 칸까지, 단 visible:false 는 여기서도 숨김
|
||||
out.definitionKept = wallType.options.map((o) => o.key); // 정의는 그대로
|
||||
|
||||
// [수정]/[추가] 저장 — 폼 값이 정본 기록으로 나가는 자리(Commit.commit)를 fake 폼으로 돌림
|
||||
const saveType = { type_id: "wall", placement: "point", managed_by: null, options: [] };
|
||||
async function saveWith({ previous, summary, formOptions }) {
|
||||
const structures = previous ? [previous] : [];
|
||||
let summaryValue = summary;
|
||||
let changed = null;
|
||||
const ctx = {
|
||||
callbacks: { onChange: (list) => (changed = list) },
|
||||
structures,
|
||||
groupSelect: {},
|
||||
memoField: { value: "" },
|
||||
anchorFields: { read: () => 10, station: { focus() {} } },
|
||||
startFields: {},
|
||||
endFields: {},
|
||||
facilityOptions: {},
|
||||
optionInputs: Object.entries(formOptions).map(([key, value]) => ({
|
||||
key,
|
||||
required: false,
|
||||
input: {},
|
||||
read: () => value,
|
||||
isEmpty: () => false,
|
||||
})),
|
||||
pipeFacilities: () => [],
|
||||
interval: () => 20,
|
||||
typeMap: () => new Map(),
|
||||
currentType: () => saveType,
|
||||
facilityFormKind: () => null,
|
||||
hasComputedRange: () => false,
|
||||
readLengthM: () => null,
|
||||
readBeforeM: () => 0,
|
||||
cancelTempPipe() {},
|
||||
renderList() {},
|
||||
renderOptionFields() {},
|
||||
syncButtons() {},
|
||||
syncFacilityForm() {},
|
||||
syncPlacementFields() {},
|
||||
syncTypeOptions() {},
|
||||
editingId: () => previous?.structure_id ?? null,
|
||||
setEditingId() {},
|
||||
selectedPipeChainage: () => null,
|
||||
setSelectedPipeChainage() {},
|
||||
positionConfirmed: () => true,
|
||||
setPositionConfirmed() {},
|
||||
tempPipeChainage: () => null,
|
||||
setTempPipeChainage() {},
|
||||
summary: () => summaryValue,
|
||||
setSummary: (value) => (summaryValue = value),
|
||||
};
|
||||
await commitModule.commit(ctx, true);
|
||||
return changed[0];
|
||||
}
|
||||
const prev = () => ({
|
||||
structure_id: "s1",
|
||||
type_id: "wall",
|
||||
placement: "point",
|
||||
chainage_m: 10,
|
||||
options: { H: 2, hidden_cost_key: "keep", detail_only: 9 },
|
||||
memo: "",
|
||||
placement_source: "manual",
|
||||
status: "draft",
|
||||
revision: 0,
|
||||
geometry: null,
|
||||
summary_item: A,
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const fresh = await saveWith({ previous: null, summary: null, formOptions: { H: 3 } });
|
||||
out.commitNew = { hasKey: "summary_item" in fresh, options: fresh.options };
|
||||
const cleared = await saveWith({ previous: prev(), summary: null, formOptions: { H: 3 } });
|
||||
out.commitCleared = { hasKey: "summary_item" in cleared, options: cleared.options };
|
||||
const kept = await saveWith({ previous: prev(), summary: A, formOptions: { H: 3 } });
|
||||
out.commitKept = { key: kept.summary_item?.key, options: kept.options };
|
||||
const swapped = await saveWith({ previous: prev(), summary: B, formOptions: { H: 3 } });
|
||||
out.commitSwapped = swapped.summary_item?.key;
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
}
|
||||
main();
|
||||
@@ -0,0 +1,114 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B05/B06 좌측 패널 「집계 항목」 회귀검증 — PLAN 21-4 (2026-09-29).
|
||||
|
||||
폼 옵션 보이기(`visible`) · [수정] 옵션 보존(안 그린 칸 · 상세 칸) · 집계 항목 줄(이름 ·
|
||||
고르기 · 해제) · 관 계열 merge · 목록 줄 색(`.is-assigned`) + 툴팁을 본다.
|
||||
화면 그릇은 Node 헬퍼가 흉내낸다(`helper_b05_structures_form_summary.cjs` —
|
||||
TS 를 그 자리에서 트랜스파일).
|
||||
실제 화면 조작은 ORCA(자체 확인)로 따로 본다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
A_KEY = "SS000007"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_structures_form_summary.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_visible_false_만_숨김(out):
|
||||
assert out["visible"] == [True, True, False]
|
||||
|
||||
|
||||
def test_visible_false_옵션은_폼에_안_그림_정의는_그대로(out):
|
||||
assert out["drawB05"] == {
|
||||
"keys": ["height_m"],
|
||||
"hidden": False,
|
||||
} # 길이 숨김 · 상세는 B05 에 없음
|
||||
assert out["drawB06"]["keys"] == ["height_m", "stone_kind"] # B06 은 상세까지 · 숨김은 여기도
|
||||
assert out["definitionKept"] == ["height_m", "length_m", "stone_kind", "back_len_cm"]
|
||||
|
||||
|
||||
def test_수정은_안_그린_칸을_보존(out):
|
||||
# 폼이 그린 H · stone_kind 는 갈아 끼우고, 안 그린 상세 칸 · 숨긴 칸은 옛 값 그대로
|
||||
assert out["mergeSame"] == {"back_length_m": 0.6, "memo_only": "x", "H": 3}
|
||||
|
||||
|
||||
def test_그린_칸을_비우면_지워짐(out):
|
||||
assert out["mergeCleared"] == {"back_length_m": 0.6, "memo_only": "x"}
|
||||
|
||||
|
||||
def test_종류를_바꾸면_통째로_새_값(out):
|
||||
assert out["mergeTypeChange"] == {"H": 1}
|
||||
assert out["mergeNoPrev"] == {"H": 9}
|
||||
assert out["mergePrevNoOptions"] == {"H": 9}
|
||||
|
||||
|
||||
def test_참조_모양_검사(out):
|
||||
assert out["isItem"] == [True, False, False, False]
|
||||
assert out["readItem"] == [A_KEY, None]
|
||||
assert out["same"] == [True, False, True, False] # 이름만 바뀌면 같음
|
||||
|
||||
|
||||
def test_키는_앞에_안_내세움_툴팁에만(out):
|
||||
assert out["label"] == ["미지정", "돌기슭막이 메쌓기 1.2m"]
|
||||
assert A_KEY not in out["label"][1]
|
||||
assert A_KEY in out["tip"][1] and "개인용" in out["tip"][1]
|
||||
assert out["rowSet"]["nameShowsKey"] is False
|
||||
|
||||
|
||||
def test_집계_항목_줄(out):
|
||||
empty = out["rowEmpty"]
|
||||
assert (empty["name"], empty["assigned"], empty["clearDisabled"], empty["get"]) == (
|
||||
"미지정",
|
||||
False,
|
||||
True,
|
||||
None,
|
||||
)
|
||||
assert (empty["label"], empty["pick"], empty["clear"]) == ("집계 항목", "고르기", "해제")
|
||||
row = out["rowSet"]
|
||||
assert row["assigned"] is True and row["clearDisabled"] is False
|
||||
assert row["name"] == "돌기슭막이 메쌓기 1.2m" and A_KEY in row["title"]
|
||||
assert out["clicks"] == ["pick", "clear"]
|
||||
assert out["rowCleared"] == {"name": "미지정", "clearDisabled": True}
|
||||
|
||||
|
||||
def test_관_계열_merge_가_집계_항목을_잇고_지움(out):
|
||||
assert out["pipeKeep"]["key"] == A_KEY # 다음 값에 칸이 없으면 옛 지정을 이음
|
||||
assert out["pipeClear"] is None # 폼이 null 을 보내면 해제
|
||||
assert out["pipeSet"] == "SS000004" # 다른 항목으로 바꿈
|
||||
|
||||
|
||||
def test_목록_줄_색과_툴팁(out):
|
||||
rows = out["list"] # 측점 순: 구조물(지정) · 구조물(미지정) · 관(지정) · 관(미지정)
|
||||
assert [r["assigned"] for r in rows] == [True, False, True, False]
|
||||
assert A_KEY in rows[0]["title"] and "SS000004" in rows[2]["title"]
|
||||
assert rows[1]["title"] == "" and rows[3]["title"] == "" # 미지정은 지금 그대로
|
||||
|
||||
|
||||
def test_저장_기록에_집계_항목이_실리고_해제하면_칸이_빠짐(out):
|
||||
# 새 구조물(미지정) — 칸을 아예 안 실음(서버가 모르는 칸을 거절하는 동안에도 저장이 안 깨짐)
|
||||
assert out["commitNew"] == {"hasKey": False, "options": {"H": 3}}
|
||||
# 지정돼 있던 것을 해제 — 옛 지정이 살아오지 않음 · 안 그린 칸은 보존
|
||||
assert out["commitCleared"]["hasKey"] is False
|
||||
assert out["commitCleared"]["options"] == {"hidden_cost_key": "keep", "detail_only": 9, "H": 3}
|
||||
# 지정 유지 · 바꿈
|
||||
assert out["commitKept"] == {
|
||||
"key": A_KEY,
|
||||
"options": {"hidden_cost_key": "keep", "detail_only": 9, "H": 3},
|
||||
}
|
||||
assert out["commitSwapped"] == "SS000004"
|
||||
@@ -19,6 +19,8 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types # noqa: E402
|
||||
|
||||
_PANEL = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts"
|
||||
# 옵션 칸 그리기는 700줄 제한으로 이 조각으로 옮겨 감(2026-09-29 · PLAN 21-4)
|
||||
_PANEL_OPTIONS = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel_Options.ts"
|
||||
_B06 = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Panel.ts"
|
||||
_B05 = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Panel.ts"
|
||||
|
||||
@@ -38,7 +40,7 @@ def test_B05_는_종전대로_유무_종류_위치만():
|
||||
|
||||
def test_기본값_없는_필수_항목은_빈_칸으로_연다():
|
||||
"""첫 항목을 슬쩍 고르면 **근거 없는 값**(뒷길이 25㎝ 같은)이 수량·단가로 흘러간다."""
|
||||
panel = _PANEL.read_text(encoding="utf-8")
|
||||
panel = _PANEL.read_text(encoding="utf-8") + _PANEL_OPTIONS.read_text(encoding="utf-8")
|
||||
fields = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user