Files
Aislo/B09_Estimation/B09_Estimation_UI_BaseData.ts
T
eomsangdonandClaude Opus 5 e38b832353 feat(B09): 설계서 구성표 — 법이 정한 목차와 우리가 내는 것을 맞대 봄
별표2 (5)(가) 가 설계서 작성 순서를 열셋으로 못 박아 두었는데, 우리가 무엇을 내고
무엇을 못 내는지가 계획서에만 있었음. 원가계산에 「설계서 구성」 탭으로 세움.

- 법이 적은 차례 그대로. 줄마다 상태(있음·반쪽·없음)와 어디서 나오는지를 적음.
- ⚠ 「없음」과 「우리 몫 아님」을 가름 — 일반시방서·특별시방서·예정공정표는 현장을
  아는 사람이 쓰는 문서라 프로그램이 지어낼 것이 아님. 법이 목차를 정했다고 열셋을
  다 우리가 만드는 것은 아님.
- 못 내는 것에는 무엇을 받아야 내는지를 적음(공사설명서 = 서식·설계하중 표기).
- 사방사업은 목록이 다름(별표0 은 위치도·관계지적조서·설계도면을 더 둠)을 함께 적음.

⇒ 법이 정한 13 중 7 이 서 있고, 3 은 설계자·발주청 몫, 우리가 더 낼 것은 3
  (공사설명서 · 각종 중기경비계산서 · 산출기초).

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

