Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
226 lines
8.6 KiB
TypeScript
226 lines
8.6 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Tabs_Groups.ts
|
|
* B09 묶음 탭 셋(PLAN 171-3) — 일위대가표 · 중기사용료 · 단가산출근거 = 공용 묶음 카드(171-2) 기본 ·
|
|
* [표로 보기] = 공용 표 그대로(맞은편 단추 = [박스보기]).
|
|
*
|
|
* 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음
|
|
* 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로)
|
|
* 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선
|
|
* 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2
|
|
* ========================================================================== */
|
|
|
|
import type { DataRow, DataTable } from "@ui/ui_template_data_table";
|
|
import { createButton } from "@ui/ui_template_elements";
|
|
import {
|
|
clampColumns,
|
|
createGroupCards,
|
|
type GroupCard,
|
|
type GroupFoot,
|
|
type GroupLine,
|
|
type GroupParts,
|
|
} from "@ui/ui_template_group_cards";
|
|
import { readState, writeState } from "../A00_Common/b_page_state";
|
|
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;
|
|
}
|
|
|
|
/** 고른 호표의 표 줄 구간 [첫 줄, 끝 줄](키 `|` 앞머리가 같은 줄) · 없으면 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));
|
|
}
|
|
|
|
/* ── 그리기 ───────────────────────────────────────────────────────────── */
|
|
|
|
/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */
|
|
const chosen = new Map<TabId, GroupView>();
|
|
|
|
/** 묶음 탭 — 카드 = 안내 + 카드 판(보기 단추는 가로 열 수 줄) · 표 = 머리(안내 + 보기 단추) + 표 */
|
|
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;
|
|
}): HTMLElement[] {
|
|
const view = chosen.get(o.tab) ?? readGroupView(o.tab);
|
|
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: 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], // 185-2 가로 열 수 단추와 한 줄
|
|
});
|
|
cards.dataset.b09Table = o.tab;
|
|
return [...(o.hint ? [o.hint] : []), cards];
|
|
}
|
|
const bar = document.createElement("div");
|
|
bar.className = "b09-estimation__bar";
|
|
bar.append(...(o.hint ? [o.hint] : []), toggle);
|
|
const range = pickRange(o.spec.rows, o.focus);
|
|
const table = o.makeTable((i) => pickClass(range, i));
|
|
if (at >= 0) requestAnimationFrame(() => table.focusRow(at));
|
|
return [bar, table.root];
|
|
}
|