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
This commit is contained in:
@@ -0,0 +1,295 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B09_Estimation_UI_Factors.ts
|
||||||
|
* 산출 조건 — 품셈이 범위로 준 계수·장비 규격·공구손료·수송·품 할증 (사용자 확정 ① 딸림 지시, 2026-09-09)
|
||||||
|
*
|
||||||
|
* - 옛 `B09_Estimation_UI_BaseData.ts` 의 산출 조건 구역을 그대로 옮김(PLAN 12장 옛 탭 옮기기).
|
||||||
|
* - ⚠ **계산 입력**이다 — 고르면 PUT `/estimation/factors` 로 저장되고 단가가 다시 섬.
|
||||||
|
* 고를 수 있는 것은 원문에 적힌 값뿐이고, 왜 그 값인지를 칸 밑에 그대로 적음.
|
||||||
|
* - ⚠ 기본값으로 돌고 있음을 숨기지 않음 — 조용히 기본으로 돌면 잠정인 줄도 모름.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@config/config_frontend";
|
||||||
|
import { el } from "./B09_Estimation_UI_Sheet";
|
||||||
|
import { head, note } from "./B09_Estimation_UI_Table";
|
||||||
|
|
||||||
|
export interface FactorOption {
|
||||||
|
key: string;
|
||||||
|
value?: string;
|
||||||
|
label: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RangeFactorRow {
|
||||||
|
key: string;
|
||||||
|
work_item_code: string;
|
||||||
|
work_item_name: string;
|
||||||
|
factor: string;
|
||||||
|
raw_cell: string;
|
||||||
|
chosen: string;
|
||||||
|
value: string;
|
||||||
|
is_default: boolean;
|
||||||
|
options: FactorOption[];
|
||||||
|
basis: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MachineChoiceRow {
|
||||||
|
work_item_code: string;
|
||||||
|
work_item_name: string;
|
||||||
|
chosen: string;
|
||||||
|
default: string;
|
||||||
|
is_default: boolean;
|
||||||
|
source: string;
|
||||||
|
options: FactorOption[];
|
||||||
|
basis: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 공구손료·잡재료 칸 — **비어 있는 것이 기본**이고, 비면 안 붙음(산림품셈 1-2-6). */
|
||||||
|
interface MiscMaterialRow {
|
||||||
|
percent: string;
|
||||||
|
min: string;
|
||||||
|
max: string;
|
||||||
|
basis: string[];
|
||||||
|
base_items: number;
|
||||||
|
base_note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기계 수송비 칸 — 거리·도로 구분이 있어야 줄이 섬(산림품셈 10-4). */
|
||||||
|
interface TransportRow {
|
||||||
|
distance_km: string;
|
||||||
|
road: string;
|
||||||
|
roads: Array<{ key: string; label: string }>;
|
||||||
|
variants: Array<{ key: string; label: string; unit_price_krw: string }>;
|
||||||
|
basis: string[];
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 품의 할인·할증 26계열 — 안 고르면 안 붙음(산림품셈 1-4). */
|
||||||
|
interface LaborSurchargeRow {
|
||||||
|
chosen: Record<string, string>;
|
||||||
|
total_percent: string;
|
||||||
|
reasons: string[];
|
||||||
|
series: Array<{
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
section: string;
|
||||||
|
source_note: string;
|
||||||
|
options: FactorOption[];
|
||||||
|
}>;
|
||||||
|
basis: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FactorChoicesDto {
|
||||||
|
ranges: RangeFactorRow[];
|
||||||
|
machines: MachineChoiceRow[];
|
||||||
|
misc_material?: MiscMaterialRow;
|
||||||
|
transport?: TransportRow;
|
||||||
|
labor_surcharge?: LaborSurchargeRow;
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFactorChoices(projectId: string): Promise<FactorChoicesDto> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
|
||||||
|
{ credentials: "include" },
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`factors ${response.status}`);
|
||||||
|
return (await response.json()) as FactorChoicesDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveFactorChoices(
|
||||||
|
projectId: string,
|
||||||
|
body: {
|
||||||
|
range_factor_choices?: Record<string, string>;
|
||||||
|
machine_choices?: Record<string, string>;
|
||||||
|
misc_material_percent?: string;
|
||||||
|
fuel_region?: string;
|
||||||
|
transport_distance_km?: string;
|
||||||
|
transport_road?: string;
|
||||||
|
labor_surcharge?: Record<string, string>;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response
|
||||||
|
.json()
|
||||||
|
.then((payload: { message?: string }) => payload.message ?? "")
|
||||||
|
.catch(() => "");
|
||||||
|
throw new Error(message || `factors save ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 숫자 칸 하나 — **빈 칸이 기본**. [적용]을 눌러야 저장됨.
|
||||||
|
* ⚠ 고르는 칸과 달리 사용자가 값을 짓는 자리라 누를 때만 보냄(「2」를 치는 도중에 2% 로 저장되지 않게).
|
||||||
|
*/
|
||||||
|
function percentBox(
|
||||||
|
label: string,
|
||||||
|
value: string,
|
||||||
|
placeholder: string,
|
||||||
|
onApply: (text: string) => void,
|
||||||
|
): HTMLElement {
|
||||||
|
const wrap = el("div", "b09s-hint b09s-inline");
|
||||||
|
const input = el("input");
|
||||||
|
input.type = "number";
|
||||||
|
input.step = "0.1";
|
||||||
|
input.min = "0";
|
||||||
|
input.value = value;
|
||||||
|
input.placeholder = placeholder;
|
||||||
|
input.style.width = "72px";
|
||||||
|
const apply = el("button", "", "적용");
|
||||||
|
apply.type = "button";
|
||||||
|
apply.addEventListener("click", () => onApply(input.value.trim()));
|
||||||
|
wrap.append(el("span", "b09s-head", label), input, el("span", "", "%"), apply);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function picker(
|
||||||
|
label: string,
|
||||||
|
options: FactorOption[],
|
||||||
|
chosen: string,
|
||||||
|
onPick: (key: string) => void,
|
||||||
|
): HTMLElement {
|
||||||
|
const wrap = el("div", "b09s-hint b09s-inline");
|
||||||
|
const select = el("select");
|
||||||
|
for (const option of options) {
|
||||||
|
const item = el("option", "", option.label);
|
||||||
|
item.value = option.key;
|
||||||
|
item.selected = option.key === chosen;
|
||||||
|
select.append(item);
|
||||||
|
}
|
||||||
|
select.addEventListener("change", () => onPick(select.value));
|
||||||
|
wrap.append(el("span", "b09s-head", label), select);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 산출 조건 구역 — 기초자료 탭 맨 위. `reload` = 저장 뒤 다시 받아 그리기. */
|
||||||
|
export function drawFactorChoices(
|
||||||
|
body: HTMLElement,
|
||||||
|
data: FactorChoicesDto,
|
||||||
|
projectId: string,
|
||||||
|
reload: () => void,
|
||||||
|
): void {
|
||||||
|
const save = (payload: Parameters<typeof saveFactorChoices>[1]): void => {
|
||||||
|
void saveFactorChoices(projectId, payload)
|
||||||
|
.then(reload)
|
||||||
|
.catch((error: Error) => body.append(note(`⚠ ${error.message}`)));
|
||||||
|
};
|
||||||
|
body.append(head("산출 조건 — 품셈이 한 값으로 안 준 자리"));
|
||||||
|
|
||||||
|
for (const row of data.ranges) {
|
||||||
|
body.append(
|
||||||
|
picker(`${row.work_item_name} 작업효율(${row.factor})`, row.options, row.chosen, (key) =>
|
||||||
|
save({ range_factor_choices: { [row.key]: key } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
body.append(
|
||||||
|
note(
|
||||||
|
`품셈 원문은 「${row.raw_cell}」 — 지금 쓰는 값 ${row.value}` +
|
||||||
|
(row.is_default ? " (기본값으로 돌고 있습니다)" : " (사용자가 고른 값입니다)"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const line of row.basis) body.append(note(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of data.machines) {
|
||||||
|
body.append(
|
||||||
|
picker(`${row.work_item_name} 장비 규격`, row.options, row.chosen, (key) =>
|
||||||
|
save({ machine_choices: { [row.work_item_code]: key } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
body.append(
|
||||||
|
note(
|
||||||
|
row.source === "note"
|
||||||
|
? "⚠ 이 장비는 품셈 표가 아니라 [주] 에 적혀 있어 공종 마스터가 아직 못 싣는 값입니다 — 이 칸이 그 자리를 대신합니다."
|
||||||
|
: "품셈 표가 정한 장비입니다." +
|
||||||
|
(row.is_default ? "" : " ⚠ 지금은 사용자가 바꾼 값으로 돌고 있습니다."),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const line of row.basis) body.append(note(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
const misc = data.misc_material;
|
||||||
|
if (misc) {
|
||||||
|
body.append(
|
||||||
|
percentBox("공구손료·잡재료 (주재료비의)", misc.percent, "비움", (text) =>
|
||||||
|
save({ misc_material_percent: text }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
body.append(
|
||||||
|
note(
|
||||||
|
misc.percent
|
||||||
|
? `지금 ${misc.percent}% 로 붙고 있습니다 — 칸을 비우고 [적용]하면 도로 안 붙습니다.`
|
||||||
|
: `비어 있어 안 붙고 있습니다 — 넣을 수 있는 값은 ${misc.min}~${misc.max}% 입니다.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (misc.base_note) body.append(note(misc.base_note));
|
||||||
|
for (const line of misc.basis) body.append(note(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = data.transport;
|
||||||
|
if (transport) {
|
||||||
|
body.append(
|
||||||
|
percentBox(
|
||||||
|
"기계 수송 거리 (인근 시·군·구청 → 현장, 편도 ㎞)",
|
||||||
|
transport.distance_km,
|
||||||
|
"비움",
|
||||||
|
(text) => save({ transport_distance_km: text }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const roadOptions: FactorOption[] = [
|
||||||
|
{ key: "", label: "안 고름" },
|
||||||
|
...transport.roads.map((road) => ({ key: road.key, label: road.label })),
|
||||||
|
];
|
||||||
|
body.append(
|
||||||
|
picker("수송 도로 구분", roadOptions, transport.road, (key) => save({ transport_road: key })),
|
||||||
|
);
|
||||||
|
for (const variant of transport.variants) {
|
||||||
|
body.append(
|
||||||
|
note(
|
||||||
|
variant.unit_price_krw
|
||||||
|
? `${variant.label} — 회당 ${variant.unit_price_krw}원`
|
||||||
|
: `${variant.label} — 아직 안 섬`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const line of transport.notes) body.append(note(`⚠ ${line}`));
|
||||||
|
for (const line of transport.basis) body.append(note(line));
|
||||||
|
body.append(
|
||||||
|
note(
|
||||||
|
"⚠ 단가는 「회당」입니다 — 몇 대를 몇 번 나르는지(회수)는 설계 입력이라 여기서 안 정합니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const surcharge = data.labor_surcharge;
|
||||||
|
if (surcharge) {
|
||||||
|
body.append(head("품의 할인·할증 (산림품셈 1-4) — 고른 것만 붙습니다"));
|
||||||
|
body.append(
|
||||||
|
note(
|
||||||
|
Number(surcharge.total_percent) === 0
|
||||||
|
? "지금 한 계열도 안 골라 한 원도 안 움직이고 있습니다."
|
||||||
|
: `지금 ${surcharge.total_percent}% 가 품에 붙고 있습니다 — ${surcharge.reasons.join(" · ")}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const line of surcharge.basis) body.append(note(line));
|
||||||
|
for (const item of surcharge.series) {
|
||||||
|
const options: FactorOption[] = [{ key: "", label: "안 고름" }, ...item.options];
|
||||||
|
body.append(
|
||||||
|
picker(`${item.section}`, options, surcharge.chosen[item.key] ?? "", (key) =>
|
||||||
|
save({ labor_surcharge: { [item.key]: key } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (item.source_note) body.append(note(item.source_note));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of data.notes) body.append(note(line));
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
|||||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||||
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
|
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
|
||||||
import { L, el, injectSheetStyles } from "./B09_Estimation_UI_Sheet";
|
import { L, el, injectSheetStyles } from "./B09_Estimation_UI_Sheet";
|
||||||
import { legacyTab } from "./B09_Estimation_UI_Tab_Legacy";
|
|
||||||
import { costSheetTab } from "./B09_Estimation_UI_Tab_CostSheet";
|
import { costSheetTab } from "./B09_Estimation_UI_Tab_CostSheet";
|
||||||
import { rateTableTab } from "./B09_Estimation_UI_Tab_RateTable";
|
import { rateTableTab } from "./B09_Estimation_UI_Tab_RateTable";
|
||||||
import { billTab } from "./B09_Estimation_UI_Tab_Bill";
|
import { billTab } from "./B09_Estimation_UI_Tab_Bill";
|
||||||
@@ -26,8 +25,9 @@ import { listsTab } from "./B09_Estimation_UI_Tab_Lists";
|
|||||||
import { designDocTab } from "./B09_Estimation_UI_Tab_DesignDoc";
|
import { designDocTab } from "./B09_Estimation_UI_Tab_DesignDoc";
|
||||||
import { basisSheetTab } from "./B09_Estimation_UI_Tab_BasisSheet";
|
import { basisSheetTab } from "./B09_Estimation_UI_Tab_BasisSheet";
|
||||||
import { supplyTab } from "./B09_Estimation_UI_Tab_Supply";
|
import { supplyTab } from "./B09_Estimation_UI_Tab_Supply";
|
||||||
|
import { baseDataTab } from "./B09_Estimation_UI_Tab_BaseData";
|
||||||
|
|
||||||
/** 탭 등록 — 한 줄에 탭 하나. 옛 탭(`legacyTab`)은 새 탭 파일이 서면 그 줄만 바꿈. */
|
/** 탭 등록 — 한 줄에 탭 하나. */
|
||||||
const TABS: B09Tab[] = [
|
const TABS: B09Tab[] = [
|
||||||
costSheetTab, // 랩탑_메인
|
costSheetTab, // 랩탑_메인
|
||||||
rateTableTab, // 랩탑_메인
|
rateTableTab, // 랩탑_메인
|
||||||
@@ -39,7 +39,7 @@ const TABS: B09Tab[] = [
|
|||||||
summaryTab,
|
summaryTab,
|
||||||
listsTab,
|
listsTab,
|
||||||
supplyTab,
|
supplyTab,
|
||||||
legacyTab("base_data", "B09_Estimation_Tab_BaseData"),
|
baseDataTab,
|
||||||
designDocTab,
|
designDocTab,
|
||||||
basisSheetTab,
|
basisSheetTab,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -180,6 +180,11 @@ async function getJson<T>(path: string): Promise<T> {
|
|||||||
return body as T;
|
return body as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 산출 조건을 저장한 뒤 — 단가가 다시 서므로 다음에 고르는 탭이 새로 받게 비움. */
|
||||||
|
export function forgetBill(projectId: string): void {
|
||||||
|
bills.delete(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
/** 설계내역서 한 벌 — 세 탭이 같은 응답을 봄. `force` 면 다시 받음. */
|
/** 설계내역서 한 벌 — 세 탭이 같은 응답을 봄. `force` 면 다시 받음. */
|
||||||
export function loadBill(projectId: string, force = false): Promise<BillDto> {
|
export function loadBill(projectId: string, force = false): Promise<BillDto> {
|
||||||
if (force || !bills.has(projectId)) {
|
if (force || !bills.has(projectId)) {
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user