879 lines
30 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.667배)으로 셉니다. " +
"공표 노임은 기본급여액뿐이라 제수당·상여금·퇴직급여충당금을 따로 계상해야 " +
"합니다(건협 임금적용요령 4-나 · 기재부 정부 입찰·계약 집행기준 제76조의3). " +
"⚠ 계수 자체의 예규 원문은 아직 못 봐 실무 관행을 따랐습니다 — 실무 두 공사지· " +
"임도교본 예제·상용 적산 프로그램이 모두 같은 계수를 씁니다.",
),
);
body.append(note("잡재료(주연료의 %)는 연료 소요량에 포함되어 있습니다 — 따로 세지 않습니다."));
}
/* =============================================================================
* 설계서 구성표 — 법이 정한 목차와 우리가 내는 것을 맞대 본다(별표2 (5)(가)).
* ⚠ 「없음」과 「우리 몫 아님」을 갈라 보인다 — 갈라야 다음에 할 일이 달라진다.
* ========================================================================== */
export interface DesignDocDto {
status: string;
law: string;
summary: string;
counts: Record<string, number>;
items: Array<{
order: number;
name: string;
status: string;
owner: string;
where: string;
note: string;
}>;
notes: string[];
}
export async function fetchDesignDocIndex(projectId: string): Promise<DesignDocDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/design-doc-index`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`design-doc-index ${response.status}`);
return (await response.json()) as DesignDocDto;
}
export function drawDesignDocTab(body: HTMLElement, data: DesignDocDto): void {
body.append(head(`설계서 구성 (법이 정한 ${data.items.length})`));
body.append(note(data.summary));
body.append(note(data.law));
body.append(
table(
["차례", "이 름", "상 태", "누가 만드나", "어디서 나오나", "비 고"],
data.items.map((row) => [
String(row.order),
row.name,
row.status,
row.owner,
row.where || "—",
row.note,
]),
[0, 1, 2, 3, 4, 5],
),
);
for (const line of data.notes) body.append(note(line));
}
/** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */
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[];
/** 고를 수 있는 시도 — 판에 있는 것만. 값을 함께 실어 고르기 전에 견줄 수 있다. */
regions: Array<{ code: string; name: string; diesel_krw_per_l: string | null }>;
region: string;
region_name: string;
region_missing?: string;
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"],
projectId: string,
reload: () => void,
): 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";
picker.style.flexWrap = "wrap";
const label = document.createElement("span");
label.textContent = "유가 적용 범위";
const select = document.createElement("select");
const national = document.createElement("option");
national.value = "";
national.textContent = "전국 공시가";
national.selected = !fuel.region;
select.append(national);
for (const region of fuel.regions) {
const option = document.createElement("option");
option.value = region.code;
option.textContent = `${region.name} ${region.diesel_krw_per_l ?? ""}원/L`;
option.selected = region.code === fuel.region;
select.append(option);
}
select.disabled = fuel.regions.length === 0;
select.addEventListener("change", () => {
void saveFactorChoices(projectId, { fuel_region: select.value })
.then(reload)
.catch((error: Error) => body.append(note(`⚠ ${error.message}`)));
});
picker.append(label, select);
body.append(picker);
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));
}
/** 기초자료 탭 아래쪽 — A9·A10 두 장. */
export function drawPriceSourcesSections(
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));
}
for (const text of comparison.notes) body.append(note(text));
baseReferenceSections(body, data.base_reference, projectId, reload);
}
/** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */
export function drawPriceSourcesPending(body: HTMLElement): void {
body.append(note("자재단가대비표·환율및기초자료를 불러오는 중입니다…"));
}
/* =============================================================================
* 산출 조건 — 품셈이 범위로 준 계수·장비 규격 (사용자 확정 ① 딸림 지시, 2026-09-09)
*
* 「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」
* 라는 지시 그대로다. 고를 수 있는 것은 **원문에 적힌 값뿐**이고, 왜 그 값인지를
* 칸 밑에 그대로 적는다.
* ========================================================================== */
export interface FactorOption {
key: string;
value?: string;
label: string;
note?: string;
}
export 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[];
}
export 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). */
export interface MiscMaterialRow {
percent: string;
min: string;
max: string;
basis: string[];
/** 주재료비가 선 일위대가 수 — 0 이면 넣어도 붙을 밑수가 없다. */
base_items: number;
base_note: string;
}
/** 기계 수송비 칸 — 거리·도로 구분이 있어야 줄이 선다(산림품셈 10-4). */
export 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). */
export 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 {
status: string;
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((body: { message?: string }) => body.message ?? "")
.catch(() => "");
throw new Error(message || `factors save ${response.status}`);
}
}
/**
* 숫자 칸 하나 — **빈 칸이 기본**이다. [적용]을 눌러야 저장된다.
*
* ⚠ 고르는 칸(`picker`)과 달리 여기는 **사용자가 값을 짓는 자리**라 누를 때만 보낸다 —
* 타자 한 자마다 보내면 「2」를 치는 도중에 2% 로 저장돼 버린다.
*/
function percentBox(
label: string,
value: string,
placeholder: string,
onApply: (text: string) => void,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-hint";
wrap.style.display = "flex";
wrap.style.alignItems = "center";
wrap.style.gap = "8px";
wrap.style.flexWrap = "wrap";
const name = document.createElement("span");
name.style.fontWeight = "600";
name.textContent = label;
const input = document.createElement("input");
input.type = "number";
input.step = "0.1";
input.min = "0";
input.value = value;
input.placeholder = placeholder;
input.style.width = "72px";
const unit = document.createElement("span");
unit.textContent = "%";
const apply = document.createElement("button");
apply.type = "button";
apply.textContent = "적용";
apply.addEventListener("click", () => onApply(input.value.trim()));
wrap.append(name, input, unit, apply);
return wrap;
}
function picker(
label: string,
options: FactorOption[],
chosen: string,
onPick: (key: string) => void,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-hint";
wrap.style.display = "flex";
wrap.style.alignItems = "center";
wrap.style.gap = "8px";
wrap.style.flexWrap = "wrap";
const name = document.createElement("span");
name.style.fontWeight = "600";
name.textContent = label;
const select = document.createElement("select");
for (const option of options) {
const item = document.createElement("option");
item.value = option.key;
item.textContent = option.label;
item.selected = option.key === chosen;
select.append(item);
}
select.addEventListener("change", () => onPick(select.value));
wrap.append(name, select);
return wrap;
}
/**
* 산출 조건 구역 — 기초자료 탭 맨 위에 선다.
*
* ⚠ **기본값으로 돌고 있음을 숨기지 않는다** — 조용히 기본으로 돌면 사용자는 그것이
* 잠정인 줄도 모른다(타설 방식에서 이미 겪은 자리).
*/
export function drawFactorChoices(
body: HTMLElement,
data: FactorChoicesDto,
projectId: string,
reload: () => void,
): void {
body.append(head("산출 조건 — 품셈이 한 값으로 안 준 자리"));
for (const row of data.ranges) {
const title = `${row.work_item_name} 작업효율(${row.factor})`;
body.append(
picker(title, row.options, row.chosen, (key) => {
void saveFactorChoices(projectId, { range_factor_choices: { [row.key]: key } }).then(
reload,
);
}),
);
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) => {
void saveFactorChoices(projectId, {
machine_choices: { [row.work_item_code]: key },
}).then(reload);
}),
);
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) => {
void saveFactorChoices(projectId, { misc_material_percent: text })
.then(reload)
.catch((error: Error) => {
body.append(note(`⚠ ${error.message}`));
});
}),
);
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) => {
void saveFactorChoices(projectId, { transport_distance_km: text })
.then(reload)
.catch((error: Error) => body.append(note(`⚠ ${error.message}`)));
},
),
);
const roadOptions: FactorOption[] = [
{ key: "", label: "안 고름" },
...transport.roads.map((road) => ({ key: road.key, label: road.label })),
];
body.append(
picker("수송 도로 구분", roadOptions, transport.road, (key) => {
void saveFactorChoices(projectId, { transport_road: key })
.then(reload)
.catch((error: Error) => body.append(note(`⚠ ${error.message}`)));
}),
);
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) => {
void saveFactorChoices(projectId, { labor_surcharge: { [item.key]: key } })
.then(reload)
.catch((error: Error) => body.append(note(`⚠ ${error.message}`)));
}),
);
if (item.source_note) body.append(note(item.source_note));
}
}
for (const line of data.notes) body.append(note(line));
}