refactor(B08,B09): 폴더째 old_code 로 옮기고 빈 화면 둘만 남김 (PLAN 7-3)
B08_Quantity 86 · B09_Estimation 111 파일을 old_code/ 로 옮김(지우지 않음). 화면은 메뉴·주소·단계 막대만 남은 빈 틀 둘 — main.py 라우터 13 개는 끊음. B07 이 빌려 쓰던 비탈 길이·면적은 필요한 함수만 B07_DesignDetail_Engine_SlopeGeometry 로 옮겨 적음(면적 적분·노면 면적·측점 묶음은 안 옮김) · 시험 하나를 새로 둠. B07 구조물도 조립(Cad_StandardSheet)은 2026-09-13 에 이미 도면 목록에서 빠져 부르는 곳이 없어 old_code 로 같이 보냄 — 구조물 그림은 되살리지 않음. B06 구조물 몫 조회는 빈 값으로 두어 화면이 그대로 서게 함. B08·B09 를 부르던 시험 25 개도 old_code/resources/tester 로 옮김. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
/* =============================================================================
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user