Files
Aislo/B06_Section/B06_Section_UI_Missing_Stations.ts
T
eomsangdonandClaude Opus 5.5 e2b3a87828 refactor(B06): 줌 · 조정창 단추 · 머리 알약 · 알림 칩 · 접이 넷 · 띠 알림 · 빈 상태를 부품으로 (PLAN 553-2)
- 줌 [+ − ⤢] = 아이콘 단추 22 · 상세 [칸에 넣기] = 단추 22 · [여럿] = 켜기 칩 22 · 사이 금 지움
- 구조물 조정창 십자 · 9키 · 단 수 [-][+] = 아이콘 단추(선) 22 · ✕ = 고스트 · 옵션 연동 · 경사 = 켜기 칩 24(유입 · 유출 칸 꼴은 그대로)
- 카드 머리 단면유형 = 배지(정보) 20 · 구조물 = 배지(강조) 20
- 알림 칩 = 단추(고스트) 20(색만 경고 / 정보) · 측점 목록 날 단추 = 단추(고스트) 20
- 날 details 넷(변수 위치 안내 · 상세값 · 다른 프로젝트 불러오기 · 횡단 조정) = 접이 구역(ui-collapsible) · 가이드 장면 선택자 `details.` 뗌
- 측점 없는 구조물 줄 · 표준 미반영 알림 = 띠 알림(경고) — 날 색 #fff8e6 · #e0b872 · #7a5a12 · #8a6a22 · 폴백 #d98324 지움
- 빈 그림 자리 = 빈 상태 · 선 치수 글 옛 보라 이름 → --color-accent
- 흉내 DOM 시험 · 서버 Node 묶음이 끄는 파일이라 부품 함수 대신 클래스 · data-size 계약 + 부품 CSS 직접 실음(묶음 셋 빌드 정상)
- 시험 넷 기대 고침 — 108(닫기 클래스) · 179-1(줌 사이 금 규칙 없음) · 196-1(칩 클래스) · 207-1(칩 반경 = 부품)
- 그대로 둔 것 — 수량 산출표(b06-detail__qtable · B07 산출서 병합 칸 · 세로선 = 부품 표에 없는 꼴) · 조정창 고르기 · 암 절토 칸(작은 단 없음) · 조정창 · ⋯ · 측점 목록 떠 있는 판

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:08:13 +09:00

