Files
Aislo/B09_Estimation/B09_Estimation_UI_BaseData.ts
T
eomsangdonandClaude Opus 5 a6c9ca0185 feat(B09): 자재단가대비표·환율및기초자료를 기초자료 탭에 붙임
표를 냈는데 화면에 없으면 낸 것이 아니라 붙임. 목록표 넷 아래에 A9·A10 두 장 추가.

- 자재단가대비표: 원천마다 「단가·페이지」 두 칸으로 머리글 두 줄, 채택 원천 굵게
- 슬롯 6 이 곧 「적용 단가」라 「적 용」 칸을 따로 세우지 않음 (같은 값 두 번 서는 것 방지)
- 환율및기초자료: 환율·인건비(운전사 3)·단가 및 재료비 세 구획
- 유가 적용 범위 고르는 칸 — 지역 공시가는 자료가 없어 고를 수 없게 두고 까닭 표시 (확정 ⑮)
- 두 표는 목록표와 따로 받아 옴 — 못 받아도 목록표 넷은 그대로 남음
- 시간당 노임이 소수로 남는 까닭을 화면에 밝힘 (여기서 자르면 기계 사용료가 어긋남)

검증: tsc 통과 · pytest 267 통과 · 공용 브라우저 5174 에서 표 6장·줄 737 확인,
body.scrollWidth 1696 < innerWidth 1704 (가로 넘침 없음)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 19:57:00 +09:00

