/* ============================================================================= * ui_template_group_cards.ts * 묶음 카드 공용 양식(PLAN 171-2) — 여러 줄이 한 묶음인 엑셀 꼴 표(호표 블록)를 카드로. * * 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 줄(이름 · 규격 · 수량 · 단위 · 합계 · 비고) / * 합계 줄 = 노무 · 재료 · 경비 · 계 · 줄 금액 = 합계만(비목 셋 = 풍선) * 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽 · 가로 열 수 1~4(기억은 부르는 쪽) * 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기) * 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 펼침 + 스크롤 + 강조 * ========================================================================== */ import "./ui_template_group_cards.css"; import { createButton } from "./ui_template_elements"; /** 비목 셋 — 노무 · 재료 · 경비 */ export type GroupParts = [number | null, number | null, number | null]; export interface GroupLine { at: number; name: string; /** 이름 앞 작은 꼬리표(예 산근 공종 「노무 · 중기」) */ tag?: string; spec?: string; qty?: number | null; unit?: string; sum?: number | null; parts?: GroupParts | null; note?: string; /** 규격 · 비고 칸 풍선(원문번호 · 까닭) */ specTip?: string | null; noteTip?: string | null; /** 누르면 이동하는 줄 */ link?: boolean; } export interface GroupFoot { at: number; parts: GroupParts; sum: number | null; note?: string; tip?: string | null; } export interface GroupCard { key: string; at: number; no: string; name: string; spec?: string; specTip?: string | null; unit?: string; sum?: number | null; lines: GroupLine[]; foot?: GroupFoot | null; } export interface GroupCardsOptions { groups: GroupCard[]; /** 가로 열 수(1~4 · 모르는 값 = 2) */ columns?: unknown; onColumns?: (columns: number) => void; onPick?: (at: number) => void; /** 이동해 온 줄(표 줄 번호) */ focus?: number; money?: (value: number | null | undefined) => string; qty?: (value: number | null | undefined) => string; /** 머리 띠 끝 단추(예 [표로 보기]) */ actions?: HTMLElement[]; } export const GROUP_SUMMARY_OVER = 12; export const GROUP_COLUMNS = [1, 2, 3, 4] as const; export const PART_LABELS = ["노무", "재료", "경비"] as const; /** 가로 열 수 — 1~4 · 모르는 값 = 2 */ export const clampColumns = (value: unknown): number => GROUP_COLUMNS.includes(value as 1) ? (value as number) : 2; /** 요약 보기 — 묶음 12 넘음 */ export const isSummary = (count: number): boolean => count > GROUP_SUMMARY_OVER; const won = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 1 }); const plain = (value: number | null | undefined): string => value === null || value === undefined || !Number.isFinite(value) ? "" : won.format(value); /** 줄 비목 풍선 — 「노무 1,234 · 재료 0 · 경비 56」 · 셋 다 빈칸 = 없음 */ export function partsTip( parts: GroupParts | null | undefined, money: (value: number | null | undefined) => string = plain, ): string | null { if (!parts || parts.every((v) => v === null || v === undefined)) return null; return parts.map((v, i) => `${PART_LABELS[i]} ${money(v) || "-"}`).join(" · "); } function span(className: string, value: string, tip?: string | null): HTMLSpanElement { const s = document.createElement("span"); s.className = className; s.textContent = value; if (tip) s.title = tip; return s; } function cell(tag: "td" | "th", className: string, value: string, tip?: string | null) { const c = document.createElement(tag); c.className = className; c.textContent = value; if (tip) c.title = tip; return c; } const COLS = ["이름", "규격", "수량", "단위", "합계", "비고"]; const NUM_COL = new Set([2, 4]); function linesTable(g: GroupCard, o: Required>) { const table = document.createElement("table"); table.className = "ui-group-card__lines"; const head = document.createElement("tr"); head.append(...COLS.map((c, i) => cell("th", NUM_COL.has(i) ? "is-num" : "", c))); const thead = document.createElement("thead"); thead.append(head); const tbody = document.createElement("tbody"); for (const ln of g.lines) { const tr = document.createElement("tr"); tr.dataset.at = String(ln.at); if (ln.link) tr.classList.add("is-link"); const name = cell("td", "ui-group-card__name", ln.name); if (ln.tag) name.prepend(span("ui-group-card__tag", ln.tag)); tr.append( name, cell("td", "", ln.spec ?? "", ln.specTip), cell("td", "is-num", o.qty(ln.qty)), cell("td", "", ln.unit ?? ""), cell("td", "is-num", o.money(ln.sum), partsTip(ln.parts, o.money)), cell("td", "ui-group-card__note", ln.note ?? "", ln.noteTip), ); tbody.append(tr); } table.append(thead, tbody); return table; } function footRow(f: GroupFoot, money: (v: number | null | undefined) => string): HTMLElement { const foot = document.createElement("div"); foot.className = "ui-group-card__foot"; foot.dataset.at = String(f.at); f.parts.forEach((v, i) => foot.append(span("ui-group-card__part", `${PART_LABELS[i]} ${money(v)}`)), ); foot.append(span("ui-group-card__sum", `계 ${money(f.sum)}`, f.tip)); if (f.note) foot.append(span("ui-group-card__foot-note", f.note, f.tip)); return foot; } function card(g: GroupCard, o: Required>): HTMLElement { const box = document.createElement("article"); box.className = "ui-group-card"; box.dataset.key = g.key; const head = document.createElement("div"); head.className = "ui-group-card__head"; head.dataset.at = String(g.at); head.append( span("ui-group-card__no", g.no), span("ui-group-card__title", g.name), span("ui-group-card__spec", g.spec ?? "", g.specTip), span("ui-group-card__unit", g.unit ?? ""), span("ui-group-card__total", o.money(g.sum ?? g.foot?.sum)), ); const body = document.createElement("div"); body.className = "ui-group-card__body"; if (g.lines.length) body.append(linesTable(g, o)); box.append(head, body); if (g.foot) box.append(footRow(g.foot, o.money)); return box; } /** 묶음 카드 판 — 머리 띠(가로 열 수 · 요약 단추 · 부르는 쪽 단추) + 카드 격자 */ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { const fmt = { money: opts.money ?? plain, qty: opts.qty ?? plain }; const root = document.createElement("div"); root.className = "ui-group-cards"; const grid = document.createElement("div"); grid.className = "ui-group-cards__grid"; let columns = clampColumns(opts.columns); grid.style.setProperty("--group-cols", String(columns)); const cards = opts.groups.map((g) => card(g, fmt)); grid.append(...cards); const summary = isSummary(opts.groups.length); const setOpen = (box: Element, open: boolean) => box.classList.toggle("is-closed", !open); if (summary) cards.forEach((c) => setOpen(c, false)); const bar = document.createElement("div"); bar.className = "ui-group-cards__bar"; const pick = document.createElement("div"); pick.className = "ui-btn-group ui-group-cards__cols"; pick.append(span("ui-group-cards__label", "가로")); const colButtons = GROUP_COLUMNS.map((n) => { const b = createButton({ label: String(n), variant: n === columns ? "filled" : "ghost", size: "sm", onClick: () => { columns = n; grid.style.setProperty("--group-cols", String(n)); colButtons.forEach((x, i) => { x.classList.toggle("ui-btn--filled", GROUP_COLUMNS[i] === n); x.classList.toggle("ui-btn--ghost", GROUP_COLUMNS[i] !== n); x.setAttribute("aria-pressed", String(GROUP_COLUMNS[i] === n)); }); opts.onColumns?.(n); }, }); b.setAttribute("aria-pressed", String(n === columns)); b.dataset.groupCols = String(n); return b; }); pick.append(...colButtons); bar.append(pick, span("ui-group-cards__count", `묶음 ${opts.groups.length}`)); if (summary) { const all = (open: boolean) => () => cards.forEach((c) => setOpen(c, open)); bar.append( span("ui-group-cards__count", "요약 보기 — 머리를 누르면 펼침"), createButton({ label: "모두 펼침", variant: "ghost", size: "sm", onClick: all(true) }), createButton({ label: "모두 접기", variant: "ghost", size: "sm", onClick: all(false) }), ); } const tail = document.createElement("div"); tail.className = "ui-group-cards__actions"; tail.append(...(opts.actions ?? [])); bar.append(tail); root.append(bar, grid); grid.addEventListener("click", (event) => { const el = (event.target as Element).closest("[data-at]"); if (!el || !grid.contains(el)) return; const box = el.closest(".ui-group-card"); if (summary && box && el.classList.contains("ui-group-card__head")) return setOpen(box, box.classList.contains("is-closed")); opts.onPick?.(Number(el.dataset.at)); }); if (opts.focus !== undefined && opts.focus >= 0) { const el = grid.querySelector(`[data-at="${opts.focus}"]`); const box = el?.closest(".ui-group-card"); if (box) setOpen(box, true); el?.classList.add("is-focus"); requestAnimationFrame(() => el?.scrollIntoView({ block: "center" })); } return root; }