Files
Aislo/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts
T
eomsangdonandClaude Opus 5 3a64bb5bd1 feat(B07): 표준도 제원에 상부·하부 두께 칸 — 비우면 실무 구조물도 식
확정 2차 ② 후반 「사용자가 값을 바꿀 수 있게」. 식은 이미 있었고 없던 것은 덮어쓸 칸이었음.

- 등록부(돌쌓기 계열 다섯 종류)에 `thickness_top_m`·`thickness_bottom_m` 칸을 둠.
  큰돌쌓기는 두께 식이 달라 뺐고 화면에서도 그 장에는 안 뜸.
- 빈 칸은 「안 정함」 — 자동값을 저장에 박지 않음. 상부만 넣으면 하부는 그 위에서 식을 이음.
- 근거 문구가 어디서 온 값인지 적음(「사용자 입력」 / 「뒷길이 45㎝ + 0.3」).

화면 실측(랩탑_보조, 표준도 3장 돌쌓기(찰)) — 0.60 넣고 저장하니 2개소 반영,
입적 24.375 → 20.625㎥ 로 움직였고 칸을 비워 되돌리니 24.375 로 복귀함.
시험 다섯 추가, 전체 1203 통과·실패 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 21:27:17 +09:00

223 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<string, unknown>;
}
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<string[]>,
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×(H1)).
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×(H1) 로 섭니다."),
);
}
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;
}