diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts b/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts index e4012364c..d8cdcb0c2 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts @@ -4,7 +4,7 @@ * * 평면도 = 평면도 관리 모달 그대로(`openPlanManager`) * 횡단도 = 장 배치(열 · 행 · [이 장] · [모든 장]) · 표 배율 · [안내선 숨기기] — 좌측 카드 밑에서 옮김 - * 구조물 도면 = 「M02 그림 — 축척 없음」 한 줄 · 종단 · 토적 · 전개도 = 몸 비움(축척 칸은 485-2) + * 구조물 도면 = 「M02 그림 — 축척 없음」 한 줄 · 종단 · 횡단 · 토적 · 전개도 = X · Y 축척 칸(`_UI_Settings_Scale`) * [원본에서 다시 그리기] = 지금 연 도면이 그 묶음일 때만 켬 * ========================================================================== */ @@ -17,6 +17,7 @@ import { isCrossSheet, } from "./B07_DesignDetail_UI_Panels"; import { openPlanManager, type PlanManageControls } from "./B07_DesignDetail_UI_PlanManage"; +import { mountDrawingScale } from "./B07_DesignDetail_UI_Settings_Scale"; type Kind = DesignDrawingItem["kind"]; @@ -81,6 +82,7 @@ export function openDrawingSettings(kind: Kind, deps: DrawingSettingsDeps): void closeModal = close; if (mine) body.append(note(`지금 도면: ${current?.label}`)); if (kind === "structure") body.append(note("M02 그림 — 축척 없음")); + mountDrawingScale(body, kind, deps, close); if (kind === "cross") mountCross(body, deps, close); }, }); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Settings_Scale.ts b/B07_DesignDetail/B07_DesignDetail_UI_Settings_Scale.ts new file mode 100644 index 000000000..28352bd08 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_Settings_Scale.ts @@ -0,0 +1,157 @@ +/* ============================================================================= + * B07_DesignDetail_UI_Settings_Scale.ts + * ⚙ 모달 X · Y 축척 칸(485-2 · 요구 280-5) — 종단 · 횡단 · 토적 · 전개도만. + * + * 칸 = 분모 정수 · 범위 밖 = 칸 붉게 + [축척 저장] 꺼짐 · 기본이 자동인 칸(토적 X · 전개도) = 「자동」 + * [축척 저장] = 그 종류 저장본이 있으면 n 장 물음(취소 = 그대로) → PUT → 화면 다시 세움(도면 다시 그림) + * [기본값] = 칸만 기본으로 채움 — 저장은 [축척 저장] + * ========================================================================== */ + +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { + createButton, + createInputField, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { + type DrawingScaleAxes, + type DrawingScaleKind, + type DrawingScales, + fetchDrawingScales, + saveDrawingScales, +} from "./B07_DesignDetail_Api_Fetch"; +import type { DrawingSettingsDeps } from "./B07_DesignDetail_UI_Settings"; +import { readScaleAxis, scaleToSend } from "./logic/B07_DesignDetail_Scale_Check"; + +const LABEL: Record = { + longitudinal: "종단도", + cross: "횡단도", + mass_haul: "토적도", + development: "전개도", +}; +const AXES = [ + ["x", "X(가로) 1/"], + ["y", "Y(세로) 1/"], +] as const; + +const isScaleKind = (kind: string): kind is DrawingScaleKind => kind in LABEL; + +/** ⚙ 모달 몸에 축척 칸을 붙임 — 축척 없는 종류는 아무것도 안 함. */ +export function mountDrawingScale( + body: HTMLElement, + kind: string, + deps: DrawingSettingsDeps, + close: () => void, +): void { + if (!isScaleKind(kind) || !deps.plan) return; + const { projectId, onClosed } = deps.plan; + const section = document.createElement("div"); + section.className = "b07-scale"; + section.textContent = "축척 받는 중…"; + body.append(section); + void fetchDrawingScales(projectId) + .then((data) => { + section.textContent = ""; + buildScaleFields(section, kind, data, async (next) => { + const saved = data.saved[kind] ?? 0; + const ask = `${LABEL[kind]} 축척을 바꾸면 저장본 ${saved}장이 풀립니다(원본에서 다시 그림). 바꿀까요?`; + if (saved > 0 && !(await showConfirmDialog(ask, "바꿈"))) return; + showLoadingOverlay(); + try { + await saveDrawingScales(projectId, { [kind]: next }); + close(); + showToast(`${LABEL[kind]} 축척을 바꿨습니다.`, "success"); + onClosed(true, deps.current()?.id); // 목록 · 도면을 서버에서 다시 받아 다시 그림 + } catch (error) { + showToast( + error instanceof Error ? error.message : "축척을 저장하지 못했습니다.", + "error", + ); + } finally { + hideLoadingOverlay(); + } + }); + }) + .catch(() => { + section.textContent = "축척을 받지 못했습니다."; + }); +} + +function buildScaleFields( + section: HTMLElement, + kind: DrawingScaleKind, + data: DrawingScales, + save: (next: DrawingScaleAxes) => Promise, +): void { + const fields = AXES.map(([axis, label]) => { + const fallback = data.defaults[kind][axis]; + const range = data.ranges[kind][axis]; + const [min, max] = range; + const field = createInputField({ label, type: "number", compact: true, min, max }); + field.root.classList.add("b07-scale__field"); + const auto = fallback === null ? createCheckbox("자동") : undefined; + if (auto) field.root.append(auto.root); + const set = (value: number | null): void => { + field.input.value = value === null ? "" : String(value); + if (auto) auto.input.checked = value === null; + field.input.disabled = value === null && !!auto; + }; + set(data.scales[kind][axis]); + const read = () => readScaleAxis(field.input.value, !!auto?.input.checked, range); + return { axis, field, auto, fallback, set, read }; + }); + + const saveButton = createButton({ + label: "축척 저장", + onClick: () => { + const [x, y] = fields.map((f) => f.read()) as [number | null, number | null]; + const now = data.scales[kind]; + if (x === now.x && y === now.y) { + showToast("바뀐 축척이 없습니다.", "info"); + return; + } + void save({ x: scaleToSend(x, fields[0].fallback), y: scaleToSend(y, fields[1].fallback) }); + }, + }); + const check = (): void => { + let bad = false; + for (const f of fields) { + const result = f.read(); + f.field.setError(typeof result === "string" ? result : undefined); + bad ||= typeof result === "string"; + } + saveButton.disabled = bad; + }; + for (const f of fields) { + f.field.input.addEventListener("input", check); + f.auto?.input.addEventListener("change", () => { + if (f.auto!.input.checked) f.set(null); + else f.field.input.disabled = false; // 빈 칸 — 숫자를 적어야 저장 + check(); + }); + } + const reset = createButton({ + label: "기본값", + variant: "ghost", + onClick: () => { + fields.forEach((f) => f.set(f.fallback)); + check(); + }, + }); + reset.title = "칸을 기본 축척으로 채움 — [축척 저장]으로 씀"; + + const title = document.createElement("p"); + title.className = "b07-settings__note"; + title.textContent = + kind === "cross" ? "축척 — 횡단도 장(표준 횡단면도 뺌)" : `축척 — ${LABEL[kind]}`; + const row = document.createElement("div"); + row.className = "b07-scale__row"; + row.append(...fields.map((f) => f.field.root)); + const buttons = document.createElement("div"); + buttons.className = "b07-scale__actions"; + buttons.append(reset, saveButton); + section.append(title, row, buttons); +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index bd92bbb2c..7ac6e74ca 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -503,3 +503,27 @@ html.is-bm-route .b07-cad-stack > .cad-tabs { gap: var(--spacing-8); min-width: 280px; } + +/* ⚙ 축척 칸(485-2) — X · Y 두 칸 한 줄 · 단추 오른쪽 */ +.b07-scale { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 280px; + margin-bottom: var(--spacing-8); +} + +.b07-scale__row { + display: flex; + gap: var(--spacing-8); +} + +.b07-scale__field { + flex: 1; +} + +.b07-scale__actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); +} diff --git a/B07_DesignDetail/logic/B07_DesignDetail_Scale_Check.ts b/B07_DesignDetail/logic/B07_DesignDetail_Scale_Check.ts new file mode 100644 index 000000000..7c3b2c307 --- /dev/null +++ b/B07_DesignDetail/logic/B07_DesignDetail_Scale_Check.ts @@ -0,0 +1,24 @@ +/* ============================================================================= + * B07_DesignDetail_Scale_Check.ts + * ⚙ 축척 칸(485-2) 판정 — 칸 글 → 분모(정수 · 범위 안) · 자동 = null · 틀리면 까닭 글. + * 서버 `checked_scales`(`_Router_Support_Scale.py`)와 같은 규칙 — 범위는 서버가 내려 줌. + * ========================================================================== */ + +/** 칸 하나 읽기 — 숫자 · null(자동) · 까닭 글(틀림). */ +export function readScaleAxis( + raw: string, + auto: boolean, + [low, high]: [number, number], +): number | null | string { + if (auto) return null; + const text = raw.trim().replace(/,/g, ""); + const value = Number(text); + if (text === "" || !Number.isInteger(value) || value < low || value > high) { + return `1/${low.toLocaleString()}~1/${high.toLocaleString()} 사이 정수`; + } + return value; +} + +/** 보낼 값 — 기본값과 같으면 null(파일엔 기본 그대로 남김). */ +export const scaleToSend = (value: number | null, fallback: number | null): number | null => + value === fallback ? null : value; diff --git a/resources/tester/helper_280_5_b07_settings.cjs b/resources/tester/helper_280_5_b07_settings.cjs index 179a6018e..be4ebe47d 100644 --- a/resources/tester/helper_280_5_b07_settings.cjs +++ b/resources/tester/helper_280_5_b07_settings.cjs @@ -96,6 +96,27 @@ const modals = []; const plans = []; const calls = []; let failConfirm = false; +const SCALES = { + scales: { + longitudinal: { x: 1000, y: 200 }, + cross: { x: 100, y: 100 }, + mass_haul: { x: null, y: 50000 }, + development: { x: null, y: null }, + }, + defaults: { + longitudinal: { x: 1000, y: 200 }, + cross: { x: 100, y: 100 }, + mass_haul: { x: null, y: 50000 }, + development: { x: null, y: null }, + }, + ranges: { + longitudinal: { x: [100, 10000], y: [10, 2000] }, + cross: { x: [10, 1000], y: [10, 1000] }, + mass_haul: { x: [100, 20000], y: [1000, 1000000] }, + development: { x: [10, 10000], y: [10, 10000] }, + }, + saved: { longitudinal: 2, cross: 0, mass_haul: 0, development: 0 }, +}; let answer = true; const button = (opts) => { @@ -118,13 +139,24 @@ const STUBS = { label.className = "ui-field__label"; label.textContent = opts.label; root.append(label, input); - return { root, input }; + const setError = (msg) => (input.error = msg ?? null); + return { root, input, setError }; }, showToast: (message, kind) => toasts.push([kind, message]), showLoadingOverlay: () => calls.push(["overlay+"]), hideLoadingOverlay: () => calls.push(["overlay-"]), showConfirmDialog: async (message) => (calls.push(["ask", message]), answer), }, + "@ui/ui_template_checkbox": { + createCheckbox: (text) => { + const root = new FakeNode("label"); + root.className = "ui-checkbox"; + const input = new FakeNode("input"); + input.checked = false; + root.append(input); + return { root, input }; + }, + }, "@ui/ui_template_modal": { openModal: (opts) => { const body = new FakeNode("div"); @@ -152,6 +184,9 @@ const STUBS = { return { all_confirmed: true }; }, invalidateDesignDrawing: async (projectId, id) => calls.push(["invalidate", id]), + // 485-2 축척 — 범위 · 기본은 서버(`_Router_Support_Scale.py`)와 같은 값 + fetchDrawingScales: async () => JSON.parse(JSON.stringify(SCALES)), + saveDrawingScales: async (projectId, scales) => calls.push(["scales", scales]), }, }; @@ -274,12 +309,14 @@ const drawings = [ openDrawingSettings("cross", deps); await new Promise((r) => setTimeout(r, 0)); const cross = modals.at(-1); - const crossInputs = all(cross.body).filter((n) => n.tagName === "INPUT"); + const layoutNode = byClass(cross.body, "b07-cross-layout")[0]; + const crossInputs = all(layoutNode).filter((n) => n.tagName === "INPUT"); out.crossModal = { title: cross.opts.title, - labels: byClass(cross.body, "ui-field__label").map((n) => n.textContent), + labels: byClass(layoutNode, "ui-field__label").map((n) => n.textContent), values: crossInputs.map((n) => n.value), - buttons: all(cross.body) + scaleTitle: texts(byClass(cross.body, "b07-scale")[0])[0], + buttons: all(layoutNode) .filter((n) => n.tagName === "BUTTON") .map((n) => [n.textContent, n.disabled]), guideLast: byClass(cross.body, "b07-cross-layout")[0].children.at(-1) === guideToggle, @@ -287,7 +324,7 @@ const drawings = [ notes: texts(cross.body).filter((t) => t.startsWith("지금 도면")), }; crossInputs[0].value = "2"; - all(cross.body) + all(layoutNode) .find((n) => n.textContent === "이 장") .click(); out.crossSave = { saved, closed: !cross.open }; @@ -318,6 +355,115 @@ const drawings = [ openDrawingSettings("cross", deps); out.standardInCross = !modals.at(-1).opts.actions[0].disabled; + // ── 축척 칸(485-2) ───────────────────────────────────────────────────── + const closedCalls = []; + const scaleDeps = { ...deps, plan: { projectId: "p1", onClosed: (...a) => closedCalls.push(a) } }; + const tick = () => new Promise((r) => setTimeout(r, 0)); + const scaleOf = async (kind) => { + openDrawingSettings(kind, scaleDeps); + await tick(); + const modal = modals.at(-1); + const section = byClass(modal.body, "b07-scale")[0]; + const inputs = all(section).filter( + (n) => n.tagName === "INPUT" && !n.parent.classList.contains("ui-checkbox"), + ); + const autos = byClass(section, "ui-checkbox").map((n) => n.children[0]); + const btn = (label) => + all(section).find((n) => n.tagName === "BUTTON" && n.textContent === label); + const type = (input, value) => { + input.value = value; + input.listeners.input.forEach((h) => h()); + }; + const toggle = (box) => { + box.checked = !box.checked; + box.listeners.change.forEach((h) => h()); + }; + const state = () => ({ + values: inputs.map((n) => n.value), + errors: inputs.map((n) => n.error ?? null), + disabled: inputs.map((n) => !!n.disabled), + autos: autos.map((n) => n.checked), + save: !btn("축척 저장").disabled, + }); + return { modal, section, inputs, autos, btn, type, toggle, state }; + }; + const scale = {}; + current = drawings[2]; // longitudinal + let s = await scaleOf("longitudinal"); + scale.longLabels = byClass(s.section, "ui-field__label").map((n) => n.textContent); + scale.longStart = s.state(); + s.type(s.inputs[0], "50"); + scale.longLow = s.state(); + s.type(s.inputs[0], "500.5"); + scale.longFrac = s.state(); + s.type(s.inputs[0], "500"); + scale.longOk = s.state(); + calls.length = 0; + answer = false; + s.btn("축척 저장").click(); + await tick(); + scale.cancel = { calls: calls.map((c) => c[0]), open: s.modal.open, closed: closedCalls.length }; + answer = true; + calls.length = 0; + s.btn("축척 저장").click(); + await tick(); + await tick(); + scale.save = { + ask: calls.find((c) => c[0] === "ask")?.[1], + sent: calls.find((c) => c[0] === "scales")?.[1], + open: s.modal.open, + closed: [...closedCalls], + }; + s = await scaleOf("longitudinal"); + s.type(s.inputs[1], "300"); + s.btn("기본값").click(); + scale.reset = s.state(); + calls.length = 0; + const toastAt = toasts.length; + s.btn("축척 저장").click(); + await tick(); + scale.same = { calls: calls.length, toasts: toasts.slice(toastAt).map((t) => t[1]) }; + + s = await scaleOf("mass_haul"); + scale.massStart = s.state(); + s.toggle(s.autos[0]); + scale.massManual = s.state(); + s.type(s.inputs[0], "2000"); + scale.massTyped = s.state(); + calls.length = 0; + s.btn("축척 저장").click(); + await tick(); + await tick(); + scale.massSent = { + ask: calls.filter((c) => c[0] === "ask").length, + sent: calls.find((c) => c[0] === "scales")?.[1], + }; + s = await scaleOf("mass_haul"); + s.toggle(s.autos[0]); + s.toggle(s.autos[0]); + scale.massBack = s.state(); + + s = await scaleOf("development"); + scale.dev = s.state(); + openDrawingSettings("structure", scaleDeps); + scale.structure = byClass(modals.at(-1).body, "b07-scale").length; + out.scale = scale; + const Check = loadTs(path.join(B07, "logic", "B07_DesignDetail_Scale_Check.ts")); + out.readAxis = [ + ["1,000", false], + [" 99 ", false], + ["10001", false], + ["12.5", false], + ["", false], + ["abc", false], + ["", true], + ].map(([raw, auto]) => Check.readScaleAxis(raw, auto, [100, 10000])); + out.toSend = [ + Check.scaleToSend(200, 200), + Check.scaleToSend(500, 200), + Check.scaleToSend(null, null), + ]; + // ── 저장 묶음 ──────────────────────────────────────────────────────────── const tabsOf = (list, activeId) => ({ tabs: () => list, diff --git a/resources/tester/test_280_5_b07_settings.py b/resources/tester/test_280_5_b07_settings.py index 7ff3cc559..69ba47d16 100644 --- a/resources/tester/test_280_5_b07_settings.py +++ b/resources/tester/test_280_5_b07_settings.py @@ -83,6 +83,7 @@ def test_횡단_모달은_배치_칸_표_배율_안내선(out): assert cross["guideLast"] is True assert cross["head"] == [["원본에서 다시 그리기", False]] assert cross["notes"] == ["지금 도면: cross_s1"] + assert cross["scaleTitle"] == "축척 — 횡단도 장(표준 횡단면도 뺌)" saved = out["crossSave"] assert saved["closed"] is True assert saved["saved"] == [[{"default": [4, 4], "sheets": {"1": [2, 2]}, "table_scale": 1.2}, 1]] @@ -93,7 +94,7 @@ def test_구조물_종단_평면도_모달(out): assert structure["title"] == "구조물 도면 설정" assert structure["notes"] == ["M02 그림 — 축척 없음"] assert structure["head"][0][1] is True # 다른 묶음 도면을 연 중 = 꺼짐 - assert out["longModal"] == {"title": "종단도 설정", "bodyKids": 0} # 축척 칸은 485-2 + assert out["longModal"] == {"title": "종단도 설정", "bodyKids": 1} # 축척 칸(485-2) assert out["planModal"] == {"viaManager": True, "head": [["원본에서 다시 그리기", False]]} assert out["planRedraw"] == {"redraws": 1, "closed": True} assert out["standardInCross"] is True @@ -216,3 +217,48 @@ def test_서버_단계_완료(monkeypatch, tmp_path: Path) -> None: monkeypatch.setattr(router, "_confirmed_source", blocked) assert asyncio.run(router.complete_design_stage(project)).status_code == 409 + + +RANGE = "1/100~1/10,000 사이 정수" + + +def test_485_2_축척_칸_판정(out): + assert out["readAxis"] == [1000, RANGE, RANGE, RANGE, RANGE, RANGE, None] # 자동 = null + assert out["toSend"] == [None, 500, None] # 기본과 같으면 null + + +def test_485_2_종단_축척_저장(out): + scale = out["scale"] + assert scale["longLabels"] == ["X(가로) 1/", "Y(세로) 1/"] + assert scale["longStart"]["values"] == ["1000", "200"] + assert scale["longStart"]["autos"] == [] # 종단 = 자동 없음 + for bad in ("longLow", "longFrac"): # 범위 밖 · 소수 = 붉음 + 저장 꺼짐 + assert scale[bad]["errors"] == [RANGE, None] + assert scale[bad]["save"] is False + assert scale["longOk"]["errors"] == [None, None] and scale["longOk"]["save"] is True + # 저장본 2장 — 물음 [취소] = 요청 없음 · 모달 그대로 + assert scale["cancel"] == {"calls": ["ask"], "open": True, "closed": 0} + save = scale["save"] + assert "저장본 2장" in save["ask"] + assert save["sent"] == {"longitudinal": {"x": 500, "y": None}} # Y = 기본 → null + assert save["open"] is False + assert save["closed"] == [[True, "longitudinal"]] # 화면 다시 세움 · 같은 도면 + assert scale["reset"]["values"] == ["1000", "200"] # [기본값] = 칸만 + assert scale["same"] == {"calls": 0, "toasts": ["바뀐 축척이 없습니다."]} + + +def test_485_2_토적_전개도_자동(out): + scale = out["scale"] + assert scale["massStart"] == { + "values": ["", "50000"], + "errors": [None, None], + "disabled": [True, False], + "autos": [True], + "save": True, + } + assert scale["massManual"]["errors"][0] and scale["massManual"]["save"] is False # 빈 칸 + assert scale["massTyped"]["save"] is True + assert scale["massSent"] == {"ask": 0, "sent": {"mass_haul": {"x": 2000, "y": None}}} + assert scale["massBack"]["autos"] == [True] and scale["massBack"]["save"] is True + assert scale["dev"]["autos"] == [True, True] and scale["dev"]["disabled"] == [True, True] + assert scale["structure"] == 0 # 구조물 = 축척 칸 없음