- 171-2 새 공용 ui_template_group_cards(.ts · .css) — 머리(호표 · 명칭 · 규격 · 단위 · 합계) · 몸 줄(이름 · 규격 · 수량 · 단위 · 합계 · 비고) · 합계 줄(노무 · 재료 · 경비 · 계) - 글자 14px 한 벌 · 줄 간격 좁게(공용 카드 16px 끼움 없음) · 숫자 오른쪽 · 줄 금액 = 합계만 + 비목 셋 풍선 - 가로 열 수 1~4(기본 2) · 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기) - 157-2 산근 카드 파일 갈음(지움) - 171-3 일위대가표 · 중기사용료 · 단가산출근거 = 카드 기본 · [표로 보기] = 표 그대로 · 보기 · 열 수 기억(b_page_state 두 줄 · 탭마다) - 값 · 풍선 · 칸 누름 이동 = 표 줄 그대로 · 집계표 · 설계내역서 · B08 대상 밖 - 시험 test_171_2 · test_171_3 · 공용 도우미(가짜 DOM) · test_157_2 갈음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
250 lines
9.4 KiB
TypeScript
250 lines
9.4 KiB
TypeScript
/* =============================================================================
|
|
* 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<Pick<GroupCardsOptions, "money" | "qty">>) {
|
|
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<Pick<GroupCardsOptions, "money" | "qty">>): 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<HTMLElement>("[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<HTMLElement>(`[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;
|
|
}
|