Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-10-13 08:05:54 +09:00
19 changed files with 880 additions and 94 deletions
+3 -1
View File
@@ -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 에 안 둠 */
+106
View File
@@ -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<LinkMode, string> = { link: "하이퍼링크", popup: "팝업" };
/** 행 누름 길 — 모르는 값 · 못 읽음 = 팝업 */
export function linkMode(): LinkMode {
try {
return readState<unknown>("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<T extends string>(
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<LinkMode>(
"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;
}
+54
View File
@@ -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;
}
+175
View File
@@ -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`;
}
+21 -9
View File
@@ -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);
+4 -2
View File
@@ -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<string, TabBuild>;
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"] => ({
+40 -38
View File
@@ -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<string, unknown> | 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<string, unknown>) : 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<TabId, GroupView>();
/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 보기 단추 · 열 수 = `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)));
@@ -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_화면_잇기():
+14 -15
View File
@@ -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_잇기():
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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 하단 고정 줄 맨 아래 줄
+8 -8
View File
@@ -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 탭 줄 토글 걷음
@@ -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: () => {} }) };"
@@ -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 # 판 + 안내 줄(맨 아래)
@@ -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
@@ -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 = {};"
@@ -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
+329
View File
@@ -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)
@@ -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