운영 화면과 회기 리뷰 UI 갱신

This commit is contained in:
Yun Chan 2026-06-28 20:13:21 +09:00
parent e7ebb38177
commit 3a9f70a97b
18 changed files with 2210 additions and 137 deletions

View file

@ -20,6 +20,8 @@ import {
type NotificationPreferences,
type UserPreferencesResponse,
type UserProfileResponse,
type UserSupportTicketListItem,
type UserSupportTicketsResponse,
type VoicePresetResponse,
} from "../lib/api";
import { readInitialTheme } from "../lib/theme";
@ -43,6 +45,30 @@ const ENGINE_MODE_LABEL: Record<string, string> = {
};
const ENGINE_MODES = ["claude_cli", "claude_api", "openai", "solar"] as const;
const TICKET_STATUS_LABEL: Record<UserSupportTicketListItem["status"], string> = {
open: "접수",
triaged: "분류됨",
in_progress: "처리 중",
resolved: "해결",
closed: "종료",
};
const TICKET_CATEGORY_LABEL: Record<UserSupportTicketListItem["category"], string> = {
account_access: "계정",
session_review: "회기 리뷰",
voice_browser: "음성·브라우저",
content_scenario: "콘텐츠",
safety: "안전",
other: "기타",
};
const TICKET_PRIORITY_LABEL: Record<UserSupportTicketListItem["priority"], string> = {
low: "낮음",
normal: "보통",
high: "높음",
urgent: "긴급",
};
function SpeakerIcon({ size = 15 }: { size?: number }) {
return (
<svg
@ -65,6 +91,22 @@ function healthStatusLabel(status: string | undefined): string {
return "확인 중";
}
function ticketStatusTone(status: UserSupportTicketListItem["status"]) {
if (status === "resolved" || status === "closed") return "pos";
if (status === "in_progress" || status === "triaged") return "info";
return "warn";
}
function formatTicketDate(value?: number | null): string {
if (!value) return "-";
return new Intl.DateTimeFormat("ko-KR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value * 1000));
}
function SettingsLoadingState({
children,
testId,
@ -155,11 +197,14 @@ export default function Settings() {
const [displayName, setDisplayName] = useState(user?.name ?? "");
const [affiliation, setAffiliation] = useState("");
const [preferences, setPreferences] = useState<UserPreferencesResponse | null>(null);
const [supportTickets, setSupportTickets] = useState<UserSupportTicketsResponse | null>(null);
const [voicePresets, setVoicePresets] = useState<VoicePresetResponse[]>([]);
const [engineConfig, setEngineConfig] = useState<AdminEngineConfigResponse | null>(null);
const [adminHealth, setAdminHealth] = useState<AdminHealthResponse | null>(null);
const [profileReady, setProfileReady] = useState(false);
const [preferencesReady, setPreferencesReady] = useState(false);
const [supportTicketsReady, setSupportTicketsReady] = useState(false);
const [supportTicketsError, setSupportTicketsError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [savedKey, setSavedKey] = useState<string | null>(null);
@ -192,14 +237,21 @@ export default function Settings() {
setError(null);
setProfileReady(false);
setPreferencesReady(false);
setSupportTicketsReady(false);
setPreferences(null);
setSupportTickets(null);
setSupportTicketsError(null);
setVoicePresets([]);
setEngineConfig(null);
engineConfigRef.current = null;
try {
const [nextProfile, nextPrefs, presets, nextEngine] = await Promise.all([
const [nextProfile, nextPrefs, nextTickets, presets, nextEngine] = await Promise.all([
userApi.me(),
userApi.preferences(),
userApi.supportTickets().catch((err) => {
setSupportTicketsError(err instanceof Error ? err.message : "지원 요청을 불러오지 못했습니다.");
return null;
}),
userApi.voicePresets(),
isAdmin ? adminEngineApi.get() : Promise.resolve(null),
]);
@ -215,6 +267,8 @@ export default function Settings() {
}
setPreferences(nextPrefs);
setPreferencesReady(true);
setSupportTickets(nextTickets);
setSupportTicketsReady(true);
setDark(nextPrefs.theme === "dark" || (nextPrefs.theme !== "light" && readInitialTheme() === "dark"));
setVoicePresets(presets);
if (nextEngine) {
@ -240,6 +294,7 @@ export default function Settings() {
const navItems: NavItem[] = useMemo(() => {
const base: NavItem[] = [
{ id: "account", label: "계정", icon: "users" },
{ id: "support", label: "지원 요청", icon: "review" },
{ id: "appearance", label: "테마", icon: "settings" },
{ id: "notify", label: "알림", icon: "info" },
{ id: "voice", label: "음성", icon: "mic" },
@ -550,6 +605,73 @@ export default function Settings() {
</div>
</Panel>
<Panel id="set-support" className="vg-set__group vg-set__group--wide">
<div className="vg-set__group-head">
<div>
<div className="vg-set__group-title"> </div>
<div className="vg-set__group-desc">
.
</div>
</div>
{supportTickets ? (
<Badge tone={supportTickets.durable ? "accent" : "neutral"}>
{supportTickets.tickets.length}
</Badge>
) : null}
</div>
{supportTicketsError ? (
<div className="vg-set__state vg-set__state--warn" role="alert">
<span className="vg-set__state-dot" aria-hidden="true" />
<span>{supportTicketsError}</span>
</div>
) : supportTickets ? (
supportTickets.tickets.length > 0 ? (
<div className="vg-set__tickets" aria-label="내 지원 요청 목록">
{supportTickets.tickets.map((ticket) => (
<article className="vg-set__ticket" key={ticket.ticket_id}>
<div className="vg-set__ticket-top">
<Badge tone={ticketStatusTone(ticket.status)}>
{TICKET_STATUS_LABEL[ticket.status]}
</Badge>
<span>{TICKET_CATEGORY_LABEL[ticket.category]}</span>
<span>{TICKET_PRIORITY_LABEL[ticket.priority]}</span>
</div>
<div className="vg-set__ticket-title">{ticket.subject}</div>
<div className="vg-set__ticket-meta">
<span> {formatTicketDate(ticket.created_at)}</span>
<span> {formatTicketDate(ticket.updated_at)}</span>
{ticket.assigned_group ? <span>{ticket.assigned_group}</span> : null}
</div>
{ticket.resolution_note ? (
<div className="vg-set__ticket-note">
<b> </b>
<span>{ticket.resolution_note}</span>
</div>
) : (
<div className="vg-set__ticket-note vg-set__ticket-note--empty">
.
</div>
)}
</article>
))}
</div>
) : (
<SettingsLoadingState testId="settings-support-empty">
.
</SettingsLoadingState>
)
) : supportTicketsReady ? (
<SettingsLoadingState testId="settings-support-empty">
.
</SettingsLoadingState>
) : (
<SettingsSkeleton testId="settings-support-loading" variant="rows">
.
</SettingsSkeleton>
)}
</Panel>
{isAdmin ? (
<Panel
id="set-engine"