snp-batch-validation/frontend/src/pages/BypassAccessRequest.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

452 lines
21 KiB
TypeScript

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
bypassAccountApi,
type BypassRequestSubmitRequest,
} from '../api/bypassAccountApi';
import { useToastContext } from '../contexts/ToastContext';
interface FormState {
applicantName: string;
organization: string;
purpose: string;
email: string;
requestedAccessPeriod: string;
}
interface ErrorState {
applicantName?: string;
email?: string;
requestedAccessPeriod?: string;
}
interface TouchedState {
applicantName: boolean;
email: boolean;
requestedAccessPeriod: boolean;
}
type PeriodPreset = '3개월' | '6개월' | '9개월' | '1년';
const INITIAL_FORM: FormState = {
applicantName: '',
organization: '',
purpose: '',
email: '',
requestedAccessPeriod: '',
};
const INITIAL_TOUCHED: TouchedState = {
applicantName: false,
email: false,
requestedAccessPeriod: false,
};
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function toDateString(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function addMonths(date: Date, months: number): Date {
const result = new Date(date);
result.setMonth(result.getMonth() + months);
return result;
}
function addYears(date: Date, years: number): Date {
const result = new Date(date);
result.setFullYear(result.getFullYear() + years);
return result;
}
function calcPresetPeriod(preset: PeriodPreset): string {
const today = new Date();
const from = toDateString(today);
let to: string;
switch (preset) {
case '3개월': to = toDateString(addMonths(today, 3)); break;
case '6개월': to = toDateString(addMonths(today, 6)); break;
case '9개월': to = toDateString(addMonths(today, 9)); break;
case '1년': to = toDateString(addYears(today, 1)); break;
}
return `${from} ~ ${to}`;
}
function validateForm(form: FormState): ErrorState {
const errors: ErrorState = {};
if (!form.applicantName.trim()) {
errors.applicantName = '신청자명을 입력해주세요.';
}
if (!form.email.trim()) {
errors.email = '이메일을 입력해주세요.';
} else if (!EMAIL_REGEX.test(form.email.trim())) {
errors.email = '올바른 이메일 형식을 입력해주세요.';
}
if (!form.requestedAccessPeriod.trim()) {
errors.requestedAccessPeriod = '사용 기간을 선택해주세요.';
}
return errors;
}
export default function BypassAccessRequest() {
const { showToast } = useToastContext();
const navigate = useNavigate();
const [form, setForm] = useState<FormState>(INITIAL_FORM);
const [submitting, setSubmitting] = useState(false);
const [submittedId, setSubmittedId] = useState<number | null>(null);
// Validation state
const [errors, setErrors] = useState<ErrorState>({});
const [touched, setTouched] = useState<TouchedState>(INITIAL_TOUCHED);
const [submitAttempted, setSubmitAttempted] = useState(false);
// Period mode
const [periodManual, setPeriodManual] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<PeriodPreset | null>(null);
const [periodFrom, setPeriodFrom] = useState('');
const [periodTo, setPeriodTo] = useState('');
const [periodRangeError, setPeriodRangeError] = useState('');
const handleChange = (field: keyof FormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleBlur = (field: keyof TouchedState) => {
setTouched((prev) => ({ ...prev, [field]: true }));
const currentErrors = validateForm(form);
setErrors(currentErrors);
};
const handlePresetClick = (preset: PeriodPreset) => {
setSelectedPreset(preset);
const period = calcPresetPeriod(preset);
handleChange('requestedAccessPeriod', period);
};
const handlePeriodFromChange = (value: string) => {
setPeriodFrom(value);
setPeriodRangeError('');
if (value && periodTo) {
if (value >= periodTo) {
setPeriodRangeError('시작일은 종료일보다 이전이어야 합니다.');
} else {
handleChange('requestedAccessPeriod', `${value} ~ ${periodTo}`);
}
}
};
const handlePeriodToChange = (value: string) => {
setPeriodTo(value);
setPeriodRangeError('');
if (periodFrom && value) {
if (periodFrom >= value) {
setPeriodRangeError('시작일은 종료일보다 이전이어야 합니다.');
} else {
handleChange('requestedAccessPeriod', `${periodFrom} ~ ${value}`);
}
}
};
const handleToggleManual = () => {
setPeriodManual((prev) => {
const next = !prev;
if (!next) {
// Switching back to preset: clear manual inputs
setPeriodFrom('');
setPeriodTo('');
setPeriodRangeError('');
setSelectedPreset(null);
handleChange('requestedAccessPeriod', '');
} else {
// Switching to manual: clear preset selection
setSelectedPreset(null);
handleChange('requestedAccessPeriod', '');
}
return next;
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitAttempted(true);
setTouched({ applicantName: true, email: true, requestedAccessPeriod: true });
const currentErrors = validateForm(form);
if (periodManual && periodRangeError) {
return;
}
if (Object.keys(currentErrors).length > 0) {
setErrors(currentErrors);
// 첫 번째 에러 필드로 스크롤
const firstErrorField = Object.keys(currentErrors)[0];
const el = document.getElementById(`field-${firstErrorField}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
setSubmitting(true);
try {
const requestData: BypassRequestSubmitRequest = {
applicantName: form.applicantName,
organization: form.organization,
purpose: form.purpose,
email: form.email,
phone: '',
requestedAccessPeriod: form.requestedAccessPeriod,
};
const res = await bypassAccountApi.submitRequest(requestData);
setSubmittedId(res.data.id);
showToast('신청이 완료되었습니다.', 'success');
} catch (err) {
showToast('신청 제출 실패. 다시 시도해주세요.', 'error');
console.error(err);
} finally {
setSubmitting(false);
}
};
const showError = (field: keyof TouchedState): boolean =>
(touched[field] || submitAttempted) && Boolean(errors[field]);
const inputClass = (field: keyof TouchedState) =>
`w-full px-3 py-2 text-sm rounded-lg border ${
showError(field) ? 'border-red-400' : 'border-wing-border'
} bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 ${
showError(field) ? 'focus:ring-red-400/50' : 'focus:ring-wing-accent/50'
}`;
const PRESETS: PeriodPreset[] = ['3개월', '6개월', '9개월', '1년'];
return (
<div className="space-y-6">
{/* 헤더 */}
<div>
<h1 className="text-2xl font-bold text-wing-text">S&P API </h1>
<p className="mt-1 text-sm text-wing-muted">
S&P API . .
</p>
</div>
{submittedId !== null ? (
/* 제출 완료 화면 */
<div className="bg-wing-surface rounded-xl shadow-md p-8 text-center">
<div className="w-14 h-14 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-7 h-7 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-bold text-wing-text mb-2"> </h2>
<p className="text-sm text-wing-muted mb-1">
: <span className="font-semibold text-wing-text">#{submittedId}</span>
</p>
<p className="text-sm text-wing-muted">
.
</p>
<button
type="button"
onClick={() => {
setForm(INITIAL_FORM);
setErrors({});
setTouched(INITIAL_TOUCHED);
setSubmitAttempted(false);
setSelectedPreset(null);
setPeriodManual(false);
setPeriodFrom('');
setPeriodTo('');
setPeriodRangeError('');
setSubmittedId(null);
}}
className="mt-6 px-6 py-2 text-sm font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
>
</button>
<button
type="button"
onClick={() => navigate('/bypass-catalog')}
className="mt-3 px-6 py-2 text-sm font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
>
S&P API
</button>
</div>
) : (
/* 신청 폼 */
<form onSubmit={handleSubmit}>
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="space-y-5">
{/* 신청자명 + 기관 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div id="field-applicantName">
<label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.applicantName}
onChange={(e) => handleChange('applicantName', e.target.value)}
onBlur={() => handleBlur('applicantName')}
placeholder="홍길동"
disabled={submitting}
className={inputClass('applicantName')}
/>
{showError('applicantName') && (
<p className="mt-1 text-xs text-red-500">{errors.applicantName}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<input
type="text"
value={form.organization}
onChange={(e) => handleChange('organization', e.target.value)}
placeholder="소속 기관명"
disabled={submitting}
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>
{/* 이메일 + 사용 기간 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div id="field-email">
<label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.email}
onChange={(e) => handleChange('email', e.target.value)}
onBlur={() => handleBlur('email')}
placeholder="example@domain.com"
disabled={submitting}
className={inputClass('email')}
/>
{showError('email') && (
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
)}
</div>
<div id="field-requestedAccessPeriod">
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-wing-text">
<span className="text-red-500">*</span>
</label>
<button
type="button"
onClick={handleToggleManual}
className="flex items-center gap-2 text-xs text-wing-muted hover:text-wing-text transition-colors"
>
<span> </span>
<span
className={`relative inline-flex h-4 w-8 items-center rounded-full transition-colors ${
periodManual ? 'bg-wing-accent' : 'bg-wing-border'
}`}
>
<span
className={`inline-block h-3 w-3 rounded-full bg-white shadow transition-transform ${
periodManual ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</span>
</button>
</div>
{!periodManual ? (
<div className="flex flex-wrap gap-2">
{PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => handlePresetClick(preset)}
disabled={submitting}
className={`px-4 py-1.5 text-sm rounded-full border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
selectedPreset === preset
? 'bg-wing-accent text-white border-wing-accent'
: 'bg-wing-card text-wing-text border-wing-border hover:border-wing-accent hover:text-wing-accent'
}`}
>
{preset}
</button>
))}
</div>
) : (
<div className="flex items-center gap-3">
<div className="flex-1">
<input
type="date"
value={periodFrom}
onChange={(e) => handlePeriodFromChange(e.target.value)}
disabled={submitting}
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>
<span className="text-sm text-wing-muted flex-shrink-0">~</span>
<div className="flex-1">
<input
type="date"
value={periodTo}
onChange={(e) => handlePeriodToChange(e.target.value)}
disabled={submitting}
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>
)}
{form.requestedAccessPeriod && !periodRangeError && (
<p className="mt-2 text-xs text-wing-muted">
: <span className="font-medium text-wing-text">{form.requestedAccessPeriod}</span>
</p>
)}
{periodRangeError && (
<p className="mt-1 text-xs text-red-500">{periodRangeError}</p>
)}
{showError('requestedAccessPeriod') && !periodRangeError && (
<p className="mt-1 text-xs text-red-500">{errors.requestedAccessPeriod}</p>
)}
</div>
</div>
{/* 사용 목적 */}
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<textarea
value={form.purpose}
onChange={(e) => handleChange('purpose', e.target.value)}
placeholder="Bypass API를 사용하려는 목적을 간략히 설명해주세요."
rows={4}
disabled={submitting}
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="mt-6 flex justify-end">
<button
type="submit"
disabled={submitting}
className="px-6 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? '제출 중...' : '신청 제출'}
</button>
</div>
</div>
</form>
)}
</div>
);
}