feat: add Node ACS admin workflows

This commit is contained in:
unknown
2026-07-16 11:00:09 +09:00
parent a25872394a
commit c6f342a861
24 changed files with 3442 additions and 266 deletions

View File

@@ -1,15 +1,23 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createVisitRequest } from '../api';
import { createVisitRequest, getWatcher1Settings, listPurposeCodes } from '../api';
import { DateTimePicker } from '../components/DateTimePicker';
import { useAuth } from '../auth/AuthContext';
import { PurposeCode, Watcher1Settings } from '../types';
// 코드 시트 목록을 콤보/체크박스에 반영.
// 전산실: 체크박스(다중). 선택한 개수만큼 신청/QR이 생성된다.
const SERVER_ROOM_OPTIONS = ['4층전산실', '5층전산실'];
// 추가 구역: 콤보박스(코드 시트 장소 중 전산실 외). 부가정보로만 기록. '기타' 선택 시 자유 입력.
const ROOM_OPTIONS = ['4층종합상황실', '4층BMT실', '3층사무실', '기타'];
const PURPOSE_OPTIONS = ['점검', '작업', '견학', '회의', '청소', '기타'];
const FALLBACK_PURPOSE_CODES: PurposeCode[] = [
{ id: 1, code: 'INSPECTION', name: '점검', sortOrder: 10, active: true, customAllowed: false },
{ id: 2, code: 'WORK', name: '작업', sortOrder: 20, active: true, customAllowed: false },
{ id: 3, code: 'TOUR', name: '견학', sortOrder: 30, active: true, customAllowed: false },
{ id: 4, code: 'MEETING', name: '회의', sortOrder: 40, active: true, customAllowed: false },
{ id: 5, code: 'CLEANING', name: '청소', sortOrder: 50, active: true, customAllowed: false },
{ id: 6, code: 'ETC', name: '기타', sortOrder: 900, active: true, customAllowed: true },
];
// 소속(코드 시트) — 내부 팀. 담당자·감시자 팀 콤보에 사용.
const AFFILIATION_OPTIONS = [
'IT센터관리팀', 'IT서비스팀', '네트워크팀', '클라우드팀', 'RTGS시스템팀',
@@ -17,18 +25,24 @@ const AFFILIATION_OPTIONS = [
'IT리스크팀', 'IT기획팀', '정보기획팀', 'IT전략국',
];
// 현장감시자1 — 고정 인원(백엔드 FIXED_WATCHER1과 동일 값 유지).
const FIXED_WATCHER1 = { name: '류관순', team: 'IT전략국', contact: '313' };
const FALLBACK_WATCHER1: Watcher1Settings = { 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;
const NEXT_DAY_EXIT_MESSAGE = `퇴장일이 출입일 다음날 이후가 되는 경우는 2건으로 신청해야 합니다
예컨대, 2026.07.16 18:00~2026.07.17 04:00 이라면, 아래와 같이 2건으로 등록해야 합니다.
1건) 2026.07.16 18:00~2026.07.16 24:00
2건) 2026.07.17 00:00~2026.07.17 04:00`;
const EXIT_BEFORE_ENTRY_MESSAGE = '퇴장일시는 출입일시 이후로 입력해야 합니다.';
const shouldShowValidationAlert = (message: string): boolean =>
message === NEXT_DAY_EXIT_MESSAGE || message === EXIT_BEFORE_ENTRY_MESSAGE;
const isLaterCalendarDate = (later: Date, earlier: Date): boolean => {
const laterDay = new Date(later.getFullYear(), later.getMonth(), later.getDate());
const earlierDay = new Date(earlier.getFullYear(), earlier.getMonth(), earlier.getDate());
return laterDay > earlierDay;
};
const formatPhoneLike = (value: string): string => {
@@ -58,12 +72,21 @@ export const VisitRequestFormPage: React.FC = () => {
visitFrom: '',
visitTo: '',
});
const [purposeCodes, setPurposeCodes] = useState<PurposeCode[]>(FALLBACK_PURPOSE_CODES);
const [watcher1, setWatcher1] = useState<Watcher1Settings>(FALLBACK_WATCHER1);
const [consent, setConsent] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const navigate = useNavigate();
const { user } = useAuth();
useEffect(() => {
listPurposeCodes().then(setPurposeCodes).catch(() => setPurposeCodes(FALLBACK_PURPOSE_CODES));
getWatcher1Settings().then(setWatcher1).catch(() => setWatcher1(FALLBACK_WATCHER1));
}, []);
const selectedPurpose = purposeCodes.find((p) => p.code === form.purpose);
const update = (k: keyof typeof form) => (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => setForm({ ...form, [k]: e.target.value });
@@ -90,13 +113,13 @@ export const VisitRequestFormPage: React.FC = () => {
return '출입 구역(전산실 또는 추가 구역)을 최소 1개 이상 선택하세요.';
if (form.room === '기타' && !form.roomEtc.trim()) return '기타 추가 구역을 입력하세요.';
if (!form.purpose) return '출입 목적을 선택하세요.';
if (form.purpose === '기타' && !form.purposeEtc.trim()) return '기타 출입 목적을 입력하세요.';
if (selectedPurpose?.customAllowed && !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 '퇴실 일시는 출입 일시보다 빠를 수 없습니다.';
const visitFrom = new Date(form.visitFrom);
const visitTo = new Date(form.visitTo);
if (visitFrom > visitTo) return EXIT_BEFORE_ENTRY_MESSAGE;
if (isLaterCalendarDate(visitTo, visitFrom)) return NEXT_DAY_EXIT_MESSAGE;
if (!consent) return '개인정보 사용 및 저장에 동의해야 신청할 수 있습니다.';
return null;
};
@@ -106,6 +129,9 @@ export const VisitRequestFormPage: React.FC = () => {
const message = validate();
if (message) {
setError(message);
if (shouldShowValidationAlert(message)) {
window.alert(message);
}
return;
}
setError(null);
@@ -120,7 +146,11 @@ export const VisitRequestFormPage: React.FC = () => {
vehicleNo: form.vehicleNo.trim() || undefined,
serverRooms: form.serverRooms,
roomZone: roomZone || undefined,
purpose: form.purpose === '기타' ? form.purposeEtc.trim() : form.purpose,
purpose: selectedPurpose?.customAllowed
? form.purposeEtc.trim()
: selectedPurpose?.name ?? form.purpose,
purposeCode: selectedPurpose?.code ?? form.purpose,
purposeDetail: selectedPurpose?.customAllowed ? form.purposeEtc.trim() : undefined,
workName: form.workName.trim() || undefined,
watcher2Name: form.watcher2Name.trim() || undefined,
watcher2Team: form.watcher2Team.trim() || undefined,
@@ -201,10 +231,10 @@ export const VisitRequestFormPage: React.FC = () => {
<span> <b className="required">*</b></span>
<select value={form.purpose} onChange={update('purpose')}>
<option value=""></option>
{PURPOSE_OPTIONS.map((p) => <option key={p} value={p}>{p}</option>)}
{purposeCodes.map((p) => <option key={p.code} value={p.code}>{p.name}</option>)}
</select>
</label>
{form.purpose === '기타' ? (
{selectedPurpose?.customAllowed ? (
<label className="field">
<span> <b className="required">*</b></span>
<input value={form.purposeEtc} onChange={update('purposeEtc')} placeholder="출입 목적을 입력하세요" />
@@ -259,15 +289,15 @@ export const VisitRequestFormPage: React.FC = () => {
<div className="group-grid">
<label className="field">
<span></span>
<input value={FIXED_WATCHER1.name} readOnly />
<input value={watcher1.name} readOnly />
</label>
<label className="field">
<span></span>
<input value={FIXED_WATCHER1.team} readOnly />
<input value={watcher1.team} readOnly />
</label>
<label className="field">
<span></span>
<input value={FIXED_WATCHER1.contact} readOnly />
<input value={watcher1.contact} readOnly />
</label>
</div>
@@ -285,7 +315,7 @@ export const VisitRequestFormPage: React.FC = () => {
</select>
</label>
<label className="field">
<span> <em className="hint-inline">: /</em></span>
<span></span>
<input value={form.watcher2Contact} onChange={updateFormattedContact('watcher2Contact')} placeholder="내선번호/휴대폰번호" />
</label>
</div>
@@ -295,10 +325,12 @@ export const VisitRequestFormPage: React.FC = () => {
<label className="consent-label">
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
<span>
<b>[ · ]</b><br />
<b>[ · ]</b><br />
· 항목: 이름, , , <br />
· · 목적: IT센터 <br />
· · 기간: 수집일로부터 1 ( )<br />
<span className="consent-retention-warning">
· · 기간: 전산실 , , ,
</span><br />
· · ,
</span>
</label>