feat(798-1): M01 요율 조달청 제비율 내장 엑셀 — 빌더 · 읽기 전용 화면 · RT 줄 → 칸 이동

- build_제비율책.py: 조달청 xlsx → 내장 엑셀 책 JSON(병합 · 채움 · 글꼴 · 테두리 · 열 폭 · 행 높이 · 시트 둘 · 수식 결과) · --check(판 바뀜 · RT 칸 = 정본 값)
- 제비율엑셀/조달청_제비율_책.json · /api/m01/rate-sheet · 요율 화면 위 엑셀 + 아래 RT 표 · 확대 · 축소
- 정본 요율_조달청제비율.json · B09 요율 셈 그대로

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 12:20:32 +09:00
co-authored by Claude Sonnet 5.5
parent c48580d6c9
commit 29e60a3ad6
11 changed files with 659 additions and 1 deletions
@@ -0,0 +1,103 @@
/* =============================================================================
* M01_MasterData_UI_RateSheet.ts
* 요율 화면 — 위 = 조달청 제비율 엑셀(내장 엑셀 · 읽기만 · 확대 · 축소) / 아래 = 기존 RT 표(Rows).
* RT 줄을 누르면 엑셀의 대응 칸으로 가서 칠함. 요율 셈 로직(B09)은 이 책을 읽지 않음 — 표현만.
* 책 = 서버 `/api/m01/rate-sheet`(빌더 `build_제비율책.py` 가 만든 파일) · 798-1
* ⚠ 내장 엑셀은 `import()` 로만 불러옴(요율 화면을 열 때만 받음).
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { fetchRateSheet, type RateSheetData } from "./M01_MasterData_Api_Fetch";
import { renderRows } from "./M01_MasterData_UI_Rows";
import type { Pick } from "./M01_MasterData_UI_Side";
import "./M01_MasterData_UI_RateSheet.css";
interface Handle {
select(range: string, sheet?: string): boolean;
repaint(): void;
destroy(): void;
}
/** 요율 파일이면 엑셀 + 표 · 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
export function renderRateRows(host: HTMLElement, pick: Pick, q: string): () => void {
const sheetHost = el("div", { className: "m01-rate__sheet" });
const rowsHost = el("div", { className: "m01-rate__rows" });
const zoomIn = createButton({ label: L("M01_RateZoomIn"), variant: "ghost", size: "sm" });
const zoomOut = createButton({ label: L("M01_RateZoomOut"), variant: "ghost", size: "sm" });
const bar = el("div", {
className: "ui-toolbar m01-rate__bar",
children: [
el("span", { className: "m01-rate__title", text: L("M01_RateSheetTitle") }),
zoomOut,
zoomIn,
],
});
host.replaceChildren(bar, sheetHost, rowsHost);
const offRows = renderRows(rowsHost, pick, q);
let closed = false;
let book: RateSheetData | null = null;
let sheet: Handle | null = null;
let mark: { sheet: string; r0: number; c0: number; r1: number; c1: number } | null = null;
let parse: ((a1: string) => { r0: number; c0: number; r1: number; c1: number } | null) | null =
null;
const zoom = (dir: 1 | -1): void => {
const grid = sheetHost.querySelector<HTMLElement>(".ss-grid-host");
if (!grid) return;
const probe = new WheelEvent("wheel", { ctrlKey: true, deltaY: -120 });
const sign = wheelZoomsIn(probe) ? -1 : 1; // ☰ [설정] 휠 방향을 따라감
grid.dispatchEvent(
new WheelEvent("wheel", {
ctrlKey: true,
deltaY: sign * dir * 120,
bubbles: true,
cancelable: true,
}),
);
};
zoomIn.addEventListener("click", () => zoom(1));
zoomOut.addEventListener("click", () => zoom(-1));
/** RT 줄 눌림 → 대응 칸으로 가서 칠함 */
rowsHost.addEventListener("click", (ev) => {
const key = (ev.target as HTMLElement).closest<HTMLElement>("tr[data-key]")?.dataset.key;
if (!key || !book || !sheet || !parse) return;
const spot = book.대응[key];
const area = spot ? parse(spot.범위) : null;
if (!spot || !area) return showToast(L("M01_RateNoCell"), "info");
mark = { sheet: spot.시트, ...area };
sheet.select(spot.칸, spot.시트);
sheet.repaint();
});
void (async () => {
try {
const [data, sheetMod, addr] = await Promise.all([
fetchRateSheet(),
import("../A00_Common/spreadsheet/spreadsheet"),
import("../A00_Common/spreadsheet/spreadsheet_address"),
]);
if (closed) return;
book = data;
parse = addr.parseRange;
sheet = sheetMod.createSpreadsheet(sheetHost, data.책, {
readOnly: true,
칠: (id, r, c) =>
mark && mark.sheet === id && r >= mark.r0 && r <= mark.r1 && c >= mark.c0 && c <= mark.c1
? "danger mine"
: null,
});
} catch (error) {
if (!closed) showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
}
})();
return () => {
closed = true;
offRows();
sheet?.destroy();
};
}