143 lines
6.2 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Missing_Stations.ts
* 「측점 없는 관 N개」 알림 + [측점 만들기] 단추 (계획서 3-14 ㉯).
*
* 무엇이 문제였나 — 측점을 만드는 자리는 **B05 노선 [확정] 한 곳뿐**이라, 관을 나중에
* 놓거나 옮기면 그 측점이 안 생긴다. 그 관은 횡단도에도 안 서고 **수량·금액에서 통째로
* 빠지는데 아무 말도 안 나온다**(실측: 배수관 넷이 B09 에서 막혀 있었다).
*
* ⚠ 자동으로 만들지 않는다 — 사용자가 누를 때만 돈다(비용이 큰 지표 샘플링이다).
* 대신 **왜 값이 없는지**가 화면에 남는다.
* ⚠ 지표 샘플링 조건이 저장에 없으면 **단추를 잠그고 사유를 보인다** — 조건을 지어내면
* 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
* [저장] 뒤 다시 봄(428) — 관 · 구조물 초안이 정본으로 나가 비면 저장이 끝난 뒤 한 번 더 묻고 줄을 갈음
* (서버가 관 PUT 끝에 측점을 다시 세움 · 옛 알림이 남지 않게).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, showToast } from "@ui/ui_template_elements";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { useDesignStore } from "../A00_Common/design/design_store";
import { savingDesign } from "../A00_Common/design/design_save";
import { pageRouteId } from "../A00_Common/design/design_page_route";
import { invalidateSectionDetail, loadSectionDetail } from "./B06_Section_Section_Store";
import "@ui/ui_template_banner.css"; // 줄 = 부품 띠 알림 클래스(553-2)
interface MissingStation {
chainage_m: number;
label: string;
}
interface MissingResponse {
missing?: MissingStation[];
can_create?: boolean;
reason?: string;
created?: number;
message?: string;
}
async function call(projectId: string, method: "GET" | "POST"): Promise<MissingResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/section/missing-stations`, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
});
const payload = (await response.json()) as MissingResponse;
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
/**
* 빠진 측점이 있으면 알림 줄을 `host` 맨 앞에 얹는다. 없으면 아무것도 하지 않는다.
* `onCreated` 는 측점이 실제로 생긴 뒤에만 불린다(화면을 다시 읽는 자리).
*/
export async function mountMissingStationNotice(
host: HTMLElement,
projectId: string,
onCreated: () => void | Promise<void>,
): Promise<void> {
watchSaves(host, projectId, onCreated);
let data: MissingResponse;
try {
data = await call(projectId, "GET");
} catch {
return; // 점검이 안 되는 것으로 화면을 막지 않는다 — 이 줄은 덤이다.
}
host.querySelector(":scope > .b06-missing-stations")?.remove(); // 다시 봄 = 줄 하나
const missing = data.missing ?? [];
if (!missing.length) return;
const box = document.createElement("div");
// 부품 띠 알림(경고 · 553-2) — 클래스 계약(흉내 DOM 시험이 띠 부품 파일을 안 풂)
box.className = "ui-banner ui-banner--warning b06-missing-stations";
box.role = "status";
const text = document.createElement("p");
text.className = "ui-banner__text b06-missing-stations__text";
const where = missing
.slice(0, 6)
.map((item) => `${item.chainage_m.toFixed(2)}m ${item.label}`)
.join(" · ");
text.textContent =
`측점이 없는 구조물 ${missing.length}개 — ${where}` +
(missing.length > 6 ? ` 외 ${missing.length - 6}개` : "");
// 긴 까닭 글 = 호버(196-1)
attachHoverInfo(text, "이 자리는 횡단도에도 안 서고 수량에서도 빠집니다.");
box.append(text);
if (data.can_create) {
const button = createButton({ label: "측점 만들기", variant: "filled" });
button.addEventListener("click", async () => {
button.disabled = true;
button.textContent = "만드는 중…";
try {
const result = await call(projectId, "POST");
showToast(`측점 ${result.created ?? 0}개를 만들었습니다.`, "success");
box.remove();
// 서버가 측점을 세움 = 옛 상세 — 캐시 비우고 강제로 받아 듣는 쪽(종단 보기)까지 새것(463)
const routeId = pageRouteId(projectId);
if (routeId !== null) {
invalidateSectionDetail(projectId, routeId);
await loadSectionDetail(projectId, routeId, { force: true });
}
await onCreated();
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`측점을 만들지 못했습니다.${detail}`, "error");
button.disabled = false;
button.textContent = "측점 만들기";
}
});
box.append(button);
} else if (data.reason) {
const reason = document.createElement("p");
reason.className = "b06-missing-stations__reason";
reason.textContent = data.reason;
box.append(reason);
}
host.prepend(box);
}
const watched = new WeakSet<HTMLElement>();
/** 관 · 구조물 초안이 비면(저장 · 되돌림) 저장이 끝난 뒤 다시 물음 — 자리(`host`)가 떠나면 끊음. */
function watchSaves(
host: HTMLElement,
projectId: string,
onCreated: () => void | Promise<void>,
): void {
if (watched.has(host)) return;
watched.add(host);
const store = useDesignStore(projectId, null);
let timer = 0;
// ponytail: 저장 끝 신호가 없어 0.3 s 마다 봄 — 저장 끝 알림 창구가 생기면 그리로
const recheck = (): void => {
window.clearTimeout(timer);
if (!host.isConnected) return stops.forEach((stop) => stop());
if (savingDesign(projectId)) timer = window.setTimeout(recheck, 300);
else void mountMissingStationNotice(host, projectId, onCreated);
};
const stops = (["pipes", "structures"] as const).map((key) =>
store.subscribe(key, (value) => value === null && recheck()),
);
}