701 lines
40 KiB
TypeScript
701 lines
40 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-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={7} 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">
|
|
<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 space-y-2 text-xs">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<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>
|
|
<div>
|
|
<span className="text-wing-muted">이메일: </span>
|
|
<span className="text-wing-text">{approveTarget.email ?? '-'}</span>
|
|
</div>
|
|
{approveTarget.projectName && (
|
|
<div>
|
|
<span className="text-wing-muted">프로젝트/서비스명: </span>
|
|
<span className="text-wing-text">{approveTarget.projectName}</span>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<span className="text-wing-muted">신청 사용 기간: </span>
|
|
<span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span>
|
|
</div>
|
|
{approveTarget.serviceIps && (() => {
|
|
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
|
|
try { ips = JSON.parse(approveTarget.serviceIps); } catch {}
|
|
if (ips.length === 0) return null;
|
|
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
|
|
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
|
|
return (
|
|
<div>
|
|
<div className="text-wing-muted mb-1">서비스 IP</div>
|
|
<div className="bg-wing-surface rounded-lg border border-wing-border p-2 space-y-1">
|
|
{ips.map((ip, i) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<code className="font-mono text-wing-text">{ip.ip}</code>
|
|
<span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
|
|
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
|
|
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
{approveTarget.purpose && (
|
|
<div>
|
|
<div className="text-wing-muted mb-1">신청 사유</div>
|
|
<div className="text-wing-text bg-wing-surface rounded-lg border border-wing-border p-2 whitespace-pre-wrap">{approveTarget.purpose}</div>
|
|
</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 space-y-2 text-xs">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<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>
|
|
<div>
|
|
<span className="text-wing-muted">이메일: </span>
|
|
<span className="text-wing-text">{rejectTarget.email ?? '-'}</span>
|
|
</div>
|
|
{rejectTarget.projectName && (
|
|
<div>
|
|
<span className="text-wing-muted">프로젝트/서비스명: </span>
|
|
<span className="text-wing-text">{rejectTarget.projectName}</span>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<span className="text-wing-muted">신청 사용 기간: </span>
|
|
<span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span>
|
|
</div>
|
|
{rejectTarget.serviceIps && (() => {
|
|
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
|
|
try { ips = JSON.parse(rejectTarget.serviceIps); } catch {}
|
|
if (ips.length === 0) return null;
|
|
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
|
|
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
|
|
return (
|
|
<div>
|
|
<div className="text-wing-muted mb-1">서비스 IP</div>
|
|
<div className="bg-wing-surface rounded-lg border border-wing-border p-2 space-y-1">
|
|
{ips.map((ip, i) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<code className="font-mono text-wing-text">{ip.ip}</code>
|
|
<span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
|
|
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
|
|
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
{rejectTarget.purpose && (
|
|
<div>
|
|
<div className="text-wing-muted mb-1">신청 사유</div>
|
|
<div className="text-wing-text bg-wing-surface rounded-lg border border-wing-border p-2 whitespace-pre-wrap">{rejectTarget.purpose}</div>
|
|
</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 className="text-xs text-wing-muted mb-0.5">프로젝트/서비스명</div>
|
|
<div className="text-wing-text">{detailTarget.projectName ?? '-'}</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-wing-muted mb-0.5">신청 사용 기간</div>
|
|
<div className="text-wing-text">{detailTarget.requestedAccessPeriod ?? '-'}</div>
|
|
</div>
|
|
{detailTarget.serviceIps && (() => {
|
|
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
|
|
try { ips = JSON.parse(detailTarget.serviceIps); } catch {}
|
|
if (ips.length === 0) return null;
|
|
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
|
|
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
|
|
return (
|
|
<div>
|
|
<div className="text-xs text-wing-muted mb-1">서비스 IP</div>
|
|
<div className="bg-wing-card rounded-lg border border-wing-border p-2 space-y-1">
|
|
{ips.map((ip, i) => (
|
|
<div key={i} className="flex items-center gap-2 text-xs">
|
|
<code className="font-mono text-wing-text">{ip.ip}</code>
|
|
<span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
|
|
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
|
|
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
|
|
</div>
|
|
))}
|
|
</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>
|
|
);
|
|
}
|