Files
Aislo/B08_Quantity/B08_Quantity_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 676b46eb9e feat(B 페이지): 떠남 물음 가드 · 지금 프로젝트 = 탭마다 (PLAN 572-2 · 요구 400~403)
- B04 · B07 · B08 · B09 가 공용 guardProjectLeave 에 {dirty, save, discard} 등록 — 고침 있을 때만 [취소 · 폐기 · 저장]
  - B04 = 관 지점 고침(drainageDirty) · 저장 = 관 지점 · 세부유역 영구저장
  - B07 = CAD · 탭 고침 · 저장 = saveEditedDrawings · 폐기 = 고친 도면 CAD 자동백업도 버림(discardEditedRecovery)
  - B08 = 세션 b08-* 칸 넷 · 저장 = saveShown · B09 = b09-estimation 칸 · 저장 = saveChanged
- 지금 프로젝트 직접 읽기(localStorage) → currentProjectId() · B02 새 프로젝트 = setCurrentProject — B02 · B03 · B04 · B07 · B08 · B09 · B11
- 다른 프로젝트 보관함 지우기 셋(B04 · B05 종단 · B11) 뺌 — enterProject 한 곳 · 다른 탭 보관분 안 지움
- B02 · B03 은 가드 없음 — 저장 안 한 프로젝트 값 없음(B03 올리기 끊김은 이어 올리기 배너가 받음)
- 시험 test_560_4 넓힘(B 가드 · 탭 프로젝트 · 보관함) · helper_298_2 · helper_481_2 · test_204_2 흉내를 currentProjectId 로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:07:58 +09:00

