Files
Aislo/B09_Estimation/B09_Estimation_UI_Tab_Execution.ts
T
eomsangdonandClaude Opus 5 f1f8482e7d feat(b09): 실행예산 단계 1벌 — 중기 실행단가(1단위당 사용료 ÷ 시간 · 성분 비율 · 절사 · 차액 보정) · 실행수량
- 단가표 복사본의 중기(X) 호표만 실행단가로 갈아 끼워 일위대가 다시 조립 — 설계·계약 불변
- 조립값과 다른 줄은 설계 단가 그대로 + 비고에 까닭
- 「기본보정」은 뜻 미확인 — 차액을 안 몰고 남김(확인 대기)
- 계약 모듈의 다시 조립·묶음 합 조각을 두 단계가 같이 씀
- 실행예산 탭 파일 · 사전 키(등록은 서브) · 시험 7건

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

399 lines
12 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Tab_Execution.ts
* 실행예산 탭 — STmate 「시간/단위당 중기실행단가 계산 및 입력」(wM_Boq_ExecX)을 본뜸 (PLAN 12장 · 랩탑 메인).
*
* - 본문 위 = 중기 실행단가 표 — 최초단가(노·재·경) · 1단위당 중기사용료 · 1단위 = 시간 ·
* 절사 · 차액 보정 → 실행단가.
* - 본문 아래 = 설계 내역 줄 옆에 **실행수량** 칸 · 실행 단가 · 실행 금액.
* - ⚠ 값은 서버(`/estimation/execution`)가 설계 단가표를 복사해 셈 — 설계·계약은 안 바뀜.
* - ⚠ 실행예산 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, showToast } from "@ui/ui_template_elements";
import { API_BASE_URL } from "@config/config_frontend";
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
interface Money {
material_krw: string;
labor_krw: string;
expense_krw: string;
total_krw: string;
}
interface MachineEntry {
unit_price_krw: string;
hours_per_unit: string;
cut_unit_krw: string;
correction: string;
}
interface MachineRow {
code: string;
name: string;
spec: string;
design?: Money;
execution?: Money;
note?: string;
}
interface ExecutionRow {
item_no: string;
name: string;
spec: string;
unit: string;
quantity: string | null;
is_group: boolean;
in_bill: boolean;
amount_krw: string | null;
execution_quantity?: string;
execution_unit_price_krw?: string;
execution_amount_krw?: string;
execution_note?: string;
}
interface Settings {
machines: Record<string, MachineEntry>;
quantities: Record<string, string>;
}
interface ExecutionDto {
status: string;
message?: string;
rows: ExecutionRow[];
machines: MachineRow[];
totals: { design: Money; execution: Money };
settings: Settings;
fields: { cut_units: string[]; corrections: { key: string; label: string }[] };
bill_missing_count: number;
limit_note: string;
}
const STYLE_ID = "b09-execution-styles";
function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.b09ex { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; }
.b09ex__meta { font-size: 12px; color: var(--color-text-secondary); }
.b09ex__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); }
.b09ex__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
.b09ex__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; }
.b09ex__table th, .b09ex__table td { border: 1px solid var(--color-border); padding: 2px 6px; }
.b09ex__table td.num { text-align: right; font-variant-numeric: tabular-nums; }
.b09ex__table tr.is-group td { font-weight: 600; }
.b09ex__table input { width: 7em; text-align: right; }
.b09ex__table input.is-changed { font-weight: 600; }
.b09ex__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-variant-numeric: tabular-nums; }
`;
document.head.append(style);
}
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;
}
function won(value: string | null | undefined): string {
if (value === null || value === undefined || value === "") return "";
const n = Number(value);
return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value;
}
/** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). */
const drafts = new Map<string, Settings>();
function draftOf(projectId: string, data: ExecutionDto): Settings {
let draft = drafts.get(projectId);
if (!draft) {
draft = {
machines: structuredClone(data.settings.machines),
quantities: { ...data.settings.quantities },
};
drafts.set(projectId, draft);
}
return draft;
}
function endpoint(projectId: string): string {
return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/execution`;
}
async function fetchExecution(projectId: string): Promise<ExecutionDto> {
const response = await fetch(endpoint(projectId), { credentials: "include" });
const body = (await response.json()) as ExecutionDto;
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
return body;
}
async function saveExecution(projectId: string, values: Settings): Promise<void> {
const response = await fetch(endpoint(projectId), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
const body = (await response.json()) as { message?: string };
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
}
function drawPanel(ctx: B09TabContext, data: ExecutionDto, reload: () => void): void {
const projectId = ctx.projectId as string;
const box = el("div", "b09ex__panel");
box.append(
createButton({
label: "저장",
onClick: async () => {
try {
await saveExecution(projectId, draftOf(projectId, data));
drafts.delete(projectId);
showToast("실행예산 저장 — 설계·계약 내역은 그대로", "success");
reload();
} catch (error) {
showToast(error instanceof Error ? error.message : "저장 못 함", "error");
}
},
}),
);
const t = data.totals;
for (const [label, part] of [
["재료비", "material"],
["노무비", "labor"],
["경비", "expense"],
["직접공사비", "total"],
] as const) {
box.append(
el(
"div",
"",
`${label} ${won(t.design[`${part}_krw`])}${won(t.execution[`${part}_krw`])}`,
),
);
}
ctx.panel.append(box);
}
function input(value: string, onInput: (value: string) => void): HTMLInputElement {
const node = el("input");
node.type = "number";
node.min = "0";
node.value = value;
node.addEventListener("input", () => onInput(node.value));
return node;
}
function select(
options: { key: string; label: string }[],
value: string,
onChange: (value: string) => void,
): HTMLSelectElement {
const node = el("select");
for (const option of options) {
const item = el("option", "", option.label);
item.value = option.key;
node.append(item);
}
node.value = value;
node.addEventListener("change", () => onChange(node.value));
return node;
}
/** 중기 실행단가 표 — 사용료가 빈 중기는 설계 사용료 그대로. */
function drawMachines(data: ExecutionDto, draft: Settings): HTMLElement {
const box = el("div");
box.append(el("strong", "", `시간/단위당 중기실행단가 — 중기 ${data.machines.length}종`));
const table = el("table", "b09ex__table");
const head = el("tr");
for (const label of [
"코드",
"명칭",
"규격",
"최초 노무비",
"최초 재료비",
"최초 경비",
"최초 계",
"1단위당 중기사용료(원)",
"1단위 = 시간",
"절사",
"차액 보정",
"실행 노무비",
"실행 재료비",
"실행 경비",
"실행 계",
"비고",
]) {
head.append(el("th", "", label));
}
table.append(head);
const cuts = data.fields.cut_units.map((unit) => ({
key: unit,
label: `${won(unit)}원 미만절사`,
}));
for (const machine of data.machines) {
const entry = (): MachineEntry =>
(draft.machines[machine.code] ??= {
unit_price_krw: "",
hours_per_unit: "",
cut_unit_krw: "1",
correction: "basic",
});
const saved = draft.machines[machine.code];
const tr = el("tr");
const d = machine.design;
const x = machine.execution;
tr.append(
el("td", "", machine.code),
el("td", "", machine.name),
el("td", "", machine.spec ?? ""),
el("td", "num", won(d?.labor_krw)),
el("td", "num", won(d?.material_krw)),
el("td", "num", won(d?.expense_krw)),
el("td", "num", won(d?.total_krw)),
);
const cells: HTMLElement[] = [
input(saved?.unit_price_krw ?? "", (v) => (entry().unit_price_krw = v)),
input(saved?.hours_per_unit ?? "", (v) => (entry().hours_per_unit = v)),
select(cuts, saved?.cut_unit_krw ?? "1", (v) => (entry().cut_unit_krw = v)),
select(
data.fields.corrections,
saved?.correction ?? "basic",
(v) => (entry().correction = v),
),
];
for (const cell of cells) {
const td = el("td");
td.append(cell);
tr.append(td);
}
tr.append(
el("td", "num", won(x?.labor_krw)),
el("td", "num", won(x?.material_krw)),
el("td", "num", won(x?.expense_krw)),
el("td", "num", won(x?.total_krw)),
el("td", "", machine.note ?? ""),
);
table.append(tr);
}
box.append(table);
return box;
}
function drawBill(data: ExecutionDto, draft: Settings): HTMLElement {
const box = el("div");
box.append(el("strong", "", "실행예산 내역 — 최초수량 → 실행수량"));
const table = el("table", "b09ex__table");
const head = el("tr");
for (const label of [
"공종번호",
"명칭",
"규격",
"단위",
"최초수량",
"금액(설계)",
"실행수량",
"실행 단가",
"실행 금액",
"비고",
]) {
head.append(el("th", "", label));
}
table.append(head);
for (const row of data.rows) {
const tr = el("tr", row.is_group ? "is-group" : "");
tr.append(
el("td", "", row.item_no),
el("td", "", row.name),
el("td", "", row.spec ?? ""),
el("td", "", row.unit ?? ""),
el("td", "num", row.quantity ?? ""),
el("td", "num", won(row.amount_krw)),
);
const cell = el("td");
if (!row.is_group && row.in_bill && row.execution_quantity !== undefined) {
const qty = input(draft.quantities[row.item_no] ?? row.execution_quantity, (v) => {
if (v === "" || v === row.quantity) delete draft.quantities[row.item_no];
else draft.quantities[row.item_no] = v;
});
qty.step = "any";
qty.classList.toggle("is-changed", row.item_no in draft.quantities);
cell.append(qty);
}
tr.append(
cell,
el("td", "num", won(row.execution_unit_price_krw)),
el("td", "num", won(row.execution_amount_krw)),
el("td", "", row.execution_note ?? ""),
);
table.append(tr);
}
box.append(table);
return box;
}
function drawBody(ctx: B09TabContext, data: ExecutionDto): void {
const draft = draftOf(ctx.projectId as string, data);
const wrap = el("div", "b09ex");
wrap.append(el("div", "b09ex__warn", `⚠ ${data.limit_note}`));
if (data.bill_missing_count) {
wrap.append(
el(
"div",
"b09ex__warn",
`⚠ 설계 내역 ${L("B09_Sheet_Missing")} ${data.bill_missing_count}${L("B09_Sheet_Count")} — 실행단가도 못 섬`,
),
);
}
wrap.append(
el("div", "b09ex__meta", "1단위당 중기사용료를 비우면 설계 사용료 그대로 — [저장]하면 반영"),
);
const scroll = el("div", "b09ex__scroll");
scroll.append(drawMachines(data, draft), drawBill(data, draft));
wrap.append(scroll);
ctx.body.append(wrap);
}
function render(ctx: B09TabContext): void {
injectStyles();
if (!ctx.projectId) {
ctx.body.append(el("div", "b09ex__meta", "프로젝트를 고르세요"));
return;
}
const load = (): void => {
ctx.body.replaceChildren(el("div", "b09ex__meta", "실행예산 계산 중…"));
ctx.panel.replaceChildren();
fetchExecution(ctx.projectId as string)
.then((data) => {
ctx.body.replaceChildren();
drawPanel(ctx, data, load);
drawBody(ctx, data);
})
.catch((error: unknown) => {
ctx.body.replaceChildren(
el(
"div",
"b09ex__warn",
`실행예산을 세우지 못함 — ${error instanceof Error ? error.message : ""}`,
),
);
});
};
load();
}
export const executionTab: B09Tab = {
key: "execution",
label: () => L("B09_Estimation_Tab_Execution"),
render,
};