feat(B09): 공구손료·잡재료 칸 — 기본은 빔, 넣으면 주재료비의 %로 붙음

사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」.
근거는 산림품셈 1-2-6 — 주재료비(할증수량 제외)의 2~5%까지, 산정 근거 명시.

- 기초자료 탭 「산출 조건」에 칸 하나 + [적용]. 비면 줄 자체가 안 섬(지금 상태 그대로).
- 밑수는 **자재 줄만** — 노무·경비, 하위 일위대가 재료비는 안 듦(층마다 거듭 세지 않음).
- 상한 5% 초과는 거절(400) — 조용히 깎아 넣지 않음.
- ⚠ 지금은 일위대가에 주재료비가 선 공종이 0개라 붙을 밑수가 없음 — 그 사실을 칸 밑에 띄움.
- 곁다리: 비율 줄(제잡비·공구손료)이 저장했다 읽으면 사라지던 것을 고침.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 20:30:10 +09:00
co-authored by Claude Opus 5
parent 404ec16e65
commit 83c842e37a
8 changed files with 445 additions and 5 deletions
+89 -2
View File
@@ -486,10 +486,22 @@ export interface MachineChoiceRow {
basis: string[];
}
/** 공구손료·잡재료 칸 — **비어 있는 것이 기본**이고, 비면 안 붙는다(산림품셈 1-2-6). */
export interface MiscMaterialRow {
percent: string;
min: string;
max: string;
basis: string[];
/** 주재료비가 선 일위대가 수 — 0 이면 넣어도 붙을 밑수가 없다. */
base_items: number;
base_note: string;
}
export interface FactorChoicesDto {
status: string;
ranges: RangeFactorRow[];
machines: MachineChoiceRow[];
misc_material?: MiscMaterialRow;
notes: string[];
}
@@ -504,7 +516,11 @@ export async function fetchFactorChoices(projectId: string): Promise<FactorChoic
export async function saveFactorChoices(
projectId: string,
body: { range_factor_choices?: Record<string, string>; machine_choices?: Record<string, string> },
body: {
range_factor_choices?: Record<string, string>;
machine_choices?: Record<string, string>;
misc_material_percent?: string;
},
): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
@@ -515,7 +531,56 @@ export async function saveFactorChoices(
body: JSON.stringify(body),
},
);
if (!response.ok) throw new Error(`factors save ${response.status}`);
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(
@@ -599,5 +664,27 @@ export function drawFactorChoices(
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));
}
for (const line of data.notes) body.append(note(line));
}