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 = ``;
+ 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`;
+}