운영 화면과 회기 리뷰 UI 갱신
This commit is contained in:
parent
e7ebb38177
commit
3a9f70a97b
18 changed files with 2210 additions and 137 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue