Files
Aislo/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts
T
eomsangdonandClaude Opus 5.5 04659a028f feat(b09): 운반 단가 창 왼쪽 폭 줄임 · 단가산출근거 박스보기 머리 줄 = 입력값 · 중간값 칸 (278-1·2)
- 운반 단가 창 몸 두 칸 비율 — 왼쪽(옵션 값) 약 0.8 배
- 단가산출근거 [박스보기] 머리 줄 = 운반 창 ③ 칸과 같은 꼴(이름 · 값 오른쪽 굵게 · 식 작게) · 출처 = 칸 맨 위
- 줄 나눔 · 그리기 한 벌 — Haul_Steps headSteps 내보내 같이 씀 · 공용 묶음 카드 = 선택 몸 위 칸(top)
- 호표 줄 · 총계 · 표 보기 · 다른 탭 카드 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-12 18:44:11 +09:00

317 lines
13 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Tabs_Groups.ts
* B09 묶음 탭 셋(PLAN 171-3) — 일위대가표 · 중기사용료 · 단가산출근거 = 공용 묶음 카드(171-2) 기본 ·
* [표로 보기] = 공용 표 그대로(맞은편 단추 = [박스보기]).
*
* 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음
* 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로)
* 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선
* 248-1 빨강 하나(공용 고름 강조 안 붙임 · 스크롤만) · 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 ·
* 누른 줄만 고름(is-focus) · Esc = 고름도 풂
* 보기 · 가로 열 수 기억 = `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,
createGroupCards,
unpickCards,
type GroupCard,
type GroupFoot,
type GroupLine,
type GroupParts,
} from "@ui/ui_template_group_cards";
import { readState, writeState } from "../A00_Common/b_page_state";
import { headSteps } from "./B09_Estimation_UI_Haul_Steps";
import { rowIndex, type Link, type TabId, type TabTable } from "./B09_Estimation_UI_Tabs";
export type GroupView = "cards" | "table";
/** 묶음 카드로 보이는 탭 */
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");
type Saved = Record<string, unknown> | null;
const saved = (name: "b09-group-view" | "b09-group-cols"): Saved => {
try {
return readState<Record<string, unknown>>(name);
} catch {
return null;
}
};
/** 고른 보기 — 모르는 값 · 못 읽음 = 카드 */
export const readGroupView = (tab: TabId, kept: Saved = saved("b09-group-view")): GroupView =>
kept?.[tab] === "table" ? "table" : "cards";
/** 고른 가로 열 수 — 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 {
try {
writeState(name, { ...(saved(name) ?? {}), [tab]: value });
} catch {
// 기억 못 해도 이 화면 안에서는 고른 값 그대로
}
}
/* ── 표 줄 → 묶음 ─────────────────────────────────────────────────────── */
const num = (v: unknown): number | null => (typeof v === "number" ? v : null);
const str = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
const parts = (r: DataRow): GroupParts => [num(r.labor), num(r.material), num(r.expense)];
/** 들여쓰기 · 「ㄴ」 뗀 이름 */
const bare = (v: unknown): string => str(v).replace(/^\s*(ㄴ\s+)?/, "");
const isFoot = (key: string): boolean => /\|(합계|총계)$/.test(key);
/** 탭마다 머리 · 몸 줄 칸 */
function headOf(tab: TabId, r: DataRow, at: number, spec: TabTable): Omit<GroupCard, "lines"> {
const tip = (key: string) => spec.tip?.(at, key) ?? null;
if (tab === "sg")
return {
key: str(r.key),
at,
no: str(r.kind),
name: str(r.name ?? r.text),
spec: str(r.note),
specTip: tip("note"),
unit: str(r.unit),
};
return {
key: str(r.key),
at,
no: `제 ${str(r.no)} 호표`,
name: str(r.title ?? r.name),
spec: str(r.spec),
specTip: tip("spec"),
unit: str(r.unit),
};
}
function lineOf(tab: TabId, r: DataRow, at: number, spec: TabTable, first: string): GroupLine {
const tip = (key: string) => spec.tip?.(at, key) ?? null;
const base = {
at,
sum: num(r.sum),
parts: parts(r),
note: str(r.note),
noteTip: tip("note"),
link: !!spec.link?.(at, first),
};
if (tab === "sg") return { ...base, name: bare(r.text), tag: str(r.kind) };
return {
...base,
name: bare(r.name),
spec: str(r.spec),
specTip: tip("spec"),
qty: num(r.qty),
unit: str(r.unit),
};
}
const footOf = (r: DataRow, at: number, spec: TabTable): GroupFoot => ({
at,
parts: parts(r),
sum: num(r.sum),
note: str(r.note),
tip: spec.tip?.(at, "sum") ?? spec.tip?.(at, "note") ?? null,
});
/** 탭 표 → 묶음 카드(줄 차례 · 표 줄 번호 그대로) */
export function tabGroups(tab: TabId, spec: TabTable): GroupCard[] {
const first = spec.columns[0]?.key ?? "";
const cards: GroupCard[] = [];
const byKey = new Map<string, GroupCard>();
spec.rows.forEach((r, at) => {
const key = str(r.key);
const group = byKey.get(key.split("|")[0]);
if (!group) {
const made: GroupCard = { ...headOf(tab, r, at, spec), lines: [] };
byKey.set(key.split("|")[0], made);
return void cards.push(made);
}
if (isFoot(key)) group.foot = footOf(r, at, spec);
else group.lines.push(lineOf(tab, r, at, spec, first));
});
for (const c of cards) c.sum = c.foot?.sum ?? null;
return cards;
}
const HEAD_LINE = /\|머리\d+$/;
/** 278-2 단가산출근거 박스 — 머리 줄을 몸 줄에서 빼 입력값 · 중간값 칸(`top`)으로 · 줄 번호 그대로 */
export function sgHeadCards(cards: GroupCard[], rows: DataRow[]): GroupCard[] {
return cards.map((c) => {
const heads = c.lines.filter((l) => HEAD_LINE.test(str(rows[l.at]?.key)));
if (!heads.length) return c;
const source = str(rows[c.at]?.source);
const top = document.createElement("div");
top.className = "b09-sgsteps";
if (source) {
const cite = document.createElement("div");
cite.className = "b09-haulstep__group";
cite.textContent = source;
top.append(cite);
}
const head = heads.map((l) => str(rows[l.at].text));
top.append(
...headSteps(
{ name: c.name, source, head },
heads.map((l) => l.at),
),
);
return { ...c, top, lines: c.lines.filter((l) => !heads.includes(l)) };
});
}
/** 고른 호표의 표 줄 구간 [첫 줄, 끝 줄](키 `|` 앞머리가 같은 줄) · 없으면 null */
export function pickRange(rows: DataRow[], focus: string | undefined): [number, number] | null {
if (focus === undefined) return null;
const head = focus.split("|")[0];
const at = rows.flatMap((r, i) => (str(r.key).split("|")[0] === head ? [i] : []));
return at.length ? [at[0], at[at.length - 1]] : null;
}
/** 표 줄 class — 구간 안 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end` */
export function pickClass(range: [number, number] | null, i: number): string | null {
if (!range || i < range[0] || i > range[1]) return null;
return ["is-pick", i === range[0] && "is-pick-top", i === range[1] && "is-pick-end"]
.filter(Boolean)
.join(" ");
}
/** 표 열과 같은 숫자 꼴(열 소수 자리) */
function numberFormat(spec: TabTable, key: string): (v: number | null | undefined) => string {
const c = spec.columns.find((col) => col.key === key);
const f = new Intl.NumberFormat("ko-KR", {
minimumFractionDigits: c?.trim ? 0 : (c?.decimals ?? 0),
maximumFractionDigits: c?.decimals ?? 0,
});
return (v) => (v === null || v === undefined || !Number.isFinite(v) ? "" : f.format(v));
}
/* ── 그리기 ───────────────────────────────────────────────────────────── */
/** 안내 글 = 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */
export function lineHint(hint: HTMLElement | null): HTMLElement | null {
if (!hint || hint.classList.contains("b09-estimation__line-hint")) return hint;
hint.classList.add("b09-estimation__line-hint");
attachHoverInfo(hint, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null));
return hint;
}
/** 248-1 지금 그린 묶음 판 빨강 풀기(esc = 고름도) — 방향키 · Esc 가 부름 */
let live: { root: HTMLElement; unpick: (esc: boolean) => void } | null = null;
let keysOn = false;
const UNPICK_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Escape"]);
function listenKeys(): void {
if (keysOn) return;
keysOn = true;
document.addEventListener("keydown", (event) => {
if (!live?.root.isConnected || !UNPICK_KEYS.has(event.key)) return;
if ((event.target as Element | null)?.closest?.("input, textarea, select")) return;
live.unpick(event.key === "Escape");
});
}
/** 257-2 이동해 온 줄 빨강(묶음 아닌 표 · 설계내역서 빼고) — 줄 누름 · 방향키 · Esc 로 풂(다시 그리지 않음) */
export function unpickOnRelease(root: HTMLElement): void {
const clear = (): void => {
for (const tr of root.querySelectorAll("tr.is-pick"))
tr.classList.remove("is-pick", "is-pick-top", "is-pick-end");
};
root.addEventListener("click", clear);
live = { root, unpick: clear };
listenKeys();
}
/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */
const chosen = new Map<TabId, GroupView>();
/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 보기 단추 · 열 수 = `setTools`(보조 탭 줄 오른쪽) */
export function renderGroupPanel(o: {
tab: TabId;
spec: TabTable;
hint: HTMLElement | null;
focus?: string;
goto: (link: Link | 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 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 ?? "";
const cards = createGroupCards({
groups:
o.tab === "sg"
? sgHeadCards(tabGroups(o.tab, o.spec), o.spec.rows)
: 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),
focus: at,
money: numberFormat(o.spec, "sum"),
qty: numberFormat(o.spec, "qty"),
actions: [toggle],
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)));
// 줄 class 만 고침(다시 그리지 않음 — 더블클릭 입력 그대로)
const mark = (row: number): void => {
range = null;
chose = row;
for (const tr of table.root.querySelectorAll("tr.is-pick, tr.is-focus"))
tr.classList.remove("is-pick", "is-pick-top", "is-pick-end", "is-focus");
table.root.querySelector(`tr[data-i="${row}"]`)?.classList.add("is-focus");
};
table.root.addEventListener("click", (event) => {
const tr = (event.target as Element).closest<HTMLElement>("tr[data-i]");
mark(tr ? Number(tr.dataset.i) : chose);
});
live = { root: table.root, unpick: (esc) => mark(esc ? -1 : chose) };
listenKeys();
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
return [table.root, ...(lead ? [lead] : [])];
}