feat(805-1): M01 요율 「시트 | 리스트」 보기 단추 · 우클릭 서로 이동 · 시트 ↔ RT 전수 대조

- 요율 화면 오른쪽 위 보기 단추 둘(기본 시트 · 세션 기억) — 한 번에 하나만 보임
- 우클릭: 리스트 줄 「시트에서 보기」 · 시트 칸 「리스트에서 보기」 · 대응 없으면 흐림 + 대응 요율 없음(메뉴 부품에 disabled 항목 · 시트 부품에 cellMenu 옵션)
- 시트 칸 → RT 줄 찾기 순수 함수(UI_RateSheet_Map.ts) · 병합 칸 보정 · 겹치면 좁은 범위
- 책 대응 범위를 상자 표제 칸까지 넓힘(RT000004 값 칸 BR51 누락 바로잡음) — 값 · RT 정본 그대로
- 대조 스크립트 audit_요율대조.py(RT 잎 216 · 시트 칸 없음 8 · 시트에만 있는 칸 1 · B09 사용 표시)
- 시험: test_805_1_rate_views 신설 · test_798_1 대응표 전수 추가 · 옛 「줄 누르면 이동」은 우클릭 메뉴로 대체

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 16:15:08 +09:00
co-authored by Claude Sonnet 5.5
parent 21bba5a027
commit 7c2efde22f
13 changed files with 915 additions and 76 deletions
+133 -42
View File
@@ -1,16 +1,21 @@
/* =============================================================================
* M01_MasterData_UI_RateSheet.ts
* 요율 화면 — 위 = 조달청 제비율 엑셀(내장 엑셀 · 읽기만 · 확대 · 축소) / 아래 = 기존 RT 표(Rows).
* RT 줄을 누르면 엑셀의 대응 칸으로 가서 칠함. 요율 셈 로직(B09)은 이 책을 읽지 않음 — 표현만.
* 책 = 서버 `/api/m01/rate-sheet`(빌더 `build_제비율책.py` 가 만든 파일) · 798-1
* ⚠ 내장 엑셀은 `import()` 로만 불러옴(요율 화면을 열 때만 받음).
* 요율 화면 — 오른쪽 위 「시트 | 리스트」 보기 단추 · 한 번에 하나만 보임(기본 시트 · 고른 보기는 세션에 기억).
* 시트 = 조달청 제비율 엑셀(내장 엑셀 · 읽기만 · 확대 · 축소) / 리스트 = 기존 RT 표(Rows).
* 오른쪽 클릭 — 리스트 줄 「시트에서 보기」 → 대응 칸으로 가서 칠함 / 시트 칸 「리스트에서 보기」 → 그 RT 줄 강조.
* 대응이 없으면 메뉴 줄이 흐림(대응 요율 없음). 요율 셈 로직(B09)은 이 책을 읽지 않음 — 표현만.
* 책 = 서버 `/api/m01/rate-sheet`(빌더 `build_제비율책.py` 가 만든 파일) · 798-1 · 805-1
* ⚠ 내장 엑셀은 `import()` 로만 불러옴(시트 보기를 처음 열 때만 받음).
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { createSegment } from "@ui/ui_template_segment";
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { fetchRateSheet, type RateSheetData } from "./M01_MasterData_Api_Fetch";
import { renderRows } from "./M01_MasterData_UI_Rows";
import { keyAt, type ParseArea, type RateArea } from "./M01_MasterData_UI_RateSheet_Map";
import type { Pick } from "./M01_MasterData_UI_Side";
import "./M01_MasterData_UI_RateSheet.css";
@@ -20,29 +25,107 @@ interface Handle {
destroy(): void;
}
/** 요율 파일이면 엑셀 + 표 · 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
type View = "sheet" | "list";
const VIEW_KEY = "aislo_m01_rate_view";
const savedView = (): View => (sessionStorage.getItem(VIEW_KEY) === "list" ? "list" : "sheet");
/** 요율 파일이면 시트 · 리스트 보기 · 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
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 menu = createMapContextMenu("m01-rate");
const zoomIn = createButton({ label: L("M01_RateZoomIn"), variant: "ghost", size: "sm" });
const zoomOut = createButton({ label: L("M01_RateZoomOut"), variant: "ghost", size: "sm" });
const zoomBox = el("span", { className: "m01-rate__zoom", children: [zoomOut, zoomIn] });
const seg = createSegment({
options: [
{ value: "sheet", label: L("M01_RateViewSheet") },
{ value: "list", label: L("M01_RateViewList") },
],
value: savedView(),
ariaLabel: L("M01_RateViewLabel"),
onChange: (v) => setView(v as View),
});
const bar = el("div", {
className: "ui-toolbar m01-rate__bar",
children: [
el("span", { className: "m01-rate__title", text: L("M01_RateSheetTitle") }),
zoomOut,
zoomIn,
zoomBox,
seg.root,
],
});
host.replaceChildren(bar, sheetHost, rowsHost);
const offRows = renderRows(rowsHost, pick, q);
const root = el("div", {
className: "m01-rate",
children: [bar, sheetHost, rowsHost, menu.element],
});
host.replaceChildren(root);
let closed = false;
let view: View = savedView();
let offRows: (() => void) | null = null;
let book: RateSheetData | null = null;
let parse: ParseArea | 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 fail = (error: unknown): void => {
if (!closed) showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
};
/** 책(대응표) + 칸 주소 읽기 — 한 번만 */
let bookP: Promise<void> | null = null;
const loadBook = (): Promise<void> =>
(bookP ??= (async () => {
const [data, addr] = await Promise.all([
fetchRateSheet(),
import("../A00_Common/spreadsheet/spreadsheet_address"),
]);
book = data;
parse = addr.parseRange;
})());
/** 내장 엑셀 만들기 — 시트 보기를 처음 열 때 한 번 */
let sheetP: Promise<void> | null = null;
const loadSheet = (): Promise<void> =>
(sheetP ??= (async () => {
const [, sheetMod] = await Promise.all([
loadBook(),
import("../A00_Common/spreadsheet/spreadsheet"),
]);
if (closed || !book) return;
sheet = sheetMod.createSpreadsheet(sheetHost, book.책, {
readOnly: true,
칠: (id, r, c) =>
mark && mark.sheet === id && r >= mark.r0 && r <= mark.r1 && c >= mark.c0 && c <= mark.c1
? "danger mine"
: null,
cellMenu: (id, r, c) => {
const key =
book && parse
? keyAt(book.대응, parse, book.책.시트.find((s) => s.id === id)?.병합, id, r, c)
: null;
return key
? [[L("M01_RateGoList"), () => setView("list", key)]]
: [[`${L("M01_RateGoList")} (${L("M01_RateNoRate")})`, () => {}, { disabled: true }]];
},
});
})());
/** 보기 바꿈 · 리스트는 보일 때마다 새로 그림(`focusKey` 줄을 찾아 강조) · 시트는 만들어 둔 것을 다시 보임 */
function setView(next: View, focusKey?: string): void {
if (next === view && !focusKey && (next === "sheet" ? sheet : offRows)) return;
view = next;
sessionStorage.setItem(VIEW_KEY, next);
seg.set(next);
sheetHost.hidden = next !== "sheet";
rowsHost.hidden = next !== "list";
zoomBox.hidden = next !== "sheet";
offRows?.();
offRows = null;
if (next === "list") offRows = renderRows(rowsHost, pick, q, focusKey);
else void loadSheet().then(() => sheet?.repaint(), fail);
}
const zoom = (dir: 1 | -1): void => {
const grid = sheetHost.querySelector<HTMLElement>(".ss-grid-host");
@@ -61,43 +144,51 @@ export function renderRateRows(host: HTMLElement, pick: Pick, q: string): () =>
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");
/** RT 줄 → 시트 보기로 바꾸고 대응 칸으로 가서 칠함 */
const showInSheet = async (key: string): Promise<void> => {
await loadSheet();
const spot = book?.대응[key];
const area: RateArea | null = spot && parse ? parse(spot.범위) : null;
if (!spot || !area || !sheet) return;
mark = { sheet: spot.시트, ...area };
sheet.select(spot.칸, spot.시트);
sheet.repaint();
setView("sheet");
requestAnimationFrame(() => {
sheet?.select(spot.칸, spot.시트);
sheet?.repaint();
});
};
/** 리스트 줄 오른쪽 클릭 */
rowsHost.addEventListener("contextmenu", (ev) => {
const key = (ev.target as HTMLElement).closest<HTMLElement>("tr[data-key]")?.dataset.key;
if (!key) return;
ev.preventDefault();
const [x, y] = [ev.clientX, ev.clientY];
void loadBook().then(() => {
if (closed) return;
const box = root.getBoundingClientRect();
menu.open(
x - box.left,
y - box.top,
book?.대응[key]
? [[L("M01_RateGoSheet"), () => void showInSheet(key).catch(fail)]]
: [
[
`${L("M01_RateGoSheet")} (${L("M01_RateNoCellShort")})`,
() => {},
{ disabled: true },
],
],
);
}, fail);
});
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");
}
})();
setView(view);
return () => {
closed = true;
offRows();
menu.close();
offRows?.();
sheet?.destroy();
};
}