- 요율 화면 오른쪽 위 보기 단추 둘(기본 시트 · 세션 기억) — 한 번에 하나만 보임 - 우클릭: 리스트 줄 「시트에서 보기」 · 시트 칸 「리스트에서 보기」 · 대응 없으면 흐림 + 대응 요율 없음(메뉴 부품에 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>
195 lines
7.5 KiB
TypeScript
195 lines
7.5 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_RateSheet.ts
|
|
* 요율 화면 — 오른쪽 위 「시트 | 리스트」 보기 단추 · 한 번에 하나만 보임(기본 시트 · 고른 보기는 세션에 기억).
|
|
* 시트 = 조달청 제비율 엑셀(내장 엑셀 · 읽기만 · 확대 · 축소) / 리스트 = 기존 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";
|
|
|
|
interface Handle {
|
|
select(range: string, sheet?: string): boolean;
|
|
repaint(): void;
|
|
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") }),
|
|
zoomBox,
|
|
seg.root,
|
|
],
|
|
});
|
|
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;
|
|
|
|
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");
|
|
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 줄 → 시트 보기로 바꾸고 대응 칸으로 가서 칠함 */
|
|
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 };
|
|
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);
|
|
});
|
|
|
|
setView(view);
|
|
|
|
return () => {
|
|
closed = true;
|
|
menu.close();
|
|
offRows?.();
|
|
sheet?.destroy();
|
|
};
|
|
}
|