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

@@ -29,11 +29,12 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
IT센터
</Link>
<nav className="nav">
<NavLink to="/visit-requests"></NavLink>
<NavLink to="/visit-requests/new"></NavLink>
{hasRole('ADMIN') && <NavLink to="/approvals"></NavLink>}
{hasRole('HOST', 'SECURITY', 'ADMIN') && <NavLink to="/access"></NavLink>}
{hasRole('SECURITY', 'ADMIN') && <NavLink to="/reports"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/blacklist"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/admin"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/deliveries"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/audit"></NavLink>}
</nav>

View File

@@ -0,0 +1,162 @@
import React, { useEffect, useState } from 'react';
import { getVisitRequest, listTodayAccess } from '../api';
import { AccessRecord, VisitRequestView } from '../types';
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
interface Props {
requestId: number;
onClose: () => void;
}
export const VisitRequestDetailDialog: React.FC<Props> = ({ requestId, onClose }) => {
const [request, setRequest] = useState<VisitRequestView | null>(null);
const [access, setAccess] = useState<AccessRecord | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
Promise.all([
getVisitRequest(requestId),
listTodayAccess().catch(() => [] as AccessRecord[]),
])
.then(([detail, records]) => {
if (!alive) return;
setRequest(detail);
setAccess(records.find((r) => r.visitRequestId === requestId) ?? null);
})
.catch((e) => {
if (!alive) return;
setError(e instanceof Error ? e.message : '상세 조회에 실패했습니다.');
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [requestId]);
const status = getDisplayStatus(request, access);
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-box detail-modal" onClick={(e) => e.stopPropagation()}>
<div className="detail-head">
<div>
<h3 className="modal-title"> </h3>
{request && <p className="modal-message">{request.visitorName} / {request.zoneName || '-'}</p>}
</div>
<button className="btn-ghost" onClick={onClose}></button>
</div>
{loading ? (
<p className="muted"> ...</p>
) : error ? (
<div className="alert alert-error">{error}</div>
) : request ? (
<>
<div className="detail-status">
<span className={`badge badge-${status.className}`}>{status.label}</span>
</div>
<div className="form-grid visit-form detail-form">
<fieldset className="form-group span-2">
<legend></legend>
<div className="group-grid">
<ReadOnlyField label="방문자 이름" value={request.visitorName} />
<ReadOnlyField label="회사/소속" value={request.company} />
<ReadOnlyField label="연락처" value={request.contact} />
<ReadOnlyField label="이메일" value={request.email} />
<div className="field">
<span> </span>
<div className="checkbox-row">
{['4층전산실', '5층전산실'].map((room) => (
<label key={room} className="checkbox-inline">
<input type="checkbox" checked={request.zoneName?.includes(room) ?? false} readOnly />
<span>{room}</span>
</label>
))}
</div>
</div>
<ReadOnlyField label="추가 구역" value={extraZone(request.zoneName)} />
<ReadOnlyField label="차량번호" value={request.vehicleNo} />
<ReadOnlyField label="출입 목적" value={request.purpose} />
<ReadOnlyField label="작업명" value={request.workName} />
<ReadOnlyField label="출입 일시" value={formatDateTime(request.visitFrom)} />
<ReadOnlyField label="퇴실 예정일시" value={formatDateTime(request.visitTo)} />
</div>
</fieldset>
<fieldset className="form-group span-2">
<legend></legend>
<div className="group-grid">
<ReadOnlyField label="이름" value={request.controlName || request.hostName} />
<ReadOnlyField label="담당팀" value={request.controlTeam || request.hostDepartment} />
<ReadOnlyField label="연락처" value={request.controlContact} />
</div>
</fieldset>
<fieldset className="form-group span-2">
<legend></legend>
<div className="subsection-title">1 <em className="hint-inline">: IT센터 </em></div>
<div className="group-grid">
<ReadOnlyField label="이름" value={request.watcher1Name} />
<ReadOnlyField label="소속" value={request.watcher1Team} />
<ReadOnlyField label="연락처" value={request.watcher1Contact} />
</div>
<div className="subsection-title">2 <em className="hint-inline">: </em></div>
<div className="group-grid">
<ReadOnlyField label="이름" value={request.watcher2Name} />
<ReadOnlyField label="소속" value={request.watcher2Team} />
<ReadOnlyField label="연락처" value={request.watcher2Contact} />
</div>
</fieldset>
<fieldset className="form-group span-2">
<legend> </legend>
<div className="group-grid">
<ReadOnlyField label="상태" value={status.label} />
<ReadOnlyField label="실제 입장" value={formatDateTime(access?.checkInAt)} />
<ReadOnlyField label="실제 퇴장" value={formatDateTime(access?.checkOutAt)} />
</div>
</fieldset>
</div>
</>
) : null}
</div>
</div>
);
};
const ReadOnlyField: React.FC<{ label: string; value?: string | null }> = ({ label, value }) => (
<label className="field">
<span>{label}</span>
<input value={value || '-'} readOnly />
</label>
);
function extraZone(zoneName?: string): string {
if (!zoneName) return '-';
const parts = zoneName.split('/').map((part) => part.trim()).filter(Boolean);
if (parts.length > 1) return parts.slice(1).join(' / ');
if (zoneName.includes('전산실')) return '-';
return zoneName;
}
function getDisplayStatus(request: VisitRequestView | null, access: AccessRecord | null) {
if (access?.inside) {
return { label: '재실중', className: 'green' };
}
if (access?.checkOutAt) {
return { label: '퇴장', className: 'gray' };
}
if (!request) {
return { label: '-', className: 'gray' };
}
return { label: STATUS_LABEL[request.status], className: STATUS_CLASS[request.status] };
}