feat: prepare ACS deployment

This commit is contained in:
unknown
2026-07-10 15:18:29 +09:00
parent da0d35ae7f
commit 01d48fe808
25 changed files with 1070 additions and 83 deletions

View File

@@ -2,15 +2,35 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createVisitRequest } from '../api';
import { DateTimePicker } from '../components/DateTimePicker';
import { useAuth } from '../auth/AuthContext';
const ZONE_OPTIONS = [
'4층전산실', '5층전산실', '3층사무실', '4층사무실', '5층사무실',
'종합상황실', 'BMT실', '의사결정실', '기타',
// 코드 시트 목록을 콤보/체크박스에 반영.
// 전산실: 체크박스(다중). 선택한 개수만큼 신청/QR이 생성된다.
const SERVER_ROOM_OPTIONS = ['4층전산실', '5층전산실'];
// 추가 구역: 콤보박스(코드 시트 장소 중 전산실 외). 부가정보로만 기록. '기타' 선택 시 자유 입력.
const ROOM_OPTIONS = ['4층종합상황실', '4층BMT실', '3층사무실', '기타'];
const PURPOSE_OPTIONS = ['점검', '작업', '견학', '회의', '청소', '기타'];
// 소속(코드 시트) — 내부 팀. 담당자·감시자 팀 콤보에 사용.
const AFFILIATION_OPTIONS = [
'IT센터관리팀', 'IT서비스팀', '네트워크팀', '클라우드팀', 'RTGS시스템팀',
'금융IT인프라팀', '정보인프라팀', 'AI플랫폼팀', '보안운영팀', '보안관제반',
'IT리스크팀', 'IT기획팀', '정보기획팀', 'IT전략국',
];
const PURPOSE_OPTIONS = ['유지점검', '장비반입', '업무협의', '공사', '기타'];
// 현장감시자1 — 고정 인원(백엔드 FIXED_WATCHER1과 동일 값 유지).
const FIXED_WATCHER1 = { name: '류관순', team: 'IT전략국', contact: '313' };
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** True if the datetime's calendar date is before today (time-of-day ignored). */
const isPastDate = (iso: string): boolean => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return false;
const day = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const today = new Date();
const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate());
return day < todayStart;
};
export const VisitRequestFormPage: React.FC = () => {
const [form, setForm] = useState({
visitorName: '',
@@ -18,10 +38,15 @@ export const VisitRequestFormPage: React.FC = () => {
contact: '',
email: '',
vehicleNo: '',
zone: '',
zoneEtc: '',
serverRooms: [] as string[],
room: '',
roomEtc: '',
purpose: '',
purposeEtc: '',
workName: '',
watcher2Name: '',
watcher2Team: '',
watcher2Contact: '',
visitFrom: '',
visitTo: '',
});
@@ -29,22 +54,34 @@ export const VisitRequestFormPage: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const navigate = useNavigate();
const { user } = useAuth();
const update = (k: keyof typeof form) => (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => setForm({ ...form, [k]: e.target.value });
const toggleServerRoom = (room: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({
...f,
serverRooms: e.target.checked
? [...f.serverRooms, room]
: f.serverRooms.filter((r) => r !== room),
}));
/** Returns the first Korean validation error, or null if valid. */
const validate = (): string | null => {
if (!form.visitorName.trim()) return '방문자 이름을 입력하세요.';
if (!form.contact.trim()) return '방문자 연락처를 입력하세요.';
if (form.email.trim() && !EMAIL_RE.test(form.email.trim()))
return '이메일 형식이 올바르지 않습니다. (예: name@example.com)';
if (!form.zone) return '출입 구역을 선택하세요.';
if (form.zone === '기타' && !form.zoneEtc.trim()) return '기타 출입 구역을 입력하세요.';
if (form.serverRooms.length === 0 && !form.room)
return '출입 구역(전산실 또는 추가 구역)을 최소 1개 이상 선택하세요.';
if (form.room === '기타' && !form.roomEtc.trim()) return '기타 추가 구역을 입력하세요.';
if (!form.purpose) return '출입 목적을 선택하세요.';
if (form.purpose === '기타' && !form.purposeEtc.trim()) return '기타 출입 목적을 입력하세요.';
if (!form.visitFrom) return '출입 일시를 입력하세요.';
// 오늘 이전(전일자)은 불가. 같은 날 안에서 현재보다 이른 시각은 허용(날짜만 비교).
if (isPastDate(form.visitFrom)) return '과거일자는 입력이 안됩니다.';
if (!form.visitTo) return '퇴실 일시를 입력하세요.';
if (new Date(form.visitTo) < new Date(form.visitFrom))
return '퇴실 일시는 출입 일시보다 빠를 수 없습니다.';
@@ -62,14 +99,20 @@ export const VisitRequestFormPage: React.FC = () => {
setError(null);
setBusy(true);
try {
const roomZone = form.room === '기타' ? form.roomEtc.trim() : form.room;
await createVisitRequest({
visitorName: form.visitorName.trim(),
company: form.company.trim() || undefined,
contact: form.contact.trim(),
email: form.email.trim() || undefined,
vehicleNo: form.vehicleNo.trim() || undefined,
zoneName: form.zone === '기타' ? form.zoneEtc.trim() : form.zone,
serverRooms: form.serverRooms,
roomZone: roomZone || undefined,
purpose: form.purpose === '기타' ? form.purposeEtc.trim() : form.purpose,
workName: form.workName.trim() || undefined,
watcher2Name: form.watcher2Name.trim() || undefined,
watcher2Team: form.watcher2Team.trim() || undefined,
watcher2Contact: form.watcher2Contact.trim() || undefined,
visitFrom: form.visitFrom,
visitTo: form.visitTo,
});
@@ -84,7 +127,6 @@ export const VisitRequestFormPage: React.FC = () => {
return (
<div>
<div className="page-head"><h2> </h2></div>
{error && <div className="alert alert-error">{error}</div>}
{/* noValidate: use our Korean messages instead of the browser's native popups */}
<form className="card form-grid" onSubmit={onSubmit} noValidate>
@@ -110,17 +152,33 @@ export const VisitRequestFormPage: React.FC = () => {
<input className="ime-ko" value={form.vehicleNo} onChange={update('vehicleNo')} />
</label>
<div className="field span-2">
<span> <em className="hint-inline">: (QR) .</em></span>
<div className="checkbox-row">
{SERVER_ROOM_OPTIONS.map((z) => (
<label key={z} className="checkbox-inline">
<input
type="checkbox"
checked={form.serverRooms.includes(z)}
onChange={toggleServerRoom(z)}
/>
<span>{z}</span>
</label>
))}
</div>
</div>
<label className="field">
<span> *</span>
<select value={form.zone} onChange={update('zone')}>
<span> </span>
<select value={form.room} onChange={update('room')}>
<option value=""></option>
{ZONE_OPTIONS.map((z) => <option key={z} value={z}>{z}</option>)}
{ROOM_OPTIONS.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</label>
{form.zone === '기타' ? (
{form.room === '기타' ? (
<label className="field">
<span> *</span>
<input value={form.zoneEtc} onChange={update('zoneEtc')} placeholder="출입 구역을 입력하세요" />
<input value={form.roomEtc} onChange={update('roomEtc')} placeholder="추가 구역을 입력하세요" />
</label>
) : <div />}
@@ -138,6 +196,11 @@ export const VisitRequestFormPage: React.FC = () => {
</label>
) : <div />}
<label className="field span-2">
<span> <em className="hint-inline">: (: Active-Active )</em></span>
<input className="ime-ko" value={form.workName} onChange={update('workName')} placeholder="작업 내용을 입력하세요 (선택)" />
</label>
<label className="field">
<span> *</span>
<DateTimePicker
@@ -155,6 +218,56 @@ export const VisitRequestFormPage: React.FC = () => {
/>
</label>
<div className="form-section span-2">
<em className="hint-inline">: .</em>
</div>
<label className="field">
<span></span>
<input value={user?.fullName ?? ''} readOnly />
</label>
<label className="field">
<span></span>
<input value={user?.department ?? ''} readOnly />
</label>
<label className="field">
<span></span>
<input value={user?.email ?? ''} readOnly />
</label>
<div />
<div className="form-section span-2">1 <em className="hint-inline">: ( )</em></div>
<label className="field">
<span></span>
<input value={FIXED_WATCHER1.name} readOnly />
</label>
<label className="field">
<span></span>
<input value={FIXED_WATCHER1.team} readOnly />
</label>
<label className="field">
<span></span>
<input value={FIXED_WATCHER1.contact} readOnly />
</label>
<div />
<div className="form-section span-2">2 <em className="hint-inline">: </em></div>
<label className="field">
<span></span>
<input className="ime-ko" value={form.watcher2Name} onChange={update('watcher2Name')} />
</label>
<label className="field">
<span></span>
<select value={form.watcher2Team} onChange={update('watcher2Team')}>
<option value=""></option>
{AFFILIATION_OPTIONS.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</label>
<label className="field">
<span></span>
<input value={form.watcher2Contact} onChange={update('watcher2Contact')} placeholder="내선/휴대번호" />
</label>
<div />
<div className="span-2 consent-box">
<label className="consent-label">
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
@@ -169,6 +282,7 @@ export const VisitRequestFormPage: React.FC = () => {
</div>
<div className="form-actions span-2">
{error && <span className="form-error" role="alert">{error}</span>}
<button type="button" className="btn-ghost" onClick={() => navigate(-1)}></button>
<button type="submit" className="btn-primary" disabled={busy}>
{busy ? '신청 중…' : '출입 신청'}