diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index 7bc9a47f..7b170044 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -43,3 +43,28 @@ export function rowCells(row: Record, columns: MasterColumn[]): export function pageCount(total: number, size: number): number { return Math.max(1, Math.ceil(total / size)); } + +/** 쪽 번호 칸에 적은 값을 1~끝 쪽으로 — 큰 표(800쪽)는 한 칸씩 넘길 수 없음. */ +export function clampPage(value: number, pages: number): number { + return Number.isFinite(value) ? Math.min(pages, Math.max(1, Math.round(value))) : 1; +} + +/** + * 최신 요청의 응답만 — 검색 글자가 바뀌어 옛 요청이 늦게 오면 새 표를 덮음. + * 옛 요청은 undefined 로 끝나고(오류도 삼킴) 최신 요청의 오류만 던짐. + */ +export function latestOnly( + fn: (...args: A) => Promise, +): (...args: A) => Promise { + let seq = 0; + return async (...args) => { + const mine = ++seq; + try { + const value = await fn(...args); + return mine === seq ? value : undefined; + } catch (error) { + if (mine === seq) throw error; + return undefined; + } + }; +} diff --git a/Z01_MasterData/Z01_MasterData_UI_Page.ts b/Z01_MasterData/Z01_MasterData_UI_Page.ts index eccef02b..b87be416 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Page.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Page.ts @@ -27,7 +27,14 @@ import { type MasterRows, type MasterTable, } from "./Z01_MasterData_Api_Fetch"; -import { columnTitle, pageCount, rowCells, shownColumns } from "./Z01_MasterData_UI_Cells"; +import { + clampPage, + columnTitle, + latestOnly, + pageCount, + rowCells, + shownColumns, +} from "./Z01_MasterData_UI_Cells"; import "./Z01_MasterData_UI_Style.css"; const PAGE_SIZE = 50; @@ -136,8 +143,8 @@ function buildRowsView(): RowsView { }; let showHidden = false; let last: MasterRows | null = null; - let requestSeq = 0; let searchTimer = 0; + const fetchLatest = latestOnly(fetchMasterRows); const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") }); const titleKey = el("span", { className: "z01-master__key" }); @@ -163,22 +170,27 @@ function buildRowsView(): RowsView { children: [hiddenToggle, L("Z01_MasterData_ShowHidden")], }); const grid = el("div", { className: "z01-master__grid-wrap" }); + const pageInput = el("input", { + className: "ui-input z01-master__page-input", + attrs: { type: "number", min: "1", "aria-label": L("Z01_MasterData_Page") }, + }); + pageInput.addEventListener("change", () => goTo(Number(pageInput.value))); const pageInfo = el("span", { className: "z01-master__page-info" }); - const prev = createButton({ - label: L("Z01_MasterData_Prev"), - variant: "ghost", - onClick: () => turn(-1), - }); - const next = createButton({ - label: L("Z01_MasterData_Next"), - variant: "ghost", - onClick: () => turn(1), - }); + // 큰 표(수만 줄 · 수백 쪽)는 한 칸씩 못 넘김 — 처음·끝 단추와 쪽 번호 칸을 둠(2026-09-15 브레인). + const pagerButton = (key: Parameters[0], onClick: () => void) => + createButton({ label: L(key), variant: "ghost", onClick }); + const first = pagerButton("Z01_MasterData_First", () => goTo(1)); + const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1)); + const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1)); + const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity)); const toolbar = el("div", { className: "z01-master__toolbar", children: [search.root, hiddenLabel], }); - const pager = el("div", { className: "z01-master__pager", children: [prev, pageInfo, next] }); + const pager = el("div", { + className: "z01-master__pager", + children: [first, prev, pageInput, pageInfo, next, end], + }); const root = el("section", { className: "z01-master__panel", children: [el("div", { children: [title, titleKey] }), toolbar, grid, pager], @@ -186,33 +198,34 @@ function buildRowsView(): RowsView { toolbar.hidden = true; pager.hidden = true; - function turn(step: number): void { - state.page += step; + function goTo(page: number): void { + const pages = pageCount(last?.total ?? 0, PAGE_SIZE); + state.page = page === Infinity ? pages : clampPage(page, pages); void load(); } async function load(): Promise { if (!state.file || !state.table) return; - const mine = ++requestSeq; grid.classList.add("is-loading"); + let data: MasterRows | undefined; try { - const data = await fetchMasterRows({ + data = await fetchLatest({ file: state.file.id, table: state.table.id, page: state.page, size: PAGE_SIZE, q: state.q, }); - if (mine !== requestSeq) return; - last = data; - draw(); } catch (error) { - if (mine === requestSeq) { - showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); - } - } finally { - if (mine === requestSeq) grid.classList.remove("is-loading"); + grid.classList.remove("is-loading"); + showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); + return; } + // 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠. + if (!data) return; + grid.classList.remove("is-loading"); + last = data; + draw(); } function draw(): void { @@ -243,9 +256,11 @@ function buildRowsView(): RowsView { : el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }), ); const pages = pageCount(last.total, PAGE_SIZE); - pageInfo.textContent = `${state.page} / ${pages} ${L("Z01_MasterData_Page")} · ${last.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`; - prev.disabled = state.page <= 1; - next.disabled = state.page >= pages; + pageInput.value = String(state.page); + pageInput.max = String(pages); + pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${last.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`; + first.disabled = prev.disabled = state.page <= 1; + next.disabled = end.disabled = state.page >= pages; } function pick(file: MasterFile, table: MasterTable): void { diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css index 7a37171a..a0c0a9aa 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -155,6 +155,11 @@ font-size: var(--text-body-sm); } +.z01-master__page-input { + width: 6em; + text-align: right; +} + @media (max-width: 860px) { .z01-master__content { display: block; diff --git a/main.py b/main.py index 07cf9a3b..85f2344a 100644 --- a/main.py +++ b/main.py @@ -75,6 +75,7 @@ from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_r from B09_Estimation.B09_Estimation_Router_MaterialPrices import ( router as b09_material_prices_router, ) +from Z01_MasterData.Z01_MasterData_Router import router as z01_master_data_router from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, @@ -656,11 +657,8 @@ app.include_router(b09_edits_router, dependencies=protected_with_company) app.include_router(b09_factors_router, dependencies=protected_with_company) app.include_router(b09_material_prices_router, dependencies=protected_with_company) # Z01 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다(2026-09-15 브레인 Z01). -# ⚠ 라우터 모듈(랩탑 메인)이 아직 없음 — 없는 것을 import 하면 서버가 안 뜸. -# 올라오면 아래 두 줄 주석을 풂. system_admin_only = [Depends(verify_session), Depends(require_system_admin)] -# from Z01_MasterData.Z01_MasterData_Router import router as z01_master_data_router -# app.include_router(z01_master_data_router, dependencies=system_admin_only) +app.include_router(z01_master_data_router, dependencies=system_admin_only) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 그 위에 서버가 환경까지 한 번 더 본다. app.include_router(dev_unlock_router, dependencies=protected_with_company) diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index e9433950..d96a9299 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -6,6 +6,8 @@ ④ 쪽 수는 줄이 없어도 1 ⑤ columns 차례가 열 차례 — rows 에 columns 에 없는 열이 섞여도 안 그림 · columns 에 있는데 줄에 그 열이 없으면 「—」(값이 null 이면 빈칸) (2026-09-15 브레인) + ⑥ 큰 표(40,000줄 = 800쪽) — 쪽 번호 칸에 적은 값은 1~끝 쪽으로 조임(끝 쪽으로 바로 감) + ⑦ 검색·쪽 요청이 겹치면 최신 응답만 — 늦게 온 옛 응답·옛 오류는 버림 (2026-09-15 브레인) TS 를 실제로 돌린다(`test_b06_berm_review_info` 와 같은 방식). """ @@ -22,9 +24,20 @@ SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Cells.ts" _RUNNER = """ import { writeFileSync } from "node:fs"; import { - cellText, columnTitle, pageCount, rowCells, shownColumns, + cellText, clampPage, columnTitle, latestOnly, pageCount, rowCells, shownColumns, } from "./Z01_MasterData_UI_Cells.js"; +const wait = (ms, value, fail) => + new Promise((ok, no) => setTimeout(() => (fail ? no(new Error(value)) : ok(value)), ms)); +const fetchLatest = latestOnly(wait); +// 옛 검색(느림) → 새 검색(빠름) · 옛 오류(느림) → 새 성공(빠름) +const race = await Promise.all([fetchLatest(60, "old"), fetchLatest(10, "new")]); +const raceError = await Promise.all([ + fetchLatest(60, "old error", true).catch((e) => "thrown " + e.message), + fetchLatest(10, "new"), +]); +const latestError = await fetchLatest(5, "boom", true).catch((e) => "thrown " + e.message); + const columns = [ { key: "id", label: "id", unit: null, hidden: true }, { key: "occupation_name", label: "직종명", unit: null, hidden: false }, @@ -45,6 +58,10 @@ writeFileSync(process.argv[2], JSON.stringify({ ], pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)], row: rowCells({ extra: "x", daily_wage_krw: 250000, occupation_name: null }, columns), + clamp: [clampPage(801, 800), clampPage(0, 800), clampPage(NaN, 800), clampPage(12.6, 800)], + race: race.map((v) => v ?? null), + raceError: raceError.map((v) => v ?? null), + latestError, })); """ @@ -87,3 +104,8 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert got["pages"] == [1, 140, 1] # columns 차례 · id 는 줄에 없어 「—」 · null 은 빈칸 · extra 는 안 그림 assert got["row"] == ["—", "", "250,000"] + assert got["clamp"] == [800, 1, 1, 13] + # 옛 응답은 늦게 와도 버림(null) · 옛 오류도 안 던짐 · 최신 오류는 던짐 + assert got["race"] == [None, "new"] + assert got["raceError"] == [None, "new"] + assert got["latestError"] == "thrown boom" diff --git a/resources/tester/test_z01_master_registration.py b/resources/tester/test_z01_master_registration.py new file mode 100644 index 00000000..0370159a --- /dev/null +++ b/resources/tester/test_z01_master_registration.py @@ -0,0 +1,32 @@ +"""Z01 마스터 데이터 라우터 등록 — `main.py` 에 붙었고 시스템 관리자만 통과하는지. + +(2026-09-15 브레인 Z01) + ① 경로 둘(`/api/master-data/tree` · `/rows`)이 앱에 붙어 있음 + ② 보호 = 로그인 + 시스템 관리자 — 회사·프로젝트 보호는 안 붙음 + (시스템 관리자는 회사가 없을 수 있음) +서버를 띄우지 않고 경로의 의존성만 훑음(`test_app_imports` 와 같은 방식). +""" + +import importlib + +from fastapi.routing import APIRoute + +from common_util.common_util_auth import require_company, require_system_admin, verify_session + + +def _calls(dependant) -> set: + found = set() + for sub in dependant.dependencies: + found.add(sub.call) + found |= _calls(sub) + return found + + +def test_마스터_데이터_경로는_시스템_관리자만() -> None: + app = importlib.import_module("main").app + routes = {r.path: r for r in app.routes if isinstance(r, APIRoute)} + for path in ("/api/master-data/tree", "/api/master-data/rows"): + assert path in routes, f"라우터 경로가 없습니다: {path}" + calls = _calls(routes[path].dependant) + assert {verify_session, require_system_admin} <= calls + assert require_company not in calls diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 67183d6a..8194f52d 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -157,6 +157,8 @@ export const ui_locales_b3 = { "Not source data — calculation records", ], Z01_MasterData_NoRows: ["줄 없음", "No rows"], + Z01_MasterData_First: ["처음", "First"], + Z01_MasterData_Last: ["끝", "Last"], Z01_MasterData_Prev: ["이전", "Prev"], Z01_MasterData_Next: ["다음", "Next"], Z01_MasterData_Page: ["쪽", "pages"],