diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 1a3396462..72e2f5a61 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -64,9 +64,11 @@ export const STATE_REGISTRY = { "m02-card-pills": { bucket: "pref", scope: "global" }, /** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */ "m01-logic-panel": { bucket: "pref", scope: "global" }, - /** B09 묶음 탭(171-3) 보기 `{탭: cards · table}` · 가로 열 수 `{탭: 1~4}` */ + /** B09 묶음 탭(171-3) 보기 `cards · table`(304-1 셋 탭 한 값) · 가로 열 수 `{탭: 1~4}` */ "b09-group-view": { bucket: "pref", scope: "global" }, "b09-group-cols": { bucket: "pref", scope: "global" }, + /** B09 행 누름(303-1) `popup · link` */ + "b09-link-mode": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ diff --git a/B09_Estimation/B09_Estimation_UI_Dock.ts b/B09_Estimation/B09_Estimation_UI_Dock.ts new file mode 100644 index 000000000..c71514586 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Dock.ts @@ -0,0 +1,106 @@ +/* ============================================================================= + * B09_Estimation_UI_Dock.ts + * B09 좌측 하단 고정 줄(PLAN 303-1 · 304-1) — 위에서부터 + * ① [박스보기|호표보기] 묶음 탭 셋 보기(304-1 · 한 값) + * ② [하이퍼링크|팝업] 행 누름(303 · 기본 팝업) + * ③ `DRAFT.actions` 단추(초안 · 비면 줄 없음) + * ④ [?][저장][내역 초기화](도움말 줄 `data-help-row`) + * + * 스위치 값 = pref global(계정 공용 · [저장] 필요 없음) · 바닥 고정 = 공용 `.ui-sidebar-actions` 한 벌 + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_elements"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { DRAFT } from "./B09_Estimation_UI_Tabs"; +import { + chooseGroupView, + groupView, + viewLabel, + type GroupView, +} from "./B09_Estimation_UI_Tabs_Groups"; + +export type LinkMode = "popup" | "link"; + +const LINK_LABEL: Record = { link: "하이퍼링크", popup: "팝업" }; + +/** 행 누름 길 — 모르는 값 · 못 읽음 = 팝업 */ +export function linkMode(): LinkMode { + try { + return readState("b09-link-mode") === "link" ? "link" : "popup"; + } catch { + return "popup"; + } +} + +function setLinkMode(mode: LinkMode): void { + try { + writeState("b09-link-mode", mode); + } catch { + // 기억 못 하면 기본(팝업)으로 돌아감 + } +} + +/** 단추 둘 스위치 — 켜진 쪽 filled · aria-pressed */ +function switchRow( + name: string, + values: T[], + label: (value: T) => string, + current: () => T, + pick: (value: T) => void, +): HTMLElement { + const row = document.createElement("div"); + row.className = "ui-btn-group b09-dock__row"; + row.dataset.b09Switch = name; + const buttons = values.map((value) => { + const button = createButton({ + label: label(value), + variant: "ghost", + onClick: () => { + if (value === current()) return; + pick(value); + sync(); + }, + }); + button.dataset.value = value; + return button; + }); + const sync = (): void => + buttons.forEach((button, k) => { + const on = values[k] === current(); + button.classList.toggle("ui-btn--filled", on); + button.classList.toggle("ui-btn--ghost", !on); + button.setAttribute("aria-pressed", String(on)); + }); + sync(); + row.append(...buttons); + return row; +} + +/** 하단 고정 줄 — onView = 보기 바뀜(묶음 탭이면 다시 그림) */ +export function createDock(buttons: HTMLElement[], onView: () => void): HTMLElement { + const views: GroupView[] = ["cards", "table"]; + const view = switchRow("view", views, viewLabel, groupView, (value) => { + chooseGroupView(value); + onView(); + }); + const link = switchRow( + "link", + ["link", "popup"], + (mode) => LINK_LABEL[mode], + linkMode, + setLinkMode, + ); + const extra = DRAFT.actions.map((make) => make()); + const draft = document.createElement("div"); + draft.className = "b09-dock__row"; + draft.dataset.b09DraftActions = ""; + draft.append(...extra); + const main = document.createElement("div"); + main.className = "b09-dock__row"; + main.dataset.helpRow = ""; + main.append(...buttons); + const dock = document.createElement("div"); + dock.className = "ui-sidebar-actions b09-dock"; + dock.append(view, link, ...(extra.length ? [draft] : []), main); + return dock; +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 81137921b..43773307e 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -248,3 +248,57 @@ .b09-estimation [data-b09-table="bill"] tbody tr.is-drag-src td { opacity: 0.45; } + +/* 303-1 · 304-1 좌측 하단 고정 줄 — 줄 여럿 세로(보기 · 행 누름 스위치 · 초안 단추 · [?][저장][내역 초기화]) */ +.b09-dock.ui-sidebar-actions { + flex-direction: column; +} + +.b09-dock.ui-sidebar-actions > * { + flex: none; +} + +.b09-dock .b09-dock__row { + display: flex; + gap: var(--btn-group-gap); +} + +.b09-dock .b09-dock__row > .ui-btn:not(.ui-help-btn) { + flex: 1 1 0; + min-width: auto; + padding-inline: var(--spacing-4); + white-space: nowrap; +} + +/* 303-2 행 고름 팝업 — 누른 줄 곁 표 조각 창(겹쳐 쌓임 · 창 안 b09 표 꼴 그대로 · 빨강 테두리) */ +.b09-estimation.b09-popup { + position: fixed; + z-index: var(--z-modal); + gap: var(--spacing-4); + width: max-content; + max-width: 90vw; + max-height: 80vh; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} + +.b09-popup__head { + display: flex; + flex: none; + align-items: center; + gap: var(--spacing-4); +} + +.b09-popup__title { + margin-right: auto; + font-weight: var(--font-weight-bold); +} + +.b09-popup .ui-data-table { + flex: 1 1 auto; + min-height: 0; + max-height: none; +} diff --git a/B09_Estimation/B09_Estimation_UI_Popup.ts b/B09_Estimation/B09_Estimation_UI_Popup.ts new file mode 100644 index 000000000..62c9ee360 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Popup.ts @@ -0,0 +1,175 @@ +/* ============================================================================= + * B09_Estimation_UI_Popup.ts + * B09 행 고름 팝업(PLAN 303-2) — 링크 있는 줄 · 박스 카드 누름 = 대상 탭 표 조각 창(옮기지 않음). + * + * 창 = 열 제목(머리 여러 줄 그대로) + 강조 범위 줄 전체 · 표 짜기 = `…_UI_Builders`(화면과 같은 옵션) + * 강조 범위 = 설계내역서 그 줄 + 자손 · 묶음 탭 호표 구간 · 그 밖 한 줄(하이퍼링크 강조와 같음) + * 자리 = 누른 줄 아래(모자라면 위 · 화면 안) · 너비 = 표 너비(최대 화면 90%) · 넘치면 창 안 스크롤 + * 창 안 링크 줄 누름 = 이어진 창(겹쳐 위에 · 그 줄 곁) · Esc · × = 맨 위 하나 · 탭 바꿈 · 주소 바뀜 = 모두 + * 머리 [탭으로] = 하이퍼링크처럼 옮김(모두 닫음) · 창 안 = 보기 전용(고칠 칸 · 메뉴 없음) + * ========================================================================== */ + +import { createDataTable, type DataTableOptions } from "@ui/ui_template_data_table"; +import { createButton } from "@ui/ui_template_elements"; +import { placeHoverInfo } from "@ui/ui_template_hover_info"; +import { tableOptionsOf } from "./B09_Estimation_UI_Builders"; +import { rowIndex, tabName, type Link, type TabId, type TabTable } from "./B09_Estimation_UI_Tabs"; +import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { subtreeEnd } from "./B09_Estimation_UI_Tabs_Bill_Level"; +import { isGroupTab, pickClass, pickRange } from "./B09_Estimation_UI_Tabs_Groups"; + +const EDGE = 8; + +/** 강조 범위 [첫 줄, 끝 줄] — 대상 줄 없음 = null */ +export function popupRange(tab: TabId, spec: TabTable, key: string): [number, number] | null { + if (isGroupTab(tab)) return pickRange(spec.rows, key); + const at = rowIndex(spec.rows, key); + if (at < 0) return null; + return (spec as BillTable).menu ? [at, subtreeEnd(spec.rows, at)] : [at, at]; +} + +/** 창 표 옵션 — 범위 줄만 · 줄 번호 곁값은 원래 줄 번호로 · 빨강 테두리 · 합계 · 고칠 칸 없음 */ +export function popupOptions( + spec: TabTable, + tab: TabId, + range: [number, number], +): DataTableOptions { + const [from, to] = range; + const at = (i: number): number => i + from; + const level = spec.level; + return { + ...tableOptionsOf(spec, tab), + rows: spec.rows.slice(from, to + 1), + total: null, + editable: undefined, + onEdit: undefined, + rowClass: (i) => + [spec.rowClass?.(at(i)), pickClass(range, at(i))].filter(Boolean).join(" ") || null, + cellClass: spec.cellClass && ((i, key) => spec.cellClass?.(at(i), key)), + level: level && { + ...level, + of: (i) => level.of(at(i)), + group: level.group && ((i) => level.group?.(at(i)) ?? false), + }, + }; +} + +/** 창 자리 — 누른 줄 아래(모자라면 위) · 화면 밖으로 안 나감 */ +export function popupPlace( + anchor: { left: number; top: number; right: number; bottom: number }, + size: { width: number; height: number }, + view: { width: number; height: number }, +): { x: number; y: number } { + const { x, y } = placeHoverInfo(anchor, size, view); + return { x, y: Math.max(EDGE, Math.min(y, view.height - EDGE - size.height)) }; +} + +/** 열린 창(아래 → 위) */ +const stack: HTMLElement[] = []; +let listening = false; + +/** from 번째 창부터 위로 모두 닫음(0 = 모두) */ +export function closePopups(from = 0): void { + for (const box of stack.splice(from)) box.remove(); +} + +export const popupCount = (): number => stack.length; + +function listen(): void { + if (listening) return; + listening = true; + // Esc = 맨 위 하나(뒤 화면의 Esc 풂보다 먼저 · 창 있을 때만 가로챔) + window.addEventListener( + "keydown", + (event) => { + if (event.key !== "Escape" || !stack.length) return; + event.preventDefault(); + event.stopPropagation(); + closePopups(stack.length - 1); + }, + true, + ); + window.addEventListener("hashchange", () => closePopups()); +} + +export interface PopupOpen { + link: Link; + /** 누른 줄 · 카드 줄(자리 기준) — 없으면 화면 가운데 위 */ + anchor: Element | null; + /** 대상 탭 표(화면과 같은 표 짜기) — 없으면 null */ + specOf: (tab: TabId) => TabTable | null; + /** 하이퍼링크처럼 옮김(대상 줄 없음 · [탭으로]) */ + go: (link: Link) => void; + /** 참 = 창 안 누름(이어진 창) · 거짓 = 화면 누름(있던 창 모두 닫고 새로) */ + chain?: boolean; +} + +export function openRowPopup(o: PopupOpen): void { + if (!o.chain) closePopups(); + const { tab, key } = o.link; + const spec = o.specOf(tab); + const range = spec && popupRange(tab, spec, key); + if (!spec || !range) return o.go(o.link); // 보일 줄 없음 = 옮김 + listen(); + const level = stack.length; + const box = document.createElement("div"); + box.className = "b09-estimation b09-popup"; + box.setAttribute("role", "dialog"); + box.setAttribute("aria-label", tabName(tab)); + box.dataset.b09Popup = tab; + const title = document.createElement("span"); + title.className = "b09-popup__title"; + title.textContent = tabName(tab); + const goButton = createButton({ + label: "탭으로", + variant: "ghost", + size: "sm", + onClick: () => { + closePopups(); + o.go(o.link); + }, + }); + goButton.dataset.b09PopupGo = ""; + const closeButton = createButton({ + label: "×", + variant: "ghost", + size: "sm", + onClick: () => closePopups(level), + }); + closeButton.dataset.b09PopupClose = ""; + closeButton.setAttribute("aria-label", "닫기"); + const head = document.createElement("div"); + head.className = "b09-popup__head"; + head.append(title, goButton, closeButton); + const from = range[0]; + const table = createDataTable({ + ...popupOptions(spec, tab, range), + // 창 안 링크 줄 = 이어진 창(고칠 칸 · 「단가연결」 칸은 화면처럼 안 엶) + onCellClick: (i, cellKey) => { + if (spec.editable?.(i + from, cellKey)) return; + const next = spec.link?.(i + from, cellKey); + if (next) + openRowPopup({ + ...o, + link: next, + anchor: table.root.querySelector(`tr[data-i="${i}"]`), + chain: true, + }); + }, + }); + table.root.dataset.b09Table = tab; + box.append(head, table.root); + document.body.append(box); + stack.push(box); + const view = { width: window.innerWidth, height: window.innerHeight }; + const size = box.getBoundingClientRect(); + const anchor = o.anchor?.getBoundingClientRect() ?? { + left: (view.width - size.width) / 2, + right: (view.width + size.width) / 2, + top: view.height / 4, + bottom: view.height / 4, + }; + const { x, y } = popupPlace(anchor, size, view); + box.style.left = `${x}px`; + box.style.top = `${y}px`; +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index c84584803..81dc5157f 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -33,6 +33,8 @@ import { computeTotal } from "./B09_Estimation_Calc_Total"; import { createTabBars } from "./B09_Estimation_UI_TabBar"; import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; +import { createDock, linkMode } from "./B09_Estimation_UI_Dock"; +import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; import { DRAFT, rowIndex, @@ -154,6 +156,7 @@ export function renderB09Estimation(root: HTMLElement): void { /** 탭 하나 그림 · focus = 이동해 온 줄 키 */ const show = (tab: TabId, focus?: string): void => { current = tab; + closePopups(); // 303-2 탭 바꿈 · 다시 그림 = 창 모두 닫음 bars.draw(tab); bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1) if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); @@ -162,8 +165,19 @@ export function renderB09Estimation(root: HTMLElement): void { const spec = tabTableOf(tab, ctx); if (!spec) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); if (typeof spec === "string") return void panel.replaceChildren(note(spec)); - const goto = (link: Link | null): void => { - if (link) show(link.tab, link.key); + // 303 행 누름 = 팝업(기본 · 누른 줄 곁 표 조각) · 하이퍼링크(그 탭 그 줄로) + const goto = (link: Link | null, anchor?: Element | null): void => { + if (!link) return; + if (linkMode() === "link") return show(link.tab, link.key); + openRowPopup({ + link, + anchor: anchor ?? null, + specOf: (t) => { + const made = tabTableOf(t, ctx); + return made && typeof made === "object" ? made : null; + }, + go: (to) => show(to.tab, to.key), + }); }; // extra = 줄 class 곁값(181-2 표 보기 호표 구간) const makeTable = (extra?: (index: number) => string | null): DataTable => { @@ -174,7 +188,8 @@ export function renderB09Estimation(root: HTMLElement): void { : spec.rowClass, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); - if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); + if (spec.link && !spec.editable?.(i, key)) + goto(spec.link(i, key), made.root.querySelector(`tr[data-i="${i}"]`)); }, }); made.root.dataset.b09Table = tab; @@ -216,9 +231,8 @@ export function renderB09Estimation(root: HTMLElement): void { return made; }; const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 236-1 안내 글 = 한 줄 - // 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 머리 단추로 표 + // 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 좌측 하단 스위치로 표(304-1) if (isGroupTab(tab)) { - const redraw = (): void => show(tab); return void panel.replaceChildren( ...renderGroupPanel({ tab, @@ -227,7 +241,6 @@ export function renderB09Estimation(root: HTMLElement): void { focus, goto, makeTable, - redraw, setTools: bars.setTools, }), ); @@ -321,9 +334,8 @@ export function renderB09Estimation(root: HTMLElement): void { // 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식) const section = createPanelCard("데이터 점검"); section.append(refresh, check.list); - const actions = document.createElement("div"); - actions.className = "ui-sidebar-actions"; - actions.append(save, reset); + // 303-1 · 304-1 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · 초안 단추 · [?][저장][내역 초기화] + const actions = createDock([save, reset], () => isGroupTab(current) && show(current)); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-options"; left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 97d937b6e..b17177b4b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -123,12 +123,14 @@ export type TabBuild = (ctx: TabContext) => TabTable | string; /** 초안 탭(252 · `draft/*_UI.ts` 가 TABS · TAB_GROUPS 와 함께 넣음 · 폴더째 지우면 빈 채로) — * builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) · - * panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) */ + * panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) · + * actions = 좌측 하단 고정 줄 단추(303-1 · [저장] 줄 위 한 줄 · 비면 줄 없음) */ export const DRAFT: { builders: Record; extend: Array<(payload: Row, user: Row, sheets: Row) => void>; panels: Array<(user: () => Row, setUser: (next: Row) => void) => HTMLElement>; -} = { builders: {}, extend: [], panels: [] }; + actions: Array<() => HTMLElement>; +} = { builders: {}, extend: [], panels: [], actions: [] }; /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 641818e31..5ffe20bb2 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -1,22 +1,22 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Groups.ts * B09 묶음 탭 셋(PLAN 171-3) — 일위대가표 · 중기사용료 · 단가산출근거 = 공용 묶음 카드(171-2) 기본 · - * [표로 보기] = 공용 표 그대로(맞은편 단추 = [박스보기]). + * 호표보기 = 공용 표 그대로 · 고름 = 좌측 하단 [박스보기|호표보기] 한 벌(304-1 · 셋 탭 함께). * * 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음 * 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로) * 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선 * 248-1 빨강 하나(공용 고름 강조 안 붙임 · 스크롤만) · 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 · * 누른 줄만 고름(is-focus) · Esc = 고름도 풂 - * 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2 - * 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래 + * 보기 기억 = `b_page_state`(`b09-group-view` · 한 값 · 옛 탭별 값은 첫 읽기에 하나로) · 가로 열 수 = + * `b09-group-cols`(탭마다) · 못 읽으면 카드 · 2 + * 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래 * 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1) * 278-2 단가산출근거 박스 = 머리 줄(`|머리n`)을 운반 단가 창 ③ 칸 꼴로(`headSteps` 한 벌 · 몸 위 칸) · * 출처 = 그 칸 맨 위 · 호표 줄 · 총계 = 그대로 · 묶음 자료(`tabGroups`) · 표는 안 바뀜 * ========================================================================== */ import type { DataRow, DataTable } from "@ui/ui_template_data_table"; -import { createButton } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { clampColumns, @@ -38,10 +38,8 @@ export const GROUP_TABS: TabId[] = ["dg", "machine_blocks", "sg"]; export const isGroupTab = (tab: TabId): boolean => GROUP_TABS.includes(tab); -/** 단추 글 = 바꿀 보기 이름 */ -export const viewLabel = (view: GroupView): string => (view === "cards" ? "표로 보기" : "박스보기"); - -export const flipView = (view: GroupView): GroupView => (view === "cards" ? "table" : "cards"); +/** 단추 글 = 그 보기 이름(304-1 좌측 스위치) */ +export const viewLabel = (view: GroupView): string => (view === "cards" ? "박스보기" : "호표보기"); type Saved = Record | null; @@ -53,19 +51,39 @@ const saved = (name: "b09-group-view" | "b09-group-cols"): Saved => { } }; -/** 고른 보기 — 모르는 값 · 못 읽음 = 카드 */ -export const readGroupView = (tab: TabId, kept: Saved = saved("b09-group-view")): GroupView => - kept?.[tab] === "table" ? "table" : "cards"; +/** 고른 보기(셋 탭 한 값) — 옛 탭별 값 = dg · 다른 탭 차례 · 모르는 값 · 못 읽음 = 카드 */ +export function readGroupView(kept: unknown = saved("b09-group-view")): GroupView { + const old = kept && typeof kept === "object" ? (kept as Record) : null; + const value = old ? (old.dg ?? old.machine_blocks ?? old.sg) : kept; + return value === "table" ? "table" : "cards"; +} + +/** 이 화면 안 보기(기억이 안 돼도 고른 보기 유지) */ +let chosen: GroupView | null = null; + +/** 지금 보기 — 첫 읽기에 옛 탭별 값을 한 값으로 옮겨 적음 */ +export function groupView(): GroupView { + if (chosen) return chosen; + const kept = saved("b09-group-view"); + chosen = readGroupView(kept); + if (kept && typeof kept === "object") chooseGroupView(chosen); + return chosen; +} + +export function chooseGroupView(view: GroupView): void { + chosen = view; + try { + writeState("b09-group-view", view); + } catch { + // 기억 못 해도 이 화면 안에서는 고른 값 그대로 + } +} /** 고른 가로 열 수 — 1~4 · 모르는 값 = 2 */ export const readGroupCols = (tab: TabId, kept: Saved = saved("b09-group-cols")): number => clampColumns(kept?.[tab]); -export function rememberGroup( - name: "b09-group-view" | "b09-group-cols", - tab: TabId, - value: unknown, -): void { +export function rememberGroup(name: "b09-group-cols", tab: TabId, value: unknown): void { try { writeState(name, { ...(saved(name) ?? {}), [tab]: value }); } catch { @@ -244,33 +262,18 @@ export function unpickOnRelease(root: HTMLElement): void { listenKeys(); } -/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */ -const chosen = new Map(); - -/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 보기 단추 · 열 수 = `setTools`(보조 탭 줄 오른쪽) */ +/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 열 수 = `setTools`(보조 탭 줄 오른쪽) */ export function renderGroupPanel(o: { tab: TabId; spec: TabTable; hint: HTMLElement | null; focus?: string; - goto: (link: Link | null) => void; + goto: (link: Link | null, anchor?: Element | null) => void; makeTable: (rowClass?: (index: number) => string | null) => DataTable; - redraw: () => void; setTools: (tools: HTMLElement[]) => void; }): HTMLElement[] { - const view = chosen.get(o.tab) ?? readGroupView(o.tab); + const view = groupView(); const lead = lineHint(o.hint); - const toggle = createButton({ - label: viewLabel(view), - variant: "ghost", - size: "sm", - onClick: () => { - chosen.set(o.tab, flipView(view)); - rememberGroup("b09-group-view", o.tab, flipView(view)); - o.redraw(); - }, - }); - toggle.dataset.b09GroupView = view; const at = o.focus === undefined ? -1 : rowIndex(o.spec.rows, o.focus); if (view === "cards") { const first = o.spec.columns[0]?.key ?? ""; @@ -281,19 +284,18 @@ export function renderGroupPanel(o: { : tabGroups(o.tab, o.spec), columns: readGroupCols(o.tab), onColumns: (n) => rememberGroup("b09-group-cols", o.tab, n), - onPick: (i) => o.goto(o.spec.link?.(i, first) ?? null), + onPick: (i) => + o.goto(o.spec.link?.(i, first) ?? null, cards.querySelector(`[data-at="${i}"]`)), focus: at, money: numberFormat(o.spec, "sum"), qty: numberFormat(o.spec, "qty"), - actions: [toggle], - placeTools: o.setTools, // 202-1 가로 열 수 · 보기 단추 = 보조 탭 줄 오른쪽 + placeTools: o.setTools, // 202-1 가로 열 수 = 보조 탭 줄 오른쪽 }); cards.dataset.b09Table = o.tab; live = { root: cards, unpick: (esc) => unpickCards(cards, esc) }; listenKeys(); return [cards, ...(lead ? [lead] : [])]; } - o.setTools([toggle]); let range = pickRange(o.spec.rows, o.focus); let chose = -1; // 누른 줄(다시 그려도 유지) const table = o.makeTable((i) => (i === chose ? "is-focus" : pickClass(range, i))); diff --git a/resources/tester/test_157_2_b09_sangeun_views.py b/resources/tester/test_157_2_b09_sangeun_views.py index b8cbb03d4..068632436 100644 --- a/resources/tester/test_157_2_b09_sangeun_views.py +++ b/resources/tester/test_157_2_b09_sangeun_views.py @@ -62,20 +62,19 @@ def test_157_2_두_보기_같은_값(): def test_157_2_단추_바꿈(): - out = _node( - "\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards')," - " flipView('table'), flipView(flipView('table'))]));" - ) - assert out == ["박스보기", "표로 보기", "cards", "table"] + out = _node("\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards')]));") + assert out == ["호표보기", "박스보기"] # 304-1 단추 글 = 그 보기 이름 def test_157_2_기억_실패_때_카드(): out = _node( - "\nconst a = [readGroupView('sg', null), readGroupView('sg', { sg: 'x' })," - " readGroupView('sg', { dg: 'table' }), readGroupView('sg', { sg: 'table' })];" + "\nconst a = [readGroupView(null), readGroupView({ sg: 'x' }), readGroupView({ dg: 'table' })," + " readGroupView({ sg: 'table' }), readGroupView('table'), readGroupView('x')," + " readGroupView({ dg: 'cards', sg: 'table' })];" "\nconsole.log(JSON.stringify(a));" ) - assert out == ["cards", "cards", "cards", "table"] + # 304-1 한 값 · 옛 탭별 값 = dg 먼저 · 없으면 다른 탭 · 모르는 값 = 카드 + assert out == ["cards", "cards", "table", "table", "table", "cards", "cards"] def test_157_2_화면_잇기(): diff --git a/resources/tester/test_171_3_b09_group_tabs.py b/resources/tester/test_171_3_b09_group_tabs.py index 139aab8d6..36668ade1 100644 --- a/resources/tester/test_171_3_b09_group_tabs.py +++ b/resources/tester/test_171_3_b09_group_tabs.py @@ -118,27 +118,27 @@ def test_단가산출근거_카드(): assert out["links"] == [None, {"tab": "labor", "key": "LB2"}] -def test_보기_기억_탭마다(): +def test_보기_한_값_열_수_탭마다(): out = run( [COMMON, TABS, GROUPS], - "\nconst a = [readGroupView('dg'), readGroupCols('dg')];" - "\nrememberGroup('b09-group-view', 'dg', 'table');" + "\nconst a = [readGroupView(), readGroupCols('dg')];" + "\nkept.set('b09-group-view', JSON.stringify({ machine_blocks: 'table' }));" # 옛 탭별 값 "\nrememberGroup('b09-group-cols', 'sg', 3);" - "\nconst b = [readGroupView('dg'), readGroupView('sg'), readGroupView('machine_blocks')," + "\nconst b = [groupView(), readGroupView(), readGroupView()," " readGroupCols('sg'), readGroupCols('dg'), readGroupCols('dg', { dg: 9 })];" "\nconsole.log(JSON.stringify({ a, b, kept: Object.fromEntries(kept)," " tabs: GROUP_TABS, other: ['bill', 'sum_material', 'sum_labor', 'sum_expense'," " 'sum_machine', 'dg_list'].map(isGroupTab) }));", ) assert out["a"] == ["cards", 2] # 기본 = 카드 · 가로 2 - assert out["b"] == ["table", "cards", "cards", 3, 2, 2] - assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"} + assert out["b"] == ["table", "table", "table", 3, 2, 2] # 304-1 셋 탭 한 값 + assert json.loads(out["kept"]["b09-group-view"]) == "table" # 첫 읽기에 옛 값 → 한 값 assert json.loads(out["kept"]["b09-group-cols"]) == {"sg": 3} assert out["tabs"] == ["dg", "machine_blocks", "sg"] assert out["other"] == [False] * 6 # 집계표 넷 · 설계내역서 · 목록표 = 대상 밖 -def test_화면_보기_바꿈_누름_이동(): +def test_화면_보기_고름_누름_이동(): out = run( [COMMON, TABS, f"{B09}Labor.ts", GROUPS], f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" @@ -147,26 +147,25 @@ def test_화면_보기_바꿈_누름_이동(): " redraw: () => redraws++, setTools: (t) => calls.push(t), makeTable: () => ({ root: document.createElement('table')," " focusRow: (i) => focused.push(i) }) };" "\nconst [view] = renderGroupPanel(o);" - "\nconst btn = calls[0].find((e) => e.dataset.b09GroupView);" - "\nconst label = btn.textContent;" + "\nconst label = calls.flat().filter((e) => e.dataset.b09GroupView).length;" "\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');" "\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');" "\nview.querySelector('[data-at=\"2\"]').click();" "\nview.querySelector('[data-at=\"0\"]').click();" - "\nbtn.click();" + "\nconst before = calls.length; chooseGroupView('table');" "\nconst [view2] = renderGroupPanel(o);" "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws," - " label2: calls[1].find((e) => e.dataset.b09GroupView).textContent, tag2: view2.tagName, focused," + " tools2: calls.length - before, tag2: view2.tagName, focused," " kept: Object.fromEntries(kept) }));", RAW, ) - assert out["label"] == "표로 보기" and out["table"] == "dg" # 기본 = 카드 + assert out["label"] == 0 and out["table"] == "dg" # 기본 = 카드 · 304-1 탭 줄 보기 단추 없음 assert out["gone"] == [{"tab": "labor", "key": "L00005"}, None] # 줄 누름 = 표와 같은 이동 assert out["lit"] is False # 248-1 이동해 온 줄 = 빨강 하나(줄 강조 안 붙임) assert out["box"] is True # 그 호표 박스 = 붉은 외곽선(181-2) - assert out["redraws"] == 1 - assert out["label2"] == "박스보기" and out["tag2"] == "TABLE" and out["focused"] == [3] - assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"} + assert out["redraws"] == 0 + assert out["tools2"] == 0 and out["tag2"] == "TABLE" and out["focused"] == [3] + assert json.loads(out["kept"]["b09-group-view"]) == "table" def test_잇기(): diff --git a/resources/tester/test_181_2_b09_box_pick.py b/resources/tester/test_181_2_b09_box_pick.py index 0233c6458..211dd7009 100644 --- a/resources/tester/test_181_2_b09_box_pick.py +++ b/resources/tester/test_181_2_b09_box_pick.py @@ -1,6 +1,6 @@ """181-2 박스보기 — 이름 · 접기 없음 · 목록표에서 고른 호표 = 박스 붉은 외곽선 · 표 보기 = 줄 구간 전체. -- 단추 글 「박스보기」 ↔ 「표로 보기」 · 요약(접기) 보기 지움 +- 단추 글 「박스보기」 · 「호표보기」(304-1 좌측 스위치) · 요약(접기) 보기 지움 - 표 보기 = 고른 호표 줄 구간(키 `|` 앞머리 같은 줄) 모두 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end` - 붉은 색 = `--color-danger`(B06 횡단 카드 고름 꼴) """ @@ -52,7 +52,7 @@ def test_표_보기_줄_class_넘김(): "\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}, setTools: () => {}," " makeTable: (e) => { extra = e; return { root: document.createElement('table')," " focusRow: () => {} }; } };" - "\nchosen.set('dg', 'table');" + "\nchooseGroupView('table');" "\nrenderGroupPanel(o);" "\nconsole.log(JSON.stringify(spec.rows.map((_, i) => extra(i))));", ) @@ -63,7 +63,7 @@ def test_표_보기_줄_class_넘김(): def test_글_접기_없음_CSS_잇기(): groups = (ROOT / GROUPS).read_text("utf-8") - assert '"표로 보기" : "박스보기"' in groups and "호표별 보기" not in groups + assert '"박스보기" : "호표보기"' in groups and "호표별 보기" not in groups # 304-1 글 common = (ROOT / COMMON).read_text("utf-8") for gone in ("isSummary", "GROUP_SUMMARY_OVER", "is-closed", "모두 펼침", "모두 접기"): assert gone not in common diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index b3daf1fba..8fbb62580 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -286,4 +286,4 @@ def test_내역_초기화_배선(): assert "showConfirmDialog(" in block and "if (!ok) return;" in block assert "const { bill: _bill, ...rest } = user;" in block assert 'run("/estimation", "PUT"' in block and "{ user: rest }" in block - assert "actions.append(save, reset);" in src + assert "createDock([save, reset]," in src # 303-1 하단 고정 줄 맨 아래 줄 diff --git a/resources/tester/test_185_2_b09_view_bar.py b/resources/tester/test_185_2_b09_view_bar.py index 4aebd6501..e18aa71c4 100644 --- a/resources/tester/test_185_2_b09_view_bar.py +++ b/resources/tester/test_185_2_b09_view_bar.py @@ -1,6 +1,6 @@ -"""185-2 B09 묶음 탭 보기 바꿈 단추 글 한 줄 · 가로 열 수 단추와 같은 줄. +"""185-2 B09 묶음 탭 가로 열 수 단추 한 줄 · 보기 고름 = 좌측 하단(304-1 · 탭 줄에 없음). -- 보기 바꿈 단추 · 가로 열 수 = 도구 묶음(`setTools` · 202-1 로 페이지 보조 탭 줄 오른쪽) · 카드 판 머리 띠 = 묶음 수만 +- 가로 열 수 = 도구 묶음(`setTools` · 202-1 로 페이지 보조 탭 줄 오른쪽) · 카드 판 머리 띠 = 묶음 수만 - 단추 · 라벨 = `white-space: nowrap` · 도구 묶음 = 줄바꿈 없음(`flex-wrap: nowrap`) """ @@ -31,7 +31,7 @@ def _panel(view: str, hint: str): return run( [COMMON, TABS, f"{B09}Labor.ts", GROUPS], f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" - f"\nchosen.set('dg', '{view}');" + f"\nchooseGroupView('{view}');" "\nconst tools = [];" f"\nconst o = {{ tab: 'dg', spec, hint: {hint}, goto: () => {{}}, redraw: () => {{}}," " setTools: (t) => tools.push(...t)," @@ -39,7 +39,7 @@ def _panel(view: str, hint: str): "\nconst out = renderGroupPanel(o);" "\nconst btn = tools.find((e) => e.dataset.b09GroupView);" "\nconst bar = out[0].all('.ui-group-cards__bar')[0] ?? null;" - "\nconsole.log(JSON.stringify({ n: out.length, first: out[0].className, label: btn.textContent," + "\nconsole.log(JSON.stringify({ n: out.length, first: out[0].className, label: btn ? btn.textContent : null, nTools: tools.length," " cols: tools.flatMap((e) => e.all('[data-group-cols]')).map((b) => b.dataset.groupCols)," " barBtn: bar ? bar.all('[data-b09-group-view]').length : null," " barCols: bar ? bar.all('[data-group-cols]').length : null }));", @@ -49,7 +49,7 @@ def _panel(view: str, hint: str): def test_카드_보기_단추가_열수_단추와_같은_도구_묶음에(): out = _panel("cards", "null") - assert out["label"] == "표로 보기" + assert out["label"] is None # 304-1 보기 단추 = 좌측 하단(탭 줄에 없음) assert out["cols"] == ["1", "2", "3", "4"] # 같은 묶음에 열 수 단추 넷 assert out["barBtn"] == 0 and out["barCols"] == 0 # 카드 판 머리 띠에는 없음 assert out["n"] == 1 # 안내 글 없으면 카드 판 하나뿐 @@ -63,8 +63,8 @@ def test_카드_보기_안내_글은_카드_판_아래에(): def test_표_보기_단추는_도구_묶음에(): out = _panel("table", "null") - assert out["label"] == "박스보기" and out["n"] == 1 - assert out["cols"] == [] # 표 보기 = 열 수 단추 없음 + assert out["label"] is None and out["n"] == 1 + assert out["cols"] == [] and out["nTools"] == 0 # 표 보기 = 도구 없음 def test_CSS_한_줄_nowrap(): @@ -81,4 +81,4 @@ def test_CSS_한_줄_nowrap(): page, ".b09-estimation__tools .ui-btn,\n.b09-estimation__tools .ui-group-cards__label" ) assert "white-space: nowrap" in tools - assert "actions: [toggle]" in (ROOT / GROUPS).read_text("utf-8") + assert "toggle" not in (ROOT / GROUPS).read_text("utf-8") # 304-1 탭 줄 토글 걷음 diff --git a/resources/tester/test_200_1_b09_box_tool_line.py b/resources/tester/test_200_1_b09_box_tool_line.py index e2ac75c0f..203d08e9b 100644 --- a/resources/tester/test_200_1_b09_box_tool_line.py +++ b/resources/tester/test_200_1_b09_box_tool_line.py @@ -36,7 +36,7 @@ def _panel(view: str, hint: str): return run( [COMMON, TABS, f"{B09}Labor.ts", GROUPS], f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" - f"\nchosen.set('dg', '{view}');" + f"\nchooseGroupView('{view}');" f"\nconst hint = {hint};" "\nconst o = { tab: 'dg', spec, hint, goto: () => {}, redraw: () => {}, setTools: () => {}," " makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) };" diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index 35bacaafc..8cbe24bb4 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -80,7 +80,7 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): "\nconst hint = document.createElement('p');" "\nconst res = {};" "\nfor (const view of ['cards', 'table']) {" - "\n chosen.set('dg', view);" + "\n chooseGroupView(view);" "\n bars.draw('dg'); bars.setTools([]);" "\n const panel = renderGroupPanel({ tab: 'dg', spec, hint, goto: () => {}, redraw: () => {}," " makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} })," @@ -95,8 +95,9 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): "\nconsole.log(JSON.stringify(res));", RAW, ) - assert out["cards"]["inRow"] == ["표로 보기"] and out["cards"]["cols"] == 4 - assert out["table"]["inRow"] == ["박스보기"] and out["table"]["cols"] == 0 + # 304-1 보기 고름 = 좌측 하단 스위치(보조 줄에 없음) · 가로 열 수 = 보조 줄 그대로 + assert out["cards"]["inRow"] == [] and out["cards"]["cols"] == 4 + assert out["table"]["inRow"] == [] and out["table"]["cols"] == 0 for view in ("cards", "table"): assert out[view]["inPanel"] == 0 # 판 안에는 단추 없음 assert out[view]["hintLast"] and out[view]["n"] == 2 # 판 + 안내 줄(맨 아래) diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index e8e03aadd..f03bdb57e 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -112,8 +112,9 @@ def test_B09_표_만드는_길_한_곳_더블클릭_기본(): calls = [ p.name for p in _sources("B09_Estimation") if "createDataTable({" in p.read_text("utf-8") ] - assert calls == ["B09_Estimation_UI_Shell.ts"] - shell = (ROOT / "B09_Estimation" / calls[0]).read_text("utf-8") + # 303-2 팝업 창 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders) + assert calls == ["B09_Estimation_UI_Popup.ts", "B09_Estimation_UI_Shell.ts"] + shell = (ROOT / "B09_Estimation" / calls[1]).read_text("utf-8") assert "...tableOptionsOf(spec, tab)," in shell # 303-2 옵션 짜기 = Builders 한 곳 builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8") assert "editOnDouble: spec.editOnDouble ?? true," in builders @@ -135,5 +136,6 @@ def test_B08_고칠_칸_있는_표_모두_editOnDouble(): def test_고르기_메뉴_이동_그대로(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));" in shell + assert "if (spec.link && !spec.editable?.(i, key))" in shell # 303-2 누른 줄 = 팝업 자리 + assert 'goto(spec.link(i, key), made.root.querySelector(`tr[data-i="${i}"]`));' in shell assert "spec.choices?.(Number(cell.parentElement?.dataset.i), column.key)" in shell diff --git a/resources/tester/test_248_1_b09_group_jump_unpick.py b/resources/tester/test_248_1_b09_group_jump_unpick.py index 0ebb03c45..5c555a3b9 100644 --- a/resources/tester/test_248_1_b09_group_jump_unpick.py +++ b/resources/tester/test_248_1_b09_group_jump_unpick.py @@ -60,7 +60,7 @@ def test_표_빨강_하나_누름_키_풂(): " return { root, focusRow: (i, mark) => scrolled.push([i, mark]) }; };" "\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}," " setTools: () => {}, makeTable };" - "\nchosen.set('dg', 'table');" + "\nchooseGroupView('table');" "\nconst marks = () => [root.children.map((tr) => tr.className || null)," " spec.rows.map((_, i) => extra(i))];" "\nconst out = {};" diff --git a/resources/tester/test_264_2_b09_schedule_options.py b/resources/tester/test_264_2_b09_schedule_options.py index 2266968f5..93163e72f 100644 --- a/resources/tester/test_264_2_b09_schedule_options.py +++ b/resources/tester/test_264_2_b09_schedule_options.py @@ -205,6 +205,6 @@ def test_패널_훅_draft_없으면_빈칸(): tabs = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs.ts").read_text("utf-8") shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") ui = (ROOT / "B09_Estimation/draft/B09_Draft_Schedule_UI.ts").read_text("utf-8") - assert "{ builders: {}, extend: [], panels: [] }" in tabs # draft 없으면 카드 없음 + assert "{ builders: {}, extend: [], panels: [], actions: [] }" in tabs # draft 없으면 카드 없음 assert "...DRAFT.panels.map((make) => make(() => user, setUser))" in shell assert "DRAFT.panels.push(" in ui diff --git a/resources/tester/test_303_b09_row_popup.py b/resources/tester/test_303_b09_row_popup.py new file mode 100644 index 000000000..f1aa069ed --- /dev/null +++ b/resources/tester/test_303_b09_row_popup.py @@ -0,0 +1,329 @@ +"""303 B09 행 고름 = 팝업 · 하이퍼링크 스위치 · 좌측 하단 줄. + +303-1 좌측 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · `DRAFT.actions`(비면 줄 없음) · + [?][저장][내역 초기화](도움말 줄) · 스위치 = pref global · 기본 = 팝업 +303-2 표 짜기 = `…_UI_Builders`(화면 · 팝업 · 인쇄 같은 옵션) +""" + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import COMMON, GROUPS, TABS, run # noqa: E402 + +B09 = ROOT / "B09_Estimation" +DOCK = "B09_Estimation/B09_Estimation_UI_Dock.ts" +BUILDERS = "B09_Estimation/B09_Estimation_UI_Builders.ts" + +DOCK_JS = ( + "\nconst save = document.createElement('button'); save.textContent = '저장';" + "\nconst reset = document.createElement('button'); reset.textContent = '내역 초기화';" + "\nconst shape = (dock) => dock.children.map((r) => ({ sw: r.dataset.b09Switch ?? null," + " draft: 'b09DraftActions' in r.dataset, help: 'helpRow' in r.dataset," + " text: r.children.map((b) => b.textContent) }));" +) + + +def test_하단_줄_차례_초안_단추_없으면_줄_없음(): + out = run( + [COMMON, TABS, GROUPS, DOCK], + DOCK_JS + "\nconst a = createDock([save, reset], () => {});" + "\nDRAFT.actions.push(() => { const b = document.createElement('button');" + " b.textContent = '인쇄 미리보기'; return b; });" + "\nconst b = createDock([save, reset], () => {});" + "\nconsole.log(JSON.stringify({ cls: a.className, a: shape(a), b: shape(b) }));", + ) + assert out["cls"] == "ui-sidebar-actions b09-dock" # 바닥 고정 = 공용 액션 줄 한 벌 + view = {"sw": "view", "draft": False, "help": False, "text": ["박스보기", "호표보기"]} + link = {"sw": "link", "draft": False, "help": False, "text": ["하이퍼링크", "팝업"]} + main = {"sw": None, "draft": False, "help": True, "text": ["저장", "내역 초기화"]} + assert out["a"] == [view, link, main] # 초안 단추 없음 = 줄 없음 + draft = {"sw": None, "draft": True, "help": False, "text": ["인쇄 미리보기"]} + assert out["b"] == [view, link, draft, main] # 초안 단추 = [저장] 줄 위 + + +def test_스위치_기본_팝업_누름_pref(): + out = run( + [COMMON, TABS, GROUPS, DOCK], + DOCK_JS + "\nconst dock = createDock([save, reset], () => {});" + "\nconst row = dock.children[1];" + "\nconst pressed = () => row.children.map((b) => b.attrs['aria-pressed']);" + "\nconst a = [linkMode(), pressed()];" + "\nrow.children[0].click();" + "\nconst b = [linkMode(), pressed(), kept.get('b09-link-mode')];" + "\nrow.children[1].click();" + "\nconst c = [linkMode(), pressed(), kept.get('b09-link-mode')];" + "\nkept.set('b09-link-mode', '\"x\"');" + "\nconsole.log(JSON.stringify({ a, b, c, odd: linkMode() }));", + ) + assert out["a"] == ["popup", ["false", "true"]] # 기본 = 팝업 + assert out["b"] == ["link", ["true", "false"], '"link"'] + assert out["c"] == ["popup", ["false", "true"], '"popup"'] + assert out["odd"] == "popup" # 모르는 값 = 팝업 + + +def test_DRAFT_actions_자리(): + tabs = (B09 / "B09_Estimation_UI_Tabs.ts").read_text("utf-8") + assert "actions: Array<() => HTMLElement>;" in tabs + assert "{ builders: {}, extend: [], panels: [], actions: [] }" in tabs + state = (ROOT / "A00_Common/b_page_state.ts").read_text("utf-8") + assert '"b09-link-mode": { bucket: "pref", scope: "global" },' in state + css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") + m = re.search(r"\.b09-dock\.ui-sidebar-actions\s*\{([^}]*)\}", css) + assert m and "flex-direction: column" in m.group(1) # 줄 여럿 세로 + + +# 표 짜기 — 탭 짜기 18 개 대신 가짜(줄 하나) · 옵션 = 표 그대로 넘김 +FAKE_BUILDS = "".join( + f"\nconst {name} = () => ({{ columns: [], rows: [{{ key: '{name}' }}] }});" + for name in re.findall( + r":\s*(build\w+),", + (ROOT / BUILDERS).read_text("utf-8"), + ) +) + + +def test_Builders_화면_같은_옵션(): + out = run( + [TABS, BUILDERS], + "\nDRAFT.builders.draft_x = () => '안내';" + "\nconst spec = { columns: [{ key: 'a', label: 'A' }], rows: [{ key: 'r' }], frozen: 2," + " editable: () => true, onEdit: () => {}, rowClass: () => 'x', cellClass: () => 'y'," + " level: { key: 'a', of: () => 1 }, mergeSame: ['a'], link: () => null, menu: () => [] };" + "\nconst o = tableOptionsOf(spec, 'bill');" + "\nconst plain = tableOptionsOf({ columns: [], rows: [], editOnDouble: false });" + "\nconsole.log(JSON.stringify({ keys: Object.keys(o).sort(), caption: o.caption," + " same: ['columns', 'rows', 'editable', 'onEdit', 'rowClass', 'cellClass', 'level', 'mergeSame']" + ".every((k) => o[k] === spec[k]), frozen: o.frozen, total: o.total, empty: o.empty," + " dbl: [o.editOnDouble, plain.editOnDouble], key: [o.keyEdit, plain.keyEdit]," + " plainCaption: plain.caption ?? null, plainFrozen: plain.frozen," + " bill: tabTableOf('bill', {}).rows[0].key, draft: tabTableOf('draft_x', {})," + " none: tabTableOf('nope', {}) }));", + FAKE_BUILDS, + ) + assert out["keys"] == sorted( + [ + "columns", + "rows", + "total", + "frozen", + "empty", + "caption", + "editable", + "editOnDouble", + "keyEdit", + "onEdit", + "rowClass", + "cellClass", + "level", + "mergeSame", + ] + ) # 누름 · 메뉴 · 풍선 없음(그리는 쪽 몫) + assert out["caption"] == "설계내역서" and out["same"] is True + assert out["frozen"] == 2 and out["total"] is None and out["empty"] == "" + assert out["dbl"] == [True, False] # 243-1 기본 = 더블클릭 + assert out["key"] == [False, True] # 설계내역서(메뉴) Enter · F2 = 레벨 키 몫 + assert out["plainCaption"] is None and out["plainFrozen"] == 0 + assert out["bill"] == "buildBill" and out["draft"] == "안내" and out["none"] is None + + +def test_화면이_Builders_옵션을_씀(): + shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "...tableOptionsOf(spec, tab)," in shell + assert "const spec = tabTableOf(tab, ctx);" in shell + assert "BUILDERS" not in shell + + +# ── 303-2 팝업 창 ────────────────────────────────────────────────────────── +POPUP = "B09_Estimation/B09_Estimation_UI_Popup.ts" + + +def _fn(path: str, name: str) -> str: + """파일에서 함수 하나 꺼냄(맨 앞 `export` 뗌) — 이어 붙이면 이름이 부딪히는 파일 대신.""" + src = (ROOT / path).read_text("utf-8") + m = re.search(rf"export function {name}\(.*?\n}}\n", src, re.S) + assert m, name + return m.group(0).replace("export ", "", 1) + + +POPUP_PRELUDE = ( + FAKE_BUILDS + + "\nconst GAP = 6;\n" + + _fn("ui_template/ui_template_hover_info.ts", "placeHoverInfo") + + _fn("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts", "subtreeEnd") + + r""" +E.prototype.remove = function () { + if (this.parent) this.parent.children = this.parent.children.filter((c) => c !== this); + this.parent = null; +}; +E.prototype.getBoundingClientRect = function () { + return this.rect ?? { left: 0, top: 0, right: 0, bottom: 0, width: 400, height: 300 }; +}; +document.body = new E("body"); +const winKeys = []; +const hashFns = []; +globalThis.window = { innerWidth: 1200, innerHeight: 800, + addEventListener: (t, f) => (t === "keydown" ? winKeys : hashFns).push(f) }; +const esc = () => { + const ev = { key: "Escape", stopped: false, preventDefault() {}, stopPropagation() { ev.stopped = true; } }; + winKeys.forEach((f) => f(ev)); + return ev.stopped; +}; +const tables = []; +const createDataTable = (o) => { + const root = document.createElement("div"); + o.rows.forEach((r, i) => { + const tr = document.createElement("tr"); + tr.dataset.i = String(i); + tr.rect = { left: 40, top: 300 + i * 28, right: 900, bottom: 328 + i * 28 }; + root.append(tr); + }); + tables.push(o); + return { root }; +}; +""" +) + +SCENE = r""" +const col = [{ key: "name", label: "명칭", group: ["머리"] }]; +const specs = { + bill: { columns: col, menu: () => [], level: { key: "name", of: (i) => [1, 2, 2, 1][i] }, + rows: [{ key: "b1", level: 1 }, { key: "b2", level: 2 }, { key: "b3", level: 2 }, { key: "b4", level: 1 }], + rowClass: (i) => (i === 0 ? "is-title" : null), + editable: (i, k) => k === "qty", + link: (i) => ({ tab: "labor", key: "L1" }) }, + labor: { columns: col, rows: [{ key: "L0" }, { key: "L1" }, { key: "L2" }], + link: (i) => (i === 1 ? { tab: "dg", key: "D1|1" } : null) }, + dg: { columns: col, rows: [{ key: "D1" }, { key: "D1|1" }, { key: "D1|합계" }, { key: "D2" }] }, +}; +const gone = []; +const base = { specOf: (t) => specs[t] ?? null, go: (l) => gone.push(l) }; +const anchor = document.createElement("tr"); +anchor.rect = { left: 40, top: 72, right: 900, bottom: 100 }; +const boxes = () => document.body.children.map((b) => b.dataset.b09Popup); +const rows = (k) => tables[k].rows.map((r) => r.key); +const cls = (k) => tables[k].rows.map((_, i) => tables[k].rowClass(i)); +""" + + +def _popup(body: str): + return run([TABS, GROUPS, BUILDERS, POPUP], SCENE + body, POPUP_PRELUDE) + + +def test_강조_범위_줄_수(): + out = _popup( + "\nconsole.log(JSON.stringify([" + "popupRange('bill', specs.bill, 'b1'), popupRange('bill', specs.bill, 'b4')," + "popupRange('labor', specs.labor, 'L1'), popupRange('dg', specs.dg, 'D1|1')," + "popupRange('labor', specs.labor, 'X')]));" + ) + # 설계내역서 = 그 줄 + 자손 · 그 밖 = 한 줄 · 묶음 = 호표 구간 · 없음 = null + assert out == [[0, 2], [3, 3], [1, 1], [0, 2], None] + + +def test_창_열_제목_강조_줄_자리(): + out = _popup( + "\nopenRowPopup({ ...base, link: { tab: 'bill', key: 'b1' }, anchor });" + "\nconst box = document.body.children[0];" + "\nconst o = tables[0];" + "\nconsole.log(JSON.stringify({ boxes: boxes(), n: popupCount(), cls: box.className," + " head: box.children[0].children.map((e) => e.textContent), rows: rows(0), rowCls: cls(0)," + " columns: o.columns === specs.bill.columns, caption: o.caption, total: o.total," + " editable: o.editable ?? null, lv: [0, 1, 2].map((i) => o.level.of(i))," + " table: box.children[1].dataset.b09Table, left: box.style.left, top: box.style.top }));" + ) + assert out["boxes"] == ["bill"] and out["n"] == 1 + assert out["cls"] == "b09-estimation b09-popup" # 창 안 = b09 표 꼴(빨강 테두리) + assert out["head"] == ["설계내역서", "탭으로", "×"] + assert out["rows"] == ["b1", "b2", "b3"] # 그 줄 + 자손 + assert out["rowCls"] == ["is-title is-pick is-pick-top", "is-pick", "is-pick is-pick-end"] + assert out["columns"] is True and out["caption"] == "설계내역서" # 열 제목(머리 여러 줄) 그대로 + assert out["total"] is None and out["editable"] is None # 합계 · 고칠 칸 없음 + assert out["lv"] == [1, 2, 2] and out["table"] == "bill" + assert out["left"] == "40px" and out["top"] == "106px" # 누른 줄 아래(간격 6) + + +def test_이어진_창_겹침_Esc_차례(): + out = _popup( + "\nopenRowPopup({ ...base, link: { tab: 'bill', key: 'b1' }, anchor });" + "\ntables[0].onCellClick(1, 'qty');" # 고칠 칸 = 안 엶 + "\nconst a = boxes();" + "\ntables[0].onCellClick(1, 'name');" # 창 안 링크 줄 = 이어진 창 + "\nconst second = document.body.children[1];" + "\ntables[1].onCellClick(0, 'name');" # 창 안 줄 0 = 원래 줄 1(L1) → dg + "\nconst b = boxes();" + "\nconst dg = rows(2);" + "\nconst e1 = esc(); const c = boxes();" + "\nconst e2 = esc(); const e3 = esc(); const e4 = esc();" + "\nconsole.log(JSON.stringify({ a, b, dg, top2: second.style.top, c, e: [e1, e2, e3, e4]," + " d: boxes(), gone }));" + ) + assert out["a"] == ["bill"] + assert out["b"] == ["bill", "labor", "dg"] # 겹쳐 위에(뒤 DOM = 위) + assert out["dg"] == ["D1", "D1|1", "D1|합계"] # 호표 구간 + assert out["top2"] == "362px" # 그 줄(창 안 2번째 줄) 곁 + assert out["c"] == ["bill", "labor"] # Esc = 맨 위 하나 + assert out["e"] == [True, True, True, False] # 창 없으면 Esc 안 가로챔 + assert out["d"] == [] and out["gone"] == [] + + +def test_닫기_탭으로_새_누름_주소(): + out = _popup( + "\nconst open = (key = 'b1') => openRowPopup({ ...base, link: { tab: 'bill', key }, anchor });" + "\nopen(); tables[0].onCellClick(0, 'name');" + "\nconst head = (k) => document.body.children[k].children[0].children;" + "\nhead(0)[2].click();" # 아래 창 × = 그 창 + 위 창 + "\nconst a = boxes();" + "\nopen(); tables[2].onCellClick(0, 'name'); open('b4');" # 화면 새 누름 = 있던 창 모두 닫고 새로 + "\nconst b = [boxes(), rows(4)];" + "\nhead(0)[1].click();" # [탭으로] = 옮기고 모두 닫음 + "\nconst c = [boxes(), [...gone]];" + "\nopen(); hashFns.forEach((f) => f());" + "\nconst d = boxes();" + "\nopenRowPopup({ ...base, link: { tab: 'labor', key: 'X' }, anchor });" # 보일 줄 없음 = 옮김 + "\nconsole.log(JSON.stringify({ a, b, c, d, e: [boxes(), gone.length] }));" + ) + assert out["a"] == [] + assert out["b"] == [["bill"], ["b4"]] + assert out["c"] == [[], [{"tab": "bill", "key": "b4"}]] + assert out["d"] == [] # 주소 바뀜 = 모두 닫음 + assert out["e"] == [[], 2] + + +def test_창_자리_화면_안(): + out = _popup( + "\nconst v = { width: 1200, height: 800 };" + "\nconsole.log(JSON.stringify([" + "popupPlace({ left: 40, top: 700, right: 900, bottom: 728 }, { width: 400, height: 300 }, v)," + "popupPlace({ left: 1000, top: 100, right: 1190, bottom: 128 }, { width: 400, height: 300 }, v)," + "popupPlace({ left: 40, top: 400, right: 900, bottom: 428 }, { width: 400, height: 700 }, v)]));" + ) + assert out[0] == {"x": 40, "y": 394} # 아래 모자람 = 위 + assert out[1] == {"x": 794, "y": 134} # 오른쪽 화면 안 + assert out[2] == {"x": 40, "y": 92} # 위아래 다 모자람 = 화면 안으로 밀어 넣음 + + +def test_화면_잇기_모드별_누름_길(): + shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert ( + 'if (linkMode() === "link") return show(link.tab, link.key);' in shell + ) # 하이퍼링크 그대로 + assert "openRowPopup({" in shell and "go: (to) => show(to.tab, to.key)," in shell + assert "closePopups(); // 303-2 탭 바꿈" in shell # 탭 바꿈 = 모두 닫음 + assert 'goto(spec.link(i, key), made.root.querySelector(`tr[data-i="${i}"]`));' in shell + assert "if (spec.link && !spec.editable?.(i, key))" in shell # 고칠 칸 = 안 옮김 + assert ( + "createCheckPanel(refresh, (link) => show(link.tab, link.key));" in shell + ) # 연결 없음 = 옮김 + groups = (B09 / "B09_Estimation_UI_Tabs_Groups.ts").read_text("utf-8") + assert 'cards.querySelector(`[data-at="${i}"]`)' in groups # 박스 카드 누름도 같은 길 + popup = (ROOT / POPUP).read_text("utf-8") + assert "...tableOptionsOf(spec, tab)," in popup # 화면과 같은 표 짜기 + css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") + m = re.search(r"\.b09-estimation\.b09-popup\s*\{([^}]*)\}", css) + assert m and "max-width: 90vw" in m.group(1) and "position: fixed" in m.group(1) diff --git a/resources/tester/test_304_b09_view_setting.py b/resources/tester/test_304_b09_view_setting.py new file mode 100644 index 000000000..5f29ce13b --- /dev/null +++ b/resources/tester/test_304_b09_view_setting.py @@ -0,0 +1,103 @@ +"""304-1 B09 박스보기 · 호표보기 = 좌측 하단 한 설정(묶음 탭 셋 함께). + +- 한 값(`b09-group-view` · pref global)이 dg · machine_blocks · sg 셋에 함께 · 기본 = 박스(카드) +- 옛 탭별 값(`{탭: 보기}`) = 첫 읽기에 한 값으로(dg · 없으면 다른 탭 · 없으면 기본) 옮겨 적음 +- 보조 탭 줄 보기 토글 걷음 · 가로 열 수 단추(박스 보기)는 그대로 +- 좌측 하단 스위치 [박스보기|호표보기] 누름 = 값 바꿈 · 다시 그림 부름 +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402 + +LABOR = "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts" +DOCK = "B09_Estimation/B09_Estimation_UI_Dock.ts" +DG = {"id": "DG1", "no": 1, "name": "찰쌓기", "unit": "m2", "lines": [], "sum": 0} +SPEC = ( + "\nconst spec = buildDaegaBlocks({ sheets: { daega_blocks: [" + + json.dumps(DG, ensure_ascii=False) + + "] } });" + "\nconst tools = [];" + "\nconst draw = (tab) => renderGroupPanel({ tab, spec, hint: null, goto: () => {}," + " setTools: (t) => tools.push(...t)," + " makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) })[0];" + "\nconst kinds = () => ['dg', 'machine_blocks', 'sg'].map((t) => draw(t).tagName);" +) + + +def test_한_설정_셋_탭_함께(): + out = run( + [COMMON, TABS, LABOR, GROUPS], + SPEC + "\nconst a = kinds();" + "\nchooseGroupView('table');" + "\nconst b = kinds();" + "\nconsole.log(JSON.stringify({ a, b, kept: Object.fromEntries(kept)," + " toggles: tools.filter((e) => e.dataset.b09GroupView).length," + " cols: tools.flatMap((e) => e.all('[data-group-cols]')).length }));", + RAW, + ) + assert out["a"] == ["DIV"] * 3 # 기본 = 박스 + assert out["b"] == ["TABLE"] * 3 # 한 값이 셋 탭 함께 + assert json.loads(out["kept"]["b09-group-view"]) == "table" + assert out["toggles"] == 0 # 탭 줄 보기 토글 없음 + assert out["cols"] == 4 * 3 # 박스 보기 가로 열 수 단추 = 탭 줄 그대로 + + +def test_옛_탭별_값_첫_읽기에_한_값으로(): + cases = [ + ({"dg": "table", "sg": "cards"}, "table"), + ({"dg": "cards", "sg": "table"}, "cards"), # dg 먼저 + ({"sg": "table"}, "table"), # dg 없으면 다른 탭 + ({"machine_blocks": "table", "sg": "cards"}, "table"), + ({}, "cards"), # 없으면 기본 + ] + for old, want in cases: + out = run( + [COMMON, TABS, LABOR, GROUPS], + "\nkept.set('b09-group-view', " + + json.dumps(json.dumps(old)) + + ");" + + SPEC + + "\nconst got = kinds();" + "\nconsole.log(JSON.stringify({ got, kept: Object.fromEntries(kept) }));", + RAW, + ) + assert out["got"] == ["TABLE" if want == "table" else "DIV"] * 3, old + assert json.loads(out["kept"]["b09-group-view"]) == want, old # 옮겨 적음 + + +def test_좌측_스위치_누름(): + out = run( + [COMMON, TABS, GROUPS, DOCK], + "\nlet redraws = 0;" + "\nconst dock = createDock([document.createElement('button')], () => redraws++);" + "\nconst row = dock.children[0];" + "\nconst pressed = () => row.children.map((b) => b.attrs['aria-pressed']);" + "\nconst labels = row.children.map((b) => b.textContent);" + "\nconst a = pressed();" + "\nrow.children[0].click();" # 이미 박스 = 그대로 + "\nconst b = [redraws, pressed()];" + "\nrow.children[1].click();" + "\nconsole.log(JSON.stringify({ name: row.dataset.b09Switch, labels, a, b, c: [redraws, pressed()]," + " view: groupView(), kept: Object.fromEntries(kept) }));", + ) + assert out["name"] == "view" and out["labels"] == ["박스보기", "호표보기"] + assert out["a"] == ["true", "false"] # 기본 = 박스보기 + assert out["b"] == [0, ["true", "false"]] + assert out["c"] == [1, ["false", "true"]] and out["view"] == "table" + assert json.loads(out["kept"]["b09-group-view"]) == "table" # [저장] 없이 pref + + +def test_잇기(): + groups = (ROOT / GROUPS).read_text("utf-8") + assert "toggle" not in groups and "redraw" not in groups and "createButton" not in groups + shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "createDock([save, reset], () => isGroupTab(current) && show(current))" in shell + state = (ROOT / "A00_Common/b_page_state.ts").read_text("utf-8") + assert '"b09-group-view": { bucket: "pref", scope: "global" },' in state