/** * Copyright 2025 LY Corporation * * LY Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at: * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations * under the License. */ import { useEffect, useMemo, useState } from 'react'; import type { GetServerSideProps } from 'next'; import { toast } from '@ufb/react'; import { formatSupportTimestamp } from '@/features/support-portal/lib/format-support-timestamp'; import SupportOperationsShell from '@/features/support-portal/ui/support-operations-shell.ui'; import SupportStatusBadge from '@/features/support-portal/ui/support-status-badge.ui'; import { SUPPORT_TEST_ADMIN_USER_EMAIL, SUPPORT_TEST_TENANT_ID } from '@/shared'; import type { NextPageWithLayout } from '@/shared/types'; import type { SupportTicketRecord } from '@/server/support-stub'; import serverSideTranslations from '@/server-side-translations'; import { AnonymousLayout } from '@/widgets/anonymous-layout'; interface SupportTicketCommentRecord { comment_id: number; ticket_id: number; author_id: string; author_tenant_id: string; author_name: string; content: string; is_internal: boolean; created_at: string; updated_at: string; edited_at: string | null; } const AdminIssuesPage: NextPageWithLayout = () => { const [tickets, setTickets] = useState([]); const [ticketComments, setTicketComments] = useState>({}); const [commentDrafts, setCommentDrafts] = useState>({}); const [isLoading, setIsLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(''); const [workingId, setWorkingId] = useState(null); const loadTickets = async () => { setIsLoading(true); setErrorMessage(''); try { const response = await fetch('/api/support/tickets'); const data = (await response.json()) as SupportTicketRecord[] | { message?: string }; if (!response.ok) { throw new Error('message' in data && data.message ? data.message : '이슈 대상을 불러오지 못했습니다.'); } const nextTickets = data as SupportTicketRecord[]; setTickets(nextTickets); const commentEntries = await Promise.all( nextTickets.map(async (ticket) => { try { const commentsResponse = await fetch(`/api/support/tickets/${ticket.ticket_id}/comments`); const commentsData = (await commentsResponse.json()) as SupportTicketCommentRecord[] | { message?: string }; if (!commentsResponse.ok) { return [ticket.ticket_id, []] as const; } return [ticket.ticket_id, commentsData as SupportTicketCommentRecord[]] as const; } catch { return [ticket.ticket_id, []] as const; } }), ); setTicketComments((Object.fromEntries(commentEntries) as Record)); } catch (error) { setErrorMessage(error instanceof Error ? error.message : '이슈 대상 조회 중 오류가 발생했습니다.'); } finally { setIsLoading(false); } }; useEffect(() => { void loadTickets(); }, []); const issueCandidates = useMemo( () => tickets.filter( (ticket) => ticket.approval_status === 'APPROVED' || ticket.issue_link_status === 'LINKED', ), [tickets], ); const handleCreateIssue = async (ticketId: number) => { setWorkingId(ticketId); try { const response = await fetch(`/api/support/tickets/${ticketId}/issue`, { method: 'POST', }); const data = (await response.json()) as { message?: string }; if (!response.ok) { throw new Error(data.message ?? '이슈 연결에 실패했습니다.'); } await loadTickets(); } catch (error) { setErrorMessage(error instanceof Error ? error.message : '이슈 연결 중 오류가 발생했습니다.'); } finally { setWorkingId(null); } }; const handleCreateComment = async (ticketId: number) => { const nextContent = (commentDrafts[ticketId] ?? '').trim(); if (!nextContent) { toast.error('관리자 댓글 내용을 입력해 주세요.'); return; } setWorkingId(ticketId); try { const response = await fetch(`/api/support/tickets/${ticketId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ requester_id: SUPPORT_TEST_ADMIN_USER_EMAIL, requester_tenant_id: SUPPORT_TEST_TENANT_ID, author_name: 'Admin User', content: nextContent, is_internal: true, }), }); const data = (await response.json()) as SupportTicketCommentRecord | { message?: string }; if (!response.ok) { throw new Error('message' in data && data.message ? data.message : '관리자 댓글 저장에 실패했습니다.'); } setTicketComments((current) => ({ ...current, [ticketId]: [...(current[ticketId] ?? []), data as SupportTicketCommentRecord], })); setCommentDrafts((current) => ({ ...current, [ticketId]: '', })); toast.success('관리자 댓글이 저장되었습니다.'); } catch (error) { const message = error instanceof Error ? error.message : '관리자 댓글 저장 중 오류가 발생했습니다.'; setErrorMessage(message); toast.error(message); } finally { setWorkingId(null); } }; return (

Issue status

candidates={issueCandidates.length}
linked={issueCandidates.filter((ticket) => ticket.issue_link_status === 'LINKED').length}

} >

Issue handoff

이슈 생성 대상

{isLoading &&

이슈 대상을 불러오는 중입니다...

} {!isLoading && errorMessage && (
{errorMessage}
)} {!isLoading && !errorMessage && issueCandidates.length === 0 && (
승인 완료된 요청이 아직 없어 이슈 생성 대상이 없습니다.
)}
{issueCandidates.map((ticket) => { const canCreateIssue = ticket.approval_status === 'APPROVED' && ticket.issue_link_status !== 'LINKED'; const comments = ticketComments[ticket.ticket_id] ?? []; const commentDraft = commentDrafts[ticket.ticket_id] ?? ''; const isWorking = workingId === ticket.ticket_id; return (
{ticket.workspace_code} • Ticket {ticket.ticket_id}

{ticket.title}

{ticket.description}

{ticket.external_issue_status && ( )}

Latest activity

{ticket.activity[0]?.detail ?? '활동 이력이 없습니다.'}

Comments

{comments.length}건
{comments.length === 0 ? (

등록된 댓글이 없습니다.

) : (
{comments.slice(-3).reverse().map((comment) => (

{comment.author_name}

{comment.is_internal ? 'internal' : 'user'}
{formatSupportTimestamp(comment.created_at)}

{comment.content}

))}
)}

Admin internal note