Files
Aislo/B09_Estimation/B09_Estimation_UI_BillRates.ts
T
eomsangdonandClaude Opus 5 b9d0ac0866 feat(b09): 2차 편집 ④ 내역 할증율·일괄보정 — 범위(선택 줄·내역서 전체) · 비목별 절사/반올림 · 소수 0~4 · 줄 ↺ · 할증 전부 되돌리기
- 서버가 내역 줄 성분 단가에 율을 얹어 다시 셈(줄 칸이 전체 칸보다 우선) · 수량은 그대로
- 공용 작은 버튼 글자색 상속 — 어두운 화면에서 검정 글씨로 묻히던 것

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

153 lines
5.0 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_BillRates.ts
* 설계내역서 **할증율·일괄보정** 칸 — STmate `wBoqRate`·`wM_Rate` 본 (PLAN 12장 2차 ④)
*
* - 범위: 선택 항목만(줄마다 한 칸) / 내역서 전체(`*`) · 방식: 같은 비율 / 비목별(재·노·경)
* - 비목마다 절사·반올림 × 소수 0~4 자리(안 고르면 원 미만 절사).
* - ⚠ 여러 줄을 한꺼번에 바꾸므로 「할증 전부 되돌리기」를 둠 — 줄마다 ↺ 도 있음.
* - 화면은 고른 값만 보냄 — 금액은 서버가 다시 셈(내역 한 벌을 새로 받음).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { L, el, userMark } from "./B09_Estimation_UI_Sheet";
import { loadEdits, saveEdits, type BillRowDto, type EditChange } from "./B09_Estimation_UI_Store";
const SECTION = "bill_rates";
const PARTS = [
["material", "B09_Sheet_Col_Material"],
["labor", "B09_Sheet_Col_Labor"],
["expense", "B09_Sheet_Col_Expense"],
] as const;
function save(projectId: string, changes: EditChange[], reload: () => void): void {
if (changes.length === 0) return;
void saveEdits(projectId, changes)
.then(() => {
showToast(L("B09_Sheet_Saved"), "success");
reload();
})
.catch((error: Error) => showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error"));
}
function numberBox(placeholder: string): HTMLInputElement {
const input = el("input", "b09s-edit-input");
input.type = "text";
input.inputMode = "decimal";
input.placeholder = placeholder;
return input;
}
function choice(options: Array<[string, string]>): HTMLSelectElement {
const select = el("select");
for (const [value, label] of options) {
const option = el("option", "", label);
option.value = value;
select.append(option);
}
return select;
}
/** 할증·일괄보정 칸 한 벌 — `selected` = 고른 줄의 할증 칸 이름. */
export function rateForm(
projectId: string,
selected: Set<string>,
reload: () => void,
): HTMLElement {
const box = el("div", "b09s-group");
const scope = choice([
["selected", L("B09_Sheet_Rate_Selected")],
["all", L("B09_Sheet_Rate_All")],
]);
const way = choice([
["same", L("B09_Sheet_Rate_Same")],
["parts", L("B09_Sheet_Rate_Parts")],
]);
const same = numberBox("%");
const parts = PARTS.map(([key, label]) => {
const rate = numberBox("%");
const mode = choice([
["floor", L("B09_Sheet_Rate_Floor")],
["round", L("B09_Sheet_Rate_Round")],
]);
const digits = choice(
[0, 1, 2, 3, 4].map((n): [string, string] => [
String(n),
`${L("B09_Sheet_Rate_Digits")} ${n}`,
]),
);
const line = el("span", "b09s-inline");
line.append(el("span", "b09s-head", L(label)), rate, mode, digits);
return { key, rate, mode, digits, line };
});
const apply = el("button", "b09s-undo", L("B09_Sheet_Apply"));
apply.type = "button";
apply.addEventListener("click", () => {
const rounding = Object.fromEntries(
parts.map((part) => [part.key, { mode: part.mode.value, digits: Number(part.digits.value) }]),
);
const value =
way.value === "same"
? { all: same.value.trim() || "0", rounding }
: {
...Object.fromEntries(parts.map((part) => [part.key, part.rate.value.trim() || "0"])),
rounding,
};
const keys = scope.value === "all" ? ["*"] : [...selected];
if (keys.length === 0) {
showToast(L("B09_Sheet_Rate_PickRows"), "info");
return;
}
save(
projectId,
keys.map((key) => ({ section: SECTION, key, value })),
reload,
);
});
const clear = el("button", "b09s-undo", L("B09_Sheet_Rate_ClearAll"));
clear.type = "button";
clear.addEventListener("click", () => {
void loadEdits(projectId).then((stored) =>
save(
projectId,
Object.keys(stored.edits[SECTION] ?? {}).map((key) => ({
section: SECTION,
key,
value: null,
})),
reload,
),
);
});
const head = el("div", "b09s-bar");
head.append(
el("span", "b09s-head", L("B09_Sheet_Rate")),
scope,
way,
same,
apply,
clear,
el("span", "b09s-hint", `${L("B09_Sheet_Rate_SelectedCount")} ${selected.size}`),
);
const partLine = el("div", "b09s-bar");
partLine.append(...parts.map((part) => part.line));
box.append(head, partLine);
return box;
}
/** 줄 비고의 할증 표시 — 그 줄 칸으로 얹혔으면 「사용자」+↺, 내역서 전체로 얹혔으면 표시만. */
export function rateMark(
projectId: string,
row: BillRowDto,
reload: () => void,
): HTMLElement | null {
if (!row.rate) return null;
if (row.rate.source === "*")
return el("span", "b09s-badge b09s-badge--user", L("B09_Sheet_Rate_AllBadge"));
return userMark(() =>
save(projectId, [{ section: SECTION, key: row.rate_key, value: null }], reload),
);
}