Files
egbim_qa_platform/apps/web/src/pages/admin/issues.tsx
T
root 33453ecc55
Deploy staging / deploy (push) Failing after 6s
Initial deployment setup
2026-08-31 16:45:24 +09:00

354 lines
15 KiB
TypeScript

/**
* 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<SupportTicketRecord[]>([]);
const [ticketComments, setTicketComments] = useState<Record<number, SupportTicketCommentRecord[]>>({});
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [workingId, setWorkingId] = useState<number | null>(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<number, SupportTicketCommentRecord[]>));
} 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 (
<SupportOperationsShell
currentTab="admin"
eyebrow="Admin Console"
title="승인 완료 요청을 ABC 이슈로 연결하는 관리자 화면"
description={[
'운영 승인 이후 항목만 활성화해서 이슈 생성 책임이 어디서 시작되는지 분리했습니다.',
'feedback_status는 고객 피드백 처리 상태로, issue_link_status와 분리되어 관리됩니다.',
].join('\n')}
aside={
<div className="rounded-[24px] bg-[#143125] p-4 text-[#edf4e9]">
<p className="text-xs uppercase tracking-[0.22em] text-[#afc5b0]">Issue status</p>
<p className="mt-2 text-sm leading-6">
candidates={issueCandidates.length}
<br />
linked={issueCandidates.filter((ticket) => ticket.issue_link_status === 'LINKED').length}
</p>
</div>
}
>
<section className="rounded-[32px] border border-white/80 bg-white/82 p-7 shadow-[0_18px_50px_rgba(43,63,50,0.08)]">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.22em] text-[#6f8374]">Issue handoff</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.02em] text-[#1d3324]">이슈 생성 대상</h2>
</div>
<button
type="button"
onClick={() => void loadTickets()}
className="rounded-full border border-[#ccd8ca] bg-white px-4 py-2 text-sm font-medium text-[#284133] transition hover:bg-[#f7faf5]"
>
새로고침
</button>
</div>
{isLoading && <p className="mt-6 text-sm text-[#506055]">이슈 대상을 불러오는 중입니다...</p>}
{!isLoading && errorMessage && (
<div className="mt-6 rounded-[24px] border border-[#f2c7c7] bg-[#fff4f2] p-4 text-sm text-[#8d4343]">
{errorMessage}
</div>
)}
{!isLoading && !errorMessage && issueCandidates.length === 0 && (
<div className="mt-6 rounded-[24px] bg-[#f3f5ef] p-8 text-sm text-[#526556]">
승인 완료된 요청이 아직 없어 이슈 생성 대상이 없습니다.
</div>
)}
<div className="mt-6 grid gap-4 lg:grid-cols-2">
{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 (
<article
key={ticket.ticket_id}
className="rounded-[28px] border border-[#dde7dc] bg-[linear-gradient(135deg,_rgba(255,255,255,0.96),_rgba(245,250,244,0.92))] p-6"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#6f8374]">
<span>{ticket.workspace_code}</span>
<span>•</span>
<span>Ticket {ticket.ticket_id}</span>
</div>
<h3 className="text-2xl font-semibold tracking-[-0.02em] text-[#1f3526]">{ticket.title}</h3>
<p className="text-sm leading-7 text-[#546759]">{ticket.description}</p>
</div>
<div className="flex flex-col items-start gap-2">
<SupportStatusBadge label="approval" value={ticket.approval_status} />
<SupportStatusBadge label="issue" value={ticket.issue_link_status} />
</div>
</div>
<div className="mt-5 flex flex-wrap items-center gap-2">
<SupportStatusBadge label="feedback" value={ticket.feedback_status} />
<SupportStatusBadge label="workflow" value={ticket.status_code} />
{ticket.external_issue_status && (
<SupportStatusBadge label="external issue" value={ticket.external_issue_status} />
)}
<SupportStatusBadge label="sync" value={ticket.sync_status} />
</div>
<div className="mt-5 rounded-[24px] bg-[#eef4eb] p-4">
<p className="text-xs uppercase tracking-[0.18em] text-[#6b816e]">Latest activity</p>
<p className="mt-2 text-sm leading-7 text-[#425647]">
{ticket.activity[0]?.detail ?? '활동 이력이 없습니다.'}
</p>
</div>
<div className="mt-5 rounded-[24px] bg-[#f8faf6] p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs uppercase tracking-[0.18em] text-[#6b816e]">Comments</p>
<span className="text-xs text-[#758877]">{comments.length}건</span>
</div>
{comments.length === 0 ? (
<p className="mt-2 text-sm leading-7 text-[#647665]">등록된 댓글이 없습니다.</p>
) : (
<div className="mt-3 flex flex-col gap-3">
{comments.slice(-3).reverse().map((comment) => (
<div key={comment.comment_id} className="rounded-[18px] border border-[#dde7dc] bg-white px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-[#2e4332]">{comment.author_name}</p>
<span className={`rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${comment.is_internal ? 'bg-[#143125] text-[#edf4e9]' : 'bg-[#edf4e9] text-[#38533e]'}`}>
{comment.is_internal ? 'internal' : 'user'}
</span>
</div>
<span className="text-xs text-[#839483]">{formatSupportTimestamp(comment.created_at)}</span>
</div>
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-[#506055]">{comment.content}</p>
</div>
))}
</div>
)}
<div className="mt-4 rounded-[18px] border border-[#d7e2d5] bg-white p-3">
<p className="text-xs uppercase tracking-[0.18em] text-[#6b816e]">Admin internal note</p>
<textarea
value={commentDraft}
onChange={(event) =>
setCommentDrafts((current) => ({
...current,
[ticket.ticket_id]: event.target.value,
}))
}
rows={3}
placeholder="관리자 메모나 처리 상황을 남겨 주세요."
className="mt-3 w-full resize-none rounded-[14px] border border-[#d7e2d5] px-3 py-2 text-sm text-[#233426] outline-none transition focus:border-[#6b816e]"
/>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={() => void handleCreateComment(ticket.ticket_id)}
disabled={isWorking}
className="rounded-full bg-[#284133] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#355542] disabled:cursor-not-allowed disabled:bg-[#9aaea1]"
>
{isWorking ? '저장 중...' : '관리자 댓글 저장'}
</button>
</div>
</div>
</div>
<button
type="button"
disabled={!canCreateIssue || isWorking}
onClick={() => void handleCreateIssue(ticket.ticket_id)}
className="mt-5 rounded-full bg-[#173629] px-5 py-2 text-sm font-medium text-white transition hover:bg-[#214937] disabled:cursor-not-allowed disabled:bg-[#9aaea1]"
>
{ticket.issue_link_status === 'LINKED'
? `이슈 연결 완료 ABC-${ticket.ticket_id}`
: isWorking
? '이슈 연결 중...'
: '이슈 생성'}
</button>
</article>
);
})}
</div>
</section>
</SupportOperationsShell>
);
};
AdminIssuesPage.getLayout = (page: React.ReactNode) => {
return <AnonymousLayout>{page}</AnonymousLayout>;
};
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
return {
props: {
...(await serverSideTranslations(locale)),
},
};
};
export default AdminIssuesPage;