- 서버: /estimation/cost-sheet(GET·PUT) · /estimation/rate-table — 새 라우터 파일
- 직접비 = 설계내역서 합계 · 기준 입력 = estimation.cost_sheet · 폐기물처리비 = 임목폐기물 톤 × 수동 단가
- 서식 줄: 나.순공사원가 … 바.총공사비 (DFM TWM_KANJUB 차례·표기) · 경비 세목 번호 차례
- 기준 입력판: 형식(일반만) · 2.공사의종류 · 3.공사기간 · 6.관/사급 · 7.산업안전 · 8.환경보전 · 9.건설기계 — 선택지는 요율 데이터에서
- 요율표: 첫 탭 묶음 10개(보기 전용 · 수정은 잠김)
- 탭 계약 파일은 서브 314c0040 과 같은 내용(blob 동일) · 사전은 새 벌 b4
- 시험: 서식 차례·엔진 값 일치·폐기물 자리·저장값 거름·구간 표기
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
348 lines
12 KiB
TypeScript
348 lines
12 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";
|
|
|
|
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 }[];
|
|
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.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");
|
|
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: "outlined",
|
|
onClick: () => ctx.open("rate_table"),
|
|
}),
|
|
createButton({
|
|
label: "다시 계산",
|
|
variant: "outlined",
|
|
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} · 직접비는 설계내역서 합계`,
|
|
),
|
|
);
|
|
if (sheet.bill_missing_count || sheet.bill_unconfirmed_count) {
|
|
head.append(
|
|
el(
|
|
"span",
|
|
"b09cs__warn",
|
|
`⚠ 내역서 단가 없는 줄 ${sheet.bill_missing_count} · 미확정 ${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();
|
|
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,
|
|
};
|