Files
Aislo/B09_Estimation/B09_Estimation_UI_Tab_Completion.ts
T
eomsangdonandClaude Opus 5 a7a1b246b6 feat(b09): 준공 단계 — 계약금액 | 준공금액(기성 마지막 회차 누계를 옮기기만)
- 준공 별도 계산 규칙 미확인(35번 §3) — 규칙을 짓지 않고 기성 누계를 옮김 · 기성 한 장 불변
- 기성 회차가 없으면 준공금액 비움(0 원으로 안 채움)
- 기성 계산 조각(progress_for)을 떼어 준공이 같은 길로 받음
- 기성 간접재료비 확인 대기 닫음 — 예정가격작성기준 제17조·제39조② 근거(브레인 판정)
- 준공 탭 파일 · 사전 키(등록은 서브) · 시험 2건 · 전체 1730 통과

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

179 lines
6.1 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Tab_Completion.ts
* 준공 탭 — STmate 「준공조서(을/병)」의 「계약금액 | 준공금액」 두 열을 본뜸 (PLAN 12장 · 랩탑 메인).
*
* - 위 = 제잡비 줄 + 합계 줄(직접공사비 · 제잡비 계 · 공급가액 · 부가세 · 기성금액).
* - 아래 = 공종별 계약금액 | 준공금액.
* - ⚠ 준공 별도 계산 규칙은 미확인 — 서버(`/estimation/completion`)가 기성 마지막 회차 누계를
* 옮기기만 함. 입력 칸·[저장] 없음(기성 탭에서 고침).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
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 CompletionRow {
item_no: string;
name: string;
spec: string | null;
unit: string | null;
is_group: boolean;
contract_amount_krw: string | null;
completion_amount_krw: string | null;
}
interface CompletionLine {
key: string;
name: string;
total: boolean;
contract_krw: string;
completion_krw: string | null;
completion_pct: string | null;
}
interface CompletionDto {
status: string;
message?: string;
rows: CompletionRow[];
lines: CompletionLine[];
from_round: number;
notes: string[];
limit_note: string;
}
const STYLE_ID = "b09-completion-styles";
function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.b09cp { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; }
.b09cp__meta { font-size: 12px; color: var(--color-text-secondary); }
.b09cp__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); }
.b09cp__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
.b09cp__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; }
.b09cp__table th, .b09cp__table td { border: 1px solid var(--color-border); padding: 2px 6px; }
.b09cp__table td.num { text-align: right; font-variant-numeric: tabular-nums; }
.b09cp__table tr.is-total td, .b09cp__table tr.is-group td { font-weight: 600; }
`;
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;
}
async function fetchCompletion(projectId: string): Promise<CompletionDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/completion`,
{ credentials: "include" },
);
const body = (await response.json()) as CompletionDto;
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
return body;
}
function table(headers: string[]): HTMLTableElement {
const node = el("table", "b09cp__table");
const head = el("tr");
for (const label of headers) head.append(el("th", "", label));
node.append(head);
return node;
}
function draw(ctx: B09TabContext, data: CompletionDto): void {
const wrap = el("div", "b09cp");
wrap.append(el("div", "b09cp__warn", `⚠ ${data.limit_note}`));
for (const note of data.notes) wrap.append(el("div", "b09cp__warn", `⚠ ${note}`));
wrap.append(
el(
"div",
"b09cp__meta",
data.from_round
? `준공금액 = 기성 ${data.from_round}회(마지막 회차) 누계 — 고칠 곳은 기성 탭`
: "기성 탭에서 회차를 넣으면 그 누계가 준공금액으로 옮겨짐",
),
);
const scroll = el("div", "b09cp__scroll");
const summary = table(["명칭", "계약금액", "준공금액", "준공(%)"]);
for (const line of data.lines) {
const tr = el("tr", line.total ? "is-total" : "");
tr.append(
el("td", "", line.name),
el("td", "num", won(line.contract_krw)),
el("td", "num", won(line.completion_krw)),
el("td", "num", line.completion_pct ? `${line.completion_pct}%` : ""),
);
summary.append(tr);
}
const bill = table(["공종번호", "명칭", "규격", "단위", "계약금액", "준공금액"]);
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", won(row.contract_amount_krw)),
el("td", "num", won(row.completion_amount_krw)),
);
bill.append(tr);
}
const top = el("div");
top.append(el("strong", "", "준공조서 — 계약금액 | 준공금액"), summary);
const bottom = el("div");
bottom.append(el("strong", "", "공종별"), bill);
scroll.append(top, bottom);
wrap.append(scroll);
ctx.body.append(wrap);
}
function render(ctx: B09TabContext): void {
injectStyles();
if (!ctx.projectId) {
ctx.body.append(el("div", "b09cp__meta", "프로젝트를 고르세요"));
return;
}
ctx.body.replaceChildren(
el("div", "b09cp__meta", `${L("B09_Estimation_Tab_Completion")} 계산 중…`),
);
fetchCompletion(ctx.projectId)
.then((data) => {
ctx.body.replaceChildren();
draw(ctx, data);
})
.catch((error: unknown) => {
ctx.body.replaceChildren(
el(
"div",
"b09cp__warn",
`준공을 세우지 못함 — ${error instanceof Error ? error.message : ""}`,
),
);
});
}
export const completionTab: B09Tab = {
key: "completion",
label: () => L("B09_Estimation_Tab_Completion"),
render,
};