Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
395 lines
15 KiB
TypeScript
395 lines
15 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;
|
||
/** 형식 서식만 선 줄(수공·실적)은 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,
|
||
};
|