260706_0_회사싱크 후 커밋
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/* =============================================================================
|
||||
* B06_wf3_ProfileCross_Api_Fetch.ts
|
||||
* 3차 워크플로우(종·횡단 생성) API 클라이언트
|
||||
*
|
||||
* 백엔드 계약 (B06_wf3_ProfileCross_Router.py):
|
||||
* POST /api/projects/{project_id}/sections/generate → 종횡단 생성 + DB 기록
|
||||
* GET /api/projects/{project_id}/sections/{route_id} → 종단 요약 조회
|
||||
* POST /api/projects/{project_id}/sections/{route_id}/confirm → 종횡단 확정
|
||||
*
|
||||
* 규칙:
|
||||
* - 모든 제어 상수는 config_frontend에서 참조 (하드코딩 금지).
|
||||
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL, API_TIMEOUT_MS, AUTH_TOKEN_KEY } from "@config/config_frontend";
|
||||
|
||||
/** 종횡단 생성 실행 요청 (SectionGenerateRequest) */
|
||||
export interface SectionGenerateRequest {
|
||||
route_id: number;
|
||||
filter_key: string;
|
||||
method?: string;
|
||||
smooth?: boolean;
|
||||
crs?: string | null;
|
||||
station_interval_m?: number | null;
|
||||
cross_half_width_m?: number | null;
|
||||
cross_sample_interval_m?: number | null;
|
||||
long_sample_interval_m?: number | null;
|
||||
}
|
||||
|
||||
/** 종횡단 생성 결과 (SectionGenerateResponse) */
|
||||
export interface SectionGenerateResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
longitudinal_id: number;
|
||||
cross_section_count: number;
|
||||
length_m: number;
|
||||
longitudinal_file_path: string;
|
||||
}
|
||||
|
||||
/** 종단 요약 조회 결과 (SectionSummaryResponse) */
|
||||
export interface SectionSummaryResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
longitudinal: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** 종횡단 확정 결과 (SectionConfirmResponse) */
|
||||
export interface SectionConfirmResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
|
||||
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
const token = localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = (await response.json()) as T & { message?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 확정 경로에서 종·횡단을 생성한다 (측점 배열 → 종단 프로필 → 횡단 샘플). */
|
||||
export async function generateSections(
|
||||
projectId: string,
|
||||
request: SectionGenerateRequest,
|
||||
): Promise<SectionGenerateResponse> {
|
||||
return requestJson<SectionGenerateResponse>(`/projects/${projectId}/sections/generate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
/** 경로의 종단면 요약을 조회한다. */
|
||||
export async function getSections(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): Promise<SectionSummaryResponse> {
|
||||
return requestJson<SectionSummaryResponse>(`/projects/${projectId}/sections/${routeId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
/** 경로의 종·횡단면을 확정한다. */
|
||||
export async function confirmSections(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): Promise<SectionConfirmResponse> {
|
||||
return requestJson<SectionConfirmResponse>(`/projects/${projectId}/sections/${routeId}/confirm`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -2,27 +2,251 @@
|
||||
* B06_wf3_ProfileCross_UI_Page.ts
|
||||
* 로그인 후 06: 3차 워크플로우 (종·횡단 생성)
|
||||
*
|
||||
* ⚠️ 좌측 입력 패널 / 우측 WebCAD 뷰어 본문은 준비 중 — 워크플로우 셸(헤더+
|
||||
* 스텝바 = 3단 레이아웃)만 구성. 실제 본문은 0_old 참고하여 추후 구체화.
|
||||
* 3단 레이아웃 (frontend.md §2):
|
||||
* 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowShell)
|
||||
* 좌측: 대상 경로 ID + 지표면 참조 + 측점/횡단 옵션 폼
|
||||
* 우측: 종·횡단 생성 결과(연장·횡단 개수·파일) 카드
|
||||
*
|
||||
* 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowShell 재사용.
|
||||
* 이벤트 핸들러 명명 (frontend.md §4): onB06_Profile_[기능]_[액션]
|
||||
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createWorkflowShell,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
confirmSections,
|
||||
generateSections,
|
||||
type SectionGenerateResponse,
|
||||
} from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
import "./B06_wf3_ProfileCross_UI_Style.css";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 페이지 진입점
|
||||
* -------------------------------------------------------------------------- */
|
||||
/** 라벨 + fieldset 그룹 컨테이너 생성. */
|
||||
function buildGroup(legend: string): HTMLElement {
|
||||
const group = document.createElement("fieldset");
|
||||
group.className = "b06-profile__group";
|
||||
const legendEl = document.createElement("legend");
|
||||
legendEl.className = "b06-profile__group-legend";
|
||||
legendEl.textContent = legend;
|
||||
group.append(legendEl);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** 숫자 입력값을 파싱. 빈 값이면 null. */
|
||||
function parseNumber(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function renderB06ProfileCross(root: HTMLElement): void {
|
||||
renderPendingWorkflow(root, {
|
||||
const shell = createWorkflowShell({
|
||||
title: L("B06_Profile_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 2,
|
||||
});
|
||||
|
||||
let currentRouteId: number | null = null;
|
||||
|
||||
/* ---- 좌측: 대상 경로 ---- */
|
||||
const routeGroup = buildGroup(L("B06_Profile_Group_Route"));
|
||||
const routeIdField = createInputField({
|
||||
label: L("B06_Profile_Field_RouteId"),
|
||||
type: "number",
|
||||
min: 1,
|
||||
});
|
||||
const filterField = createInputField({ label: L("B06_Profile_Field_Filter"), type: "text" });
|
||||
const methodField = createInputField({
|
||||
label: L("B06_Profile_Field_Method"),
|
||||
type: "text",
|
||||
value: "dtm",
|
||||
});
|
||||
const crsField = createInputField({
|
||||
label: L("B06_Profile_Field_Crs"),
|
||||
type: "text",
|
||||
placeholder: "EPSG:5178",
|
||||
});
|
||||
routeGroup.append(routeIdField.root, filterField.root, methodField.root, crsField.root);
|
||||
|
||||
/* ---- 좌측: 측점/횡단 옵션 ---- */
|
||||
const optionGroup = buildGroup(L("B06_Profile_Group_Options"));
|
||||
const stationField = createInputField({
|
||||
label: L("B06_Profile_Field_StationInterval"),
|
||||
type: "number",
|
||||
});
|
||||
const halfWidthField = createInputField({
|
||||
label: L("B06_Profile_Field_CrossHalfWidth"),
|
||||
type: "number",
|
||||
});
|
||||
const crossSampleField = createInputField({
|
||||
label: L("B06_Profile_Field_CrossSample"),
|
||||
type: "number",
|
||||
});
|
||||
const longSampleField = createInputField({
|
||||
label: L("B06_Profile_Field_LongSample"),
|
||||
type: "number",
|
||||
});
|
||||
|
||||
const smoothLabel = document.createElement("label");
|
||||
smoothLabel.className = "b06-profile__check";
|
||||
const smoothBox = document.createElement("input");
|
||||
smoothBox.type = "checkbox";
|
||||
const smoothText = document.createElement("span");
|
||||
smoothText.textContent = L("B06_Profile_Field_Smooth");
|
||||
smoothLabel.append(smoothBox, smoothText);
|
||||
|
||||
optionGroup.append(
|
||||
stationField.root,
|
||||
halfWidthField.root,
|
||||
crossSampleField.root,
|
||||
longSampleField.root,
|
||||
smoothLabel,
|
||||
);
|
||||
|
||||
const generateButton = createButton({
|
||||
label: L("B06_Profile_Btn_Generate"),
|
||||
variant: "filled",
|
||||
onClick: () => void onB06_Profile_Generate_Click(),
|
||||
});
|
||||
const confirmButton = createButton({
|
||||
label: L("B06_Profile_Btn_Confirm"),
|
||||
variant: "ghost",
|
||||
onClick: () => void onB06_Profile_Confirm_Click(),
|
||||
});
|
||||
const actionRow = document.createElement("div");
|
||||
actionRow.className = "b06-profile__actions";
|
||||
actionRow.append(generateButton, confirmButton);
|
||||
|
||||
const leftForm = document.createElement("div");
|
||||
leftForm.className = "b06-profile__form";
|
||||
leftForm.append(routeGroup, optionGroup, actionRow);
|
||||
shell.leftPanel.append(leftForm);
|
||||
|
||||
/* ---- 우측: 결과 ---- */
|
||||
const resultTitle = document.createElement("h3");
|
||||
resultTitle.className = "b06-profile__result-title";
|
||||
resultTitle.textContent = L("B06_Profile_Result_Title");
|
||||
const resultBody = document.createElement("div");
|
||||
resultBody.className = "b06-profile__result-body";
|
||||
|
||||
function renderEmptyResult(): void {
|
||||
resultBody.replaceChildren();
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b06-profile__empty";
|
||||
empty.textContent = L("B06_Profile_Result_Empty");
|
||||
resultBody.append(empty);
|
||||
}
|
||||
|
||||
function metricRow(label: string, value: string): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "b06-profile__metric";
|
||||
const key = document.createElement("span");
|
||||
key.className = "b06-profile__metric-key";
|
||||
key.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.className = "b06-profile__metric-val";
|
||||
val.textContent = value;
|
||||
row.append(key, val);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderResult(result: SectionGenerateResponse): void {
|
||||
resultBody.replaceChildren();
|
||||
resultBody.append(
|
||||
metricRow(L("B06_Profile_Result_Length"), result.length_m.toFixed(2)),
|
||||
metricRow(L("B06_Profile_Result_CrossCount"), String(result.cross_section_count)),
|
||||
metricRow(L("B06_Profile_Result_Path"), result.longitudinal_file_path),
|
||||
);
|
||||
}
|
||||
|
||||
const resultCard = document.createElement("div");
|
||||
resultCard.className = "b06-profile__result";
|
||||
resultCard.append(resultTitle, resultBody);
|
||||
shell.rightArea.append(resultCard);
|
||||
|
||||
/* ---- 이벤트 핸들러 ---- */
|
||||
function getProjectId(): string | null {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (!projectId) showToast(L("B06_Profile_Error_Project"), "error");
|
||||
return projectId;
|
||||
}
|
||||
|
||||
async function onB06_Profile_Generate_Click(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId) return;
|
||||
|
||||
const routeId = parseNumber(routeIdField.input.value);
|
||||
if (routeId === null || !Number.isInteger(routeId) || routeId <= 0) {
|
||||
routeIdField.setError(L("B06_Profile_Error_RouteId"));
|
||||
return;
|
||||
}
|
||||
routeIdField.setError();
|
||||
|
||||
const filterKey = filterField.input.value.trim();
|
||||
if (!filterKey) {
|
||||
filterField.setError(L("B06_Profile_Error_Filter"));
|
||||
return;
|
||||
}
|
||||
filterField.setError();
|
||||
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const result = await generateSections(projectId, {
|
||||
route_id: routeId,
|
||||
filter_key: filterKey,
|
||||
method: methodField.input.value.trim() || "dtm",
|
||||
smooth: smoothBox.checked,
|
||||
crs: crsField.input.value.trim() || null,
|
||||
station_interval_m: parseNumber(stationField.input.value),
|
||||
cross_half_width_m: parseNumber(halfWidthField.input.value),
|
||||
cross_sample_interval_m: parseNumber(crossSampleField.input.value),
|
||||
long_sample_interval_m: parseNumber(longSampleField.input.value),
|
||||
});
|
||||
currentRouteId = result.route_id;
|
||||
renderResult(result);
|
||||
showToast(L("B06_Profile_Generate_Success"), "success");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B06_Profile_Generate_Failed");
|
||||
showToast(`${L("B06_Profile_Generate_Failed")} ${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function onB06_Profile_Confirm_Click(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId) return;
|
||||
const routeId = currentRouteId ?? parseNumber(routeIdField.input.value);
|
||||
if (routeId === null || !Number.isInteger(routeId) || routeId <= 0) {
|
||||
routeIdField.setError(L("B06_Profile_Error_RouteId"));
|
||||
return;
|
||||
}
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await confirmSections(projectId, routeId);
|
||||
showToast(L("B06_Profile_Confirm_Success"), "success");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed");
|
||||
showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
renderEmptyResult();
|
||||
root.replaceChildren(shell.root);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/* =============================================================================
|
||||
* B06_wf3_ProfileCross_UI_Style.css
|
||||
* 3차 워크플로우(종·횡단 생성) 페이지 전용 스타일.
|
||||
*
|
||||
* 원칙(frontend.md §1): 하드코딩 색상 금지. theme.css 변수(var(--...))만 참조.
|
||||
* 공통 컴포넌트 스타일은 ui_template_elements.ts가 주입하므로 여기서는
|
||||
* B06 고유 레이아웃(옵션 그룹/결과 메트릭)만 정의한다.
|
||||
* ========================================================================== */
|
||||
|
||||
/* --- 좌측 입력 폼 --- */
|
||||
.b06-profile__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b06-profile__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
margin: 0;
|
||||
padding: var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b06-profile__group-legend {
|
||||
padding: 0 var(--spacing-8);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* --- 체크박스 --- */
|
||||
.b06-profile__check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-body);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-profile__check input {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* --- 액션 버튼 행 --- */
|
||||
.b06-profile__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* --- 우측 결과 --- */
|
||||
.b06-profile__result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
padding: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b06-profile__result-title {
|
||||
font-size: var(--text-subheading);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.b06-profile__empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b06-profile__result-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.b06-profile__metric {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-16);
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
border-radius: var(--radius-inputs);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
.b06-profile__metric-key {
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b06-profile__metric-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-body);
|
||||
word-break: break-all;
|
||||
text-align: right;
|
||||
}
|
||||
Reference in New Issue
Block a user