- 단가표 복사본의 중기(X) 호표만 실행단가로 갈아 끼워 일위대가 다시 조립 — 설계·계약 불변 - 조립값과 다른 줄은 설계 단가 그대로 + 비고에 까닭 - 「기본보정」은 뜻 미확인 — 차액을 안 몰고 남김(확인 대기) - 계약 모듈의 다시 조립·묶음 합 조각을 두 단계가 같이 씀 - 실행예산 탭 파일 · 사전 키(등록은 서브) · 시험 7건 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
399 lines
12 KiB
TypeScript
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,
|
|
};
|