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

221 lines
9.2 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 SupportOperationsShell from '@/features/support-portal/ui/support-operations-shell.ui';
import SupportStatusBadge from '@/features/support-portal/ui/support-status-badge.ui';
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';
const OpsPage: NextPageWithLayout = () => {
const [tickets, setTickets] = useState<SupportTicketRecord[]>([]);
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 : '승인 큐를 불러오지 못했습니다.');
}
setTickets(data as SupportTicketRecord[]);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '승인 큐 조회 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void loadTickets();
}, []);
const pendingTickets = useMemo(
() => tickets.filter((ticket) => ticket.requires_approval && ticket.approval_status === 'PENDING'),
[tickets],
);
const approvedTickets = useMemo(
() => tickets.filter((ticket) => ticket.approval_status === 'APPROVED'),
[tickets],
);
const handleApprove = async (ticketId: number) => {
setWorkingId(ticketId);
try {
const response = await fetch(`/api/support/tickets/${ticketId}/approve`, {
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);
}
};
return (
<SupportOperationsShell
currentTab="ops"
eyebrow="Ops Console"
title="운영 승인 큐에서 내부 승인 흐름을 정리하는 화면"
description={[
'승인형 요청만 추려서 운영자가 빠르게 확인하고 승인할 수 있게 구성했습니다.',
'승인 후에는 같은 스텁 저장소에서 관리자 이슈 연결 페이지로 곧바로 이어집니다.',
].join('\n')}
aside={
<div className="rounded-[24px] bg-[#f5efe2] p-4 text-[#594f39]">
<p className="text-xs uppercase tracking-[0.22em] text-[#8a7c5c]">Queue state</p>
<p className="mt-2 text-sm leading-6">
pending={pendingTickets.length}
<br />
approved={approvedTickets.length}
</p>
</div>
}
>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.25fr)_360px]">
<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]">Approval queue</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 && pendingTickets.length === 0 && (
<div className="mt-6 rounded-[24px] bg-[#f3f5ef] p-8 text-sm text-[#526556]">
현재 승인 대기 중인 요청이 없습니다.
</div>
)}
<div className="mt-6 grid gap-4">
{pendingTickets.map((ticket) => (
<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_name}</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="status" value={ticket.status_code} />
<SupportStatusBadge label="approval" value={ticket.approval_status} />
</div>
</div>
<div className="mt-5 flex flex-wrap items-center gap-2">
<SupportStatusBadge label="sync" value={ticket.sync_status} />
<span className="rounded-full bg-[#f0eadc] px-3 py-1 text-xs font-medium text-[#6d5a39]">
requester {ticket.requester_id}
</span>
</div>
<div className="mt-5 flex items-center justify-between gap-4 rounded-[24px] bg-[#f6faf5] px-4 py-4">
<p className="text-sm text-[#516456]">
승인 시 운영 큐 상태가 정리되고, 관리자 페이지에서 이슈 생성이 가능해집니다.
</p>
<button
type="button"
disabled={workingId === ticket.ticket_id}
onClick={() => void handleApprove(ticket.ticket_id)}
className="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]"
>
{workingId === ticket.ticket_id ? '승인 처리 중...' : '승인'}
</button>
</div>
</article>
))}
</div>
</section>
<aside className="flex flex-col gap-5">
<section className="rounded-[28px] border border-[#dce6da] bg-white/78 p-6 shadow-[0_18px_45px_rgba(43,63,50,0.08)]">
<p className="text-xs uppercase tracking-[0.24em] text-[#6d8272]">Approved backlog</p>
<div className="mt-4 flex flex-col gap-3">
{approvedTickets.length === 0 ? (
<p className="rounded-[22px] bg-[#eef4eb] px-4 py-4 text-sm text-[#516456]">
아직 승인 완료 항목이 없습니다.
</p>
) : (
approvedTickets.map((ticket) => (
<div key={ticket.ticket_id} className="rounded-[22px] bg-[#eef4eb] px-4 py-4 text-sm text-[#2d4a39]">
<p className="font-medium">{ticket.title}</p>
<p className="mt-2 text-xs uppercase tracking-[0.18em] text-[#6b816e]">
Ticket {ticket.ticket_id}
</p>
</div>
))
)}
</div>
</section>
</aside>
</div>
</SupportOperationsShell>
);
};
OpsPage.getLayout = (page: React.ReactNode) => {
return <AnonymousLayout>{page}</AnonymousLayout>;
};
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
return {
props: {
...(await serverSideTranslations(locale)),
},
};
};
export default OpsPage;