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
This commit is contained in:
2026-09-14 01:30:01 +09:00
co-authored by Claude Opus 5
parent 3ab63eb29c
commit 85f7c9c6b3
13 changed files with 1116 additions and 22 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
[ROUTES.B08_QUANTITY]: async () =>
(await import("../B08_Quantity/B08_Quantity_UI_Page")).renderB08Quantity,
[ROUTES.B09_ESTIMATION]: async () =>
(await import("../B09_Estimation/B09_Estimation_UI_Page")).renderB09Estimation,
(await import("../B09_Estimation/B09_Estimation_UI_Shell")).renderB09Estimation,
[ROUTES.B10_PAYMENT]: async () =>
(await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment,
[ROUTES.B11_STATUS]: async () =>
+142
View File
@@ -0,0 +1,142 @@
/* =============================================================================
* B09_Estimation_UI_Detail.ts
* 호표 본표(일위대가표·단가산출근거·시간당 중기) 한 장 + **들어가기 자취** (PLAN 12장 1차)
*
* - ⭐ STmate 를 익숙하게 만드는 동작 = 타고 들어가기(브레인 판정). 내역 줄 → 제 N 호표 →
* 호표 안 줄(일위대가·단산·중기) → … 을 누를 때마다 자취가 쌓이고, 자취 글을 누르면 그 자리로 되돌아감.
* - 표 모양은 실무 `일위대가표`·`단가산출근거` 시트: 명칭·규격·수량·단위 · 합계/노무/재료/경비(단가·금액) · 비고.
* - ⚠ 합계 줄은 서버 값(호표 성분 소계 원 미만 절사) — 여기서 안 더함.
* ========================================================================== */
import type { B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import {
L,
el,
hint,
linkButton,
moneyCells,
numberCell,
quantity,
sheetHead,
sheetTable,
} from "./B09_Estimation_UI_Sheet";
import { loadDetail, type DetailDto, type DetailRowDto } from "./B09_Estimation_UI_Store";
interface Crumb {
tab: string;
code: string;
label: string;
}
const crumbs: Crumb[] = [];
let drilling = false;
/** 본표 안 줄을 눌러 들어갈 때 — 자취를 이어 쌓음. 목록·내역에서 들어가면 자취가 새로 시작. */
export function drill(ctx: B09TabContext, tab: string, code: string): void {
drilling = true;
ctx.open(tab, code);
}
function visit(tab: string, code: string, label: string): void {
if (!drilling) crumbs.length = 0;
drilling = false;
const at = crumbs.findIndex((crumb) => crumb.tab === tab && crumb.code === code);
if (at >= 0) crumbs.length = at;
crumbs.push({ tab, code, label });
}
function trail(ctx: B09TabContext): HTMLElement {
const box = el("div", "b09s-trail");
crumbs.forEach((crumb, index) => {
if (index > 0) box.append(el("span", "b09s-hint", ""));
if (index === crumbs.length - 1) {
box.append(el("span", "b09s-title", crumb.label));
return;
}
box.append(linkButton(crumb.label, () => drill(ctx, crumb.tab, crumb.code)));
});
return box;
}
/** 줄이 가리키는 곳 — 단산(D)은 단가산출근거 탭, 일위대가·중기는 일위대가 탭. */
function targetTab(row: DetailRowDto): string | null {
if (!row.drillable || !row.ref_code) return null;
return row.kind === "price_basis" ? "price_basis" : "unit_price";
}
function detailRow(row: DetailRowDto, ctx: B09TabContext): HTMLElement {
const tr = el("tr");
const percent = row.unit === "%";
const unit: [string, string, string, string] | null = percent
? null
: [row.unit_total ?? "", row.unit_labor ?? "", row.unit_material ?? "", row.unit_expense ?? ""];
tr.append(
el("td", "", row.name),
el("td", "", row.spec),
numberCell(quantity(row.quantity)),
el("td", "", row.unit),
...moneyCells(unit, [row.total, row.labor, row.material, row.expense]),
);
const note = el("td", "b09s-note");
const source = row.source_label || row.source || "";
if (source) note.append(el("span", "b09s-hint", `[${source}] `));
if (row.note) note.append(el("span", "b09s-formula", row.note));
tr.append(note);
const tab = targetTab(row);
if (tab && row.ref_code) {
const code = row.ref_code;
tr.classList.add("is-clickable");
tr.title = L("B09_Sheet_Drill");
tr.addEventListener("click", () => drill(ctx, tab, code));
}
return tr;
}
function drawDetail(ctx: B09TabContext, box: HTMLElement, detail: DetailDto, label: string): void {
const title = el(
"div",
"b09s-title",
`${label} ${detail.name}${detail.spec ? ` · ${detail.spec}` : ""}`,
);
if (detail.unit) title.append(el("span", "b09s-hint", ` (${detail.unit})`));
const { wrap, tbody } = sheetTable(
sheetHead([
L("B09_Sheet_Col_Name"),
L("B09_Sheet_Col_Spec"),
L("B09_Sheet_Col_Quantity"),
L("B09_Sheet_Col_Unit"),
]),
);
for (const row of detail.rows) tbody.append(detailRow(row, ctx));
const sum = el("tr", "is-sum");
sum.append(el("td", "", L("B09_Sheet_Sum")), el("td"), el("td"), el("td"));
sum.append(
...moneyCells(null, [detail.total, detail.labor, detail.material, detail.expense]),
el("td"),
);
tbody.append(sum);
box.append(title, wrap);
if (detail.unattached_note) box.append(hint(detail.unattached_note.replace(/\*\*/g, ""), true));
if (detail.known_gap_note) box.append(hint(detail.known_gap_note, true));
}
/** 본표 한 장을 `box` 에 — 자취를 쌓고 서버 본표를 받아 그림. */
export function renderDetail(
ctx: B09TabContext,
box: HTMLElement,
tab: string,
code: string,
label: string,
): void {
if (!ctx.projectId) return;
visit(tab, code, label);
box.replaceChildren(trail(ctx), hint(L("B09_Sheet_Loading")));
loadDetail(ctx.projectId, code)
.then((detail) => {
box.replaceChildren(trail(ctx));
drawDetail(ctx, box, detail, label);
})
.catch((error: Error) => {
box.replaceChildren(trail(ctx), hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
});
}
+26 -14
View File
@@ -15,7 +15,6 @@
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
attachProvenance,
@@ -47,7 +46,6 @@ import {
type PriceSourcesDto,
} from "./B09_Estimation_UI_BaseData";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
function L(key: keyof typeof ui_locales): string {
@@ -900,7 +898,15 @@ async function confirmEstimationStage(projectId: string): Promise<void> {
* 페이지 진입점
* -------------------------------------------------------------------------- */
export async function renderB09Estimation(root: HTMLElement): Promise<void> {
/** 옛 화면을 새 틀(`B09_Estimation_UI_Shell`) 안에 이어 붙이는 자리 — 옛 탭 줄은 숨고 틀이 `select` 로 고름.
* ⚠ 옛 탭을 새 탭 파일로 다 바꾸면 이 파일째 지움(PLAN 12장 · 브레인 판정). */
export interface LegacyEstimation {
main: HTMLElement;
panel: HTMLElement;
select: (key: string) => void;
}
export function createLegacyEstimation(root: HTMLElement): LegacyEstimation {
injectStyles();
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const form: CostFormState = { ...INITIAL_FORM };
@@ -1546,16 +1552,22 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
drawTabs();
drawBody();
const layout = createWorkflowLayout({
title: L("B09_Estimation_Title"),
steps: workflowSteps(),
activeStep: 6,
leftPanel: panel.root,
mainContent: main,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
return {
main,
panel: panel.root,
select: (key: string) => {
activeTab = key;
drawTabs();
drawBody();
// 관급·사급 표는 내역 응답에 실려 옴 — 옛 내역 탭을 안 거치므로 여기서 받음.
if (key === "supply" && !bill && projectId) {
void fetchBill(projectId)
.then((data) => {
bill = data;
drawBody();
})
.catch(() => showToast(L("B09_Estimation_Boq_Failed"), "error"));
}
},
});
root.append(layout.root);
};
}
+175
View File
@@ -0,0 +1,175 @@
/* =============================================================================
* 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);
}
+83
View File
@@ -0,0 +1,83 @@
/* =============================================================================
* B09_Estimation_UI_Shell.ts
* 로그인 후 09: 6차 워크플로우 (원가계산) — 화면 **틀** (PLAN 12장 · 2026-09-14 브레인 판정)
*
* - 틀은 탭 줄과 등록만. 탭마다 파일 하나(`B09_Estimation_UI_Tab_<이름>.ts`) — 계약은 `_Shell_Types`.
* - ⚠ 등록 권한 = 랩탑_서브. 다른 창은 탭 파일을 만들고 이름을 알려 등록을 부탁함.
* - 탭을 고를 때마다 본문·좌측 칸을 비우고 `render(ctx, arg)`. `ctx.open(key, arg)` = 다른 탭으로 들어가기.
* ========================================================================== */
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import { L, el, injectSheetStyles } from "./B09_Estimation_UI_Sheet";
import { legacyTab } from "./B09_Estimation_UI_Tab_Legacy";
import { billTab } from "./B09_Estimation_UI_Tab_Bill";
import { unitPriceTab } from "./B09_Estimation_UI_Tab_UnitPrice";
import { priceBasisTab } from "./B09_Estimation_UI_Tab_PriceBasis";
/** 탭 등록 — 한 줄에 탭 하나. 옛 탭(`legacyTab`)은 새 탭 파일이 서면 그 줄만 바꿈. */
const TABS: B09Tab[] = [
legacyTab("cost_sheet", "B09_Estimation_Tab_CostSheet"), // → 랩탑_메인 원가계산서 탭
billTab,
unitPriceTab,
priceBasisTab,
legacyTab("machine", "B09_Estimation_Tab_Machine"),
legacyTab("supply", "B09_Estimation_Tab_Supply"),
legacyTab("base_data", "B09_Estimation_Tab_BaseData"),
legacyTab("design_doc", "B09_Estimation_Tab_DesignDoc"),
legacyTab("basis_sheet", "B09_Estimation_Tab_BasisSheet"),
];
export async function renderB09Estimation(root: HTMLElement): Promise<void> {
injectSheetStyles();
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const page = el("div", "b09s-page");
const bar = el("div", "b09s-tabs");
const body = el("div", "b09s-body");
const panel = el("div");
page.append(bar, body);
let active = TABS[0].key;
const open = (key: string, arg?: string): void => {
const tab = TABS.find((item) => item.key === key);
if (!tab) return;
active = key;
drawBar();
body.replaceChildren();
panel.replaceChildren();
const ctx: B09TabContext = { projectId, body, panel, root, open };
tab.render(ctx, arg);
};
const drawBar = (): void => {
bar.replaceChildren(
...TABS.map((tab) => {
const button = el(
"button",
`b09s-tab${tab.key === active ? " is-active" : ""}`,
tab.label(),
);
button.type = "button";
button.dataset.tab = tab.key;
button.addEventListener("click", () => open(tab.key));
return button;
}),
);
};
const layout = createWorkflowLayout({
title: L("B09_Estimation_Title"),
steps: workflowSteps(),
activeStep: 6,
leftPanel: panel,
mainContent: page,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
root.append(layout.root);
open(active);
}
+149
View File
@@ -0,0 +1,149 @@
/* =============================================================================
* B09_Estimation_UI_Store.ts
* B09 내역서·일위대가·단가산출근거 탭이 **한 벌로** 쓰는 서버 자료 (PLAN 12장)
*
* - 설계내역서 응답 한 벌에 호표 목록(`unit_price_sheet`)·단산 목록(`price_basis`)이 함께 옴 —
* 세 탭이 같은 응답을 봄(번호가 탭끼리 갈리지 않게). 번호는 서버가 낼 때마다 매김(저장 안 함).
* - 캐시는 메모리 한 벌(프로젝트별) — [다시 불러오기] 가 비움. 값을 저장하지 않음(보기 전용 1차).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
export interface BillNoteDto {
column: string;
text: string;
}
export interface BillRowDto {
item_no: string;
level: number;
code: string | null;
name: string;
spec: string;
unit: string;
quantity: string | null;
quantity_shown: string | null;
quantity_digits: number | null;
unit_price_krw: string | null;
amount_krw: string | null;
material_krw: string;
labor_krw: string;
expense_krw: string;
unit_material_krw: string | null;
unit_labor_krw: string | null;
unit_expense_krw: string | null;
price_code: string;
unconfirmed: number;
price_basis_label: string;
is_group: boolean;
in_bill: boolean;
note: string;
notes: BillNoteDto[];
}
export interface SheetEntryDto {
number: number;
label: string;
code: string;
name: string;
spec: string;
unit: string;
material_krw: string;
labor_krw: string;
expense_krw: string;
total_krw?: string;
unit_price_krw?: string;
source?: string;
unconfirmed?: number;
ref_code?: string;
unit_price_codes?: string[];
}
export interface MissingDto {
name: string;
unit?: string;
quantity?: string;
reason: string;
code?: string;
blocked_kind?: string;
}
export interface BillDto {
rows: BillRowDto[];
excluded: BillRowDto[];
summary: {
body_total_krw: string;
detail_rows: number;
missing: MissingDto[];
unconfirmed_count: number;
notes: string[];
};
price_basis: { entries: SheetEntryDto[] };
unit_price_sheet: { entries: SheetEntryDto[] };
}
export interface DetailRowDto {
ref_code?: string;
code?: string;
name: string;
spec: string;
unit: string;
kind?: string;
source_label?: string;
source?: string;
drillable: boolean;
quantity: string;
unit_material?: string;
unit_labor?: string;
unit_expense?: string;
unit_total?: string;
material: string;
labor: string;
expense: string;
total: string;
note: string;
}
export interface DetailDto {
code: string;
name: string;
spec: string;
unit: string;
kind: string;
material: string;
labor: string;
expense: string;
total: string;
rows: DetailRowDto[];
unattached_note: string;
known_gap_note: string;
}
const bills = new Map<string, Promise<BillDto>>();
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(String((body as { message?: string }).message || response.status));
}
return body as T;
}
/** 설계내역서 한 벌 — 세 탭이 같은 응답을 봄. `force` 면 다시 받음. */
export function loadBill(projectId: string, force = false): Promise<BillDto> {
if (force || !bills.has(projectId)) {
const request = getJson<BillDto>(`/projects/${encodeURIComponent(projectId)}/estimation/bill`);
request.catch(() => bills.delete(projectId));
bills.set(projectId, request);
}
return bills.get(projectId) as Promise<BillDto>;
}
/** 호표 본표 — 일위대가(B)·시간당 중기(X)는 `unit-prices`, 단가산출(D)은 `price-basis`. */
export function loadDetail(projectId: string, code: string): Promise<DetailDto> {
const kind = code.startsWith("D-") ? "price-basis" : "unit-prices";
return getJson<DetailDto>(
`/projects/${encodeURIComponent(projectId)}/estimation/${kind}/${encodeURIComponent(code)}`,
);
}
@@ -0,0 +1,233 @@
/* =============================================================================
* B09_Estimation_UI_Tab_Bill.ts
* B09 설계내역서 탭 — STmate `wBoq` 본 (PLAN 12장 1차: 표시 + 들어가기)
*
* - 열은 실무 설계내역서 그대로: 공종 · 명칭 · 규격 · 수량 · 단위 · 합계/노무비/재료비/경비(단가·금액) · 비고.
* - 머리글 줄은 ▸/▾ 로 접고, 「레벨」 고르개로 한 번에 접음(STmate `소계~총대계` 고르기의 우리 꼴).
* - ⭐ 줄을 누르면 그 줄 호표(일위대가) → 호표 안 줄을 누르면 단가산출근거로 들어감(브레인 판정 핵심).
* - 수동 단가로 선 줄 = 빨간 테두리 + 「미확정 N건」.
* - ⚠ 금액·합계는 서버가 실은 값 — 여기서 더하지 않음(머리글 줄 금액도 서버 합).
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import {
L,
el,
hint,
linkButton,
moneyCells,
numberCell,
quantity,
sheetHead,
sheetTable,
unconfirmedBadge,
won,
} from "./B09_Estimation_UI_Sheet";
import { loadBill, type BillDto, type BillRowDto } from "./B09_Estimation_UI_Store";
/** 접힌 머리글 줄(공종 번호) — 탭을 오가도 남음. */
const collapsed = new Set<string>();
let levelLimit = 0; // 0 = 모두
function rowMoney(row: BillRowDto): {
unit: [string, string, string, string] | null;
amount: [string, string, string, string] | null;
} {
const amount: [string, string, string, string] | null =
row.amount_krw === null
? null
: [row.amount_krw, row.labor_krw, row.material_krw, row.expense_krw];
if (row.is_group || row.unit_price_krw === null) return { unit: null, amount };
return {
unit: [
row.unit_price_krw,
row.unit_labor_krw ?? "",
row.unit_material_krw ?? "",
row.unit_expense_krw ?? "",
],
amount,
};
}
function isHidden(row: BillRowDto, rows: BillRowDto[]): boolean {
if (levelLimit > 0 && row.level > levelLimit) return true;
for (const group of rows) {
if (
group.is_group &&
collapsed.has(group.item_no) &&
row.item_no.startsWith(`${group.item_no}-`)
) {
return true;
}
}
return false;
}
function noteCell(row: BillRowDto, bill: BillDto, ctx: B09TabContext): HTMLTableCellElement {
const cell = el("td", "b09s-note");
const sheet = bill.unit_price_sheet.entries.find((entry) => entry.code === row.price_code);
if (sheet) cell.append(linkButton(sheet.label, () => ctx.open("unit_price", sheet.code)));
const basis = bill.price_basis.entries.filter((entry) =>
(entry.unit_price_codes ?? []).includes(row.price_code),
);
for (const entry of basis) {
cell.append(
linkButton(`${L("B09_Sheet_Basis_Short")} ${entry.number}`, () =>
ctx.open("price_basis", entry.code),
),
);
}
if (row.unconfirmed > 0) cell.append(unconfirmedBadge(row.unconfirmed));
// 비고 글 — 단산 번호는 위 단추로 보였으니 그 조각만 뺌.
const rest = row.notes
.map((note) => note.text)
.filter((text) => text && text !== row.price_basis_label)
.join(" / ");
if (rest) cell.append(el("span", "", rest));
return cell;
}
function drawRows(tbody: HTMLElement, bill: BillDto, ctx: B09TabContext, redraw: () => void): void {
for (const row of bill.rows) {
if (isHidden(row, bill.rows)) continue;
const tr = el("tr", row.is_group ? "is-group" : "");
const numberTd = el("td");
if (row.is_group) {
const toggle = el("button", "b09s-toggle", collapsed.has(row.item_no) ? "▸" : "▾");
toggle.type = "button";
toggle.addEventListener("click", (event) => {
event.stopPropagation();
if (collapsed.has(row.item_no)) collapsed.delete(row.item_no);
else collapsed.add(row.item_no);
redraw();
});
numberTd.append(toggle);
}
numberTd.append(document.createTextNode(row.item_no));
const name = el("td", "", row.name);
name.style.paddingLeft = `${6 + Math.max(0, row.level - 1) * 12}px`;
const { unit, amount } = rowMoney(row);
tr.append(
numberTd,
name,
el("td", "", row.spec),
numberCell(
row.is_group ? "" : quantity(row.quantity_shown ?? row.quantity, row.quantity_digits),
),
el("td", "", row.unit),
...moneyCells(unit, amount),
);
if (row.is_group) {
tr.append(el("td"));
} else {
tr.append(noteCell(row, bill, ctx));
if (row.unconfirmed > 0) tr.classList.add("is-manual");
if (row.price_code) {
tr.classList.add("is-clickable");
tr.title = L("B09_Sheet_Open_UnitPrice");
tr.addEventListener("click", () => ctx.open("unit_price", row.price_code));
}
}
tbody.append(tr);
}
}
function levelPicker(bill: BillDto, redraw: () => void): HTMLElement {
const select = el("select", "ui-input");
const deepest = Math.max(1, ...bill.rows.map((row) => row.level));
const all = el("option", "", L("B09_Sheet_Level_All"));
all.value = "0";
select.append(all);
for (let level = 1; level < deepest; level += 1) {
const option = el("option", "", `${level}${L("B09_Sheet_Level_Upto")}`);
option.value = String(level);
select.append(option);
}
select.value = String(levelLimit);
select.addEventListener("change", () => {
levelLimit = Number(select.value);
redraw();
});
const label = el("label", "b09s-bar", L("B09_Sheet_Level"));
label.append(select);
return label;
}
function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void {
const redraw = (): void => {
ctx.body.replaceChildren();
drawBill(ctx, bill, reload);
};
const bar = el("div", "b09s-bar");
bar.append(
createButton({ label: L("B09_Sheet_Reload"), variant: "ghost", onClick: reload }),
levelPicker(bill, redraw),
el(
"span",
"",
`${L("B09_Sheet_BodyTotal")} ${won(bill.summary.body_total_krw)}${L("B09_Sheet_Won")}`,
),
);
if (bill.summary.unconfirmed_count > 0)
bar.append(unconfirmedBadge(bill.summary.unconfirmed_count));
ctx.body.append(bar);
const { wrap, tbody } = sheetTable(
sheetHead([
L("B09_Sheet_Col_ItemNo"),
L("B09_Sheet_Col_Name"),
L("B09_Sheet_Col_Spec"),
L("B09_Sheet_Col_Quantity"),
L("B09_Sheet_Col_Unit"),
]),
);
drawRows(tbody, bill, ctx, redraw);
const sum = el("tr", "is-sum");
sum.append(el("td"), el("td", "", L("B09_Sheet_BodyTotal")), el("td"), el("td"), el("td"));
sum.append(...moneyCells(null, [bill.summary.body_total_krw, "", "", ""]), el("td"));
tbody.append(sum);
ctx.body.append(wrap);
// 금액을 못 세운 줄 — 0 으로 안 때우고 이름째 보임(서버 `missing`).
if (bill.summary.missing.length > 0) {
const details = el("details");
details.append(
el(
"summary",
"b09s-hint b09s-hint--warn",
`${L("B09_Sheet_Missing")} ${bill.summary.missing.length}${L("B09_Sheet_Count")}`,
),
);
for (const item of bill.summary.missing) {
details.append(hint(`${item.name}${item.reason}`));
}
ctx.body.append(details);
}
for (const note of bill.summary.notes) ctx.body.append(hint(note));
}
export const billTab: B09Tab = {
key: "boq",
label: () => L("B09_Estimation_Tab_Boq"),
render(ctx) {
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
const projectId = ctx.projectId;
const show = (force: boolean): void => {
ctx.body.replaceChildren(hint(L("B09_Sheet_Loading")));
loadBill(projectId, force)
.then((bill) => {
ctx.body.replaceChildren();
drawBill(ctx, bill, () => show(true));
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
showToast(L("B09_Sheet_LoadFailed"), "error");
});
};
show(false);
},
};
@@ -0,0 +1,30 @@
/* =============================================================================
* B09_Estimation_UI_Tab_Legacy.ts
* 옛 B09 화면의 탭을 새 틀에 **그대로 이어 붙이는** 자리 (PLAN 12장 · 2026-09-14 브레인 판정)
*
* - 원가계산서(→ 랩탑_메인 새 탭 파일) · 중기 · 관급·사급 · 기초자료(계수 고르개 = 계산 입력) ·
* 설계서 구성 · 산출기초 — 새 탭 파일이 서면 틀 등록 한 줄을 바꾸고, 다 바뀌면 이 파일과 옛 파일을 지움.
* - 옛 화면 한 벌을 처음 고를 때 한 번 세우고 탭끼리 나눠 씀(옛 상태·캐시 그대로).
* ========================================================================== */
import type { ui_locales } from "@ui/ui_template_locale";
import type { B09Tab } from "./B09_Estimation_UI_Shell_Types";
import { L } from "./B09_Estimation_UI_Sheet";
import { createLegacyEstimation, type LegacyEstimation } from "./B09_Estimation_UI_Page";
let legacy: LegacyEstimation | null = null;
export function legacyTab(key: string, labelKey: keyof typeof ui_locales): B09Tab {
return {
key,
label: () => L(labelKey),
render(ctx) {
legacy ??= createLegacyEstimation(ctx.root);
legacy.main.classList.add("b09s-legacy");
ctx.body.append(legacy.main);
// 옛 좌측 칸은 원가계산서 입력 — 그 탭에서만 보임.
if (key === "cost_sheet") ctx.panel.append(legacy.panel);
legacy.select(key);
},
};
}
@@ -0,0 +1,76 @@
/* =============================================================================
* B09_Estimation_UI_Tab_PriceBasis.ts
* B09 단가산출근거 탭 — 목록표 + 단가산출근거 (STmate `단가산출근거목록표` · `wM_Edit_San` 보기 · PLAN 12장 1차)
*
* - 목록표 = 내역 일위대가가 품은 D 만, **내역에 처음 쓰인 차례**로 「산근 N호표」(내역 비고 「단산 N」과 한 번호).
* - 본표 줄의 비고에 Q 식·품셈 근거를 **글자 그대로** 보임(서버 줄 `note`).
* - 「부르는 호표」 칸의 글을 누르면 그 일위대가표로 되돌아 들어감.
* - Q 식 편집은 2차(브레인 판정).
* ========================================================================== */
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import { renderDetail } from "./B09_Estimation_UI_Detail";
import { L, el, hint, linkButton, sheetTable } from "./B09_Estimation_UI_Sheet";
import { listHead, listRow } from "./B09_Estimation_UI_Tab_UnitPrice";
import { loadBill, type BillDto } from "./B09_Estimation_UI_Store";
let selected = "";
function draw(ctx: B09TabContext, bill: BillDto): void {
const entries = bill.price_basis.entries;
const page = el("div", "b09s-split");
const { wrap, tbody } = sheetTable(listHead(L("B09_Sheet_Col_Basis")));
const detail = el("div", "b09s-split");
for (const entry of entries) {
const row = {
...entry,
label: `${L("B09_Sheet_Basis_Long")} ${entry.number}${L("B09_Sheet_Basis_Suffix")}`,
};
const tr = listRow(row, entry.unit_price_krw ?? "", entry.code === selected);
const note = tr.lastElementChild as HTMLElement;
for (const code of entry.unit_price_codes ?? []) {
const sheet = bill.unit_price_sheet.entries.find((item) => item.code === code);
if (sheet) note.append(linkButton(sheet.label, () => ctx.open("unit_price", code)));
}
tr.addEventListener("click", () => {
selected = entry.code;
ctx.body.replaceChildren();
draw(ctx, bill);
});
tbody.append(tr);
}
page.append(el("div", "b09s-title", L("B09_Sheet_BasisList")), wrap);
if (entries.length === 0) page.append(hint(L("B09_Sheet_EmptyList")));
page.append(detail);
ctx.body.append(page);
if (!selected) {
detail.append(hint(L("B09_Sheet_PickSheet")));
return;
}
const picked = entries.find((entry) => entry.code === selected);
const label = picked
? `${L("B09_Sheet_Basis_Long")} ${picked.number}${L("B09_Sheet_Basis_Suffix")}`
: selected;
renderDetail(ctx, detail, "price_basis", selected, label);
}
export const priceBasisTab: B09Tab = {
key: "price_basis",
label: () => L("B09_Estimation_Tab_PriceBasis"),
render(ctx, arg) {
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
if (arg) selected = arg;
ctx.body.append(hint(L("B09_Sheet_Loading")));
loadBill(ctx.projectId)
.then((bill) => {
ctx.body.replaceChildren();
draw(ctx, bill);
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
});
},
};
@@ -0,0 +1,126 @@
/* =============================================================================
* B09_Estimation_UI_Tab_UnitPrice.ts
* B09 일위대가 탭 — 목록표 + 일위대가표 (STmate `일위대가목록표` · `wM_Edit_iLWi` 보기 · PLAN 12장 1차)
*
* - 목록표 = 내역에 쓰인 호표만, **내역에 처음 쓰인 차례**로 「제 N 호표」(번호는 서버가 낼 때마다 매김).
* - 줄을 누르면 아래에 그 호표의 일위대가표 — 호표 안 줄(하위 호표·단산·중기)을 누르면 더 들어감.
* - 구조물도 호표는 B08 구조물도 탭의 일위대가 표가 본표 — 여기선 금액·안내만.
* - 편집(구성행 수정)은 2차 — B09 에 수정 저장 자리가 아직 없음(브레인 판정).
* ========================================================================== */
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import { renderDetail } from "./B09_Estimation_UI_Detail";
import {
L,
el,
hint,
numberCell,
sheetTable,
unconfirmedBadge,
won,
} from "./B09_Estimation_UI_Sheet";
import { loadBill, type BillDto, type SheetEntryDto } from "./B09_Estimation_UI_Store";
let selected = "";
/** 목록표 머리 — 호표 · 명칭 · 규격 · 단위 · 합계 · 노무비 · 재료비 · 경비 · 비고(실무 목록표). */
export function listHead(first: string): HTMLElement {
const thead = el("thead");
const tr = el("tr");
for (const label of [
first,
L("B09_Sheet_Col_Name"),
L("B09_Sheet_Col_Spec"),
L("B09_Sheet_Col_Unit"),
L("B09_Sheet_Col_Total"),
L("B09_Sheet_Col_Labor"),
L("B09_Sheet_Col_Material"),
L("B09_Sheet_Col_Expense"),
L("B09_Sheet_Col_Note"),
]) {
tr.append(el("th", "", label));
}
thead.append(tr);
return thead;
}
export function listRow(entry: SheetEntryDto, total: string, isSelected: boolean): HTMLElement {
const tr = el("tr", `is-clickable${isSelected ? " is-selected" : ""}`);
tr.append(
el("td", "", entry.label),
el("td", "", entry.name),
el("td", "", entry.spec),
el("td", "", entry.unit),
numberCell(won(total)),
numberCell(won(entry.labor_krw)),
numberCell(won(entry.material_krw)),
numberCell(won(entry.expense_krw)),
);
const note = el("td", "b09s-note");
if (entry.unconfirmed) {
note.append(unconfirmedBadge(entry.unconfirmed));
tr.classList.add("is-manual");
}
tr.append(note);
return tr;
}
function labelFor(bill: BillDto, code: string): string {
const entry = bill.unit_price_sheet.entries.find((item) => item.code === code);
if (entry) return entry.label;
return code.startsWith("X-") ? `${L("B09_Sheet_Machine")} ${code.slice(2)}` : code;
}
function draw(ctx: B09TabContext, bill: BillDto): void {
const entries = bill.unit_price_sheet.entries;
const page = el("div", "b09s-split");
const { wrap, tbody } = sheetTable(listHead(L("B09_Sheet_Col_Sheet")));
const detail = el("div", "b09s-split");
const select = (code: string): void => {
selected = code;
ctx.body.replaceChildren();
draw(ctx, bill);
};
for (const entry of entries) {
const tr = listRow(entry, entry.total_krw ?? "", entry.code === selected);
tr.addEventListener("click", () => select(entry.code));
tbody.append(tr);
}
page.append(el("div", "b09s-title", L("B09_Sheet_UnitPriceList")), wrap);
if (entries.length === 0) page.append(hint(L("B09_Sheet_EmptyList")));
page.append(detail);
ctx.body.append(page);
if (!selected) {
detail.append(hint(L("B09_Sheet_PickSheet")));
return;
}
const picked = entries.find((entry) => entry.code === selected);
if (picked?.source === "structure") {
detail.append(el("div", "b09s-title", `${picked.label} ${picked.name} · ${picked.spec}`));
detail.append(hint(L("B09_Sheet_StructureSheet")));
return;
}
renderDetail(ctx, detail, "unit_price", selected, labelFor(bill, selected));
}
export const unitPriceTab: B09Tab = {
key: "unit_price",
label: () => L("B09_Estimation_Tab_UnitPrice"),
render(ctx, arg) {
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
if (arg) selected = arg;
ctx.body.append(hint(L("B09_Sheet_Loading")));
loadBill(ctx.projectId)
.then((bill) => {
ctx.body.replaceChildren();
draw(ctx, bill);
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
});
},
};
@@ -14,7 +14,7 @@ from decimal import Decimal
from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
from B09_Estimation.B09_Estimation_MaterialCatalog import catalog_summary, load_material_catalog
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
from B09_Estimation.B09_Estimation_PriceBook import TRUNCATED_KINDS, PriceKind
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import (
DRILLABLE_KINDS,
@@ -202,7 +202,7 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
"quantity": str(detail.percent_of_labor),
"material": "0",
"labor": "0",
"expense": str(amount),
"expense": _money_text(amount),
"total": _money_text(amount),
"source": "품셈 [주]",
"drillable": False,
@@ -233,7 +233,7 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
"spec": f"{'주연료비' if misc else '주재료비'}{detail.percent_of_material}%",
"unit": "%",
"quantity": str(detail.percent_of_material),
"material": str(amount),
"material": _money_text(amount),
"labor": "0",
"expense": "0",
"total": _money_text(amount),
@@ -285,16 +285,22 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
# ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다.
# 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다.
check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표")
# 합계 줄 — 호표 안에서 자르는 층(X·D·B)은 **성분 소계 원 미만 절사** 값(실무 표 합계 줄 · 명세 7장).
shown = (
{"material": money.material, "labor": money.labor, "expense": money.expense}
if title.kind in TRUNCATED_KINDS
else {key: summed[key] for key in ("material", "labor", "expense")}
)
return {
"code": code,
"name": title.name,
"spec": title.spec,
"unit": title.unit,
"kind": title.kind.value,
"material": str(summed["material"]),
"labor": str(summed["labor"]),
"expense": str(summed["expense"]),
"total": str(summed["total"]),
"material": str(shown["material"]),
"labor": str(shown["labor"]),
"expense": str(shown["expense"]),
"total": str(sum(shown.values(), Decimal(0))),
# TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다.
"sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"],
# 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다.
+2
View File
@@ -21,6 +21,7 @@ import { ui_locales_common } from "./ui_template_locale_common";
import { ui_locales_a } from "./ui_template_locale_a";
import { ui_locales_b1 } from "./ui_template_locale_b1";
import { ui_locales_b2 } from "./ui_template_locale_b2";
import { ui_locales_b3 } from "./ui_template_locale_b3";
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
export const LANGUAGES = ["ko", "en"] as const;
@@ -53,6 +54,7 @@ export const ui_locales = {
...ui_locales_a,
...ui_locales_b1,
...ui_locales_b2,
...ui_locales_b3,
} as const;
export type LocaleKey = keyof typeof ui_locales;
+60
View File
@@ -0,0 +1,60 @@
/* =============================================================================
* ui_template_locale_b3.ts
* B 3 B09 (··, PLAN 12)
*
* b2 700 . .
* ========================================================================== */
export const ui_locales_b3 = {
/* --- B09 실무 서식 표 --- */
B09_Sheet_Col_ItemNo: ["공종", "Item"],
B09_Sheet_Col_Name: ["명칭", "Name"],
B09_Sheet_Col_Spec: ["규격", "Spec"],
B09_Sheet_Col_Quantity: ["수량", "Qty"],
B09_Sheet_Col_Unit: ["단위", "Unit"],
B09_Sheet_Col_Total: ["합계", "Total"],
B09_Sheet_Col_Labor: ["노무비", "Labor"],
B09_Sheet_Col_Material: ["재료비", "Material"],
B09_Sheet_Col_Expense: ["경비", "Expense"],
B09_Sheet_Col_UnitPrice: ["단가", "Unit price"],
B09_Sheet_Col_Amount: ["금액", "Amount"],
B09_Sheet_Col_Note: ["비고", "Note"],
B09_Sheet_Col_Sheet: ["호표", "Sheet"],
B09_Sheet_Col_Basis: ["산근", "Basis"],
B09_Sheet_Sum: ["합계", "Total"],
B09_Sheet_BodyTotal: ["내역 본체 합계", "Bill body total"],
B09_Sheet_Won: ["원", " KRW"],
B09_Sheet_Count: ["건", ""],
B09_Sheet_Unconfirmed: ["미확정", "Unconfirmed"],
B09_Sheet_Missing: ["금액을 못 세운 줄", "Rows without a price"],
B09_Sheet_Reload: ["다시 불러오기", "Reload"],
B09_Sheet_Level: ["보이는 레벨", "Show levels"],
B09_Sheet_Level_All: ["모두", "All"],
B09_Sheet_Level_Upto: ["레벨까지", " level(s)"],
B09_Sheet_Loading: ["불러오는 중…", "Loading…"],
B09_Sheet_LoadFailed: ["불러오지 못했습니다.", "Could not load."],
B09_Sheet_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."],
B09_Sheet_Open_UnitPrice: [
"누르면 이 줄의 일위대가표로 들어갑니다",
"Click to open this row's unit price sheet",
],
B09_Sheet_Drill: ["누르면 이 줄의 호표로 들어갑니다", "Click to open this row's sheet"],
B09_Sheet_Basis_Short: ["단산", "Basis"],
B09_Sheet_Basis_Long: ["산근", "Basis"],
B09_Sheet_Basis_Suffix: ["호표", ""],
B09_Sheet_Machine: ["시간당 중기", "Machine hourly"],
B09_Sheet_UnitPriceList: ["일위대가 목록표", "Unit price list"],
B09_Sheet_BasisList: ["단가산출근거 목록표", "Price basis list"],
B09_Sheet_EmptyList: [
"내역에 쓰인 호표가 없습니다 — 설계내역서가 먼저 서야 합니다.",
"No sheets used by the bill yet.",
],
B09_Sheet_PickSheet: [
"목록표에서 호표를 누르면 본표가 아래에 섭니다.",
"Click a sheet above to see its table.",
],
B09_Sheet_StructureSheet: [
"구조물도 일위대가는 수량산출 › 구조물도 탭의 일위대가 표가 본표입니다.",
"Structure unit prices are shown on the structure drawing tab.",
],
} as const;