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>
This commit is contained in:
@@ -192,3 +192,256 @@ export function drawMachineTab(body: HTMLElement, data: BaseDataDto): void {
|
|||||||
export function drawBaseDataError(body: HTMLElement): void {
|
export function drawBaseDataError(body: HTMLElement): void {
|
||||||
body.append(note(L("B09_Estimation_Tab_Pending")));
|
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("자재단가대비표·환율및기초자료를 불러오는 중입니다…"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
|||||||
import {
|
import {
|
||||||
drawBaseDataTab,
|
drawBaseDataTab,
|
||||||
drawMachineTab,
|
drawMachineTab,
|
||||||
|
drawPriceSourcesPending,
|
||||||
|
drawPriceSourcesSections,
|
||||||
fetchBaseData,
|
fetchBaseData,
|
||||||
|
fetchPriceSources,
|
||||||
type BaseDataDto,
|
type BaseDataDto,
|
||||||
|
type PriceSourcesDto,
|
||||||
} from "./B09_Estimation_UI_BaseData";
|
} from "./B09_Estimation_UI_BaseData";
|
||||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||||
@@ -780,6 +784,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
|||||||
const form: CostFormState = { ...INITIAL_FORM };
|
const form: CostFormState = { ...INITIAL_FORM };
|
||||||
let activeTab = "cost_sheet";
|
let activeTab = "cost_sheet";
|
||||||
let baseData: BaseDataDto | null = null;
|
let baseData: BaseDataDto | null = null;
|
||||||
|
let priceSources: PriceSourcesDto | null = null;
|
||||||
let sheet: CostSheetDto | null = null;
|
let sheet: CostSheetDto | null = null;
|
||||||
let unitPriceList: UnitPriceListDto | null = null;
|
let unitPriceList: UnitPriceListDto | null = null;
|
||||||
let unitPriceDetail: UnitPriceDetailDto | null = null;
|
let unitPriceDetail: UnitPriceDetailDto | null = null;
|
||||||
@@ -1151,8 +1156,28 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeTab === "machine") drawMachineTab(body, baseData);
|
if (activeTab === "machine") {
|
||||||
else drawBaseDataTab(body, baseData);
|
drawMachineTab(body, baseData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drawBaseDataTab(body, baseData);
|
||||||
|
// 자재단가대비표·환율및기초자료는 **따로 받아 온다** — 목록표 넷이 먼저 서고
|
||||||
|
// 두 표가 뒤따라 붙는다. 안 붙으면 위 넷도 못 보게 되는 것을 막는다.
|
||||||
|
if (priceSources) {
|
||||||
|
drawPriceSourcesSections(body, priceSources);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drawPriceSourcesPending(body);
|
||||||
|
if (projectId) {
|
||||||
|
void fetchPriceSources(projectId)
|
||||||
|
.then((data) => {
|
||||||
|
priceSources = data;
|
||||||
|
drawBody();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* 못 받아도 목록표 넷은 그대로 남는다. */
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeTab !== "cost_sheet") {
|
if (activeTab !== "cost_sheet") {
|
||||||
|
|||||||
Reference in New Issue
Block a user