Files
Aislo/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Cards.ts
T
eomsangdonandClaude Opus 5.5 d02c1d3f08 feat(b09): 산근 탭 보기 둘(표 · 호표별 카드) · 규격 칸 기계 · 직종 「이름 규격」(157-2 · 157-3)
- 157-2 탭 머리 단추 = 「표로 보기」 ↔ 「호표별 보기」 · 고른 보기 localStorage 기억(못 읽으면 표)
- 호표별 카드 = 표 줄 그대로 호표마다 묶음 · 머리 산근 n호표 · 공종 · 단위 · 합계 · 가름줄 없이 간격 · 긴 글 줄바꿈 · 줄 누름 이동 같음
- 157-3 산근 규격 글 = 기계 · 직종 원문번호 → 「이름 규격」(M02 85-1 도우미) · 원문번호는 풍선(두 보기 · 목록표)
- 시험 test_157_2_b09_sangeun_views — 두 보기 같은 값 · 단추 바꿈 · 기억 실패 때 표 · 이름 규격 · 풍선

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-09 21:21:37 +09:00

218 lines
7.5 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Tabs_Sangeun_Cards.ts
* B09 단가산출근거 탭 보기 둘(PLAN 157-2) — 보기 A = 표(`buildSg` 그대로) · 보기 B = 호표별 카드.
*
* 카드 = `buildSg` 가 낸 같은 줄을 호표(줄 키 앞머리)마다 묶어 그림 — 값 · 글은 표와 같음
* 머리 = 산근 n호표 · 공종 · 단위 · 합계 · 몸 = 산출내역 · 합계 · 노 · 재 · 경 · 비고 줄(가름줄 없이 간격)
* 탭 머리 단추 = 보기 바꿈 · 고른 보기 = localStorage(`b09-sg-view`) · 못 읽으면 표
* 줄 누름 = 표와 같은 이동(`spec.link`) · 이동해 온 줄 = 스크롤 + 강조
* ========================================================================== */
import type { DataRow, DataTable } from "@ui/ui_template_data_table";
import { formatCell } from "@ui/ui_template_data_table";
import { createButton, createCard } from "@ui/ui_template_elements";
import { rowIndex, type Link, type TabTable } from "./B09_Estimation_UI_Tabs";
export type SgView = "table" | "cards";
export const SG_VIEW_KEY = "b09-sg-view";
/** 단추 글 = 바꿀 보기 이름 */
export const sgViewLabel = (view: SgView): string =>
view === "cards" ? "표로 보기" : "호표별 보기";
export const flipSgView = (view: SgView): SgView => (view === "cards" ? "table" : "cards");
type Store = Pick<Storage, "getItem" | "setItem">;
function localStore(): Store | null {
try {
return localStorage;
} catch {
return null;
}
}
/** 고른 보기 — 못 읽거나 모르는 값 = 표 */
export function readSgView(store: Store | null = localStore()): SgView {
try {
return store?.getItem(SG_VIEW_KEY) === "cards" ? "cards" : "table";
} catch {
return "table";
}
}
export function writeSgView(view: SgView, store: Store | null = localStore()): void {
try {
store?.setItem(SG_VIEW_KEY, view);
} catch {
// 기억 못 해도 이 화면 안에서는 바뀐 보기 그대로
}
}
/** 카드 하나 = 호표 한 블록 — 줄 번호(`at`)는 표 줄 번호(이동 · 강조) */
export interface SgCard {
key: string;
at: number;
head: DataRow;
lines: Array<{ at: number; row: DataRow }>;
}
/** 표 줄 → 호표 카드(줄 키 `호표키|…` 앞머리로 묶음 · 줄은 그 객체 그대로) */
export function sgCards(rows: DataRow[]): SgCard[] {
const cards: SgCard[] = [];
const byKey = new Map<string, SgCard>();
rows.forEach((row, at) => {
const key = String(row.key ?? "").split("|")[0];
const card = byKey.get(key);
if (card) return void card.lines.push({ at, row });
const made: SgCard = { key, at, head: row, lines: [] };
byKey.set(key, made);
cards.push(made);
});
return cards;
}
/* ── 그리기 ───────────────────────────────────────────────────────────── */
const MONEY = ["sum", "labor", "material", "expense"] as const;
const LABEL: Record<string, string> = {
kind: "공종",
text: "산출내역",
sum: "합계",
labor: "노무비",
material: "재료비",
expense: "경비",
note: "비고 · 까닭",
};
/** 표 열과 같은 숫자 꼴(열 소수 자리) */
function formats(spec: TabTable): Record<string, Intl.NumberFormat> {
return Object.fromEntries(
spec.columns
.filter((c) => c.kind === "number")
.map((c) => [
c.key,
new Intl.NumberFormat("ko-KR", {
minimumFractionDigits: c.trim ? 0 : (c.decimals ?? 0),
maximumFractionDigits: c.decimals ?? 0,
}),
]),
);
}
function span(className: string, value: string): HTMLSpanElement {
const s = document.createElement("span");
s.className = className;
s.textContent = value;
return s;
}
function line(
row: DataRow,
fmt: Record<string, Intl.NumberFormat>,
className: string,
tip?: string | null,
): HTMLElement {
const div = document.createElement("div");
div.className = className;
div.append(
span("b09-sg-card__kind", String(row.kind ?? "")),
span("b09-sg-card__text", String(row.text ?? "").trimStart()),
...MONEY.map((k) => span("b09-sg-card__num", formatCell(row[k], fmt[k] ?? null, "")[0])),
span("b09-sg-card__note", String(row.note ?? "")),
);
if (tip) (div.lastElementChild as HTMLElement).title = tip;
return div;
}
function cardsView(
spec: TabTable,
goto: (link: Link | null) => void,
focus: string | undefined,
): HTMLElement {
const fmt = formats(spec);
const host = document.createElement("div");
host.className = "b09-sg-cards";
host.dataset.b09Table = "sg";
const tip = (at: number) => spec.tip?.(at, "note");
const clickable = (el: HTMLElement, at: number): void => {
el.dataset.i = String(at);
if (spec.link?.(at, "kind")) el.classList.add("is-clickable");
};
for (const c of sgCards(spec.rows)) {
const head = document.createElement("div");
head.className = "b09-sg-card__head";
const no = String(c.head.kind ?? "").replace(/^제\s*(\d+)\s*호표$/, "산근 $1호표");
head.append(
span("b09-sg-card__no", no),
span("b09-sg-card__name", String(c.head.name ?? c.head.text ?? "")),
span("b09-sg-card__unit", String(c.head.unit ?? "")),
span(
"b09-sg-card__total",
`합계 ${formatCell(c.lines[c.lines.length - 1]?.row.sum, fmt.sum ?? null, "")[0]}`,
),
);
const sub = line({ ...c.head, kind: "" }, fmt, "b09-sg-card__line b09-sg-card__sub", tip(c.at));
const cols = line(LABEL, {}, "b09-sg-card__line b09-sg-card__cols");
const lines = c.lines.map(({ at, row }) => {
const cls = spec.rowClass?.(at) ? " is-title" : "";
const el = line(row, fmt, `b09-sg-card__line${cls}`, tip(at));
clickable(el, at);
return el;
});
const card = createCard({ body: [head, cols, sub, ...lines] });
card.classList.add("b09-sg-card");
clickable(card, c.at);
host.append(card);
}
host.addEventListener("click", (event) => {
const el = (event.target as Element).closest<HTMLElement>("[data-i]");
if (el && host.contains(el)) goto(spec.link?.(Number(el.dataset.i), "kind") ?? null);
});
const at = focus === undefined ? -1 : rowIndex(spec.rows, focus);
if (at >= 0)
requestAnimationFrame(() => {
const el = host.querySelector<HTMLElement>(`[data-i="${at}"]`);
el?.classList.add("is-focus");
el?.scrollIntoView({ block: "center" });
});
return host;
}
/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */
let chosen: SgView | null = null;
/** 산근 탭 — 머리(안내 + 보기 단추) · 고른 보기(표 · 카드) */
export function renderSgPanel(o: {
spec: TabTable;
hint: HTMLElement | null;
focus?: string;
goto: (link: Link | null) => void;
makeTable: () => DataTable;
redraw: () => void;
}): HTMLElement[] {
const view = chosen ?? readSgView();
const toggle = createButton({
label: sgViewLabel(view),
variant: "ghost",
size: "sm",
onClick: () => {
chosen = flipSgView(view);
writeSgView(chosen);
o.redraw();
},
});
toggle.dataset.b09SgView = view;
const bar = document.createElement("div");
bar.className = "b09-estimation__bar";
bar.append(...(o.hint ? [o.hint] : []), toggle);
if (view === "cards") return [bar, cardsView(o.spec, o.goto, o.focus)];
const table = o.makeTable();
if (o.focus !== undefined) {
const index = rowIndex(o.spec.rows, o.focus);
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
}
return [bar, table.root];
}