diff --git a/B01_Dashboard/B01_Dashboard_UI_PageKit.ts b/B01_Dashboard/B01_Dashboard_UI_PageKit.ts new file mode 100644 index 000000000..daa47fb7e --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_PageKit.ts @@ -0,0 +1,293 @@ +/* ============================================================================= + * B01_Dashboard_UI_PageKit.ts + * 대시보드 세부 페이지 공용 조각(549) — 페이지 머리 · 표 · 줄 끝 ⋯ · 배지 · 탭 · 빈 상태. + * 색 · 간격 = 셸 변수(`--b01-*`) 만 · 모양은 `B01_Dashboard_UI_Style_Pages.css`. + * 아이콘 중 `ui_template_icons` 에 없는 것(⋯ · + · 휴지통 · 상자)만 여기 · Tabler Icons(MIT) 선 모양. + * ========================================================================== */ + +import { createButton, createTag, showConfirmDialog } from "@ui/ui_template_elements"; +import { icon, type IconName } from "@ui/ui_template_icons"; +import type { BadgeTone } from "./logic/B01_Dashboard_Logic_Pages"; +import "./B01_Dashboard_UI_Style_Pages.css"; + +/** Tabler Icons(MIT · https://tabler.io/icons) — `ui_template_icons` 와 같은 24 × 24 · 선 2 */ +const KIT_PATHS = { + dots: "M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0", + plus: "M12 5l0 14|M5 12l14 0", + upload: "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2|M7 9l5 -5l5 5|M12 4l0 12", + mail: "M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10|M3 7l9 6l9 -6", + inbox: + "M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12|M4 13h3l3 3h4l3 -3h3", +} as const; +export type KitIconName = keyof typeof KIT_PATHS; + +export function kitIcon(name: KitIconName, size = 16): HTMLSpanElement { + const span = document.createElement("span"); + span.className = "ui-icon"; + span.setAttribute("aria-hidden", "true"); + const paths = KIT_PATHS[name] + .split("|") + .map((d) => ``) + .join(""); + span.innerHTML = `${paths}`; + return span; +} + +function node( + tag: K, + className: string, + text?: string, +): HTMLElementTagNameMap[K] { + const n = document.createElement(tag); + n.className = className; + if (text !== undefined) n.textContent = text; + return n; +} + +/** 배지 — 한글 글 + 색 */ +export function badge(text: string, tone: BadgeTone = "neutral"): HTMLElement { + const tag = createTag(text, tone); + tag.classList.add("b01-pg-badge"); + return tag; +} + +/** 주 단추 — 오른쪽 위 하나 · 앞 아이콘 */ +export function primaryButton(label: string, onClick: () => void, name: KitIconName = "plus") { + return createButton({ label, onClick, iconStart: kitIcon(name) }); +} + +export interface PageFrame { + root: HTMLElement; + /** 머리 아래 몸통 */ + body: HTMLElement; + /** 머리 오른쪽 주 단추 자리 */ + setAction: (action: HTMLElement | null) => void; +} + +/** 페이지 제목 + 한 줄 설명 + 오른쪽 위 주 단추 */ +export function pageFrame(host: HTMLElement, title: string, desc: string): PageFrame { + const root = node("div", "b01-pg"); + const head = node("header", "b01-pg__head"); + const text = node("div", "b01-pg__title-wrap"); + text.append(node("h2", "b01-pg__title", title), node("p", "b01-pg__desc", desc)); + const slot = node("div", "b01-pg__action"); + head.append(text, slot); + const body = node("div", "b01-pg__body"); + root.append(head, body); + host.append(root); + return { + root, + body, + setAction: (action) => slot.replaceChildren(...(action ? [action] : [])), + }; +} + +export type Cell = string | Node; + +export interface RowMenuItem { + label: string; + onClick: () => void; + /** 삭제 · 빼기 = 빨강 + 확인창 */ + danger?: boolean; + /** 확인창 글(danger 일 때) */ + confirm?: string; +} + +/** 줄 끝 ⋯ — 열면 항목 · 밖을 누르면 닫힘 · danger 는 확인 뒤 실행 */ +export function rowMenu(items: RowMenuItem[]): HTMLElement { + const wrap = node("div", "b01-pg-menu"); + if (!items.length) return wrap; + const trigger = node("button", "b01-pg-menu__btn"); + trigger.type = "button"; + trigger.title = trigger.ariaLabel = "더 보기"; + trigger.setAttribute("aria-haspopup", "menu"); + trigger.setAttribute("aria-expanded", "false"); + trigger.append(kitIcon("dots", 18)); + const pop = node("div", "b01-pg-menu__pop"); + pop.setAttribute("role", "menu"); + pop.hidden = true; + const setOpen = (open: boolean): void => { + pop.hidden = !open; + trigger.setAttribute("aria-expanded", String(open)); + wrap.classList.toggle("is-open", open); + }; + for (const item of items) { + const b = node("button", `b01-pg-menu__item${item.danger ? " is-danger" : ""}`, item.label); + b.type = "button"; + b.setAttribute("role", "menuitem"); + b.addEventListener("click", () => { + setOpen(false); + if (!item.danger) return item.onClick(); + void showConfirmDialog(item.confirm ?? `${item.label} 하시겠습니까?`, item.label).then( + (ok) => ok && item.onClick(), + ); + }); + pop.append(b); + } + trigger.addEventListener("click", (e) => { + e.stopPropagation(); + setOpen(pop.hidden); + }); + const outside = (e: MouseEvent): void => { + if (!wrap.isConnected) document.removeEventListener("click", outside); + else if (!wrap.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("click", outside); + wrap.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); + wrap.append(trigger, pop); + return wrap; +} + +export interface Column { + label: string; + /** 칸 너비(CSS) — 없으면 나눠 가짐 */ + width?: string; + align?: "end"; +} + +/** 표 껍데기 — 머리 + 빈 tbody (줄은 호출 쪽이 채움 · 펼침 줄 같은 것을 끼우려고 떼어 둠) */ +export function tableShell(columns: Column[]): { root: HTMLElement; tbody: HTMLElement } { + const table = node("table", "b01-pg-table"); + const head = node("thead", ""); + const hr = node("tr", ""); + for (const col of columns) { + const th = node("th", col.align ? "is-end" : "", col.label); + if (col.width) th.style.width = col.width; + hr.append(th); + } + head.append(hr); + const tbody = node("tbody", ""); + table.append(head, tbody); + const root = node("div", "b01-pg-card b01-pg-scroll"); + root.append(table); + return { root, tbody }; +} + +/** 한 줄 — 칸 수는 columns 와 같게 · label "" 인 칸 = ⋯ 칸 */ +export function tableRow(columns: Column[], cells: Cell[]): HTMLElement { + const tr = node("tr", ""); + cells.forEach((cell, i) => { + const col = columns[i]; + const td = node("td", col?.align ? "is-end" : col?.label === "" ? "b01-pg-menucell" : ""); + td.append(cell); + tr.append(td); + }); + return tr; +} + +/** 표 — 줄 높이 40 · 세로선 없음 · 마지막 칸 = ⋯ (columns 에 label "" 로) */ +export function dataTable(columns: Column[], rows: Cell[][]): HTMLElement { + const { root, tbody } = tableShell(columns); + tbody.append(...rows.map((cells) => tableRow(columns, cells))); + return root; +} + +export interface EmptyOptions { + icon: IconName | KitIconName; + text: string; + hint?: string; + action?: HTMLElement; + /** 가이드 링크 — 누르면 */ + onGuide?: () => void; +} + +/** 빈 상태 — 선 아이콘 + 한 줄 + 주 단추 + 가이드 링크 */ +export function emptyState(opts: EmptyOptions): HTMLElement { + const box = node("div", "b01-pg-card b01-pg-empty"); + const glyph = node("div", "b01-pg-empty__icon"); + glyph.append( + opts.icon in KIT_PATHS + ? kitIcon(opts.icon as KitIconName, 32) + : icon(opts.icon as IconName, 32), + ); + box.append(glyph, node("p", "b01-pg-empty__text", opts.text)); + if (opts.hint) box.append(node("p", "b01-pg-empty__hint", opts.hint)); + if (opts.action) box.append(opts.action); + if (opts.onGuide) { + const link = node("button", "b01-pg-link", "사용법 보기"); + link.type = "button"; + link.addEventListener("click", opts.onGuide); + box.append(link); + } + return box; +} + +export interface TabsHandle { + root: HTMLElement; + setBadge: (key: string, count: number) => void; +} + +/** 탭 줄 — 눌러 바꿈 · 탭 오른쪽 수 배지(0 이면 숨김) */ +export function tabBar( + tabs: { key: string; label: string }[], + initial: string, + onChange: (key: string) => void, +): TabsHandle { + const root = node("div", "b01-pg-tabs"); + root.setAttribute("role", "tablist"); + const badges = new Map(); + const buttons = new Map(); + const select = (key: string): void => { + for (const [k, b] of buttons) { + b.classList.toggle("is-active", k === key); + b.setAttribute("aria-selected", String(k === key)); + } + }; + for (const tab of tabs) { + const b = node("button", "b01-pg-tabs__tab"); + b.type = "button"; + b.setAttribute("role", "tab"); + b.dataset.tab = tab.key; + const count = node("span", "b01-pg-tabs__count"); + count.hidden = true; + b.append(node("span", "", tab.label), count); + b.addEventListener("click", () => { + select(tab.key); + onChange(tab.key); + }); + buttons.set(tab.key, b); + badges.set(tab.key, count); + root.append(b); + } + select(initial); + return { + root, + setBadge: (key, n) => { + const c = badges.get(key); + if (!c) return; + c.textContent = String(n); + c.hidden = n <= 0; + }, + }; +} + +/** 설정 카드 한 줄(Linear 꼴) — 제목 · 설명 왼쪽 · 값 · 조작 오른쪽 */ +export function settingRow(title: string, desc: string, right: Node): HTMLElement { + const row = node("div", "b01-pg-row"); + const left = node("div", "b01-pg-row__text"); + left.append(node("strong", "", title)); + if (desc) left.append(node("span", "b01-pg-row__desc", desc)); + const side = node("div", "b01-pg-row__side"); + side.append(right); + row.append(left, side); + return row; +} + +/** 값 글 — 없으면 「-」 */ +export const valueText = (v: string | number | null | undefined): HTMLElement => + node("span", "b01-pg-row__value", v === null || v === undefined || v === "" ? "-" : String(v)); + +/** 줄 첫 칸 — 이름 + (작은 글) 보조 */ +export function nameCell(name: string, sub?: string | null): HTMLElement { + const cell = node("div", "b01-pg-name"); + cell.append(node("span", "b01-pg-name__main", name)); + if (sub) cell.append(node("span", "b01-pg-name__sub", sub)); + return cell; +} + +/** 카드 묶음 — 설정 줄을 둥근 상자 한 개에 */ +export function settingCard(rows: HTMLElement[]): HTMLElement { + const card = node("div", "b01-pg-card b01-pg-settings"); + card.append(...rows); + return card; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css b/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css new file mode 100644 index 000000000..8d63f3c6f --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css @@ -0,0 +1,424 @@ +/* ============================================================================= + * B01_Dashboard_UI_Style_Pages.css + * 대시보드 세부 페이지 공용 꼴(549) — 머리 · 표(줄 40 · 세로선 없음) · ⋯ · 탭 · 설정 줄 · 빈 상태. + * 색 · 간격은 `.b01-shell` 변수(--b01-*)만. 페이지 전용 조각은 아래 쪽에 이어 붙임. + * ========================================================================== */ + +.b01-pg { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 1120px; + color: var(--b01-text); +} +.b01-pg__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.b01-pg__title { + margin: 0; + font-size: 20px; + font-weight: 700; + color: var(--b01-text); +} +.b01-pg__desc { + margin: 4px 0 0; + color: var(--b01-muted); + font-size: 13px; +} +.b01-pg__action { + display: flex; + gap: 8px; + flex: none; +} +.b01-pg__body { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* 카드 · 표 */ +.b01-pg-card { + background: var(--b01-card); + border: 1px solid var(--b01-line); + border-radius: var(--b01-radius); +} +.b01-pg-scroll { + overflow-x: auto; +} +.b01-pg-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.b01-pg-table th { + height: 36px; + padding: 0 12px; + text-align: left; + font-weight: 600; + font-size: 12px; + color: var(--b01-muted); + border-bottom: 1px solid var(--b01-line); + white-space: nowrap; +} +.b01-pg-table td { + height: 40px; + padding: 0 12px; + border-bottom: 1px solid var(--b01-line); + vertical-align: middle; +} +.b01-pg-table tbody tr:last-child td { + border-bottom: 0; +} +.b01-pg-table tbody tr:hover { + background: var(--b01-hover); +} +.b01-pg-table .is-end { + text-align: right; +} +.b01-pg-table td.b01-pg-menucell { + width: 44px; + padding: 0 8px; + text-align: right; +} +.b01-pg-table tr.is-sub td { + height: auto; + padding: 0; + background: var(--b01-bg); +} +.b01-pg-table tr.is-sub:hover { + background: var(--b01-bg); +} + +/* 이름 칸 */ +.b01-pg-name { + display: flex; + flex-direction: column; + line-height: 1.25; + min-width: 0; +} +.b01-pg-name__main { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.b01-pg-name__sub { + font-size: 11.5px; + color: var(--b01-muted); +} +.b01-pg-muted { + color: var(--b01-muted); +} +.b01-pg-inline { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +/* 배지 */ +.b01-pg-badge { + border-radius: 999px; + font-size: 11.5px; + padding: 1px 8px; + white-space: nowrap; +} + +/* 줄 끝 ⋯ */ +.b01-pg-menu { + position: relative; + display: inline-block; +} +.b01-pg-menu__btn { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 0; + border-radius: 6px; + background: none; + color: var(--b01-muted); + cursor: pointer; +} +.b01-pg-menu__btn:hover, +.b01-pg-menu.is-open .b01-pg-menu__btn { + background: var(--b01-hover); + color: var(--b01-text); +} +.b01-pg-menu__pop { + position: absolute; + right: 0; + top: 30px; + z-index: 20; + min-width: 132px; + padding: 4px; + background: var(--b01-card); + border: 1px solid var(--b01-line); + border-radius: 8px; + box-shadow: 0 6px 18px rgb(0 0 0 / 12%); + text-align: left; +} +.b01-pg-menu__item { + display: block; + width: 100%; + padding: 7px 10px; + border: 0; + border-radius: 6px; + background: none; + color: var(--b01-text); + font: inherit; + text-align: left; + cursor: pointer; + white-space: nowrap; +} +.b01-pg-menu__item:hover { + background: var(--b01-hover); +} +.b01-pg-menu__item.is-danger { + color: var(--b01-alert); +} + +/* 탭 */ +.b01-pg-tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--b01-line); +} +.b01-pg-tabs__tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + margin-bottom: -1px; + border: 0; + border-bottom: 2px solid transparent; + background: none; + color: var(--b01-muted); + font: inherit; + font-weight: 600; + cursor: pointer; +} +.b01-pg-tabs__tab:hover { + color: var(--b01-text); +} +.b01-pg-tabs__tab.is-active { + color: var(--b01-accent); + border-bottom-color: var(--b01-accent); +} +.b01-pg-tabs__count { + min-width: 18px; + padding: 0 5px; + border-radius: 9px; + background: var(--b01-alert); + color: #fff; + font-size: 11px; + line-height: 18px; + text-align: center; +} +.b01-pg-tabs__count[hidden] { + display: none; +} + +/* 설정 줄(Linear 꼴) */ +.b01-pg-settings { + overflow: hidden; +} +.b01-pg-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 52px; + padding: 8px 16px; + border-bottom: 1px solid var(--b01-line); +} +.b01-pg-row:last-child { + border-bottom: 0; +} +.b01-pg-row__text { + display: flex; + flex-direction: column; + min-width: 0; +} +.b01-pg-row__desc { + font-size: 12px; + color: var(--b01-muted); +} +.b01-pg-row__side { + display: flex; + align-items: center; + gap: 8px; + flex: none; + max-width: 55%; +} +.b01-pg-row__value { + text-align: right; + overflow-wrap: anywhere; +} + +/* 빈 상태 */ +.b01-pg-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 48px 16px; + text-align: center; +} +.b01-pg-empty__icon { + display: grid; + place-items: center; + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--b01-accent-soft); + color: var(--b01-accent); +} +.b01-pg-empty__text { + margin: 0; + font-weight: 600; +} +.b01-pg-empty__hint { + margin: 0; + font-size: 12.5px; + color: var(--b01-muted); +} +.b01-pg-link { + border: 0; + background: none; + color: var(--b01-accent); + font: inherit; + font-size: 12.5px; + cursor: pointer; + text-decoration: underline; +} + +/* 단추 맞춤 — 셸 안 단추 높이 */ +.b01-pg .ui-btn { + display: inline-flex; + align-items: center; + gap: 6px; +} + +/* ── 임시 보관함 ──────────────────────────────────────────────────────────── */ +.b01-pg-left { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 140px; +} +.b01-pg-left__bar { + flex: none; + width: 64px; + height: 6px; + border-radius: 3px; + background: var(--b01-line); + overflow: hidden; +} +.b01-pg-left__bar > i { + display: block; + height: 100%; + background: var(--b01-accent); +} +.b01-pg-left.is-soon { + color: var(--b01-alert); + font-weight: 600; +} +.b01-pg-left.is-soon .b01-pg-left__bar > i { + background: var(--b01-alert); +} +.b01-pg-caret { + display: inline-grid; + place-items: center; + width: 20px; + height: 20px; + margin-right: 4px; + border: 0; + background: none; + color: var(--b01-muted); + cursor: pointer; + transition: transform 0.12s; +} +.b01-pg-caret.is-open { + transform: rotate(90deg); +} +.b01-pg-files { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; +} +.b01-pg-files td { + height: 34px; + padding: 0 12px; + border-bottom: 1px solid var(--b01-line); +} +.b01-pg-files tr:last-child td { + border-bottom: 0; +} +.b01-pg-files td:first-child { + padding-left: 44px; +} +.b01-pg-progress { + display: inline-flex; + align-items: center; + gap: 8px; +} +.b01-pg-progress__bar { + width: 96px; + height: 6px; + border-radius: 3px; + background: var(--b01-line); + overflow: hidden; +} +.b01-pg-progress__bar > i { + display: block; + height: 100%; + background: var(--b01-accent); +} +.b01-pg-meta { + color: var(--b01-muted); + font-size: 12px; +} + +/* ── 사람 · 회사 ──────────────────────────────────────────────────────────── */ +.b01-pg-person { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.b01-pg-avatar { + display: grid; + place-items: center; + flex: none; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--b01-accent-soft); + color: var(--b01-accent); + font-size: 12px; + font-weight: 700; +} +.b01-pg-invite { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + max-width: 520px; +} +.b01-pg-invite__form { + display: flex; + gap: 8px; + align-items: flex-end; +} +.b01-pg-invite__form > :first-child { + flex: 1; +} +.b01-pg-note { + margin: 0; + font-size: 12.5px; + color: var(--b01-muted); +} diff --git a/B01_Dashboard/logic/B01_Dashboard_Logic_Pages.ts b/B01_Dashboard/logic/B01_Dashboard_Logic_Pages.ts new file mode 100644 index 000000000..553069fab --- /dev/null +++ b/B01_Dashboard/logic/B01_Dashboard_Logic_Pages.ts @@ -0,0 +1,74 @@ +/* ============================================================================= + * B01_Dashboard_Logic_Pages.ts + * 대시보드 세부 페이지 계산(549-1) — 화면 · fetch 없음 · Node 로 바로 시험. + * 배지 글(역할 · 상태) · 보관함 남은 날 · 가입 요청 수. + * ========================================================================== */ + +export type BadgeTone = "accent" | "neutral" | "success" | "warning" | "danger"; + +const DAY_MS = 86_400_000; + +/** 역할 → 한글 */ +export function roleText(role: string | null | undefined): string { + if (role === "SYSTEM_ADMIN") return "시스템 관리자"; + if (role === "ADMIN") return "회사 관리자"; + if (role === "USER") return "일반 사용자"; + return role ?? "-"; +} + +/** 상태 → 한글 · 배지 색 (모르는 값은 그대로 · 회색) */ +export function statusBadgeOf(status: string | null | undefined): { + text: string; + tone: BadgeTone; +} { + switch ((status ?? "").toUpperCase()) { + case "ACTIVE": + return { text: "활성", tone: "success" }; + case "INACTIVE": + return { text: "비활성", tone: "neutral" }; + case "PENDING": + return { text: "대기", tone: "warning" }; + case "APPROVED": + return { text: "승인", tone: "success" }; + case "REJECTED": + return { text: "거절", tone: "danger" }; + default: + return { text: status || "-", tone: "neutral" }; + } +} + +/** 남은 날 — 만료 시각까지 올림 일수 · 지난 것 0 · 만료 없음 null */ +export function daysLeft( + expiresAt: string | null | undefined, + now: number = Date.now(), +): number | null { + if (!expiresAt) return null; + const at = new Date(expiresAt).getTime(); + if (Number.isNaN(at)) return null; + return Math.max(0, Math.ceil((at - now) / DAY_MS)); +} + +/** 막대 채움 0~100 — 남은 날 ÷ 보관 기간 */ +export function leftPercent(left: number | null, retentionDays: number): number { + if (left === null || retentionDays <= 0) return 0; + return Math.max(0, Math.min(100, Math.round((left / retentionDays) * 100))); +} + +/** 7 일 안 만료 = 강조 */ +export const EXPIRY_WARN_DAYS = 7; +export const isExpiringSoon = (left: number | null): boolean => + left !== null && left <= EXPIRY_WARN_DAYS; + +/** 가입 요청 중 처리할 것(대기) 수 — 상태 값이 없으면 대기로 봄 */ +export function pendingCount(requests: { status?: string | null }[]): number { + return requests.filter((r) => !r.status || r.status.toUpperCase() === "PENDING").length; +} + +/** 바이트 → 글 */ +export function bytesText(bytes: number): string { + const gb = bytes / 1024 / 1024 / 1024; + if (gb >= 1) return `${gb.toFixed(2)} GB`; + const mb = bytes / 1024 / 1024; + if (mb >= 1) return `${mb.toFixed(1)} MB`; + return `${Math.max(1, Math.round(bytes / 1024))} KB`; +}