snp-batch-validation/frontend/src/pages/BypassAccountRequests.tsx
HYOJIN ad18ab9c30 feat(email): Bypass API 계정 이메일 알림 및 거절 후속 조치 (#140)
- 이메일 공통 모듈 (spring-boot-starter-mail, EmailService, Thymeleaf 템플릿)
- 승인 시 계정 발급 이메일 / 거절 시 사유 이메일 자동 발송
- 재심사 기능 (REJECTED → PENDING)
- UI 텍스트 리레이블링 (S&P Global API)
- 신청 폼 전화번호 필드 제거 및 레이아웃 개선
2026-04-03 10:34:45 +09:00

625 lines
34 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import {
bypassAccountApi,
type BypassRequestResponse,
type BypassAccountResponse,
type PageResponse,
} from '../api/bypassAccountApi';
import { useToastContext } from '../contexts/ToastContext';
import Pagination from '../components/Pagination';
import LoadingSpinner from '../components/LoadingSpinner';
const STATUS_TABS = [
{ value: '', label: '전체' },
{ value: 'PENDING', label: 'PENDING' },
{ value: 'APPROVED', label: 'APPROVED' },
{ value: 'REJECTED', label: 'REJECTED' },
] as const;
const STATUS_BADGE_COLORS: Record<string, string> = {
PENDING: 'bg-amber-100 text-amber-700',
APPROVED: 'bg-emerald-100 text-emerald-700',
REJECTED: 'bg-red-100 text-red-700',
};
const PAGE_SIZE = 20;
interface ApproveFormState {
reviewedBy: string;
accessStartDate: string;
accessEndDate: string;
}
interface RejectFormState {
reviewedBy: string;
rejectReason: string;
}
export default function BypassAccountRequests() {
const { showToast } = useToastContext();
const [pageData, setPageData] = useState<PageResponse<BypassRequestResponse> | null>(null);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(0);
// Approve modal
const [approveTarget, setApproveTarget] = useState<BypassRequestResponse | null>(null);
const [approveForm, setApproveForm] = useState<ApproveFormState>({
reviewedBy: '',
accessStartDate: '',
accessEndDate: '',
});
const [approveSubmitting, setApproveSubmitting] = useState(false);
// Reject modal
const [rejectTarget, setRejectTarget] = useState<BypassRequestResponse | null>(null);
const [rejectForm, setRejectForm] = useState<RejectFormState>({ reviewedBy: '', rejectReason: '' });
const [rejectSubmitting, setRejectSubmitting] = useState(false);
// Detail modal
const [detailTarget, setDetailTarget] = useState<BypassRequestResponse | null>(null);
// Credential modal
const [credentialAccount, setCredentialAccount] = useState<BypassAccountResponse | null>(null);
const loadRequests = useCallback(async () => {
setLoading(true);
try {
const res = await bypassAccountApi.getRequests(statusFilter || undefined, page, PAGE_SIZE);
setPageData(res.data ?? null);
} catch (err) {
showToast('신청 목록 조회 실패', 'error');
console.error(err);
} finally {
setLoading(false);
}
}, [showToast, statusFilter, page]);
useEffect(() => {
loadRequests();
}, [loadRequests]);
const handleStatusFilterChange = (value: string) => {
setStatusFilter(value);
setPage(0);
};
const openApproveModal = (req: BypassRequestResponse) => {
setApproveTarget(req);
setApproveForm({ reviewedBy: '', accessStartDate: '', accessEndDate: '' });
};
const openRejectModal = (req: BypassRequestResponse) => {
setRejectTarget(req);
setRejectForm({ reviewedBy: '', rejectReason: '' });
};
const handleApproveSubmit = async () => {
if (!approveTarget) return;
if (!approveForm.reviewedBy.trim()) {
showToast('검토자명을 입력해주세요.', 'error');
return;
}
setApproveSubmitting(true);
try {
const res = await bypassAccountApi.approveRequest(approveTarget.id, {
reviewedBy: approveForm.reviewedBy,
accessStartDate: approveForm.accessStartDate || undefined,
accessEndDate: approveForm.accessEndDate || undefined,
});
setApproveTarget(null);
setCredentialAccount(res.data);
showToast('신청이 승인되었습니다.', 'success');
await loadRequests();
} catch (err) {
showToast('승인 처리 실패', 'error');
console.error(err);
} finally {
setApproveSubmitting(false);
}
};
const handleRejectSubmit = async () => {
if (!rejectTarget) return;
if (!rejectForm.reviewedBy.trim()) {
showToast('검토자명을 입력해주세요.', 'error');
return;
}
setRejectSubmitting(true);
try {
await bypassAccountApi.rejectRequest(rejectTarget.id, {
reviewedBy: rejectForm.reviewedBy,
rejectReason: rejectForm.rejectReason || undefined,
});
setRejectTarget(null);
showToast('신청이 거절되었습니다.', 'success');
await loadRequests();
} catch (err) {
showToast('거절 처리 실패', 'error');
console.error(err);
} finally {
setRejectSubmitting(false);
}
};
if (loading && !pageData) return <LoadingSpinner />;
const requests = pageData?.content ?? [];
return (
<div className="space-y-6">
{/* 헤더 */}
<div>
<h1 className="text-2xl font-bold text-wing-text"> </h1>
<p className="mt-1 text-sm text-wing-muted">
Bypass API .
</p>
</div>
{/* 상태 필터 탭 */}
<div className="bg-wing-surface rounded-xl shadow-md p-4">
<div className="flex gap-2 flex-wrap">
{STATUS_TABS.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => handleStatusFilterChange(tab.value)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
statusFilter === tab.value
? 'bg-wing-accent text-white'
: 'bg-wing-card text-wing-muted hover:text-wing-text border border-wing-border'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 테이블 */}
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border bg-wing-card">
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"> </th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"> </th>
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border">
{requests.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-wing-muted text-sm">
.
</td>
</tr>
) : (
requests.map((req) => (
<tr key={req.id} className="hover:bg-wing-hover transition-colors">
<td className="px-4 py-3">
<span
className={[
'px-2 py-0.5 text-xs font-semibold rounded-full',
STATUS_BADGE_COLORS[req.status] ?? 'bg-wing-card text-wing-muted border border-wing-border',
].join(' ')}
>
{req.status}
</span>
</td>
<td className="px-4 py-3 font-medium text-wing-text">
{req.applicantName}
</td>
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
{req.createdAt
? new Date(req.createdAt).toLocaleDateString('ko-KR')
: '-'}
</td>
<td className="px-4 py-3 text-xs text-wing-muted">
{req.requestedAccessPeriod ?? '-'}
</td>
<td className="px-4 py-3 text-xs text-wing-muted">
{req.organization ?? '-'}
</td>
<td className="px-4 py-3 text-xs text-wing-muted">
{req.email ?? '-'}
</td>
<td className="px-4 py-3 text-xs text-wing-muted max-w-[200px]">
<span className="truncate block" title={req.purpose ?? ''}>
{req.purpose ?? '-'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex justify-end">
<button
type="button"
onClick={() => setDetailTarget(req)}
className="px-3 py-1.5 text-xs font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
>
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{pageData && pageData.totalPages > 1 && (
<div className="px-4 py-3 border-t border-wing-border">
<Pagination
page={pageData.number}
totalPages={pageData.totalPages}
totalElements={pageData.totalElements}
pageSize={PAGE_SIZE}
onPageChange={setPage}
/>
</div>
)}
</div>
{/* 승인 모달 */}
{approveTarget && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
onClick={() => setApproveTarget(null)}
>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-wing-text mb-4"> </h3>
{/* 신청 상세 정보 */}
<div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4">
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text font-medium">{approveTarget.applicantName}</span>
</div>
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{approveTarget.organization ?? '-'}</span>
</div>
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{approveTarget.email ?? '-'}</span>
</div>
</div>
<div className="mt-2 text-xs">
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span>
</div>
{approveTarget.purpose && (
<div className="mt-2 pt-2 border-t border-wing-border text-xs">
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{approveTarget.purpose}</span>
</div>
)}
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={approveForm.reviewedBy}
onChange={(e) => setApproveForm((f) => ({ ...f, reviewedBy: e.target.value }))}
placeholder="검토자 이름 입력"
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<input
type="date"
value={approveForm.accessStartDate}
onChange={(e) => setApproveForm((f) => ({ ...f, accessStartDate: e.target.value }))}
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
/>
</div>
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<input
type="date"
value={approveForm.accessEndDate}
onChange={(e) => setApproveForm((f) => ({ ...f, accessEndDate: e.target.value }))}
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
/>
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={() => { setDetailTarget(approveTarget); setApproveTarget(null); }}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
<button
type="button"
onClick={handleApproveSubmit}
disabled={approveSubmitting}
className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{approveSubmitting ? '처리 중...' : '승인'}
</button>
</div>
</div>
</div>
)}
{/* 거절 모달 */}
{rejectTarget && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
onClick={() => setRejectTarget(null)}
>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-wing-text mb-4"> </h3>
{/* 신청 상세 정보 */}
<div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4">
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text font-medium">{rejectTarget.applicantName}</span>
</div>
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{rejectTarget.organization ?? '-'}</span>
</div>
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{rejectTarget.email ?? '-'}</span>
</div>
</div>
<div className="mt-2 text-xs">
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span>
</div>
{rejectTarget.purpose && (
<div className="mt-2 pt-2 border-t border-wing-border text-xs">
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{rejectTarget.purpose}</span>
</div>
)}
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={rejectForm.reviewedBy}
onChange={(e) => setRejectForm((f) => ({ ...f, reviewedBy: e.target.value }))}
placeholder="검토자 이름 입력"
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
/>
</div>
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<textarea
value={rejectForm.rejectReason}
onChange={(e) => setRejectForm((f) => ({ ...f, rejectReason: e.target.value }))}
placeholder="거절 사유를 입력하세요 (선택)"
rows={3}
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50 resize-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={() => { setDetailTarget(rejectTarget); setRejectTarget(null); }}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
<button
type="button"
onClick={handleRejectSubmit}
disabled={rejectSubmitting}
className="px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{rejectSubmitting ? '처리 중...' : '거절'}
</button>
</div>
</div>
</div>
)}
{/* 신청 상세 모달 */}
{detailTarget && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
onClick={() => setDetailTarget(null)}
>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-wing-text mb-4"> </h3>
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-wing-muted mb-0.5"></div>
<div className="text-wing-text font-medium">{detailTarget.applicantName}</div>
</div>
<div>
<div className="text-xs text-wing-muted mb-0.5"></div>
<div className="text-wing-text">{detailTarget.organization ?? '-'}</div>
</div>
<div>
<div className="text-xs text-wing-muted mb-0.5"></div>
<div className="text-wing-text">{detailTarget.email ?? '-'}</div>
</div>
</div>
<div>
<div className="text-xs text-wing-muted mb-0.5"> </div>
<div className="text-wing-text">{detailTarget.requestedAccessPeriod ?? '-'}</div>
</div>
<div>
<div className="text-xs text-wing-muted mb-0.5"> </div>
<div className="text-wing-text whitespace-pre-wrap bg-wing-card rounded-lg p-3 border border-wing-border max-h-48 overflow-y-auto">
{detailTarget.purpose || '-'}
</div>
</div>
{detailTarget.status !== 'PENDING' && (
<div className="border-t border-wing-border pt-3 space-y-2">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-wing-muted mb-0.5"> </div>
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${STATUS_BADGE_COLORS[detailTarget.status] ?? ''}`}>
{detailTarget.status}
</span>
</div>
<div>
<div className="text-xs text-wing-muted mb-0.5"></div>
<div className="text-wing-text">{detailTarget.reviewedBy ?? '-'}</div>
</div>
</div>
{detailTarget.rejectReason && (
<div>
<div className="text-xs text-wing-muted mb-0.5"> </div>
<div className="text-wing-text">{detailTarget.rejectReason}</div>
</div>
)}
</div>
)}
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={() => setDetailTarget(null)}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
{detailTarget.status === 'PENDING' && (
<>
<button
type="button"
onClick={() => { setDetailTarget(null); openRejectModal(detailTarget); }}
className="px-4 py-2 text-sm font-medium text-red-500 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
>
</button>
<button
type="button"
onClick={() => { setDetailTarget(null); openApproveModal(detailTarget); }}
className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors"
>
</button>
</>
)}
{detailTarget.status === 'REJECTED' && (
<button
type="button"
onClick={async () => {
try {
await bypassAccountApi.reopenRequest(detailTarget.id);
setDetailTarget(null);
showToast('재심사 상태로 변경되었습니다.', 'success');
await loadRequests();
} catch (err) {
showToast('재심사 처리 실패', 'error');
console.error(err);
}
}}
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors"
>
</button>
)}
</div>
</div>
</div>
)}
{/* 계정 발급 완료 모달 */}
{credentialAccount && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
onClick={() => setCredentialAccount(null)}
>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="text-lg font-bold text-wing-text mb-4"> </div>
<div className="bg-amber-50 border border-amber-300 rounded-lg p-3 text-amber-800 text-xs mb-4">
.
</div>
<div className="space-y-3">
<div>
<div className="text-xs text-wing-muted mb-1"></div>
<div className="flex items-center gap-2 bg-wing-card rounded-lg p-3 border border-wing-border">
<code className="text-sm font-mono flex-1">{credentialAccount.username}</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(credentialAccount.username);
showToast('복사됨', 'success');
}}
className="text-xs text-blue-600 hover:underline shrink-0"
>
</button>
</div>
</div>
<div>
<div className="text-xs text-wing-muted mb-1"></div>
<div className="flex items-center gap-2 bg-wing-card rounded-lg p-3 border border-wing-border">
<code className="text-sm font-mono flex-1">{credentialAccount.plainPassword}</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(credentialAccount.plainPassword!);
showToast('복사됨', 'success');
}}
className="text-xs text-blue-600 hover:underline shrink-0"
>
</button>
</div>
</div>
</div>
<button
type="button"
onClick={() => setCredentialAccount(null)}
className="mt-6 w-full py-2 rounded-lg bg-slate-900 text-white text-sm font-bold hover:bg-slate-800"
>
</button>
</div>
</div>
)}
</div>
);
}