Files
Aislo/B09_Estimation/B09_Estimation_UI_Sheet.ts
T
eomsangdonandClaude Opus 5 85f7c9c6b3 feat(b09): 원가계산 화면 새 틀 — 설계내역서·일위대가·단가산출근거 탭(실무 서식 · 내역 → 호표 → 산근 들어가기)
- 틀 B09_Estimation_UI_Shell: 탭 줄과 등록만 · 탭마다 파일 하나(계약 _Shell_Types)
- 설계내역서: 실무 열(합계/노무/재료/경비 단가·금액) · 머리글 접기·레벨 고르개 · 줄 누르면 제 N 호표 · 단산 N 단추 · 미확정 빨간 테두리
- 일위대가·단가산출근거: 목록표(내역에 처음 쓰인 차례) + 본표 · 줄 누르면 하위 호표·산근·중기로 · 자취 눌러 되돌아감 · Q 식 글자 그대로
- 옛 탭 여섯(원가계산서·중기·관급사급·기초자료·설계서 구성·산출기초)은 옛 코드 그대로 이어 붙임 — 새 탭 파일이 서면 등록 한 줄씩 바꿈
- 본표 합계 줄 = 호표 성분 소계 원 미만 절사 값 · 비율 줄 금액도 0.1원 절사 표시
- 사전 ui_template_locale_b3 새 벌(b2 700줄 넘음)
- 검증: ORCA 검증 프로젝트 — 본체 122,848,989 · 지장목제거 865·15,460,837 · 제 9 호표 합계 1,708 = 산근 8호표 합계 1,708 · 옛 탭 여섯 다 뜸 · 접기 53→51·레벨1 11줄

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

