"use client";
import { useEffect, useMemo, useState } from "react";
import {
Activity,
ArrowDownToLine,
ArrowRight,
Bell,
Check,
CheckCheck,
ChevronDown,
ChevronRight,
CircleHelp,
Clock3,
Command,
Inbox,
LayoutDashboard,
Menu,
Moon,
Plus,
Search,
Settings2,
ShieldCheck,
SlidersHorizontal,
Sparkles,
Sun,
Users,
X,
} from "lucide-react";
import {
Avatar,
Badge,
Button,
CommandPalette,
DialogDescription,
DialogDismiss,
DialogHeading,
DialogPanel,
DialogRoot,
EmptyState,
Input,
SettingsForm,
Textarea,
useOptTheme,
useToast,
type FormFieldDef,
} from "@reopt-ai/opt-ui";
import { AreaChart, Sparkline } from "@reopt-ai/opt-charts";
import {
ShellAppShell,
ShellCollapsibleNav,
ShellNavItem,
ShellProvider,
useShellNav,
} from "@reopt-ai/opt-shell";
import { createShellApp } from "@reopt-ai/opt-shell/core";
import { ShellDataGridAdapter } from "@reopt-ai/opt-shell/datagrid";
import type { DataGridColumn } from "@reopt-ai/opt-datagrid";
import {
initialEvents,
initialRequests,
initialUsers,
resolveRequest,
viewCopy,
weeklyActivity,
type AdminEvent,
type AdminRequest,
type AdminUser,
type AdminView,
type RequestState,
} from "./admin-data";
import s from "./admin.module.css";
const manifest = createShellApp({
id: "admin-panel-example",
label: "Northstar Admin",
defaults: {
density: "comfortable",
navigationMode: "sidebar",
contentWidth: "full",
},
});
const nav = [
{ id: "overview", icon: LayoutDashboard },
{ id: "approvals", icon: Inbox },
{ id: "users", icon: Users },
{ id: "roles", icon: ShieldCheck },
{ id: "logs", icon: Activity },
] as const;
const stateLabels: Record<RequestState, string> = {
pending: "대기 중",
approved: "승인됨",
rejected: "반려됨",
};
const userColumns: DataGridColumn<AdminUser>[] = [
{
id: "name",
title: "이름",
width: 170,
getValue: (row) => row.name,
renderCell: ({ row }) => (
<span className={s.userCell}>
<span className={s.miniAvatar}>{row.name.slice(1)}</span>
{row.name}
</span>
),
},
{ id: "email", title: "이메일", width: 260, getValue: (row) => row.email },
{
id: "team",
title: "팀",
width: 150,
editable: true,
getValue: (row) => row.team,
setValue: (row, team) => ({ ...row, team }),
},
{
id: "role",
title: "역할",
width: 140,
editable: true,
editor: {
kind: "select",
options: ["관리자", "운영자", "검토자"].map((value) => ({
value,
label: value,
})),
},
getValue: (row) => row.role,
setValue: (row, role) => ({ ...row, role }),
},
{
id: "status",
title: "상태",
width: 140,
editable: true,
editor: {
kind: "select",
options: ["활성", "초대 대기", "일시 중지"].map((value) => ({
value,
label: value,
})),
},
getValue: (row) => row.status,
setValue: (row, status) => ({ ...row, status }),
renderCell: ({ value }) => (
<span
className={s.status}
data-tone={
value === "활성"
? "success"
: value === "초대 대기"
? "warning"
: "neutral"
}
>
<i />
{value}
</span>
),
},
{
id: "lastSeen",
title: "최근 접속",
width: 140,
getValue: (row) => row.lastSeen,
},
];
function MobileMenu() {
const { open } = useShellNav();
return (
<Button variant="ghost" size="sm" aria-label="메뉴 열기" onClick={open}>
<Menu size={18} />
</Button>
);
}
function EventList({
events,
compact = false,
}: {
events: AdminEvent[];
compact?: boolean;
}) {
return (
<ol className={s.timeline}>
{events.slice(0, compact ? 4 : 50).map((event) => (
<li key={event.id}>
<span className={s.eventIcon} data-kind={event.kind}>
{event.kind === "access" ? (
<ShieldCheck size={15} />
) : event.kind === "user" ? (
<Users size={15} />
) : (
<CheckCheck size={15} />
)}
</span>
<div>
<strong>{event.title}</strong>
<p>{event.detail}</p>
<time>{event.time}</time>
</div>
</li>
))}
</ol>
);
}
function RequestList({
requests,
onOpen,
onViewAll,
compact = false,
}: {
requests: AdminRequest[];
onOpen: (id: string) => void;
onViewAll?: () => void;
compact?: boolean;
}) {
return (
<section className={s.panel} aria-label="요청 목록">
<div className={s.panelHeading}>
<div className={s.inline}>
<h2>{compact ? "승인을 기다리고 있어요" : "요청 목록"}</h2>
<span className={s.count}>{requests.length}</span>
</div>
{onViewAll && (
<Button variant="ghost" size="sm" onClick={onViewAll}>
전체 보기
</Button>
)}
</div>
{requests.length ? (
<>
<div className={s.requestLabels} aria-hidden="true">
<span>요청 / 요청자</span>
<span>우선순위</span>
<span>처리 기한</span>
<span />
</div>
<div className={s.requests}>
{requests.map((request) => (
<button
type="button"
className={s.request}
key={request.id}
onClick={() => onOpen(request.id)}
aria-label={`${request.title} 검토`}
>
<span className={s.requestMain}>
<span
className={s.requestAvatar}
data-priority={request.priority}
>
{request.category === "접근 권한" ? (
<ShieldCheck size={19} />
) : request.category === "사용자" ? (
<Users size={19} />
) : request.category === "결제" ? (
<ArrowDownToLine size={19} />
) : (
<Sparkles size={19} />
)}
</span>
<span>
<strong>{request.title}</strong>
<small>
{request.id} <span>·</span> {request.person}{" "}
<span>·</span> {request.team}
</small>
</span>
</span>
<span>
<Badge
variant={
request.status !== "pending"
? request.status === "approved"
? "success"
: "default"
: request.priority === "높음"
? "warning"
: "default"
}
>
{request.status === "pending"
? request.priority
: stateLabels[request.status]}
</Badge>
</span>
<span
className={s.due}
data-urgent={
request.priority === "높음" && request.status === "pending"
}
>
<Clock3 size={13} />
{request.status === "pending" ? request.due : "처리 완료"}
</span>
<ChevronRight size={16} className={s.chevron} />
</button>
))}
</div>
</>
) : (
<div className={s.empty}>
<EmptyState
title="모두 확인했어요"
description="이 조건에 해당하는 요청이 없습니다. 새로운 요청이 들어오면 여기에 표시됩니다."
/>
</div>
)}
{compact && (
<div className={s.panelFoot}>
<ShieldCheck size={14} />
모든 승인과 반려는 감사 로그에 기록됩니다.
</div>
)}
</section>
);
}
function AdminContent() {
const [view, setView] = useState<AdminView>("overview");
const [requests, setRequests] = useState(initialRequests);
const [users, setUsers] = useState(initialUsers);
const [events, setEvents] = useState(initialEvents);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [note, setNote] = useState("");
const [noteError, setNoteError] = useState("");
const [inviteOpen, setInviteOpen] = useState(false);
const [commandOpen, setCommandOpen] = useState(false);
const [query, setQuery] = useState("");
const [status, setStatus] = useState("전체");
const [requestFilter, setRequestFilter] = useState<RequestState | "all">(
"pending",
);
const [days, setDays] = useState<3 | 7>(7);
const [settings, setSettings] = useState({
name: "Northstar",
email: "ops@northstar.example",
notifications: true,
});
const workspace = settings.name;
const { addToast } = useToast();
const { resolvedMode, setMode } = useOptTheme();
const pending = requests.filter((request) => request.status === "pending");
const urgent = pending.filter((request) => request.priority === "높음");
const selected = requests.find((request) => request.id === selectedId);
const filteredUsers = useMemo(
() =>
users.filter(
(user) =>
(status === "전체" || user.status === status) &&
`${user.name} ${user.email} ${user.team}`
.toLowerCase()
.includes(query.toLowerCase()),
),
[users, query, status],
);
const completed = requests.filter(
(request) => request.status !== "pending",
).length;
const activity = weeklyActivity
.slice(-days)
.map((item) =>
item.name === "10/27"
? { ...item, completed: item.completed + completed }
: item,
);
const copy = viewCopy[view];
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setCommandOpen((open) => !open);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);
function record(title: string, detail: string, kind: AdminEvent["kind"]) {
setEvents((current) => [
{ id: crypto.randomUUID(), title, detail, kind, time: "방금 전" },
...current,
]);
}
function navigate(next: AdminView) {
setView(next);
setQuery("");
}
function openRequest(id: string) {
setSelectedId(id);
setNote("");
setNoteError("");
}
function decide(next: "approved" | "rejected") {
if (!selected || selected.status !== "pending") return;
if (next === "rejected" && !note.trim()) {
setNoteError("반려 사유를 입력해주세요.");
return;
}
setRequests((current) => resolveRequest(current, selected.id, next));
record(
`${selected.title} ${next === "approved" ? "승인" : "반려"}`,
`김도윤 · ${selected.id}${note.trim() ? ` · ${note.trim()}` : ""}`,
"review",
);
setSelectedId(null);
addToast({
variant: "success",
title:
next === "approved" ? "요청을 승인했습니다" : "요청을 반려했습니다",
message:
"처리 결과를 감사 로그에 기록했습니다. 데모 외부로는 전송되지 않습니다.",
});
}
function updateUsers(next: readonly AdminUser[]) {
const updates = new Map(next.map((user) => [user.id, user]));
setUsers((current) => current.map((user) => updates.get(user.id) ?? user));
record("사용자 정보 수정", "김도윤 · 팀 / 역할 / 상태 변경", "user");
}
function exportUsers() {
const escape = (value: string) =>
`"${(/^[=+@-]/.test(value) ? "'" : "") + value.replaceAll('"', '""')}"`;
const csv =
"\uFEFF" +
[
["이름", "이메일", "팀", "역할", "상태"],
...filteredUsers.map((user) => [
user.name,
user.email,
user.team,
user.role,
user.status,
]),
]
.map((row) => row.map(escape).join(","))
.join("\r\n");
const url = URL.createObjectURL(
new Blob([csv], { type: "text/csv;charset=utf-8" }),
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "northstar-users.csv";
anchor.click();
URL.revokeObjectURL(url);
addToast({
variant: "success",
title: `${filteredUsers.length}명의 사용자 목록을 내보냈습니다`,
message: "현재 검색 결과를 CSV 파일로 저장했습니다.",
});
}
const inviteFields: FormFieldDef[] = [
{
name: "name",
label: "이름",
type: "text",
required: true,
placeholder: "예: 홍길동",
},
{
name: "email",
label: "이메일",
type: "email",
required: true,
placeholder: "name@company.com",
validate: (value) =>
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value).trim())
? "올바른 이메일을 입력해주세요."
: users.some(
(user) =>
user.email.toLowerCase() ===
String(value).trim().toLowerCase(),
)
? "이미 등록된 이메일입니다."
: undefined,
},
{
name: "team",
label: "팀",
type: "text",
required: true,
placeholder: "예: 프로덕트",
},
{
name: "role",
label: "역할",
type: "select",
defaultValue: "검토자",
options: ["운영자", "검토자"].map((value) => ({ value, label: value })),
},
];
return (
<div className={s.app}>
<ShellAppShell
className={s.shell}
contentClassName={s.main}
nav={
<ShellCollapsibleNav
width={224}
collapsible={false}
showToggle={false}
label="관리 콘솔 메뉴"
className={s.nav}
>
<div className={s.navInner}>
<div className={s.brand}>
<span className={s.brandMark}>
<Command size={23} />
</span>
<div>
<strong>{workspace}</strong>
<span>Admin workspace</span>
</div>
<ChevronDown size={14} />
</div>
<button
className={s.searchTrigger}
type="button"
onClick={() => setCommandOpen(true)}
>
<Search size={15} />
<span>빠르게 이동</span>
<kbd>⌘ K</kbd>
</button>
<div role="group" aria-label="워크스페이스">
{nav.map(({ id, icon: Icon }) => (
<ShellNavItem
key={id}
active={view === id}
onClick={() => navigate(id)}
icon={<Icon size={18} />}
badge={
id === "approvals" && pending.length ? (
<span className={s.navCount}>{pending.length}</span>
) : undefined
}
className={s.navItem}
>
{viewCopy[id].label}
</ShellNavItem>
))}
</div>
<div className={s.navBottom}>
<div className={s.workspaceHealth}>
<span className={s.healthDot} />
<span>모든 시스템 정상</span>
</div>
<ShellNavItem
active={view === "settings"}
onClick={() => navigate("settings")}
icon={<Settings2 size={17} />}
className={s.navItem}
>
워크스페이스 설정
</ShellNavItem>
<div className={s.profile}>
<Avatar name="김도윤" size="sm" />
<div>
<strong>김도윤</strong>
<span>워크스페이스 관리자</span>
</div>
<Badge variant="default">Pro</Badge>
</div>
</div>
</div>
</ShellCollapsibleNav>
}
>
<header className={s.topbar}>
<div className={s.breadcrumb}>
<span className={s.mobileMenu}>
<MobileMenu />
</span>
<span>워크스페이스</span>
<ChevronRight size={13} />
<strong>{copy.label}</strong>
</div>
<div className={s.topActions}>
<span className={s.demoPill}>샘플 데이터</span>
<Button
variant="ghost"
size="sm"
aria-label={
resolvedMode === "dark"
? "라이트 모드로 전환"
: "다크 모드로 전환"
}
onClick={() =>
setMode(resolvedMode === "dark" ? "light" : "dark")
}
>
{resolvedMode === "dark" ? <Sun size={17} /> : <Moon size={17} />}
</Button>
<Button
variant="ghost"
size="sm"
aria-label={`승인 대기 ${pending.length}건 보기`}
onClick={() => navigate("approvals")}
>
<Bell size={17} />
<span className={s.notificationDot} />
</Button>
<Avatar name="김도윤" size="sm" />
</div>
</header>
<div className={s.content}>
<div className={s.pageHeading}>
<div>
<h1>{copy.title}</h1>
<p className={s.description}>{copy.description}</p>
</div>
<Button variant="primary" onClick={() => setInviteOpen(true)}>
<Plus size={16} />
팀원 초대
</Button>
</div>
{view === "overview" && (
<>
<div className={s.metrics}>
{[
{
label: "승인 대기",
value: String(pending.length).padStart(2, "0"),
detail: urgent.length
? `${urgent.length}건 우선 검토 필요`
: "긴급 요청 없음",
tone: "warning",
icon: Inbox,
chart: [8, 6, 9, 5, 7, 4, pending.length],
},
{
label: "오늘 처리 완료",
value: String(24 + completed),
detail: "전일 같은 시간 대비 +6건",
tone: "success",
icon: CheckCheck,
chart: [8, 14, 12, 18, 15, 22, 24 + completed],
},
{
label: "활성 팀원",
value: `${users.filter((user) => user.status === "활성").length}`,
detail: `전체 ${users.length}명 · 초대 대기 ${users.filter((user) => user.status === "초대 대기").length}명`,
tone: "neutral",
icon: Users,
chart: [2, 2, 3, 3, 4, 5, 5],
},
{
label: "평균 처리 시간",
value: "1.8",
unit: "시간",
detail: "지난주 대비 24분 단축",
tone: "success",
icon: Clock3,
chart: [3.5, 3.2, 3.6, 2.8, 2.5, 2, 1.8],
},
].map((metric) => (
<section
className={s.metric}
key={metric.label}
aria-label={metric.label}
>
<div className={s.metricLabel}>
{metric.label}
<metric.icon size={16} />
</div>
<div className={s.metricValue}>
<strong>{metric.value}</strong>
{metric.unit && <span>{metric.unit}</span>}
<div className={s.sparkline}>
<Sparkline
data={metric.chart}
height={40}
width="100%"
color="var(--opt-accent)"
type="area"
name={metric.label}
/>
</div>
</div>
<p data-tone={metric.tone}>{metric.detail}</p>
</section>
))}
</div>
<div className={s.overviewGrid}>
<div className={s.primaryColumn}>
{urgent.length > 0 && (
<div className={s.priorityBanner}>
<span className={s.priorityIcon}>
<Clock3 size={19} />
</span>
<div>
<strong>
지금 확인하면 좋은 요청 {urgent.length}건
</strong>
<p>
접근 권한과 결제 요청의 처리 기한이 다가오고 있어요.
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => openRequest(urgent[0]!.id)}
>
검토하기 <ArrowRight size={15} />
</Button>
</div>
)}
<RequestList
requests={pending}
onOpen={openRequest}
compact
onViewAll={() => navigate("approvals")}
/>
<section className={s.panel}>
<div className={s.panelHeading}>
<div>
<h2>팀의 업무 흐름</h2>
<p>접수된 요청과 처리 완료 추이</p>
</div>
<div
className={s.segmented}
role="group"
aria-label="차트 기간"
>
{([7, 3] as const).map((value) => (
<button
type="button"
key={value}
aria-pressed={days === value}
onClick={() => setDays(value)}
>
최근 {value}일
</button>
))}
</div>
</div>
<div className={s.chartSummary}>
<strong>
{activity.reduce(
(total, item) => total + item.completed,
0,
)}
<span>건 처리</span>
</strong>
<span className={s.legend}>
<i />
접수 <i data-secondary />
처리 완료
</span>
</div>
<div className={s.chart}>
<AreaChart
data={activity}
series={[
{
dataKey: "requests",
name: "접수",
color: "var(--opt-accent)",
},
{
dataKey: "completed",
name: "처리 완료",
color: "var(--opt-success)",
},
]}
height={205}
gradient
fillOpacity={0.08}
showLegend={false}
animate={false}
locale="ko-KR"
labels={{
chartName: "요청 처리 추이",
summaryCaption: "요청 처리 추이 데이터",
categoryColumn: "일자",
}}
/>
</div>
</section>
</div>
<aside className={s.secondaryColumn}>
<section className={s.panel}>
<div className={s.panelHeading}>
<h2>최근 활동</h2>
<span className={s.liveLabel}>
<i />
오늘
</span>
</div>
<EventList events={events} compact />
<button
className={s.textAction}
type="button"
onClick={() => navigate("logs")}
>
모든 활동 보기 <ArrowRight size={14} />
</button>
</section>
<section className={`${s.panel} ${s.securityCard}`}>
<span className={s.securityIcon}>
<ShieldCheck size={23} />
</span>
<h2>안전하게 운영되고 있어요</h2>
<p>
오늘의 보안 점검을 완료했습니다.
<br />
역할별 접근 범위를 주기적으로 확인하세요.
</p>
<div className={s.securityRow}>
<span>접근 정책</span>
<Badge variant="success">검토 완료</Badge>
</div>
<div className={s.securityRow}>
<span>관리자 계정</span>
<strong>
{users.filter((user) => user.role === "관리자").length}
명
</strong>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => navigate("roles")}
>
접근 권한 확인
</Button>
</section>
</aside>
</div>
</>
)}
{view === "approvals" && (
<>
<div className={s.filterBar}>
<div
className={s.segmented}
role="group"
aria-label="요청 상태"
>
{(["pending", "approved", "rejected", "all"] as const).map(
(value) => (
<button
type="button"
key={value}
aria-pressed={requestFilter === value}
onClick={() => setRequestFilter(value)}
>
{value === "all" ? "전체" : stateLabels[value]}{" "}
<span>
{
requests.filter(
(request) =>
value === "all" || request.status === value,
).length
}
</span>
</button>
),
)}
</div>
<span className={s.muted}>높은 우선순위부터 표시</span>
</div>
<RequestList
requests={requests.filter(
(request) =>
requestFilter === "all" || request.status === requestFilter,
)}
onOpen={openRequest}
/>
</>
)}
{view === "users" && (
<>
<div className={s.filterBar}>
<Input
aria-label="사용자 검색"
placeholder="이름, 이메일, 팀 검색"
value={query}
onChange={(event) => setQuery(event.target.value)}
leftIcon={<Search size={15} />}
/>
<Button variant="secondary" size="sm" onClick={exportUsers}>
<ArrowDownToLine size={15} />
CSV 내보내기
</Button>
</div>
<div
className={s.segmented}
role="group"
aria-label="사용자 상태"
>
{["전체", "활성", "초대 대기", "일시 중지"].map((value) => (
<button
type="button"
key={value}
aria-pressed={status === value}
onClick={() => setStatus(value)}
>
{value}{" "}
<span>
{
users.filter(
(user) => value === "전체" || user.status === value,
).length
}
</span>
</button>
))}
</div>
<ShellDataGridAdapter
rows={filteredUsers}
columns={userColumns}
getRowId={(user) => user.id}
onRowsChange={updateUsers}
ariaLabel="워크스페이스 사용자"
title={`사용자 ${filteredUsers.length}명`}
description="팀·역할·상태 셀을 더블클릭해 편집하세요. Enter로 저장하고 Esc로 편집을 종료합니다."
rowMarkers="both"
freezeColumns={1}
gridHeight={430}
empty="검색 조건에 맞는 사용자가 없습니다. 검색어나 상태 필터를 바꿔주세요."
footer="선택·복사·붙여넣기와 키보드 탐색을 지원합니다. 변경은 이 데모 탭에서만 유지됩니다."
/>
</>
)}
{view === "roles" && (
<>
<div className={s.roleCards}>
{[
{
role: "관리자",
description: "워크스페이스의 정책과 승인을 관리합니다.",
permissions: [
"전체 데이터 조회",
"요청 승인 및 반려",
"계정 상태·역할 관리",
"워크스페이스 설정",
],
},
{
role: "운영자",
description: "일상 업무를 처리하고 변경을 요청합니다.",
permissions: [
"업무 데이터 조회",
"승인 요청 생성",
"검토자 초대 요청",
"감사 기록 조회",
],
},
{
role: "검토자",
description: "필요한 정보를 확인하는 읽기 전용 역할입니다.",
permissions: [
"배정된 데이터 조회",
"담당 콘텐츠 검토",
"변경 요청 제출",
"내 계정 확인",
],
},
].map((item) => (
<section
className={`${s.panel} ${s.roleCard}`}
key={item.role}
>
<span className={s.securityIcon}>
<ShieldCheck size={22} />
</span>
<div className={s.inline}>
<h2>{item.role}</h2>
<Badge variant="default">
{users.filter((user) => user.role === item.role).length}
명
</Badge>
</div>
<p>{item.description}</p>
<ul>
{item.permissions.map((permission) => (
<li key={permission}>
<Check size={15} />
{permission}
</li>
))}
</ul>
<Button
variant="secondary"
onClick={() => {
navigate("users");
setQuery("");
setStatus("전체");
}}
>
사용자에서 역할 관리 <ArrowRight size={14} />
</Button>
</section>
))}
</div>
<div className={s.priorityBanner}>
<ShieldCheck size={20} />
<div>
<strong>권한 상승에는 검토가 필요합니다</strong>
<p>
요청의 목적, 범위와 만료 시점을 확인한 뒤 승인하세요. 이
예제의 역할 변경은 실제 서비스 권한을 변경하지 않습니다.
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => navigate("approvals")}
>
요청 검토
</Button>
</div>
</>
)}
{view === "logs" && (
<section className={s.panel}>
<div className={s.panelHeading}>
<div>
<h2>워크스페이스 활동</h2>
<p>오늘 · {events.length}건의 기록</p>
</div>
<Input
aria-label="감사 로그 검색"
placeholder="활동, 사용자, 요청 ID 검색"
value={query}
onChange={(event) => setQuery(event.target.value)}
leftIcon={<Search size={15} />}
/>
</div>
{events.some((event) =>
`${event.title} ${event.detail}`.includes(query),
) ? (
<EventList
events={events.filter((event) =>
`${event.title} ${event.detail}`.includes(query),
)}
/>
) : (
<div className={s.empty}>
<EmptyState
title="검색 결과가 없습니다"
description="다른 사용자 이름이나 요청 ID로 검색해주세요."
/>
</div>
)}
</section>
)}
{view === "settings" && (
<section className={`${s.panel} ${s.settings}`}>
<div className={s.panelHeading}>
<div>
<h2>기본 설정</h2>
<p>저장하면 이 탭의 워크스페이스에 바로 반영됩니다.</p>
</div>
<SlidersHorizontal size={20} />
</div>
<SettingsForm
key={workspace}
fields={[
{
name: "name",
label: "워크스페이스 이름",
type: "text",
defaultValue: workspace,
required: true,
},
{
name: "email",
label: "운영 알림 이메일",
type: "email",
defaultValue: settings.email,
required: true,
},
{
name: "notifications",
label: "승인 요청 알림",
type: "switch",
description: "새로운 검토 요청이 있을 때 알림을 받습니다.",
defaultValue: settings.notifications,
},
]}
onSubmit={(values) => {
setSettings({
name: String(values.name).trim(),
email: String(values.email).trim(),
notifications: Boolean(values.notifications),
});
record(
"워크스페이스 설정 변경",
`김도윤 · ${String(values.name).trim()}`,
"access",
);
addToast({
variant: "success",
title: "워크스페이스 설정을 저장했습니다",
message: "변경 내용은 이 데모 탭에서 유지됩니다.",
});
}}
/>
</section>
)}
<footer className={s.footer}>
<span>
<CircleHelp size={13} />
로컬 데모 · 변경은 이 탭에서만 유지됩니다.
</span>
<span>Powered by opt series</span>
</footer>
</div>
</ShellAppShell>
<DialogRoot
open={!!selected}
onOpenChange={(open) => {
if (!open) setSelectedId(null);
}}
>
<DialogPanel size="md" className={s.dialog}>
{selected && (
<>
<div className={s.dialogTop}>
<DialogDismiss aria-label="검토 닫기">
<X size={18} />
</DialogDismiss>
</div>
<DialogHeading>{selected.title}</DialogHeading>
<DialogDescription>{selected.description}</DialogDescription>
<div className={s.requestDetail}>
<div>
<span>요청 번호</span>
<strong>
{selected.id} · {selected.category}
</strong>
</div>
<div>
<span>요청자</span>
<strong>
{selected.person} · {selected.team}
</strong>
</div>
<div>
<span>접수 일시</span>
<strong>{selected.created}</strong>
</div>
<div>
<span>요청 범위</span>
<strong>{selected.scope}</strong>
</div>
<div>
<span>상태</span>
<Badge
variant={
selected.status === "pending"
? "warning"
: selected.status === "approved"
? "success"
: "default"
}
>
{stateLabels[selected.status]}
</Badge>
</div>
</div>
{selected.status === "pending" ? (
<>
<Textarea
label="처리 메모"
placeholder="승인 근거 또는 반려 사유를 기록해주세요."
value={note}
onChange={(event) => {
setNote(event.target.value);
setNoteError("");
}}
error={noteError}
rows={3}
/>
<p className={s.dialogNote}>
반려 시 사유가 필요합니다. 처리 결과는 감사 로그에 남습니다.
</p>
<div className={s.dialogActions}>
<Button
variant="secondary"
onClick={() => decide("rejected")}
>
반려
</Button>
<Button
variant="primary"
onClick={() => decide("approved")}
>
<Check size={16} />
승인하기
</Button>
</div>
</>
) : (
<div className={s.dialogActions}>
<Button
variant="secondary"
onClick={() => {
setSelectedId(null);
navigate("logs");
}}
>
감사 로그 확인 <ArrowRight size={14} />
</Button>
</div>
)}
</>
)}
</DialogPanel>
</DialogRoot>
<DialogRoot open={inviteOpen} onOpenChange={setInviteOpen}>
<DialogPanel size="sm" className={s.dialog}>
<div className={s.dialogTop}>
<span className={s.securityIcon}>
<Users size={22} />
</span>
<DialogDismiss aria-label="초대 닫기">
<X size={18} />
</DialogDismiss>
</div>
<DialogHeading>팀원 초대</DialogHeading>
<DialogDescription>
함께 일할 팀원을 등록하세요. 이 데모에서는 이메일을 발송하지
않습니다.
</DialogDescription>
{inviteOpen && (
<SettingsForm
fields={inviteFields}
labels={{
submitButton: "초대 추가",
formAriaLabel: "팀원 초대 폼",
}}
onSubmit={(values) => {
const user: AdminUser = {
id: crypto.randomUUID(),
name: String(values.name).trim(),
email: String(values.email).trim(),
team: String(values.team).trim(),
role: String(values.role),
status: "초대 대기",
lastSeen: "—",
};
setUsers((current) => [...current, user]);
record(
"새 팀원 초대",
`${user.name} · ${user.team} / ${user.role}`,
"user",
);
setInviteOpen(false);
navigate("users");
setStatus("전체");
addToast({
variant: "success",
title: `${user.name}님을 초대 목록에 추가했습니다`,
message: "데모이므로 이메일은 발송되지 않습니다.",
});
}}
/>
)}
</DialogPanel>
</DialogRoot>
<CommandPalette
open={commandOpen}
onOpenChange={setCommandOpen}
commands={Object.entries(viewCopy).map(([id, item]) => ({
id,
label: item.label,
group: "워크스페이스",
action: () => navigate(id as AdminView),
}))}
onSelect={(command) => {
navigate(command.id as AdminView);
setCommandOpen(false);
}}
/>
</div>
);
}
export default function AdminTemplatePage() {
return (
<ShellProvider manifest={manifest} applyDocumentPolicy={false}>
<AdminContent />
</ShellProvider>
);
}