448 lines
16 KiB
TypeScript
Raw 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_BaseData.ts
* 기초자료 탭 · 중기 탭 — 목록표 넷을 그린다 (사용자 확정 12번 「내야 할 표 16개 전체」).
*
* 기초자료 탭 : 노무비목록표 · 재료비목록표 · 경비목록표
* 중기 탭 : 중기목록표 (합계 + 노무·재료·경비 3분할)
*
* 서식은 지어내지 않았다 — 실무 내역서(영월 기번6 · 봉화 기번41)의 같은 이름 시트를
* 그대로 옮겼다. 칸 이름·차례가 그 시트와 같다.
*
* 화면 조립부(`B09_Estimation_UI_Page.ts`)가 이미 700줄을 크게 넘어 여기로 뺐다.
* 표를 그리는 일만 하고 **상태를 들지 않는다** — 부르는 쪽이 자료를 넘긴다.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { API_BASE_URL } from "@config/config_frontend";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 목록표 한 줄 — 실무 시트 칸 그대로. */
export interface BaseDataRow {
code: string;
name: string;
spec: string;
unit: string;
unit_price_krw: string | null;
note: string;
}
/** 중기목록표 한 줄 — 합계와 3분할을 함께 보인다. */
export interface MachineRow {
code: string;
name: string;
spec: string;
unit: string;
total_krw: string | null;
labor_krw: string | null;
material_krw: string | null;
expense_krw: string | null;
note: string;
}
export interface BaseDataDto {
status: string;
labor: BaseDataRow[];
material: BaseDataRow[];
expense: BaseDataRow[];
machine: MachineRow[];
}
export async function fetchBaseData(projectId: string): Promise<BaseDataDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/base-data`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`base-data ${response.status}`);
return (await response.json()) as BaseDataDto;
}
function money(value: string | null): string {
if (value === null || value === "") return "";
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed.toLocaleString("ko-KR") : value;
}
function head(text: string): HTMLElement {
const el = document.createElement("div");
el.className = "b09-hint";
el.style.fontWeight = "600";
el.textContent = text;
return el;
}
function note(text: string): HTMLElement {
const el = document.createElement("div");
el.className = "b09-hint";
el.textContent = text;
return el;
}
function table(headers: string[], rows: string[][], leftCols: number[]): HTMLElement {
const el = document.createElement("table");
el.className = "b09-sheet";
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
headers.forEach((text, index) => {
const th = document.createElement("th");
th.textContent = text;
if (leftCols.includes(index)) th.className = "b09-left";
headRow.append(th);
});
thead.append(headRow);
const tbody = document.createElement("tbody");
for (const cells of rows) {
const tr = document.createElement("tr");
cells.forEach((text, index) => {
const td = document.createElement("td");
td.textContent = text;
if (leftCols.includes(index)) td.className = "b09-left";
tr.append(td);
});
tbody.append(tr);
}
el.append(thead, tbody);
return el;
}
/** 목록표 한 장 — 코드·명칭·규격·단위·단가·비고 (실무 시트와 같은 칸). */
function catalogTable(rows: BaseDataRow[]): HTMLElement {
return table(
["코드번호", "명 칭", "규 격", "단위", "단 가", "비 고"],
rows.map((row) => [
row.code,
row.name,
row.spec,
row.unit,
money(row.unit_price_krw),
row.note,
]),
[0, 1, 2, 5],
);
}
/**
* 기초자료 탭 — 목록표 셋.
*
* ⚠ 표가 비거나 한 줄뿐일 때 **그냥 두지 않는다** — 「다 채운 것」으로 읽히기 때문이다.
* 재료비목록표가 지금 그 자리다(사급 자재 카탈로그가 아직 안 섰다).
*/
export function drawBaseDataTab(body: HTMLElement, data: BaseDataDto): void {
const groups: Array<[string, BaseDataRow[], string]> = [
["노무비목록표", data.labor, ""],
[
"재료비목록표",
data.material,
data.material.length <= 1
? "⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 자재값 출처(업체 견적·물가지)를 붙이면 채워집니다."
: "",
],
["경비목록표", data.expense, "기계 취득가격입니다(천원) — 시간당 사용료는 「중기」 탭입니다."],
];
for (const [title, rows, hint] of groups) {
body.append(head(`${title} (${rows.length})`));
if (hint) body.append(note(hint));
if (rows.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
continue;
}
body.append(catalogTable(rows));
}
}
/** 중기 탭 — 중기목록표. 합계와 3분할을 함께 보인다(실무 시트와 같은 칸). */
export function drawMachineTab(body: HTMLElement, data: BaseDataDto): void {
body.append(head(`중기목록표 (${data.machine.length})`));
if (data.machine.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
return;
}
body.append(
table(
["코드번호", "명 칭", "규 격", "단위", "합 계", "노 무 비", "재 료 비", "경 비", "비 고"],
data.machine.map((row) => [
row.code,
row.name,
row.spec,
row.unit,
money(row.total_krw),
money(row.labor_krw),
money(row.material_krw),
money(row.expense_krw),
row.note,
]),
[0, 1, 2, 8],
),
);
// ⚠ 계산 과정을 감추지 않는다(PLAN 8-13). 조종원 환산이 실무와 다른 것을 여기서 밝힌다.
body.append(
note(
"조종원 노임은 「노임 ÷ 8시간」으로 셉니다. 실무·임도교본은 여기에 " +
"「× 16/12 × 25/20」(약 1.67배)을 더 곱합니다 — 그 계수의 규정 원문(기재부 " +
"예정가격 작성기준)을 아직 확인하지 못해 적용하지 않았습니다. 확정되면 " +
"기계가 든 공종 단가가 약 19~24% 오릅니다.",
),
);
body.append(note("잡재료(주연료의 %)는 연료 소요량에 포함되어 있습니다 — 따로 세지 않습니다."));
}
/** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */
export function drawBaseDataError(body: HTMLElement): void {
body.append(note(L("B09_Estimation_Tab_Pending")));
}
/* =============================================================================
* 자재단가대비표(A9) · 환율및기초자료(A10) — 사용자 확정 ③·⑮ 와 한 벌.
*
* 표를 냈는데 화면에 없으면 **낸 것이 아니다.** 그래서 기초자료 탭 아래에 붙인다.
* 서식은 실무 「자재단가대비표」·「환율및기초자료」 시트를 그대로 옮겼다 —
* 원천마다 **단가·페이지** 두 칸이 서고, 채택한 원천에 표시가 붙는다.
* ========================================================================== */
/** 원천 한 칸 — 값이 없으면 **빈칸**이다. 0 을 넣으면 「0원짜리 견적」으로 읽힌다. */
export interface PriceSlot {
name: string;
price_krw: string | null;
/** 「페이지」 자리 — 물가지는 쪽수, 견적은 업체명·날짜(확정 ③). */
source_note: string;
adopted: boolean;
}
export interface MaterialComparisonRow {
code: string;
name: string;
spec: string;
unit: string;
slots: PriceSlot[];
adopted_slot: number;
adopted_price_krw: string | null;
note: string;
}
export interface FuelScope {
key: string;
label: string;
available: boolean;
why?: string;
}
export interface PriceSourcesDto {
status: string;
material_comparison: {
slot_names: string[];
rows: MaterialComparisonRow[];
notes: string[];
};
base_reference: {
exchange: { rows: unknown[]; note: string };
labor: {
rows: Array<{
code: string;
name: string;
day_wage_krw: string | null;
hourly_krw: string | null;
formula: string;
}>;
note: string;
};
fuel: {
diesel_krw_per_l: string | null;
scope: string;
effective_date: string;
dataset_id: string;
scopes: FuelScope[];
note: string;
};
};
}
export async function fetchPriceSources(projectId: string): Promise<PriceSourcesDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/price-sources`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`price-sources ${response.status}`);
return (await response.json()) as PriceSourcesDto;
}
/**
* 자재단가대비표 — 원천마다 **단가·페이지** 두 칸이라 머리글이 두 줄이다.
* 채택한 원천 칸에 표시를 넣어 「어느 것을 썼나」가 한눈에 보이게 한다.
*/
function comparisonTable(slotNames: string[], rows: MaterialComparisonRow[]): HTMLElement {
const el = document.createElement("table");
el.className = "b09-sheet";
const thead = document.createElement("thead");
const top = document.createElement("tr");
const bottom = document.createElement("tr");
// ⚠ 슬롯 6 이 곧 「적용 단가」다(`JUKNM=6`). 그 자리에 「적 용」 칸을 또 세우면
// 같은 값이 두 번 선다 — 실무 시트도 원천 다섯 + 적용 하나로 끝난다.
const appliedIsLastSlot =
rows.length > 0 && rows.every((row) => row.adopted_slot === slotNames.length);
const trailing = appliedIsLastSlot ? [] : ["적 용"];
const fixed = ["코드번호", "명 칭", "규 격", "단위"];
fixed.forEach((text, index) => {
const th = document.createElement("th");
th.textContent = text;
th.rowSpan = 2;
if (index <= 2) th.className = "b09-left";
top.append(th);
});
for (const name of [...slotNames, ...trailing]) {
const th = document.createElement("th");
th.textContent = name;
th.colSpan = 2;
top.append(th);
for (const sub of ["단 가", "페이지"]) {
const cell = document.createElement("th");
cell.textContent = sub;
bottom.append(cell);
}
}
const noteHead = document.createElement("th");
noteHead.textContent = "비 고";
noteHead.rowSpan = 2;
noteHead.className = "b09-left";
top.append(noteHead);
thead.append(top, bottom);
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
const put = (text: string, left = false): void => {
const td = document.createElement("td");
td.textContent = text;
if (left) td.className = "b09-left";
tr.append(td);
};
put(row.code, true);
put(row.name, true);
put(row.spec, true);
put(row.unit);
for (const slot of row.slots) {
const td = document.createElement("td");
td.textContent = money(slot.price_krw);
// 채택한 원천을 굵게 — 「어느 값을 썼나」를 표가 스스로 밝힌다.
if (slot.adopted) td.style.fontWeight = "700";
tr.append(td);
const page = document.createElement("td");
page.textContent = slot.source_note;
page.className = "b09-left";
tr.append(page);
}
if (!appliedIsLastSlot) {
put(money(row.adopted_price_krw));
put(row.adopted_slot ? (row.slots[row.adopted_slot - 1]?.name ?? "") : "", true);
}
put(row.note, true);
tbody.append(tr);
}
el.append(thead, tbody);
return el;
}
/** 환율및기초자료 — 실무 시트 세 구획(환율·인건비·단가 및 재료비)을 차례대로. */
function baseReferenceSections(body: HTMLElement, data: PriceSourcesDto["base_reference"]): void {
body.append(head("환율및기초자료 — ① 환율"));
body.append(note(data.exchange.note));
body.append(head(`환율및기초자료 — ② 인건비 (${data.labor.rows.length})`));
if (data.labor.rows.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
} else {
body.append(
table(
["코드번호", "직 종", "일 당", "시간당", "산 식"],
data.labor.rows.map((row) => [
row.code,
row.name,
money(row.day_wage_krw),
money(row.hourly_krw),
row.formula,
]),
[0, 1, 4],
),
);
}
// 시간당이 소수로 남는 까닭을 밝힌다 — 안 밝히면 「덜 다듬은 값」으로 읽힌다.
body.append(
note(
"시간당은 나눈 값을 그대로 둡니다 — 여기서 원 단위로 자르면 기계 시간당 사용료가 " +
"조금씩 어긋납니다. 자르는 자리는 일위대가·내역서 쪽입니다.",
),
);
body.append(note(data.labor.note));
body.append(head("환율및기초자료 — ③ 단가 및 재료비"));
const fuel = data.fuel;
body.append(
table(
["항 목", "단 가", "적용 범위", "기준일", "자료"],
[
[
"경유",
money(fuel.diesel_krw_per_l),
fuel.scopes.find((scope) => scope.key === fuel.scope)?.label || fuel.scope,
fuel.effective_date,
fuel.dataset_id,
],
],
[0, 2, 3, 4],
),
);
// ⚠ 확정 ⑮ — 전국/지역을 고르는 칸. 자료가 없는 것은 **고를 수 없게** 두고
// 까닭을 곧바로 밝힌다. 고르게만 해 두고 값이 없으면 조용히 틀린 값이 선다.
const picker = document.createElement("div");
picker.className = "b09-hint";
picker.style.display = "flex";
picker.style.alignItems = "center";
picker.style.gap = "8px";
const label = document.createElement("span");
label.textContent = "유가 적용 범위";
const select = document.createElement("select");
for (const scope of fuel.scopes) {
const option = document.createElement("option");
option.value = scope.key;
option.textContent = scope.available ? scope.label : `${scope.label} (자료 없음)`;
option.disabled = !scope.available;
option.selected = scope.key === fuel.scope;
select.append(option);
}
picker.append(label, select);
body.append(picker);
for (const scope of fuel.scopes) {
if (!scope.available && scope.why) body.append(note(`⚠ ${scope.label}: ${scope.why}`));
}
body.append(note(fuel.note));
}
/** 기초자료 탭 아래쪽 — A9·A10 두 장. */
export function drawPriceSourcesSections(body: HTMLElement, data: PriceSourcesDto): void {
const comparison = data.material_comparison;
body.append(head(`자재단가대비표 (${comparison.rows.length})`));
if (comparison.rows.length <= 1) {
body.append(
note(
"⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 재료비목록표와 같은 원인입니다.",
),
);
}
if (comparison.rows.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
} else {
body.append(comparisonTable(comparison.slot_names, comparison.rows));
}
for (const text of comparison.notes) body.append(note(text));
baseReferenceSections(body, data.base_reference);
}
/** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */
export function drawPriceSourcesPending(body: HTMLElement): void {
body.append(note("자재단가대비표·환율및기초자료를 불러오는 중입니다…"));
}