Files
Aislo/B09_Estimation/B09_Estimation_UI_Tab_Contract.ts
eomsangdonandClaude Opus 5 ff6672a13a feat(b09): 계약 단계 2벌 — 기초단가 적용(단가표 복사본 다시 조립) · 계약 일위대가/산출근거 W 호표 · 0% = 0 원
- 기초단가 적용: 자재·노임·중기 취득가 채택 단가에 적용률 — 복사본만, 설계 단가표 불변
- 조립값과 다른 줄(할증·수동 단가)·단가표 밖 코드는 성분 곱셈 + 비고에 까닭
- 계약 호표: 설계 본표와 같은 꼴(detail_of)로 W-B·W-D 따로 — 기초단가 적용과 함께만
- 0% 는 0 원(브레인 판정) · 공내역 생성은 그 줄 이름만 가름
- 시험 11건 · 전체 1699 통과(골든셋 포함)

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

368 lines
12 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Tab_Contract.ts
* 계약내역 탭 — STmate 「당초설계 → 계약내역 변환등록」(wM_Mk_Cont)을 본뜸 (PLAN 12장 · 랩탑 메인).
*
* - 좌측 = 단가 적용율 【 노 】【 재 】【 경 】 % · 적용 옵션 여섯(화면 표기 그대로) · [저장].
* - 본문 = 공종번호 · 명칭 · 규격 · 금액(설계) · 계약 단가·금액 · **적용제외** · 계약단가 코드.
* - ⚠ 값은 서버(`/estimation/contract`)가 설계 내역을 복사해 셈 — 설계는 안 바뀜.
* - ⚠ 계약 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음.
* ========================================================================== */
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 ContractRow {
item_no: string;
name: string;
spec: string;
unit: string;
quantity: string | null;
is_group: boolean;
in_bill: boolean;
amount_krw: string | null;
contract_unit_price_krw?: string;
contract_amount_krw?: string;
contract_code?: string;
contract_excluded?: boolean;
contract_note?: string;
}
interface Money {
material_krw: string;
labor_krw: string;
expense_krw: string;
total_krw: string;
}
/** 계약 호표 한 장 — 설계 본표(`detail_of`)와 같은 꼴 + 계약 코드(W-). */
interface ContractSheet {
code: string;
design_code: string;
name: string;
spec: string;
unit: string;
material: string;
labor: string;
expense: string;
total: string;
rows: {
name: string;
spec: string;
unit?: string;
quantity: string;
material: string;
labor: string;
expense: string;
total: string;
}[];
}
interface ContractDto {
status: string;
message?: string;
rows: ContractRow[];
unit_price_sheets: ContractSheet[];
totals: { design: Money; contract: Money; ratio_pct: Record<string, string | null> };
options_not_used: Record<string, string>;
settings: Record<string, string | boolean | string[]>;
fields: {
rates: { key: string; label: string }[];
options: { key: string; label: string }[];
};
bill_missing_count: number;
limit_note: string;
}
const STYLE_ID = "b09-contract-styles";
function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.b09ct { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; }
.b09ct__meta { font-size: 12px; color: var(--color-text-secondary); }
.b09ct__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); }
.b09ct__scroll { flex: 1; overflow: auto; min-height: 0; }
.b09ct__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; }
.b09ct__table th, .b09ct__table td { border: 1px solid var(--color-border); padding: 2px 6px; }
.b09ct__table td.num { text-align: right; font-variant-numeric: tabular-nums; }
.b09ct__table tr.is-group td { font-weight: 600; }
.b09ct__table tr.is-excluded td { color: var(--color-text-secondary); }
.b09ct__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
.b09ct__rates { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.b09ct__rates input { width: 4.5em; text-align: right; }
.b09ct__option { display: flex; gap: 4px; align-items: flex-start; }
.b09ct__totals { font-size: 12px; font-variant-numeric: tabular-nums; }
.b09ct__sheets { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; font-size: 12px; }
.b09ct__sheets summary { cursor: pointer; }
`;
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, Record<string, string | boolean | string[]>>();
async function fetchContract(projectId: string): Promise<ContractDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/contract`,
{ credentials: "include" },
);
const body = (await response.json()) as ContractDto;
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
return body;
}
async function saveContract(
projectId: string,
values: Record<string, string | boolean | string[]>,
): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/contract`,
{
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: ContractDto, reload: () => void): void {
const projectId = ctx.projectId as string;
const draft = drafts.get(projectId) ?? { ...data.settings };
drafts.set(projectId, draft);
const box = el("div", "b09ct__panel");
box.append(el("strong", "", "단가 적용율"));
const rates = el("div", "b09ct__rates");
for (const field of data.fields.rates) {
const label = el("label", "b09ct__rates");
label.append(el("span", "", `【 ${field.label} 】`));
const input = el("input");
input.type = "number";
input.min = "0";
input.step = "0.001";
input.value = String(draft[field.key] ?? "100");
input.addEventListener("input", () => (draft[field.key] = input.value));
label.append(input, el("span", "", "%"));
rates.append(label);
}
box.append(rates);
for (const option of data.fields.options) {
const row = el("label", "b09ct__option");
const check = el("input");
check.type = "checkbox";
check.checked = Boolean(draft[option.key]);
check.addEventListener("change", () => (draft[option.key] = check.checked));
row.append(check, el("span", "", option.label));
box.append(row);
const unused = data.options_not_used[option.key];
if (unused) box.append(el("span", "b09ct__warn", `⚠ ${unused}`));
}
box.append(
createButton({
label: "저장",
onClick: async () => {
try {
await saveContract(projectId, draft);
drafts.delete(projectId);
showToast("계약 조건 저장 — 설계 내역은 그대로", "success");
reload();
} catch (error) {
showToast(error instanceof Error ? error.message : "저장 못 함", "error");
}
},
}),
);
const t = data.totals;
const totals = el("div", "b09ct__totals");
for (const [label, part] of [
["재료비", "material"],
["노무비", "labor"],
["경비", "expense"],
] as const) {
totals.append(
el(
"div",
"",
`${label} ${won(t.design[`${part}_krw`])}${won(t.contract[`${part}_krw`])}` +
(t.ratio_pct[part] ? ` (${t.ratio_pct[part]}%)` : ""),
),
);
}
totals.append(
el("div", "", `직접공사비 ${won(t.design.total_krw)}${won(t.contract.total_krw)}`),
);
box.append(totals);
ctx.panel.append(box);
}
function drawBody(ctx: B09TabContext, data: ContractDto): void {
const projectId = ctx.projectId as string;
const draft = drafts.get(projectId) ?? { ...data.settings };
drafts.set(projectId, draft);
const excluded = new Set((draft.excluded as string[] | undefined) ?? []);
const wrap = el("div", "b09ct");
wrap.append(el("div", "b09ct__warn", `⚠ ${data.limit_note}`));
if (data.bill_missing_count) {
wrap.append(
el(
"div",
"b09ct__warn",
`⚠ 설계 내역 ${L("B09_Sheet_Missing")} ${data.bill_missing_count}${L("B09_Sheet_Count")} — 계약단가도 못 섬`,
),
);
}
wrap.append(
el("div", "b09ct__meta", "적용율을 제외할(ex 관급자재대..) 공정을 선택 — [저장]하면 반영"),
);
const scroll = el("div", "b09ct__scroll");
const table = el("table", "b09ct__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" : row.contract_excluded ? "is-excluded" : "");
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)),
el("td", "num", won(row.contract_unit_price_krw)),
el("td", "num", won(row.contract_amount_krw)),
);
const cell = el("td");
if (!row.is_group && row.in_bill) {
const check = el("input");
check.type = "checkbox";
check.checked = excluded.has(row.item_no);
check.addEventListener("change", () => {
if (check.checked) excluded.add(row.item_no);
else excluded.delete(row.item_no);
draft.excluded = [...excluded];
});
cell.append(check);
}
tr.append(cell, el("td", "", row.contract_code ?? ""), el("td", "", row.contract_note ?? ""));
table.append(tr);
}
scroll.append(table);
if (data.unit_price_sheets.length) scroll.append(drawSheets(data.unit_price_sheets));
wrap.append(scroll);
ctx.body.append(wrap);
}
/** 「적용율 적용된 일위대가/산출근거」 — 설계 호표는 그대로, 계약 호표(W-)만 따로 펼침. */
function drawSheets(sheets: ContractSheet[]): HTMLElement {
const box = el("div", "b09ct__sheets");
box.append(el("strong", "", `계약 일위대가·산출근거 ${sheets.length}장 — 설계 호표는 그대로`));
for (const sheet of sheets) {
const details = el("details");
details.append(
el(
"summary",
"",
`${sheet.code} ${sheet.name} ${sheet.spec} (설계 ${sheet.design_code}) — ` +
`재 ${won(sheet.material)} · 노 ${won(sheet.labor)} · 경 ${won(sheet.expense)} · 계 ${won(sheet.total)}`,
),
);
const table = el("table", "b09ct__table");
const head = el("tr");
for (const label of ["명칭", "규격", "단위", "수량", "재료비", "노무비", "경비", "합계"]) {
head.append(el("th", "", label));
}
table.append(head);
for (const row of sheet.rows) {
const tr = el("tr");
tr.append(
el("td", "", row.name),
el("td", "", row.spec ?? ""),
el("td", "", row.unit ?? ""),
el("td", "num", row.quantity),
el("td", "num", won(row.material)),
el("td", "num", won(row.labor)),
el("td", "num", won(row.expense)),
el("td", "num", won(row.total)),
);
table.append(tr);
}
details.append(table);
box.append(details);
}
return box;
}
function render(ctx: B09TabContext): void {
injectStyles();
if (!ctx.projectId) {
ctx.body.append(el("div", "b09ct__meta", "프로젝트를 고르세요"));
return;
}
const load = (): void => {
ctx.body.replaceChildren(el("div", "b09ct__meta", "계약내역 계산 중…"));
ctx.panel.replaceChildren();
fetchContract(ctx.projectId as string)
.then((data) => {
ctx.body.replaceChildren();
drawPanel(ctx, data, load);
drawBody(ctx, data);
})
.catch((error: unknown) => {
ctx.body.replaceChildren(
el(
"div",
"b09ct__warn",
`계약내역을 세우지 못함 — ${error instanceof Error ? error.message : ""}`,
),
);
});
};
load();
}
export const contractTab: B09Tab = {
key: "contract",
label: () => L("B09_Estimation_Tab_Contract"),
render,
};