확정 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>
223 lines
9.1 KiB
TypeScript
223 lines
9.1 KiB
TypeScript
/* =============================================================================
|
||
* 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×(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;
|
||
}
|