- 옛 탭 여섯은 모두 새 틀 탭 파일로 옮겨짐(설계서 구성 · 산출기초 · 관급·사급 · 중기경비계산서 · 기초자료, 원가계산서는 랩탑_메인) - 화면 확인(지운 뒤 13 탭 전부): 설계내역서 53줄 · 본체 122,848,989 · 일위대가 18 · 산근 10 · 중기 표 19·줄 171 · 관급·사급 13 · 기초자료 표 6·줄 737 · 설계서 구성 13 · 산출기초 표 3·줄 192 — 옮기기 전과 같음 · tsc 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
207 lines
9.3 KiB
TypeScript
207 lines
9.3 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 };
|
|
}
|
|
|
|
/** 머리 한 줄짜리 표(목록표·집계표) — 실무 시트 칸 이름 그대로 넘김. */
|
|
export function plainTable(headers: string[]): { wrap: HTMLElement; tbody: HTMLElement } {
|
|
const thead = el("thead");
|
|
const tr = el("tr");
|
|
for (const label of headers) tr.append(el("th", "", label));
|
|
thead.append(tr);
|
|
return sheetTable(thead);
|
|
}
|
|
|
|
/** 한 탭 안의 갈래 고르개(재료비·노무비…) — 고른 갈래를 돌려줌. */
|
|
export function segmented(
|
|
items: Array<[string, string]>,
|
|
active: string,
|
|
onPick: (key: string) => void,
|
|
): HTMLElement {
|
|
const bar = el("div", "b09s-bar");
|
|
for (const [key, label] of items) {
|
|
const button = el("button", `b09s-tab${key === active ? " is-active" : ""}`, label);
|
|
button.type = "button";
|
|
button.addEventListener("click", () => onPick(key));
|
|
bar.append(button);
|
|
}
|
|
return bar;
|
|
}
|
|
|
|
/** 누르면 들어가는 글 — 「제 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-head { font-weight:600; margin-top:6px; }
|
|
.b09s-info td { text-align:right; font-variant-numeric:tabular-nums; }
|
|
.b09s-info td.b09s-left, .b09s-info th.b09s-left { text-align:left; white-space:normal; }
|
|
.b09s-group { display:flex; flex-direction:column; gap:4px; border-top:1px solid var(--ui-border, #d0d4dc); padding-top:6px; }
|
|
.b09s-inline { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
.b09s-table td.b09s-adopted { font-weight:700; color:var(--ui-accent, #2f6fed); background:rgba(47,111,237,0.08); }
|
|
.b09s-min { display:inline-block; margin-left:4px; font-size:10px; color:#1e8e3e; border:1px solid #1e8e3e; border-radius:8px; padding:0 4px; }
|
|
`;
|
|
document.head.append(style);
|
|
}
|