176 lines
7.8 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Sheet.ts
* B09 실무 서식 표 공용 — 내역서·일위대가표·단가산출근거가 같은 모양으로 섬 (PLAN 12장)
*
* - 표 모양은 STmate 출력 시트 그대로: 명칭·규격·수량·단위 · 합계/노무비/재료비/경비 (단가·금액) · 비고.
* - ⚠ 여기서 곱하거나 더하지 않음 — 서버가 실은 칸을 찍기만. 숫자 꼴(천 단위 쉼표)만 바꿈.
* - 가로 넘침은 표 칸 안에서만(`min-width:0` · `overflow-x:auto`) — 옛 화면 2026-09-08 실측 교훈.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
export function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 금액 글 → 「1,234,567」 · 소수부가 있으면 한 자리(일위대가 금액란 0.1원). 숫자가 아니면 그대로. */
export function won(value: string | null | undefined): string {
if (value === null || value === undefined || value === "") return "";
const number = Number(value);
if (!Number.isFinite(number)) return String(value);
const fraction = Math.abs(number % 1) > 1e-9;
return number.toLocaleString("ko-KR", {
minimumFractionDigits: fraction ? 1 : 0,
maximumFractionDigits: fraction ? 1 : 0,
});
}
/** 수량 — 서버가 준 표시 자리(`digits`). 내역 줄인데 자리를 모르면 옛 화면처럼 둘째 자리,
* 호표 안 줄(`digits` 안 줌)은 품이 작아(0.0115 hr) 넷째 자리까지. */
export function quantity(value: string | null | undefined, digits?: number | null): string {
if (value === null || value === undefined || value === "") return "";
const number = Number(value);
if (!Number.isFinite(number)) return String(value);
if (digits === undefined) {
return number.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
}
if (digits === null) {
return number.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
}
return number.toLocaleString("ko-KR", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
export function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
className = "",
text = "",
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
}
/** 실무 시트 머리 두 줄 — 앞 칸(명칭 따위)은 두 줄을 차지, 성분 넷은 「단가·금액」 두 칸. */
export function sheetHead(
front: string[],
back: string[] = [L("B09_Sheet_Col_Note")],
): HTMLElement {
const thead = el("thead");
const top = el("tr");
const bottom = el("tr");
for (const label of front) {
const th = el("th", "", label);
th.rowSpan = 2;
top.append(th);
}
for (const key of [
"B09_Sheet_Col_Total",
"B09_Sheet_Col_Labor",
"B09_Sheet_Col_Material",
"B09_Sheet_Col_Expense",
] as const) {
const th = el("th", "", L(key));
th.colSpan = 2;
top.append(th);
bottom.append(
el("th", "", L("B09_Sheet_Col_UnitPrice")),
el("th", "", L("B09_Sheet_Col_Amount")),
);
}
for (const label of back) {
const th = el("th", "", label);
th.rowSpan = 2;
top.append(th);
}
thead.append(top, bottom);
return thead;
}
/** 숫자 칸 하나. */
export function numberCell(text: string): HTMLTableCellElement {
return el("td", "b09s-num", text);
}
/** 성분 넷 × (단가·금액) 여덟 칸 — 합계·노무·재료·경비 차례(실무 시트). */
export function moneyCells(
unit: [string, string, string, string] | null,
amount: [string, string, string, string] | null,
): HTMLTableCellElement[] {
const cells: HTMLTableCellElement[] = [];
for (let i = 0; i < 4; i += 1) {
cells.push(numberCell(unit ? won(unit[i]) : ""), numberCell(amount ? won(amount[i]) : ""));
}
return cells;
}
/** 표 한 장 — 가로로 넘치면 이 칸 안에서만 밀림. */
export function sheetTable(head: HTMLElement): { wrap: HTMLElement; tbody: HTMLElement } {
const wrap = el("div", "b09s-wrap");
const table = el("table", "b09s-table");
const tbody = el("tbody");
table.append(head, tbody);
wrap.append(table);
return { wrap, tbody };
}
/** 누르면 들어가는 글 — 「제 3 호표」·「단산 2」. */
export function linkButton(text: string, onClick: () => void): HTMLButtonElement {
const button = el("button", "b09s-link", text);
button.type = "button";
button.addEventListener("click", (event) => {
event.stopPropagation();
onClick();
});
return button;
}
/** 수동 단가 표시 — 어느 화면에서든 같게(빨간 테두리 + 「미확정 N건」). */
export function unconfirmedBadge(count: number): HTMLElement {
return el("span", "b09s-badge", `${L("B09_Sheet_Unconfirmed")} ${count}${L("B09_Sheet_Count")}`);
}
export function hint(text: string, warn = false): HTMLElement {
return el("div", warn ? "b09s-hint b09s-hint--warn" : "b09s-hint", text);
}
export function injectSheetStyles(): void {
if (document.getElementById("b09-sheet-styles")) return;
const style = document.createElement("style");
style.id = "b09-sheet-styles";
style.textContent = `
.b09s-page { display:flex; flex-direction:column; gap:8px; min-width:0; min-height:0; flex:1; }
.b09s-tabs { display:flex; flex-wrap:wrap; gap:4px; border-bottom:1px solid var(--ui-border, #d0d4dc); padding-bottom:4px; }
.b09s-tab { border:1px solid var(--ui-border, #d0d4dc); background:var(--ui-surface, #fff); padding:4px 10px; border-radius:6px 6px 0 0; cursor:pointer; font-size:13px; }
.b09s-tab.is-active { background:var(--ui-accent, #2f6fed); color:#fff; border-color:var(--ui-accent, #2f6fed); }
.b09s-body { display:flex; flex-direction:column; gap:8px; min-width:0; min-height:0; flex:1; overflow:auto; }
.b09s-bar { display:flex; flex-wrap:wrap; align-items:center; gap:8px; font-size:13px; }
.b09s-wrap { overflow-x:auto; max-width:100%; }
.b09s-table { border-collapse:collapse; font-size:12px; white-space:nowrap; }
.b09s-table th, .b09s-table td { border:1px solid var(--ui-border, #d0d4dc); padding:2px 6px; }
.b09s-table th { background:var(--ui-surface-muted, #f1f3f7); font-weight:600; text-align:center; }
.b09s-num { text-align:right; font-variant-numeric:tabular-nums; }
.b09s-table tr.is-group td { font-weight:600; background:var(--ui-surface-muted, #f7f8fb); }
.b09s-table tr.is-sum td { font-weight:700; background:var(--ui-surface-muted, #eef1f6); }
.b09s-table tr.is-clickable { cursor:pointer; }
.b09s-table tr.is-clickable:hover td { background:rgba(47,111,237,0.08); }
.b09s-table tr.is-selected td { background:rgba(47,111,237,0.16); }
.b09s-table tr.is-manual td { box-shadow:inset 0 0 0 1px #d93025; }
.b09s-table td.b09s-note { white-space:normal; min-width:160px; max-width:420px; }
.b09s-toggle { border:none; background:none; cursor:pointer; padding:0 4px 0 0; font-size:11px; }
.b09s-link { border:none; background:none; color:var(--ui-accent, #2f6fed); cursor:pointer; padding:0 4px 0 0; text-decoration:underline; font-size:12px; }
.b09s-badge { display:inline-block; border:1px solid #d93025; color:#d93025; border-radius:10px; padding:0 6px; font-size:11px; margin-left:4px; }
.b09s-hint { font-size:12px; color:var(--ui-text-muted, #5f6673); }
.b09s-hint--warn { color:#b3261e; }
.b09s-trail { display:flex; flex-wrap:wrap; gap:4px; align-items:center; font-size:13px; }
.b09s-split { display:flex; flex-direction:column; gap:12px; min-width:0; }
.b09s-title { font-weight:700; font-size:14px; }
.b09s-formula { white-space:pre-wrap; font-size:12px; color:var(--ui-text, #1f2430); }
.b09s-legacy .b09-tabs { display:none; }
`;
document.head.append(style);
}