음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue