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:
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user