- B09_Estimation_Edits: 고친 값 한 벌 — 기본 조립(cached_build) 복사본에 얹음(캐시 키에 고친 값) · 기본 벌은 그대로(골든셋 무관)
· 얹지 못한 값은 edit_skipped 로 남김 · 값 없는 슬롯은 받지 않음(422 + 까닭)
- 새 라우터 B09_Estimation_Router_Edits(GET/PUT /estimation/edits) — Router.py 는 _build_for 가 고친 값을 얹게만 바꿈
- 화면: 줄마다 채택 슬롯 고르개 · 일괄(변동없음/1~5 단가/최소단가) · 고친 줄 「사용자」 + ↺(지우면 계산값으로)
- 곁: 사용자 식 셈(B09_Estimation_Expression — ROUND·ROUNDDOWN·INT·SQRT, eval 없음)과 본표 편집 칸(UI_DetailEdit)을 먼저 둠 — 서버 편집 문이 서기 전까지 잠자 있음
- 검증: 시험 1656 통과 · ORCA 채택 저장 → 「사용자」·↺ → ↺ 뒤 고친 값 {} 로 복구 · 값 없는 슬롯 422 「경유: 1번 원천에 값이 없어」
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
170 lines
6.4 KiB
TypeScript
170 lines
6.4 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Tab_PriceCompare.ts
|
|
* B09 자재단가대비표 탭 — 원천 슬롯 여섯 · 채택 · 최소단가 (실무 `자재단가대비표` · STmate `wM_Boxa`)
|
|
*
|
|
* - 칸: 호표 · 명칭 · 규격 · 단위 · 슬롯 1~6(단가 · 페이지) · 채택 · 비고.
|
|
* - 채택 슬롯 = 굵게·색 · 최소단가 = 「최소」 표시(서버 `min_slot`). 값 없는 슬롯은 빈칸(0 아님).
|
|
* - 2차(PLAN 12장 · 프로젝트 단위) — 줄마다 채택 슬롯을 고르거나 「변동없음 / 1~5 단가 / 최소단가」로 일괄.
|
|
* 고친 줄 = 「사용자」 표시 + ↺(지우면 계산값으로). 값 없는 슬롯은 서버가 받지 않음.
|
|
* - ⚠ 저장은 고른 슬롯 번호만 — 금액은 서버가 다시 셈(다른 탭은 내역 한 벌을 새로 받음).
|
|
* ========================================================================== */
|
|
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
|
|
import { L, el, hint, numberCell, sheetTable, userMark, won } from "./B09_Estimation_UI_Sheet";
|
|
import {
|
|
loadEdits,
|
|
loadPriceCompare,
|
|
saveEdits,
|
|
type EditChange,
|
|
type EditsDto,
|
|
type PriceCompareDto,
|
|
} from "./B09_Estimation_UI_Store";
|
|
|
|
const SECTION = "adopted_slots";
|
|
|
|
function head(slotNames: string[]): HTMLElement {
|
|
const thead = el("thead");
|
|
const top = el("tr");
|
|
const bottom = el("tr");
|
|
const fixed = [
|
|
L("B09_Sheet_Col_Sheet"),
|
|
L("B09_Sheet_Col_Name"),
|
|
L("B09_Sheet_Col_Spec"),
|
|
L("B09_Sheet_Col_Unit"),
|
|
];
|
|
for (const label of fixed) {
|
|
const th = el("th", "", label);
|
|
th.rowSpan = 2;
|
|
top.append(th);
|
|
}
|
|
slotNames.forEach((name, index) => {
|
|
const th = el("th", "", `${index + 1} ${name}`);
|
|
th.colSpan = 2;
|
|
top.append(th);
|
|
bottom.append(
|
|
el("th", "", L("B09_Sheet_Col_UnitPrice")),
|
|
el("th", "", L("B09_Sheet_Col_Page")),
|
|
);
|
|
});
|
|
for (const label of [L("B09_Sheet_Adopt"), L("B09_Sheet_Col_Note")]) {
|
|
const th = el("th", "", label);
|
|
th.rowSpan = 2;
|
|
top.append(th);
|
|
}
|
|
thead.append(top, bottom);
|
|
return thead;
|
|
}
|
|
|
|
type Row = PriceCompareDto["material_comparison"]["rows"][number];
|
|
|
|
function adoptPicker(row: Row, onPick: (slot: number) => void): HTMLSelectElement {
|
|
const select = el("select");
|
|
row.slots.forEach((slot, index) => {
|
|
if (slot.price_krw === null) return; // 값 없는 원천은 고를 수 없음
|
|
const option = el("option", "", `${index + 1} ${won(slot.price_krw)}`);
|
|
option.value = String(index + 1);
|
|
option.selected = index + 1 === row.adopted_slot;
|
|
select.append(option);
|
|
});
|
|
select.addEventListener("change", () => onPick(Number(select.value)));
|
|
return select;
|
|
}
|
|
|
|
/** 일괄 — 「변동없음 / 1~5 단가 / 최소단가」. 그 슬롯에 값이 있는 줄만 고침(STmate `wM_Boxa`). */
|
|
function bulkChanges(rows: Row[], choice: string): EditChange[] {
|
|
if (choice === "none") return [];
|
|
return rows.flatMap((row) => {
|
|
const slot = choice === "min" ? row.min_slot : Number(choice);
|
|
if (!slot || row.slots[slot - 1]?.price_krw === null) return [];
|
|
return [{ section: SECTION, key: row.code, value: slot }];
|
|
});
|
|
}
|
|
|
|
function draw(ctx: B09TabContext, projectId: string, data: PriceCompareDto, edits: EditsDto): void {
|
|
const table = data.material_comparison;
|
|
const userSlots = edits[SECTION] ?? {};
|
|
const save = (changes: EditChange[]): void => {
|
|
if (changes.length === 0) return;
|
|
void saveEdits(projectId, changes)
|
|
.then(() => {
|
|
showToast(L("B09_Sheet_Saved"), "success");
|
|
ctx.open("price_compare");
|
|
})
|
|
.catch((error: Error) => showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error"));
|
|
};
|
|
|
|
const bar = el("div", "b09s-bar");
|
|
const bulk = el("select");
|
|
const choices: Array<[string, string]> = [
|
|
["none", L("B09_Sheet_Bulk_None")],
|
|
...[1, 2, 3, 4, 5].map((n): [string, string] => [
|
|
String(n),
|
|
`${n} ${table.slot_names[n - 1] ?? ""}`,
|
|
]),
|
|
["min", L("B09_Sheet_Bulk_Min")],
|
|
];
|
|
for (const [value, label] of choices) {
|
|
const option = el("option", "", label);
|
|
option.value = value;
|
|
bulk.append(option);
|
|
}
|
|
const apply = el("button", "b09s-undo", L("B09_Sheet_Apply"));
|
|
apply.type = "button";
|
|
apply.addEventListener("click", () => save(bulkChanges(table.rows, bulk.value)));
|
|
bar.append(el("span", "b09s-head", L("B09_Sheet_Bulk")), bulk, apply);
|
|
|
|
const { wrap, tbody } = sheetTable(head(table.slot_names));
|
|
table.rows.forEach((row, index) => {
|
|
const tr = el("tr", row.code in userSlots ? "is-user" : "");
|
|
tr.append(
|
|
el("td", "", String(index + 1)),
|
|
el("td", "", row.name),
|
|
el("td", "", row.spec),
|
|
el("td", "", row.unit),
|
|
);
|
|
row.slots.forEach((slot, slotIndex) => {
|
|
const price = numberCell(won(slot.price_krw));
|
|
if (slot.adopted) price.classList.add("b09s-adopted");
|
|
if (row.min_slot === slotIndex + 1) price.append(el("span", "b09s-min", L("B09_Sheet_Min")));
|
|
tr.append(price, el("td", "", slot.source_note));
|
|
});
|
|
const adopt = el("td");
|
|
adopt.append(
|
|
adoptPicker(row, (slot) => save([{ section: SECTION, key: row.code, value: slot }])),
|
|
);
|
|
if (row.code in userSlots) {
|
|
adopt.append(userMark(() => save([{ section: SECTION, key: row.code, value: null }])));
|
|
}
|
|
tr.append(adopt, el("td", "b09s-note", row.note));
|
|
tbody.append(tr);
|
|
});
|
|
ctx.body.append(el("div", "b09s-title", L("B09_Sheet_Tab_PriceCompare")), bar, wrap);
|
|
if (table.rows.length === 0) ctx.body.append(hint(L("B09_Sheet_EmptyGroup")));
|
|
for (const note of table.notes) ctx.body.append(hint(note));
|
|
}
|
|
|
|
export const priceCompareTab: B09Tab = {
|
|
key: "price_compare",
|
|
label: () => L("B09_Sheet_Tab_PriceCompare"),
|
|
render(ctx) {
|
|
if (!ctx.projectId) {
|
|
ctx.body.append(hint(L("B09_Sheet_NoProject")));
|
|
return;
|
|
}
|
|
const projectId = ctx.projectId;
|
|
ctx.body.append(hint(L("B09_Sheet_Loading")));
|
|
Promise.all([loadPriceCompare(projectId), loadEdits(projectId)])
|
|
.then(([data, stored]) => {
|
|
ctx.body.replaceChildren();
|
|
draw(ctx, projectId, data, stored.edits);
|
|
for (const line of stored.skipped) {
|
|
ctx.body.append(hint(`${L("B09_Sheet_EditSkipped")} ${line}`, true));
|
|
}
|
|
})
|
|
.catch((error: Error) => {
|
|
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
|
|
});
|
|
},
|
|
};
|