음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -7,6 +7,7 @@ import {
type AdminHealthResponse,
type AdminHealthStatus,
type AdminManagedUser,
type AdminTicketFilters,
type AdminSupportTicket,
type AdminTicketsResponse,
type AdminUsageResponse,
@ -27,9 +28,36 @@ type NewUserDraft = Required<Pick<AdminUserCreateRequest, "email" | "display_nam
Pick<UserDraft, "admin_access" | "account_status" | "affiliation" | "cohort_ids">;
type UserTab = "approval" | "manage" | "register" | "activity";
type AccessTab = "roles" | "groups" | "matrix";
type TicketStatusFilter = AdminSupportTicket["status"] | "all";
type TicketCategoryFilter = AdminSupportTicket["category"] | "all";
type TicketPriorityFilter = AdminSupportTicket["priority"] | "all";
const MAX_RENDERED_USERS = 40;
const MAX_RESOLVED_TICKET_HISTORY = 6;
const TICKET_STATUS_OPTIONS: Array<{ value: TicketStatusFilter; label: string }> = [
{ value: "all", label: "전체 상태" },
{ value: "open", label: "미해결" },
{ value: "triaged", label: "분류됨" },
{ value: "in_progress", label: "처리 중" },
{ value: "resolved", label: "해결" },
{ value: "closed", label: "종결" },
];
const TICKET_CATEGORY_OPTIONS: Array<{ value: TicketCategoryFilter; label: string }> = [
{ value: "all", label: "전체 카테고리" },
{ value: "account_access", label: "계정/권한" },
{ value: "session_review", label: "세션/리뷰" },
{ value: "voice_browser", label: "음성/브라우저" },
{ value: "content_scenario", label: "콘텐츠/시나리오" },
{ value: "safety", label: "안전" },
{ value: "other", label: "기타" },
];
const TICKET_PRIORITY_OPTIONS: Array<{ value: TicketPriorityFilter; label: string }> = [
{ value: "all", label: "전체 우선순위" },
{ value: "urgent", label: "긴급" },
{ value: "high", label: "높음" },
{ value: "normal", label: "보통" },
{ value: "low", label: "낮음" },
];
const EMPTY_NEW_USER: NewUserDraft = {
email: "",
@ -310,6 +338,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
const [ticketsLoading, setTicketsLoading] = useState(true);
const [ticketsError, setTicketsError] = useState<string | null>(null);
const [updatingTicketId, setUpdatingTicketId] = useState<string | null>(null);
const [ticketStatusFilter, setTicketStatusFilter] = useState<TicketStatusFilter>("all");
const [ticketCategoryFilter, setTicketCategoryFilter] = useState<TicketCategoryFilter>("all");
const [ticketPriorityFilter, setTicketPriorityFilter] = useState<TicketPriorityFilter>("all");
const [ticketSearch, setTicketSearch] = useState("");
const [ticketAssignedGroup, setTicketAssignedGroup] = useState("");
const [ticketStaleOnly, setTicketStaleOnly] = useState(false);
const [userTab, setUserTab] = useState<UserTab>("approval");
const [accessTab, setAccessTab] = useState<AccessTab>("roles");
const canGrantAdminAccess = currentUser?.superAdmin === true;
@ -384,13 +418,29 @@ export default function Admin({ section = "overview" }: AdminProps) {
setTicketsLoading(true);
setTicketsError(null);
try {
setTickets(await adminApi.tickets(undefined, 30));
const filters: AdminTicketFilters = {
status: ticketStatusFilter === "all" ? "" : ticketStatusFilter,
category: ticketCategoryFilter === "all" ? "" : ticketCategoryFilter,
priority: ticketPriorityFilter === "all" ? "" : ticketPriorityFilter,
assignedGroup: ticketAssignedGroup,
staleOnly: ticketStaleOnly,
search: ticketSearch,
windowDays: 30,
};
setTickets(await adminApi.tickets(filters));
} catch (err) {
setTicketsError(err instanceof Error ? err.message : "운영 티켓을 불러오지 못했습니다.");
} finally {
setTicketsLoading(false);
}
}, []);
}, [
ticketAssignedGroup,
ticketCategoryFilter,
ticketPriorityFilter,
ticketSearch,
ticketStaleOnly,
ticketStatusFilter,
]);
useEffect(() => {
void loadHealth();
@ -636,6 +686,28 @@ export default function Admin({ section = "overview" }: AdminProps) {
.slice(0, MAX_RESOLVED_TICKET_HISTORY),
[tickets],
);
const categoryQueues = useMemo(
() =>
Object.entries(tickets?.summary.by_category ?? {})
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1]),
[tickets],
);
const hasTicketFilters =
ticketStatusFilter !== "all" ||
ticketCategoryFilter !== "all" ||
ticketPriorityFilter !== "all" ||
ticketStaleOnly ||
ticketAssignedGroup.trim().length > 0 ||
ticketSearch.trim().length > 0;
const clearTicketFilters = () => {
setTicketStatusFilter("all");
setTicketCategoryFilter("all");
setTicketPriorityFilter("all");
setTicketStaleOnly(false);
setTicketAssignedGroup("");
setTicketSearch("");
};
const filteredUsers = useMemo(() => {
const query = userSearch.trim().toLowerCase();
if (!query) return users;
@ -1473,6 +1545,79 @@ export default function Admin({ section = "overview" }: AdminProps) {
<div className="ad-users-note">
DB . .
</div>
<section className="ad-ticket-filter" aria-label="운영 티켓 필터">
<input
value={ticketSearch}
onChange={(event) => setTicketSearch(event.target.value)}
placeholder="제목, 본문, 신고자, 경로 검색"
aria-label="티켓 검색"
/>
<select
value={ticketStatusFilter}
onChange={(event) => setTicketStatusFilter(event.target.value as TicketStatusFilter)}
aria-label="상태 필터"
>
{TICKET_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select
value={ticketCategoryFilter}
onChange={(event) => setTicketCategoryFilter(event.target.value as TicketCategoryFilter)}
aria-label="카테고리 필터"
>
{TICKET_CATEGORY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select
value={ticketPriorityFilter}
onChange={(event) => setTicketPriorityFilter(event.target.value as TicketPriorityFilter)}
aria-label="우선순위 필터"
>
{TICKET_PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<input
value={ticketAssignedGroup}
onChange={(event) => setTicketAssignedGroup(event.target.value)}
placeholder="담당 그룹"
aria-label="담당 그룹 필터"
/>
<label className="ad-ticket-filter__check">
<input
type="checkbox"
checked={ticketStaleOnly}
onChange={(event) => setTicketStaleOnly(event.target.checked)}
/>
<span></span>
</label>
<Button variant="secondary" onClick={clearTicketFilters} disabled={!hasTicketFilters}>
</Button>
</section>
{categoryQueues.length > 0 ? (
<div className="ad-ticket-queues" aria-label="카테고리 큐">
{categoryQueues.map(([category, count]) => (
<button
key={category}
type="button"
className={ticketCategoryFilter === category ? "is-active" : ""}
onClick={() => setTicketCategoryFilter(category as TicketCategoryFilter)}
>
<span>{ticketCategoryLabel(category as AdminSupportTicket["category"])}</span>
<b>{countLabel(count)}</b>
</button>
))}
</div>
) : null}
<section className="ad-section">
{ticketsError ? <InlineError message={ticketsError} /> : null}
@ -1525,6 +1670,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
<p>{ticket.body}</p>
<small>
{ticket.source_path || "출처 없음"} · {dateTimeLabel(ticket.created_at)}
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}` : ""}
</small>
</div>
<Badge tone={ticketTone(ticket)}>{ticketPriorityLabel(ticket.priority)}</Badge>
@ -1572,6 +1719,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
<p>{ticket.body}</p>
<small>
{ticket.source_path || "출처 없음"} · {dateTimeLabel(ticket.created_at)}
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}` : ""}
</small>
</div>
<Badge tone="neutral">{ticketPriorityLabel(ticket.priority)}</Badge>