163 lines
6.7 KiB
TypeScript
163 lines
6.7 KiB
TypeScript
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] };
|
|
}
|