feat: prepare ACS deployment
This commit is contained in:
@@ -97,7 +97,7 @@ export const getVisitRequest = (id: number) =>
|
||||
request<VisitRequestView>(`/visit-requests/${id}`);
|
||||
|
||||
export const createVisitRequest = (req: VisitRequestCreate) =>
|
||||
request<VisitRequestView>('/visit-requests', jsonInit('POST', req));
|
||||
request<VisitRequestView[]>('/visit-requests', jsonInit('POST', req));
|
||||
|
||||
export const cancelVisitRequest = (id: number) =>
|
||||
request<VisitRequestView>(`/visit-requests/${id}/cancel`, { method: 'POST' });
|
||||
|
||||
@@ -91,7 +91,7 @@ export const AccessConsolePage: React.FC = () => {
|
||||
{results.length > 0 && (
|
||||
<table className="table" style={{ marginTop: 12 }}>
|
||||
<thead>
|
||||
<tr><th>방문자</th><th>회사</th><th>구역</th><th>처리</th></tr>
|
||||
<tr><th>방문자</th><th>회사</th><th>구역</th><th>상태</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((r) => (
|
||||
@@ -101,11 +101,17 @@ export const AccessConsolePage: React.FC = () => {
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>
|
||||
{insideIds.has(r.id) ? (
|
||||
<button className="btn-danger" disabled={busy} onClick={() => forceCheckOut(r.id)}>퇴장</button>
|
||||
<span className="status-cell">
|
||||
<span className="badge badge-green">재실중</span>
|
||||
<button className="btn-danger" disabled={busy} onClick={() => forceCheckOut(r.id)}>퇴장</button>
|
||||
</span>
|
||||
) : exitedIds.has(r.id) ? (
|
||||
<span className="muted">금일 완료</span>
|
||||
<span className="badge badge-gray">금일완료</span>
|
||||
) : (
|
||||
<button className="btn-success" disabled={busy} onClick={() => forceCheckIn(r.id)}>입장</button>
|
||||
<span className="status-cell">
|
||||
<span className="badge badge-amber">입장대기</span>
|
||||
<button className="btn-success" disabled={busy} onClick={() => forceCheckIn(r.id)}>입장</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -58,7 +58,7 @@ export const LoginPage: React.FC = () => {
|
||||
{busy ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
|
||||
<p className="hint">초기 계정: admin / security / host (비밀번호 ChangeMe123!)</p>
|
||||
<p className="hint">테스트 계정: a(관리자) / s(보안) / h(호스트) · 비밀번호 1</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 ? '신청 중…' : '출입 신청'}
|
||||
|
||||
@@ -77,7 +77,7 @@ export const VisitRequestListPage: React.FC = () => {
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>방문자</th><th>회사</th><th>출입구역</th><th>출입목적</th>
|
||||
<th>방문자</th><th>회사</th><th>출입구역</th><th>출입목적</th><th>작업명</th>
|
||||
<th>출입기간</th><th>상태</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -88,6 +88,7 @@ export const VisitRequestListPage: React.FC = () => {
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>{r.purpose || '-'}</td>
|
||||
<td>{r.workName || '-'}</td>
|
||||
<td>{formatVisitRange(r.visitFrom, r.visitTo)}</td>
|
||||
<td><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td>
|
||||
<td className="row-actions">
|
||||
|
||||
@@ -174,6 +174,14 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
.form-grid .span-2 { grid-column: 1 / -1; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
|
||||
/* Inline validation message shown at the left of the action row, beside the 취소 button. */
|
||||
.form-actions .form-error { margin-right: auto; align-self: center; color: #b91c1c; font-size: 14px; font-weight: 600; }
|
||||
/* Status badge + action button shown together in a table cell (access console). */
|
||||
.status-cell { display: inline-flex; align-items: center; gap: 8px; }
|
||||
/* Sub-section heading inside a form grid (담당자/감시자 등). */
|
||||
.form-section { grid-column: 1 / -1; margin: 6px 0 -2px; padding-top: 10px; border-top: 1px solid #e2e8f0; font-weight: 700; color: #1e293b; font-size: 14px; }
|
||||
/* Read-only auto-filled fields (본인/고정값). */
|
||||
.field input[readonly] { background: #f1f5f9; color: #475569; cursor: default; }
|
||||
|
||||
/* faint inline helper next to a label */
|
||||
.hint-inline { font-weight: 400; font-style: normal; color: var(--muted); font-size: 12px; }
|
||||
@@ -189,6 +197,11 @@ a { color: inherit; text-decoration: none; }
|
||||
.consent-label { display: flex; gap: 10px; align-items: flex-start; cursor: pointer; font-size: 13px; color: #334155; line-height: 1.5; }
|
||||
.consent-label input { margin-top: 2px; width: 16px; height: 16px; flex-shrink: 0; }
|
||||
|
||||
/* Inline checkbox group (e.g. 전산실 다중 선택) */
|
||||
.checkbox-row { display: flex; flex-wrap: wrap; gap: 18px; padding: 8px 2px; }
|
||||
.checkbox-inline { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; color: #334155; }
|
||||
.checkbox-inline input { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
|
||||
/* ===== Buttons ===== */
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
.btn-primary, .btn-ghost, .btn-success, .btn-danger {
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface CurrentUser {
|
||||
id: number;
|
||||
username: string;
|
||||
fullName: string;
|
||||
department?: string;
|
||||
email?: string;
|
||||
roles: Role[];
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
@@ -45,8 +47,17 @@ export interface VisitRequestCreate {
|
||||
contact: string;
|
||||
email?: string;
|
||||
vehicleNo?: string;
|
||||
zoneName?: string;
|
||||
/** 전산실 checkboxes; each selected room yields its own request/QR. */
|
||||
serverRooms?: string[];
|
||||
/** Detail room (콤보박스, 기타 자유 입력) — auxiliary, no separate QR. */
|
||||
roomZone?: string;
|
||||
purpose: string;
|
||||
/** 작업명 — optional concrete task detail, stored separately from purpose. */
|
||||
workName?: string;
|
||||
/** 현장감시자2 (담당자 입력). 담당자·감시자1은 서버가 채운다. */
|
||||
watcher2Name?: string;
|
||||
watcher2Team?: string;
|
||||
watcher2Contact?: string;
|
||||
visitFrom: string; // ISO local datetime
|
||||
visitTo: string;
|
||||
}
|
||||
@@ -62,6 +73,16 @@ export interface VisitRequestView {
|
||||
hostDepartment?: string;
|
||||
zoneName?: string;
|
||||
purpose: string;
|
||||
workName?: string;
|
||||
controlName?: string;
|
||||
controlTeam?: string;
|
||||
controlContact?: string;
|
||||
watcher1Name?: string;
|
||||
watcher1Team?: string;
|
||||
watcher1Contact?: string;
|
||||
watcher2Name?: string;
|
||||
watcher2Team?: string;
|
||||
watcher2Contact?: string;
|
||||
visitFrom: string;
|
||||
visitTo: string;
|
||||
status: VisitStatus;
|
||||
|
||||
Reference in New Issue
Block a user