183 lines
8.2 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_Page.ts
* 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61)
*
* 탭 = 구조물 집계표(61-1) · 자재 · 토공 · 수리집수면적표 (PLAN 95-1 · 160-1) · 거리이정표(244-1). 위 탭 단추 = 공용
* 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1). 구조물도는 집계표 행을 눌러 여는 모달(95-3).
* 자재 아래 보조 탭 = 총괄자재수량집계표 · 콘크리트소요자재집계표 · 자재수량집계표(121-1 `…_UI_Material`).
* 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀.
* 좌측 패널 = 메인 탭과 같은 컨테이너 셋 + [저장] 줄(`B08_Quantity_UI_Options` · PLAN 122).
* 단계 이동(☰ · 진행 단계 · [<] [>]) = 보이는 화면(좌측 옵션 + 지금 탭) 저장 성공 뒤 · 실패면 머묾(481-2).
* B09 쪽 이동 = 저장 뒤 POST …/quantity/complete(단계 5 완료 · 요구 298-2 · 300 B1) 성공 뒤.
* ========================================================================== */
import "@ui/ui_template_general_layout.css";
import "./B08_Quantity_UI_Page.css";
import { createText } from "@ui/ui_template_text";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { fillTabs } from "../A00_Common/b_page_tabs";
import { moveStage, WORKFLOW_STEP_ROUTES, type StageStep } from "../A00_Common/b_workflow_nav";
import { renderStructureSummary } from "./B08_Quantity_UI_Summary";
import { renderMaterialSheet } from "./B08_Quantity_UI_Material";
import { renderHydro, request } from "./B08_Quantity_UI_Hydro";
import { renderEarthwork } from "./B08_Quantity_UI_Earthwork";
import { createOptionsPanel } from "./B08_Quantity_UI_Options";
import { renderMilestone } from "./B08_Quantity_UI_Milestone";
import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_switch";
import { clearState, readState } from "../A00_Common/b_page_state";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const TABS = [
{ id: "summary", label: "B08_Quantity_Tab_StructureSummary" },
{ id: "material", label: "B08_Quantity_Tab_Materials" },
{ id: "earthwork", label: "B08_Quantity_Tab_Earthwork" },
{ id: "hydrology", label: "B08_Quantity_Tab_Hydrology" },
{ id: "milestone", label: "B08_Quantity_Tab_Milestone" },
] as const;
type TabId = (typeof TABS)[number]["id"];
/** 자재 탭 보조 탭 — 실무 엑셀 차례. */
const SHEETS = [
{ id: "material", label: "B08_Quantity_Tab_MaterialTotal" },
{ id: "concrete", label: "B08_Quantity_Tab_ConcreteMaterial" },
{ id: "quantity", label: "B08_Quantity_Tab_MaterialQuantity" },
] as const;
type SheetId = (typeof SHEETS)[number]["id"];
/** 지금 탭의 이동 전 저장 — 자재 · 수리 탭이 서면 등록 · 떠난 탭(`host.isConnected` 거짓)은 안 씀(481-2). */
type TabSave = (host: HTMLElement, save: () => Promise<boolean>) => void;
function pendingNote(): HTMLElement {
return createText(L("B08_Quantity_Tab_Pending"), "muted", 14);
}
/** 자재 탭 — 보조 탭 줄 + 몸통(보조 탭마다 새 몸통 · 늦은 응답이 다른 보조 탭을 덮지 않게). */
function renderMaterials(host: HTMLElement, projectId: string | null, onSave: TabSave): void {
const tabs = document.createElement("div");
tabs.className = "b08-quantity__tabs b08-structure__tabs";
const view = document.createElement("div");
const select = (picked: SheetId): void => {
fillTabs(tabs, {
tabs: SHEETS.map(({ id, label }) => ({ key: id, label: L(label) })),
value: picked,
kind: "pill",
data: "b08StructureSheet",
onChange: (id) => select(id as SheetId),
});
const body = document.createElement("div");
view.replaceChildren(body);
if (!projectId) body.append(pendingNote());
else void renderMaterialSheet(body, projectId, picked, (save) => onSave(body, save));
};
host.replaceChildren(tabs, view);
select("material");
}
/** 탭마다 새 몸통 — 늦게 온 응답이 다른 탭 화면을 덮지 않게(떨어진 몸통에만 그림). */
function renderTab(host: HTMLElement, tab: TabId, projectId: string | null, onSave: TabSave): void {
const view = document.createElement("div");
host.replaceChildren(view);
if (tab === "material") return renderMaterials(view, projectId, onSave);
if (tab === "summary" && projectId) {
// 보조 탭 줄 자리(170-4) — 보이지 않는 보조 탭 한 칸 · 자재 탭과 같은 높이라 탭 바꿀 때 안 튐
const blank = document.createElement("div");
blank.className = "b08-quantity__tabs b08-quantity__tabs--blank";
fillTabs(blank, {
tabs: [{ key: SHEETS[0].id, label: L(SHEETS[0].label) }],
value: SHEETS[0].id,
kind: "pill",
data: "b08StructureSheet",
onChange: () => undefined,
}).forEach((button) => {
button.tabIndex = -1;
delete button.dataset.b08StructureSheet;
});
blank.setAttribute("aria-hidden", "true");
blank.setAttribute("role", "presentation");
const body = document.createElement("div");
view.append(blank, body);
return void renderStructureSummary(body, projectId);
}
if (tab === "hydrology" && projectId)
return void renderHydro(view, projectId, (save) => onSave(view, save));
if (tab === "earthwork" && projectId) return void renderEarthwork(view, projectId);
if (tab === "milestone" && projectId) return renderMilestone(view, projectId);
view.append(pendingNote());
}
export function renderB08Quantity(root: HTMLElement): void {
const projectId = currentProjectId();
const tabBar = document.createElement("div");
tabBar.className = "b08-quantity__tabs";
const panel = document.createElement("div");
panel.className = "b08-quantity__panel";
panel.setAttribute("role", "tabpanel");
let current: TabId = "summary";
let tabSave: { host: HTMLElement; save: () => Promise<boolean> } | null = null;
const onTabSave: TabSave = (host, save) => (tabSave = { host, save });
const select = (tab: TabId): void => {
current = tab;
fillTabs(tabBar, {
tabs: TABS.map(({ id, label }) => ({ key: id, label: L(label) })),
value: tab,
kind: "line",
size: 32,
data: "b08Tab",
onChange: (id) => select(id as TabId),
});
renderTab(panel, tab, projectId, onTabSave);
};
const body = document.createElement("div");
body.className = "b08-quantity";
body.append(tabBar, panel);
select("summary");
// [저장] 뒤 열린 탭을 서버 정본으로 다시 그림
const options = createOptionsPanel(projectId, () => select(current));
// 왼쪽 → 지금 탭 차례 · 탭 저장은 왼쪽 저장이 탭을 다시 그리기 전에 쥠
const saveShown = async (): Promise<boolean> => {
const tab = tabSave?.host.isConnected ? tabSave.save : null;
return (await options.save()) && (!tab || (await tab()));
};
const layout = createWorkflowLayout({
title: L("B08_Quantity_Title"),
steps: workflowSteps(),
activeStep: 5,
mainContent: body,
leftPanel: options.panel,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex: number) => {
if (!projectId) return;
const steps: StageStep[] = [{ name: "수량 저장", run: saveShown }];
// B09 쪽 = 단계 5 완료 뒤(요구 300 B1) · 실패면 머묾
if (stepIndex > 5)
steps.push({
name: "수량 산출 단계 완료",
run: async () =>
void (await request(`/projects/${projectId}/quantity/complete`, { method: "POST" })),
});
void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], steps);
},
});
root.append(layout.root);
// 572-2 떠남 물음 — 고침 = 세션 b08-* 칸 · 저장 = 단계 이동과 같은 길
const drafts = ["b08-options", "b08-materials", "b08-hydro", "b08-milestone"] as const;
if (projectId)
guardProjectLeave(layout.root, {
dirty: () => drafts.some((name) => readState(name, projectId) !== null),
save: saveShown,
discard: () => drafts.forEach((name) => clearState(name, projectId)),
});
}