Files
Aislo/B08_Quantity/B08_Quantity_UI_StructureSheet_Spec.ts
T
eomsangdonandClaude Opus 5 475ad7875e feat(b08·b05·b09): 10-A 우리가 정한 SW 규칙을 화면에 드러냄 — 훑기 21건 중 안 보이거나 반만 보이던 13건 채움 + 9-21 제근 굴착기 크기 칸
- 문구 못박음 셋: ① 옹벽 높이 칸 밑 「기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음」(등록부 default_basis 를 모든 칸 밑 회색 한 줄로) · ② 일반관리비 「임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음」 · ③ 모르타르 배합 「산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)」
- B08: 막자갈 비고 「칸을 새로 두는 것은 B05 등록부 몫」 · 일위대가 머리 5단 한도 · 야면석 계수 칸 밑 「돌 종류는 그대로」 · 식 저장 전 상태 줄 「제원을 바꿔도 따라감」 · 양식 장 머리 「반올림은 m당 값에 걸고 수량 = m당 × 연장」
- 산출 조건 제근 굴착기 크기 칸(임목축적 등급 밑 · 「안 정함」 · 회색 제안 0.7㎥ + 근거 + [제안값 넣기] 누른 때만) — 936be972 소림 + 0.7 → 뿌리뽑기 55원/㎡ × 17,873.80㎡ = 983,057원(되돌림)
- 라이브러리 이름표: [내 라이브러리에 저장]·[발행] 창이 장 이름(종류 + 제원 요약)을 채워 묻고 고친 이름으로 저장 · 취소면 안 씀 · 비우면 양식 이름
- B05 계곡 통과 시설 폼 머리 「[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸은 그대로 둠」 · B09 폐기물처리비 자리 칸 밑 「법정경비 밑수에는 안 넣음」 · 내역서 「금액을 못 세운 줄」 첫 줄 「사유는 아래 단계가 낸 것을 그대로 옮김」
- 남은 하나(표 형태 66표 까닭 form_basis 화면)는 자원 축 파일을 거쳐야 해 브레인 물음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 19:44:18 +09:00

271 lines
12 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.
/* =============================================================================
* B08_Quantity_UI_StructureSheet_Spec.ts
* 표준도 **제원 입력 칸** — 장 하나가 곧 제원 조합 하나이므로 여기서 고치면 그 조합 전부.
*
* ⚠ 2026-09-13 B07 에서 이관(PLAN 3장) — 옛 자리 `B07_DesignDetail_UI_StandardSpec.ts`.
* 모양(CSS)은 옛 B07 페이지 CSS 에서 옮겨 **이 파일이 스스로 넣음**.
*
* 왜 여기인가 (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;
/** 확정 8-2·14 — 기본은 넣음(KDS 100㎜), 빼고 싶으면 「안 넣음」. 문구가 곧 서버가 읽는 말이다. */
const BLINDINGS = ["넣음", "안 넣음"] 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;
blinding_concrete: 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"]);
const STYLE_ID = "b08-spec-style";
/* 옛 B07 페이지 CSS(`B07_DesignDetail_UI_Style.css`)에서 옮긴 것. */
const CSS = `
.b08-spec { display: flex; flex-direction: column; gap: var(--spacing-8); padding: var(--spacing-12); border-radius: var(--radius-lg); }
.b08-spec__title { margin: 0; font-size: var(--text-body-sm); font-weight: 600; }
.b08-spec__scope { margin: 0; color: var(--color-text-muted); font-size: var(--text-caption); }
.b08-spec__field { display: flex; flex-direction: column; gap: 2px; }
.b08-spec__label { color: var(--color-text-muted); font-size: var(--text-caption); }
.b08-spec__input {
width: 100%; min-width: 0; padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border); border-radius: var(--radius-buttons);
background: var(--color-surface); color: var(--color-text); font: inherit;
}
/* 「비우면 자동」처럼 **칸의 뜻**을 적는 자리 — 값이 아니라 규칙을 말한다. */
.b08-spec__hint { color: var(--color-text-muted); font-size: var(--text-caption); }
.b08-spec__save {
padding: var(--spacing-8); border: 1px solid transparent; border-radius: var(--radius-buttons);
background: var(--color-primary, #7c3aed); color: #fff; cursor: pointer;
}
.b08-spec__save:disabled { opacity: 0.6; cursor: default; }
/* 저장 뒤 안내 — 막지 않고 알리는 자리(품셈 범위 밖 기울기 등). */
.b08-spec__notes { margin: 0; padding-left: var(--spacing-16); color: var(--color-text-muted); font-size: var(--text-caption); }
`;
/** 모양을 한 번만 넣는다 — 폼을 다시 그려도 겹치지 않음. */
function injectSpecStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.append(style);
}
function field(label: string, control: HTMLElement, hint?: string): HTMLLabelElement {
const wrap = document.createElement("label");
wrap.className = "b08-spec__field";
const name = document.createElement("span");
name.className = "b08-spec__label";
name.textContent = label;
wrap.append(name, control);
if (hint) {
const help = document.createElement("small");
help.className = "b08-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 = "b08-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 {
injectSpecStyles();
const panel = document.createElement("div");
panel.className = "b08-spec ui-sidebar-section";
const title = document.createElement("h3");
title.className = "b08-spec__title";
title.textContent = `제원 — ${sheet.title}`;
const scope = document.createElement("p");
scope.className = "b08-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 = "b08-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 blinding = select(BLINDINGS, options.blinding_concrete, "— 안 정함(넣음) —");
const slope = document.createElement("input");
slope.className = "b08-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 = "b08-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 은 국가기준 하한입니다."),
field("버림 콘크리트", blinding, "비우면 넣습니다(두께 100㎜) — 빼려면 「안 넣음」."),
);
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 = "b08-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 = "b08-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,
blinding_concrete: blinding.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;
}