- 이메일 공통 모듈 (spring-boot-starter-mail, EmailService, Thymeleaf 템플릿) - 승인 시 계정 발급 이메일 / 거절 시 사유 이메일 자동 발송 - 재심사 기능 (REJECTED → PENDING) - UI 텍스트 리레이블링 (S&P Global API) - 신청 폼 전화번호 필드 제거 및 레이아웃 개선
452 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|