- 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
218 lines
7.5 KiB
TypeScript
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];
|
|
}
|