Files
Aislo/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts

395 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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;
/** 형식 서식만 선 줄(수공·실적)은 null — 0 원으로 안 채움. */
amount_krw: string | null;
blocked?: boolean;
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[]>;
/** 칸 밑 안내 — 기본값의 까닭 · 실무가 어떻게 쓰는지. */
hints: Record<string, string>;
};
waste_note: string;
form_key: string;
form_labels: Record<string, string>;
form_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 = ["general", "sugong", "actual_general", "actual_sugong"];
/** 탭 말풍선 — 금액이 안 서는 까닭(2026-09-14 요율표 셋 결론 · 줄 사유는 서버 문구). */
const FORM_TAB_NOTES: Record<string, string> = {
sugong: "서식 차례만 — 세부 경비 법정 요율 없음(값 입력 대기 · 예정가격작성기준 §34①)",
actual_general: "서식 차례만 — 임도 미적용(100억 미만 · 예정가격작성기준 §37②)",
actual_sugong: "서식 차례만 — 임도 미적용(100억 미만 · 예정가격작성기준 §37②)",
};
/** 프로젝트별 고른 형식 탭 — 일반형식만 금액이 섬. */
const formOf = new Map<string, string>();
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, form: string): Promise<CostSheetDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost-sheet?form=${encodeURIComponent(form)}`,
{ 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;
}),
);
const hint = sheet.fields.hints?.[key];
if (hint) box.append(el("div", "b09cs__meta", hint));
};
typeSelect("work_type_indirect_labor");
box.append(
numberField("3.공사의기간(일) :", draft.duration_days ?? "", (v) => {
draft.duration_days = v;
}),
);
typeSelect("employment_insurance_grade");
typeSelect("retirement_mutual_aid_mode");
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");
// 13~16 — 사급비 위치·적용기준은 칸만 받음(계산에 안 씀 · 까닭은 칸 밑 안내).
typeSelect("private_material_position");
typeSelect("bid_method");
typeSelect("subcontract_guarantee");
typeSelect("performance_guarantee_mode");
typeSelect("contract_law_basis");
typeSelect("waste_placement");
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, reload: () => void): void {
const wrap = el("div", "b09cs");
const head = el("div", "b09cs__head");
head.append(
el("span", "b09cs__title", `제잡비 계산 — ${sheet.form_labels[sheet.form_key] ?? ""}`),
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}`));
if (sheet.form_note) head.append(el("span", "b09cs__warn", `⚠ ${sheet.form_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(" · ");
// 금액 없는 줄 — 「-」 + 막힌 까닭(임도 미적용 · 법정 요율 없음 · 확인 대기). 0 원으로 안 보임.
if (row.amount_krw === null && row.blocked) label.append(el("span", "b09cs__warn", row.note));
line.append(
label,
el("span", "b09cs__amount", row.amount_krw === null ? "" : won(row.amount_krw)),
);
body.append(line);
}
wrap.append(body);
const tabs = el("div", "b09cs__tabs");
for (const key of FORM_TABS) {
const tab = el("button", "b09cs__tab", sheet.form_labels[key] ?? key);
tab.setAttribute("aria-selected", String(key === sheet.form_key));
if (FORM_TAB_NOTES[key]) tab.title = FORM_TAB_NOTES[key];
tab.addEventListener("click", () => {
formOf.set(ctx.projectId as string, key);
reload();
});
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, formOf.get(ctx.projectId as string) ?? "general")
.then((sheet) => {
ctx.body.replaceChildren();
drawPanel(ctx, sheet, load);
drawBody(ctx, sheet, load);
})
.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,
};