/* ============================================================================= * B07_DesignDetail_UI_StandardSpec.ts * 표준도 **제원 입력 칸** — 장 하나가 곧 제원 조합 하나이므로 여기서 고치면 그 조합 전부. * * 왜 여기인가 (PLAN 4-5b) — `phase: "detail"` 칸(돌 종류·조달·뒷길이·전면 기울기)을 그리는 * 화면이 없었다(2026-09-09 실측: B06 구조물 폼은 b05 phase 열 칸만 그림). B06 은 * 「어디에·몇 m」(배치), 표준도는 「어떤 제원」이다. * * ⚠ **빈 칸이 「자동」의 뜻**이다(확정 ⑨). 기울기를 비우면 품셈 표준경사 판정이 돌고, * 채우면 그 값이 이긴다. 그래서 화면도 **빈 칸을 기본으로** 두고, 판정된 값은 회색 * 도움말로만 비춘다 — 칸에 미리 적어 두면 「정한 적 없음」이 사라진다. * ⚠ **막지 않는다.** 실무 도면에 1:0.7·1:0.8 이 실재하므로 범위 밖 값도 받고 안내만 띄운다. * ========================================================================== */ const STONE_KINDS = ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"] as const; const SUPPLIES = ["채집", "구입"] as const; const BACK_LENGTHS = ["25", "30", "35", "45", "55", "60", "75"] as const; /** 정본 xls 탭 제목 그대로 — 터파기 기초 몫 0.45 대 0.07 을 가르는 축. */ const FOUNDATIONS = ["기초유", "기초버림"] as const; /** 확정 ⑨ — 품셈 열이 기본, 실무 관행(깬돌 열)으로 바꿀 수 있게. */ const COEFF_BASES = ["품셈", "실무 관행"] as const; /** 확정 2차 ⑩ — 기본 210. 180 은 국가기준 하한(돌쌓기 전용). */ const FILL_MPA = ["180", "210"] as const; /** 표준도 장 하나 — 서버가 낸 것 중 이 폼이 쓰는 것만. */ export interface StandardSheetSpec { key: string; title: string; type_id: string; member_count: number; options: Record; } export interface StandardSpecResult { sheet_key: string; stone_kind: string | null; stone_supply: string | null; back_len_cm: string | null; face_slope_ratio: string | null; foundation: string | null; stone_coeff_basis: string | null; fill_concrete_mpa: string | null; thickness_top_m: string | null; thickness_bottom_m: string | null; } /** 이 종류가 돌쌓기 계열인가 — 옹벽·집수정에는 이 칸들이 뜻이 없다. */ const STONE_TYPES = new Set(["masonry_wet", "masonry_dry", "boulder_masonry"]); /** 벽 두께 칸을 받는 종류 — **큰돌쌓기는 두께 식이 달라** 등록부에 칸이 없다(2026-09-09). */ const THICKNESS_TYPES = new Set(["masonry_wet", "masonry_dry"]); function field(label: string, control: HTMLElement, hint?: string): HTMLLabelElement { const wrap = document.createElement("label"); wrap.className = "b07-spec__field"; const name = document.createElement("span"); name.className = "b07-spec__label"; name.textContent = label; wrap.append(name, control); if (hint) { const help = document.createElement("small"); help.className = "b07-spec__hint"; help.textContent = hint; wrap.append(help); } return wrap; } function select( choices: readonly string[], current: unknown, autoLabel: string, ): HTMLSelectElement { const el = document.createElement("select"); el.className = "b07-spec__input"; // 첫 보기가 **빈 값** — 「정한 적 없음」이 고를 수 있는 상태여야 한다. const blank = document.createElement("option"); blank.value = ""; blank.textContent = autoLabel; el.append(blank); for (const choice of choices) { const option = document.createElement("option"); option.value = choice; option.textContent = choice; el.append(option); } el.value = current == null ? "" : String(current); return el; } /** * 표준도 제원 폼. `onSave` 는 서버에 넘길 값을 받는다 — 빈 문자열은 **그 칸을 지우라**는 뜻. */ export function buildStandardSpecPanel( sheet: StandardSheetSpec, judgedSlope: string | null, onSave: (result: StandardSpecResult) => Promise, initialNotes: string[] = [], ): HTMLDivElement { const panel = document.createElement("div"); panel.className = "b07-spec ui-sidebar-section"; const title = document.createElement("h3"); title.className = "b07-spec__title"; title.textContent = `제원 — ${sheet.title}`; const scope = document.createElement("p"); scope.className = "b07-spec__scope"; scope.textContent = `이 장의 ${sheet.member_count}개소에 함께 걸립니다.`; panel.append(title, scope); if (!STONE_TYPES.has(sheet.type_id)) { const none = document.createElement("p"); none.className = "b07-spec__scope"; none.textContent = "이 종류는 표준도에서 받는 제원 칸이 아직 없습니다."; panel.append(none); return panel; } const options = sheet.options ?? {}; const kind = select(STONE_KINDS, options.stone_kind, "— 안 정함 —"); const supply = select(SUPPLIES, options.stone_supply, "— 안 정함(기본 채집) —"); const back = select(BACK_LENGTHS, options.back_len_cm, "— 안 정함 —"); const foundation = select(FOUNDATIONS, options.foundation, "— 안 정함 —"); const coeff = select(COEFF_BASES, options.stone_coeff_basis, "— 안 정함(품셈) —"); const mpa = select(FILL_MPA, options.fill_concrete_mpa, "— 안 정함(210) —"); const slope = document.createElement("input"); slope.className = "b07-spec__input"; slope.type = "text"; slope.inputMode = "decimal"; slope.placeholder = "비우면 자동"; slope.value = options.face_slope_ratio == null ? "" : String(options.face_slope_ratio); // 벽 두께 — 비우면 실무 구조물도 식(상부 = 뒷길이 + 0.30 · 하부 = 상부 + 0.30×(H−1)). const thickness = (key: "thickness_top_m" | "thickness_bottom_m"): HTMLInputElement => { const input = document.createElement("input"); input.className = "b07-spec__input"; input.type = "text"; input.inputMode = "decimal"; input.placeholder = "비우면 식"; input.value = options[key] == null ? "" : String(options[key]); return input; }; const topThickness = thickness("thickness_top_m"); const bottomThickness = thickness("thickness_bottom_m"); panel.append( field("돌 종류", kind), field("조달", supply, "비우면 「캔다」로 봅니다."), field("뒷길이 (㎝)", back, "품셈 일곱 규격 밖이면 물량이 서지 않습니다."), field("기초", foundation, "터파기 몫이 갈립니다 — 기초유 0.45 · 기초버림 0.07 ㎥/m."), field( "전면 기울기 1:n", slope, judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.", ), field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."), field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."), ); if (THICKNESS_TYPES.has(sheet.type_id)) { panel.append( field("상부 두께 (m)", topThickness, "비우면 뒷길이 + 0.30 으로 섭니다."), field("하부 두께 (m)", bottomThickness, "비우면 상부 + 0.30×(H−1) 로 섭니다."), ); } const notes = document.createElement("ul"); notes.className = "b07-spec__notes"; // ⚠ 저장하면 표·그림을 다시 받으면서 **이 폼이 통째로 새로 그려진다** — 그때 안내가 // 지워지지 않게 밖에서 들고 있다가 다시 넣는다(2026-09-09 실화면에서 잡음). const showNotes = (messages: string[]): void => { notes.replaceChildren( ...messages.map((text) => { const item = document.createElement("li"); item.textContent = text; return item; }), ); notes.hidden = messages.length === 0; }; showNotes(initialNotes); const save = document.createElement("button"); save.type = "button"; save.className = "b07-spec__save"; save.textContent = "제원 저장"; save.addEventListener("click", () => { void (async () => { save.disabled = true; save.textContent = "저장 중…"; try { showNotes( await onSave({ sheet_key: sheet.key, stone_kind: kind.value || null, stone_supply: supply.value || null, back_len_cm: back.value || null, face_slope_ratio: slope.value.trim() || null, foundation: foundation.value || null, stone_coeff_basis: coeff.value || null, fill_concrete_mpa: mpa.value || null, thickness_top_m: THICKNESS_TYPES.has(sheet.type_id) ? topThickness.value.trim() || null : null, thickness_bottom_m: THICKNESS_TYPES.has(sheet.type_id) ? bottomThickness.value.trim() || null : null, }), ); } catch (error) { // 실패도 같은 자리에 적는다 — 조용히 끝나면 사용자는 저장된 줄 안다. showNotes([error instanceof Error ? error.message : "제원을 저장하지 못했습니다."]); } finally { save.disabled = false; save.textContent = "제원 저장"; } })(); }); panel.append(save, notes); return panel; }