Files
Aislo/B09_Estimation/B09_Estimation_UI_Tab_BaseData.ts
T
eomsangdonandClaude Opus 5 ef48e6edd3 refactor(b09): 옛 탭 옮기기 ⑤ 기초자료 — 산출 조건(UI_Factors) · 목록표 셋 · 자재단가대비표 · 환율및기초자료를 새 틀 탭 파일로
- 저장하면 이 탭 자료와 설계내역서 한 벌(UI_Store forgetBill)을 함께 새로 받음 — 값이 다시 섬
- 화면 확인: 옮기기 전후 같음 — 표 6 · 줄 737 · 고르개 32 개 고른 값 전부 같음 · 숫자 칸 2 · 글 35,074 자
- 계산 입력 확인: 기계 작업효율 E 평균 0.50 → 상한 0.55 를 화면에서 고르면 「사용자가 고른 값」 · 제 2 호표 2,511 → 2,284 ·
  되돌린 뒤(기본값) 2,511 그대로 — 검증 프로젝트 설정 원래대로 복구

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 02:47:03 +09:00

377 lines
13 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Tab_BaseData.ts
* B09 기초자료 탭 — 산출 조건(계산 입력) · 노무비·재료비·경비 목록표 · 자재단가대비표 · 환율및기초자료
*
* - 옛 `B09_Estimation_UI_BaseData.ts`·`_UI_Page.ts` 의 기초자료 탭을 그대로 옮김(PLAN 12장 옛 탭 옮기기).
* - ⚠ 산출 조건·유가 지역은 **계산 입력** — 저장하면 단가가 다시 서므로 이 탭 자료와
* 설계내역서 한 벌(`UI_Store`)을 함께 새로 받음.
* - 표를 냈는데 화면에 없으면 낸 것이 아님 — 목록표 셋·대비표·기초자료를 한 탭에 모두 보임.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import {
markProvenanceCell,
attachProvenance,
type ProvenancePayload,
type ProvenanceSheet,
} from "@ui/ui_template_provenance";
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import {
drawFactorChoices,
fetchFactorChoices,
picker,
saveFactorChoices,
} from "./B09_Estimation_UI_Factors";
import type { FactorChoicesDto } from "./B09_Estimation_UI_Factors";
import { L, el, hint } from "./B09_Estimation_UI_Sheet";
import { forgetBill } from "./B09_Estimation_UI_Store";
import { head, infoTable, money, note } from "./B09_Estimation_UI_Table";
interface BaseDataRow {
code: string;
name: string;
spec: string;
unit: string;
unit_price_krw: string | null;
note: string;
}
interface BaseDataDto {
labor: BaseDataRow[];
material: BaseDataRow[];
expense: BaseDataRow[];
provenance?: ProvenancePayload;
}
interface PriceSlot {
name: string;
price_krw: string | null;
source_note: string;
adopted: boolean;
}
interface MaterialComparisonRow {
code: string;
name: string;
spec: string;
unit: string;
slots: PriceSlot[];
adopted_slot: number;
adopted_price_krw: string | null;
note: string;
}
interface FuelScope {
key: string;
label: string;
available: boolean;
why?: string;
}
interface PriceSourcesDto {
provenance?: ProvenancePayload;
material_comparison: { slot_names: string[]; rows: MaterialComparisonRow[]; notes: string[] };
base_reference: {
exchange: { 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[];
regions: Array<{ code: string; name: string; diesel_krw_per_l: string | null }>;
region: string;
region_name: string;
region_missing?: string;
note: string;
};
};
}
async function getJson<T>(projectId: string, path: string): Promise<T> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/${path}`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`${path} ${response.status}`);
return (await response.json()) as T;
}
/**
* 목록표 셋 — 코드·명칭·규격·단위·단가·비고.
* ⚠ 표가 비거나 한 줄뿐일 때 그냥 두지 않음 — 「다 채운 것」으로 읽힘(재료비목록표가 그 자리).
*/
function drawCatalogs(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, text] of groups) {
body.append(head(`${title} (${rows.length})`));
if (text) body.append(note(text));
if (rows.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
continue;
}
body.append(
infoTable(
["코드번호", "명 칭", "규 격", "단위", "단 가", "비 고"],
rows.map((row) => [
row.code,
row.name,
row.spec,
row.unit,
money(row.unit_price_krw),
row.note,
]),
[0, 1, 2, 5],
["code", "name", "spec", "unit", "unit_price_krw", "note"],
data.provenance?.sheets?.catalog,
),
);
}
}
/** 자재단가대비표 — 원천마다 **단가·페이지** 두 칸, 채택한 원천을 굵게. */
function comparisonTable(
slotNames: string[],
rows: MaterialComparisonRow[],
sheet?: ProvenanceSheet,
): HTMLElement {
const wrap = el("div", "b09s-wrap");
const table = el("table", "b09s-table b09s-info");
const thead = el("thead");
const top = el("tr");
const bottom = el("tr");
// ⚠ 슬롯 6 이 곧 「적용 단가」(`JUKNM=6`) — 그 자리에 「적 용」 칸을 또 세우면 같은 값이 두 번 섬.
const appliedIsLastSlot =
rows.length > 0 && rows.every((row) => row.adopted_slot === slotNames.length);
["코드번호", "명 칭", "규 격", "단위"].forEach((text, index) => {
const th = el("th", index <= 2 ? "b09s-left" : "", text);
th.rowSpan = 2;
top.append(th);
});
for (const name of [...slotNames, ...(appliedIsLastSlot ? [] : ["적 용"])]) {
const th = el("th", "", name);
th.colSpan = 2;
top.append(th);
bottom.append(el("th", "", "단 가"), el("th", "", "페이지"));
}
const noteHead = el("th", "b09s-left", "비 고");
noteHead.rowSpan = 2;
top.append(noteHead);
thead.append(top, bottom);
const tbody = el("tbody");
for (const row of rows) {
const tr = el("tr");
const mark = (td: HTMLElement, key: string, tier?: string): void => {
const column = sheet?.columns[key];
if (column) markProvenanceCell(td, key, tier ?? column.tier);
};
const put = (text: string, left = false, key?: string): void => {
const td = el("td", left ? "b09s-left" : "", text);
if (key) mark(td, key);
tr.append(td);
};
put(row.code, true, "code");
put(row.name, true, "name");
put(row.spec, true, "spec");
put(row.unit, false, "unit");
for (const slot of row.slots) {
const td = el("td", slot.adopted ? "b09s-adopted" : "", money(slot.price_krw));
// ⚠ 빈 칸은 「0원」이 아니라 「그 판에 그 품목이 없다」 — 막힌 자리로 표시.
mark(td, "slot_price", slot.price_krw === null ? "blocked" : undefined);
const page = el("td", "b09s-left", slot.source_note);
mark(page, "slot_page");
tr.append(td, page);
}
if (!appliedIsLastSlot) {
put(money(row.adopted_price_krw), false, "adopted_price_krw");
put(row.adopted_slot ? (row.slots[row.adopted_slot - 1]?.name ?? "") : "", true);
}
put(row.note, true, "note");
tbody.append(tr);
}
table.append(thead, tbody);
attachProvenance(table, sheet);
wrap.append(table);
return wrap;
}
/** 환율및기초자료 — 실무 시트 세 구획(환율·인건비·단가 및 재료비) + 유가 지역 고르개(계산 입력). */
function drawReference(
body: HTMLElement,
data: PriceSourcesDto,
projectId: string,
reload: () => void,
): void {
const reference = data.base_reference;
const sheets = data.provenance?.sheets;
body.append(head("환율및기초자료 — ① 환율"));
body.append(note(reference.exchange.note));
body.append(head(`환율및기초자료 — ② 인건비 (${reference.labor.rows.length})`));
if (reference.labor.rows.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
} else {
body.append(
infoTable(
["코드번호", "직 종", "일 당", "시간당", "산 식"],
reference.labor.rows.map((row) => [
row.code,
row.name,
money(row.day_wage_krw),
money(row.hourly_krw),
row.formula,
]),
[0, 1, 4],
["code", "name", "day_wage_krw", "hourly_krw", "formula"],
sheets?.base_reference_labor,
),
);
}
body.append(
note(
"시간당은 나눈 값을 그대로 둡니다 — 여기서 원 단위로 자르면 기계 시간당 사용료가 " +
"조금씩 어긋납니다. 자르는 자리는 일위대가·내역서 쪽입니다.",
),
);
body.append(note(reference.labor.note));
body.append(head("환율및기초자료 — ③ 단가 및 재료비"));
const fuel = reference.fuel;
body.append(
infoTable(
["항 목", "단 가", "적용 범위", "기준일", "자료"],
[
[
"경유",
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],
["item", "price_krw", "scope", "effective_date", "dataset_id"],
sheets?.base_reference_fuel,
),
);
// ⚠ 확정 ⑮ — 전국/시도를 한 칸에서 고름. 자료가 없으면 고를 수 없게 두고 까닭을 밝힘.
const options = [
{ key: "", label: "전국 공시가" },
...fuel.regions.map((region) => ({
key: region.code,
label: `${region.name} ${region.diesel_krw_per_l ?? ""}원/L`,
})),
];
const fuelPicker = picker("유가 적용 범위", options, fuel.region, (key) => {
void saveFactorChoices(projectId, { fuel_region: key })
.then(reload)
.catch((error: Error) => body.append(note(`⚠ ${error.message}`)));
});
const select = fuelPicker.querySelector("select");
if (select) select.disabled = fuel.regions.length === 0;
body.append(fuelPicker);
if (fuel.regions.length === 0) {
for (const scope of fuel.scopes) {
if (!scope.available && scope.why) body.append(note(`⚠ ${scope.label}: ${scope.why}`));
}
} else {
body.append(
note(
fuel.region
? `${fuel.region_name} 공시가로 서 있습니다 — 기계 연료비가 그 값으로 다시 섭니다.`
: "전국 공시가로 서 있습니다 — 현장 시도를 고르면 그 지역 값으로 바뀝니다.",
),
);
}
if (fuel.region_missing) body.append(note(`⚠ ${fuel.region_missing}`));
body.append(note(fuel.note));
}
function drawSources(
body: HTMLElement,
data: PriceSourcesDto,
projectId: string,
reload: () => void,
): 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,
data.provenance?.sheets?.material_comparison,
),
);
}
for (const text of comparison.notes) body.append(note(text));
drawReference(body, data, projectId, reload);
}
function show(ctx: B09TabContext, projectId: string): void {
// 저장 뒤 — 이 탭 자료와 내역 한 벌을 함께 새로 받음(값이 다시 섬).
const reload = (): void => {
forgetBill(projectId);
ctx.body.replaceChildren();
show(ctx, projectId);
};
ctx.body.append(hint(L("B09_Sheet_Loading")));
void Promise.allSettled([
fetchFactorChoices(projectId),
getJson<BaseDataDto>(projectId, "base-data"),
getJson<PriceSourcesDto>(projectId, "price-sources"),
]).then(([factors, lists, sources]) => {
ctx.body.replaceChildren();
// 산출 조건이 목록표보다 **먼저** — 값을 낳는 자리가 아래 있으면 「바꿀 수 있는 것」을 못 봄.
if (factors.status === "fulfilled") {
drawFactorChoices(ctx.body, factors.value as FactorChoicesDto, projectId, reload);
}
if (lists.status === "fulfilled") drawCatalogs(ctx.body, lists.value);
else ctx.body.append(hint(`${L("B09_Sheet_LoadFailed")} base-data`, true));
if (sources.status === "fulfilled") drawSources(ctx.body, sources.value, projectId, reload);
else ctx.body.append(hint(`${L("B09_Sheet_LoadFailed")} price-sources`, true));
});
}
export const baseDataTab: B09Tab = {
key: "base_data",
label: () => L("B09_Estimation_Tab_BaseData"),
render(ctx) {
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
show(ctx, ctx.projectId);
},
};