Files
Aislo/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts
T
eomsangdonandClaude Opus 5 fb4eb59cdb feat(b09): 원가계산서 기준 입력 10·11·12 — 일반관리비 주/전문 · 절사 · 부가세 방식
- 10 일반관리비: (주)공사/전문공사 = 요율 구간표만 갈림 · 밑수는 형식 칸(일반 = 순공사원가 + 관리품목자재대)
- 11 절사: 총공사비/공급가액 N원 미만 — 고른 때만 이윤 자동보정(총공사비·공급가액 비례 부가세면 ÷1.1 반올림, 실무 봉화 373→339·영월 695→632, 산림조합은 차액 그대로) · 잔차 맞춤
- 12 부가세: 공급가액·재료비·없음·산림조합 형식·산림조합-면세품 (칸 이름은 기성 선택지를 받을 수 있게 문자열)
- 기본값은 종전 계산과 같음 · 자동보정 줄은 「… 절사 자동보정」으로 적음
- 원가계산서 탭: 10·11·12 고르개 · 관리품목 자재대 · 면세품 금액 칸
- 시험: 기본값 불변 · 밑수·표 갈림 · 실무 절사 식 · 끝자리 · 골든셋 초록

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

360 lines
13 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Tab_CostSheet.ts
* 원가계산서 탭 — STmate 「제잡비 계산」(wM_KanJub) 일반형식을 본뜸 (PLAN 12장 · 랩탑 메인).
*
* - 좌측 = 기준 입력판: 번호·제목은 DFM `TWM_KANJUB` 그대로. 엔진이 받는 칸만 엶.
* - 본문 = 서식(점선 라벨 + 오른쪽 금액) · 아래 형식 탭(일반형식만 켜짐) · 상태줄(직재·직노·산경·이윤).
* - ⚠ 값은 서버(`/estimation/cost-sheet`)가 냄 — 여기는 그리기·입력만(지침 5장).
* - 입력은 캐시(모듈 안)에만 쌓이고 [저장]에서 정본으로 감 · 자동저장 없음.
* ========================================================================== */
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";
import { injectSheetStyles, unconfirmedBadge } from "./B09_Estimation_UI_Sheet";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
interface SheetRow {
key: string;
mark: string;
label: string;
level: number;
formula: string;
rate_percent: string | null;
amount_krw: string;
note: string;
total: boolean;
}
interface FieldOption {
value: string;
label: string;
}
interface CostSheetDto {
status: string;
message?: string;
rows: SheetRow[];
status_line: Record<string, string>;
settings: Record<string, string | number>;
fields: {
work_types: { key: string; label: string }[];
/** 10·11·12 — 선택지는 엔진 규칙에서(요율 데이터 아님). */
choices: { key: string; label: string }[];
amounts: { key: string; label: string }[];
options: Record<string, FieldOption[]>;
};
waste_note: string;
bill_missing_count: number;
bill_unconfirmed_count: number;
rate_version: { dataset_id: string; effective_date: string };
notes: string[];
}
/** DFM 기준 입력판 첫 칸 「제잡비계산형식」 선택지 그대로. 지금은 「일반 형식」만 엶. */
const FORMS = [
"일반 형식",
"토지개발공사 형식",
"인천상수도 형식",
"수자원공사 형식",
"지역난방공사 형식",
"고용개선지원(서울)",
];
/** DFM 본문 아래 탭 이름 그대로. */
const FORM_TABS = ["일반형식", "수공형식", "실적일반", "실적수공"];
const STYLE_ID = "b09-cost-sheet-styles";
function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.b09cs { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; }
.b09cs__head { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; }
.b09cs__title { font-weight: 600; }
.b09cs__meta { font-size: 12px; color: var(--color-text-secondary); }
.b09cs__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); }
.b09cs__sheet { flex: 1; overflow: auto; min-height: 0; border: 1px solid var(--color-border); padding: 8px 12px; }
.b09cs__line { display: flex; align-items: baseline; gap: 6px; font-size: 13px; line-height: 1.9; }
.b09cs__mark { flex: 0 0 2.4em; text-align: right; }
.b09cs__label { flex: 1; min-width: 0; display: flex; gap: 4px; overflow: hidden; white-space: nowrap; }
.b09cs__label::after { content: ""; flex: 1; border-bottom: 1px dotted var(--color-border); margin-bottom: 4px; }
.b09cs__formula { color: var(--color-text-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
.b09cs__amount { flex: 0 0 11em; text-align: right; font-variant-numeric: tabular-nums; }
.b09cs__line--total { font-weight: 600; }
.b09cs__line--total .b09cs__amount { flex-basis: 12.5em; }
.b09cs__tabs { display: flex; gap: 2px; border-top: 1px solid var(--color-border); padding-top: 4px; }
.b09cs__tab { font-size: 12px; padding: 2px 10px; border: 1px solid var(--color-border); border-top: none; background: none; }
.b09cs__tab[aria-selected="true"] { font-weight: 600; background: var(--color-surface, #fff); }
.b09cs__status { display: flex; gap: 16px; font-size: 12px; font-variant-numeric: tabular-nums; }
.b09cs__panel { display: flex; flex-direction: column; gap: 6px; }
.b09cs__field { display: flex; flex-direction: column; gap: 2px; font-size: 12px; }
.b09cs__field select, .b09cs__field input { width: 100%; }
.b09cs__group { font-size: 12px; font-weight: 600; margin-top: 6px; }
.b09cs__buttons { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
`;
document.head.append(style);
}
function won(value: string): string {
const n = Number(value);
return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value;
}
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;
}
/** 프로젝트별 입력 캐시 — 탭을 다시 골라도 [저장] 전 값이 남음. */
const drafts = new Map<string, Record<string, string>>();
async function fetchSheet(projectId: string): Promise<CostSheetDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost-sheet`,
{ credentials: "include" },
);
const body = (await response.json()) as CostSheetDto;
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
return body;
}
async function saveSheet(projectId: string, values: Record<string, string>): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost-sheet`,
{
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
},
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
}
function selectField(
label: string,
value: string,
options: { value: string; label: string; disabled?: boolean }[],
onChange: (value: string) => void,
): HTMLElement {
const wrap = el("label", "b09cs__field");
wrap.append(el("span", "", label));
const select = el("select");
for (const option of options) {
const node = el("option", "", option.label);
node.value = option.value;
node.disabled = Boolean(option.disabled);
select.append(node);
}
select.value = value;
select.addEventListener("change", () => onChange(select.value));
wrap.append(select);
return wrap;
}
function numberField(label: string, value: string, onInput: (value: string) => void): HTMLElement {
const wrap = el("label", "b09cs__field");
wrap.append(el("span", "", label));
const input = el("input");
input.type = "number";
input.min = "0";
input.value = value;
input.addEventListener("input", () => onInput(input.value));
wrap.append(input);
return wrap;
}
function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): void {
const projectId = ctx.projectId as string;
const draft =
drafts.get(projectId) ??
Object.fromEntries(Object.entries(sheet.settings).map(([k, v]) => [k, String(v)]));
drafts.set(projectId, draft);
const box = el("div", "b09cs__panel");
box.append(el("div", "b09cs__group", "기준 입력"));
box.append(
selectField(
"제잡비계산형식 :",
FORMS[0],
FORMS.map((name, i) => ({
value: name,
label: i ? `${name} (준비 중)` : name,
disabled: i > 0,
})),
() => undefined,
),
);
const workType = (key: string) =>
[...sheet.fields.work_types, ...sheet.fields.choices].find((f) => f.key === key);
const typeSelect = (key: string) => {
const field = workType(key);
if (!field) return;
box.append(
selectField(field.label + " :", draft[key] ?? "", sheet.fields.options[key] ?? [], (v) => {
draft[key] = v;
}),
);
};
typeSelect("work_type_indirect_labor");
box.append(
numberField("3.공사의기간(일) :", draft.duration_days ?? "", (v) => {
draft.duration_days = v;
}),
);
for (const field of sheet.fields.amounts.slice(0, 2)) {
box.append(
numberField(field.label + " :", draft[field.key] ?? "", (v) => (draft[field.key] = v)),
);
}
typeSelect("work_type_safety");
typeSelect("environment_work_type");
typeSelect("equipment_guarantee_work_type");
// 10·11·12 — DFM 번호 차례. 절사는 「안 함」이 기본(고른 때만 이윤 자동보정).
typeSelect("overhead_class");
typeSelect("cut_basis");
typeSelect("cut_unit_krw");
typeSelect("vat_mode");
box.append(el("div", "b09cs__group", "계산/인쇄 설정"));
for (const field of sheet.fields.amounts.slice(2)) {
box.append(
numberField(field.label + " :", draft[field.key] ?? "", (v) => (draft[field.key] = v)),
);
}
const buttons = el("div", "b09cs__buttons");
buttons.append(
createButton({
label: "제비율표",
variant: "ghost",
onClick: () => ctx.open("rate_table"),
}),
createButton({
label: "다시 계산",
variant: "ghost",
onClick: () => {
drafts.delete(projectId);
reload();
},
}),
createButton({
label: "저장",
onClick: async () => {
try {
await saveSheet(projectId, draft);
drafts.delete(projectId);
showToast("원가계산서 기준 저장", "success");
reload();
} catch (error) {
showToast(error instanceof Error ? error.message : "저장 못 함", "error");
}
},
}),
);
box.append(buttons);
ctx.panel.append(box);
}
function drawBody(ctx: B09TabContext, sheet: CostSheetDto): void {
const wrap = el("div", "b09cs");
const head = el("div", "b09cs__head");
head.append(
el("span", "b09cs__title", "제잡비 계산 — 일반형식"),
el(
"span",
"b09cs__meta",
`요율 판 ${sheet.rate_version.effective_date} · 직접비는 설계내역서 합계`,
),
);
// 표현은 내역서·구조물도와 한 벌(「금액을 못 세운 줄 N건」 · 「미확정 N건」 배지).
if (sheet.bill_missing_count) {
head.append(
el(
"span",
"b09cs__warn",
`⚠ 내역서 ${L("B09_Sheet_Missing")} ${sheet.bill_missing_count}${L("B09_Sheet_Count")} — 직접비가 덜 섬`,
),
);
}
if (sheet.bill_unconfirmed_count) head.append(unconfirmedBadge(sheet.bill_unconfirmed_count));
if (sheet.waste_note) head.append(el("span", "b09cs__meta", `폐기물처리비: ${sheet.waste_note}`));
wrap.append(head);
const body = el("div", "b09cs__sheet");
for (const row of sheet.rows) {
const line = el("div", `b09cs__line${row.total ? " b09cs__line--total" : ""}`);
line.style.paddingLeft = `${row.level * 1.2}em`;
line.append(el("span", "b09cs__mark", row.mark));
const label = el("span", "b09cs__label");
label.append(el("span", "", row.label));
if (row.formula) label.append(el("span", "b09cs__formula", `<${row.formula}>`));
label.title = [row.formula, row.note].filter(Boolean).join(" · ");
line.append(label, el("span", "b09cs__amount", won(row.amount_krw)));
body.append(line);
}
wrap.append(body);
const tabs = el("div", "b09cs__tabs");
FORM_TABS.forEach((name, i) => {
const tab = el("button", "b09cs__tab", name);
tab.setAttribute("aria-selected", String(i === 0));
tab.disabled = i > 0;
if (i > 0) tab.title = "준비 중 — 일반형식부터 세움";
tabs.append(tab);
});
wrap.append(tabs);
const status = el("div", "b09cs__status");
for (const [name, value] of Object.entries(sheet.status_line)) {
status.append(el("span", "", `${name}: ${won(value)}`));
}
wrap.append(status);
for (const note of sheet.notes) wrap.append(el("div", "b09cs__meta", note));
ctx.body.append(wrap);
}
function render(ctx: B09TabContext): void {
injectStyles();
injectSheetStyles();
if (!ctx.projectId) {
ctx.body.append(el("div", "b09cs__meta", "프로젝트를 고르세요"));
return;
}
const load = (): void => {
ctx.body.replaceChildren(el("div", "b09cs__meta", "원가계산서 계산 중…"));
ctx.panel.replaceChildren();
fetchSheet(ctx.projectId as string)
.then((sheet) => {
ctx.body.replaceChildren();
drawPanel(ctx, sheet, load);
drawBody(ctx, sheet);
})
.catch((error: unknown) => {
ctx.body.replaceChildren(
el(
"div",
"b09cs__warn",
`원가계산서를 세우지 못함 — ${error instanceof Error ? error.message : ""}`,
),
);
});
};
load();
}
export const costSheetTab: B09Tab = {
key: "cost_sheet",
label: () => L("B09_Estimation_Tab_CostSheet"),
render,
};