feat: add Node ACS admin workflows
This commit is contained in:
@@ -17,6 +17,7 @@ import { BlacklistPage } from './pages/BlacklistPage';
|
||||
import { ReportPage } from './pages/ReportPage';
|
||||
import { AuditLogPage } from './pages/AuditLogPage';
|
||||
import { DeliveryOutboxPage } from './pages/DeliveryOutboxPage';
|
||||
import { AdminManagementPage } from './pages/AdminManagementPage';
|
||||
|
||||
/** Requires a logged-in user; optionally one of the given roles. */
|
||||
const Protected: React.FC<{ roles?: Role[]; children: React.ReactNode }> = ({ roles, children }) => {
|
||||
@@ -99,6 +100,14 @@ export default function App() {
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<Protected roles={['ADMIN']}>
|
||||
<AdminManagementPage />
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AccessAction,
|
||||
AccessRecord,
|
||||
AdminExcelImportResult,
|
||||
ApiResponse,
|
||||
AuditLog,
|
||||
BlacklistCreate,
|
||||
@@ -12,10 +13,15 @@ import {
|
||||
InsideVisitor,
|
||||
LoginRequest,
|
||||
PassDelivery,
|
||||
PurposeCode,
|
||||
Role,
|
||||
StatsSummary,
|
||||
PublicPass,
|
||||
Team,
|
||||
VisitRequestCreate,
|
||||
VisitRequestView,
|
||||
AdminUser,
|
||||
Watcher1Settings,
|
||||
Zone,
|
||||
} from './types';
|
||||
|
||||
@@ -85,6 +91,7 @@ export const changePassword = (req: ChangePasswordRequest) =>
|
||||
|
||||
// ===== Zones =====
|
||||
export const listZones = () => request<Zone[]>('/zones');
|
||||
export const listPurposeCodes = () => request<PurposeCode[]>('/purpose-codes');
|
||||
|
||||
// ===== Visit requests =====
|
||||
export const listVisitRequests = () =>
|
||||
@@ -154,6 +161,9 @@ export const deleteBlacklist = (id: number) =>
|
||||
request<string>(`/blacklist/${id}`, { method: 'DELETE' });
|
||||
|
||||
// ===== Reports =====
|
||||
export const listReportVisits = (from: string, to: string) =>
|
||||
request<VisitRequestView[]>(`/reports/visits?from=${from}&to=${to}`);
|
||||
|
||||
export const reportDownloadUrl = (from: string, to: string) =>
|
||||
`/api/reports/visits.xlsx?from=${from}&to=${to}`;
|
||||
|
||||
@@ -165,3 +175,45 @@ export const listDeliveries = (status?: DeliveryStatus) =>
|
||||
request<PassDelivery[]>(`/admin/deliveries${status ? `?status=${status}` : ''}`);
|
||||
export const retryDelivery = (id: number) =>
|
||||
request<PassDelivery>(`/admin/deliveries/${id}/retry`, { method: 'POST' });
|
||||
|
||||
// ===== Admin management =====
|
||||
export const listAdminPurposeCodes = () => request<PurposeCode[]>('/admin/purpose-codes');
|
||||
export const createAdminPurposeCode = (payload: Omit<PurposeCode, 'id'>) =>
|
||||
request<PurposeCode>('/admin/purpose-codes', jsonInit('POST', payload));
|
||||
export const updateAdminPurposeCode = (id: number, payload: Omit<PurposeCode, 'id'>) =>
|
||||
request<PurposeCode>(`/admin/purpose-codes/${id}`, jsonInit('PUT', payload));
|
||||
export const getWatcher1Settings = () => request<Watcher1Settings>('/settings/watcher1');
|
||||
export const updateWatcher1Settings = (payload: Watcher1Settings) =>
|
||||
request<Watcher1Settings>('/admin/settings/watcher1', jsonInit('PUT', payload));
|
||||
export const listAdminUsers = () => request<AdminUser[]>('/admin/users');
|
||||
export const updateAdminUserRoles = (id: number, roles: Role[]) =>
|
||||
request<AdminUser>(`/admin/users/${id}/roles`, jsonInit('PUT', { roles }));
|
||||
export const listAdminTeams = () => request<Team[]>('/admin/teams');
|
||||
export const createAdminTeam = (payload: Omit<Team, 'id'>) =>
|
||||
request<Team>('/admin/teams', jsonInit('POST', payload));
|
||||
export const updateAdminTeam = (id: number, payload: Omit<Team, 'id'>) =>
|
||||
request<Team>(`/admin/teams/${id}`, jsonInit('PUT', payload));
|
||||
export const updateAdminUserTeam = (id: number, teamId: number, applyDefaultRoles: boolean) =>
|
||||
request<AdminUser>(`/admin/users/${id}/team`, jsonInit('PUT', { teamId, applyDefaultRoles }));
|
||||
export const applyTeamDefaultRolesToUser = (id: number) =>
|
||||
request<AdminUser>(`/admin/users/${id}/apply-team-default-roles`, { method: 'POST' });
|
||||
export const applyTeamDefaultRolesToMembers = (teamId: number) =>
|
||||
request<string>(`/admin/teams/${teamId}/apply-default-roles`, { method: 'POST' });
|
||||
export const uploadAdminTeams = (file: File, dryRun: boolean) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return request<AdminExcelImportResult>(`/admin/teams/upload?dryRun=${dryRun}`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
export const uploadAdminUserRoles = (file: File, dryRun: boolean) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return request<AdminExcelImportResult>(`/admin/users/roles/upload?dryRun=${dryRun}`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
export const adminTeamTemplateUrl = () => '/api/admin/teams/template';
|
||||
export const adminUserRolesTemplateUrl = () => '/api/admin/users/roles/template';
|
||||
|
||||
@@ -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>
|
||||
|
||||
162
frontend/src/components/VisitRequestDetailDialog.tsx
Normal file
162
frontend/src/components/VisitRequestDetailDialog.tsx
Normal 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] };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../api';
|
||||
import { AccessRecord, VisitRequestView } from '../types';
|
||||
import { formatShort } from '../status';
|
||||
import { VisitRequestDetailDialog } from '../components/VisitRequestDetailDialog';
|
||||
|
||||
/**
|
||||
* Staff access console (login: 담당자/보안/관리자). Name search → force check-in/out,
|
||||
@@ -19,6 +20,7 @@ export const AccessConsolePage: React.FC = () => {
|
||||
const [records, setRecords] = useState<AccessRecord[]>([]);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detailId, setDetailId] = useState<number | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const loadRecords = () => {
|
||||
@@ -95,7 +97,18 @@ export const AccessConsolePage: React.FC = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<tr
|
||||
key={r.id}
|
||||
className="clickable-row"
|
||||
tabIndex={0}
|
||||
onClick={() => setDetailId(r.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setDetailId(r.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td>{r.visitorName}</td>
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
@@ -103,14 +116,28 @@ export const AccessConsolePage: React.FC = () => {
|
||||
{insideIds.has(r.id) ? (
|
||||
<span className="status-cell">
|
||||
<span className="badge badge-green">재실중</span>
|
||||
<button className="btn-danger" disabled={busy} onClick={() => forceCheckOut(r.id)}>퇴장</button>
|
||||
<button
|
||||
className="btn-danger"
|
||||
disabled={busy}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
forceCheckOut(r.id);
|
||||
}}
|
||||
>퇴장</button>
|
||||
</span>
|
||||
) : exitedIds.has(r.id) ? (
|
||||
<span className="badge badge-gray">금일완료</span>
|
||||
) : (
|
||||
<span className="status-cell">
|
||||
<span className="badge badge-amber">입장대기</span>
|
||||
<button className="btn-success" disabled={busy} onClick={() => forceCheckIn(r.id)}>입장</button>
|
||||
<button
|
||||
className="btn-success"
|
||||
disabled={busy}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
forceCheckIn(r.id);
|
||||
}}
|
||||
>입장</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
@@ -137,7 +164,18 @@ export const AccessConsolePage: React.FC = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => (
|
||||
<tr key={r.visitRequestId}>
|
||||
<tr
|
||||
key={r.visitRequestId}
|
||||
className="clickable-row"
|
||||
tabIndex={0}
|
||||
onClick={() => setDetailId(r.visitRequestId)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setDetailId(r.visitRequestId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td>{r.visitorName}</td>
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
@@ -152,7 +190,14 @@ export const AccessConsolePage: React.FC = () => {
|
||||
</td>
|
||||
<td>
|
||||
{r.inside && (
|
||||
<button className="btn-danger" disabled={busy} onClick={() => forceCheckOut(r.visitRequestId)}>퇴장</button>
|
||||
<button
|
||||
className="btn-danger"
|
||||
disabled={busy}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
forceCheckOut(r.visitRequestId);
|
||||
}}
|
||||
>퇴장</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -161,6 +206,10 @@ export const AccessConsolePage: React.FC = () => {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detailId != null && (
|
||||
<VisitRequestDetailDialog requestId={detailId} onClose={() => setDetailId(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
584
frontend/src/pages/AdminManagementPage.tsx
Normal file
584
frontend/src/pages/AdminManagementPage.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
adminTeamTemplateUrl,
|
||||
adminUserRolesTemplateUrl,
|
||||
applyTeamDefaultRolesToMembers,
|
||||
createAdminTeam,
|
||||
createAdminPurposeCode,
|
||||
listAdminPurposeCodes,
|
||||
listAdminTeams,
|
||||
listAdminUsers,
|
||||
updateAdminTeam,
|
||||
updateAdminPurposeCode,
|
||||
updateAdminUserTeam,
|
||||
updateAdminUserRoles,
|
||||
uploadAdminTeams,
|
||||
uploadAdminUserRoles,
|
||||
updateWatcher1Settings,
|
||||
getWatcher1Settings,
|
||||
} from '../api';
|
||||
import { AdminExcelImportResult, AdminUser, PurposeCode, Role, Team, Watcher1Settings } from '../types';
|
||||
|
||||
type Tab = 'watcher' | 'purpose' | 'teams' | 'roles';
|
||||
|
||||
const EMPTY_PURPOSE = {
|
||||
code: '',
|
||||
name: '',
|
||||
sortOrder: 100,
|
||||
active: true,
|
||||
customAllowed: false,
|
||||
};
|
||||
|
||||
const ALL_ROLES: Role[] = ['ADMIN', 'SECURITY', 'HOST'];
|
||||
const EMPTY_TEAM: Omit<Team, 'id'> = {
|
||||
code: '',
|
||||
name: '',
|
||||
active: true,
|
||||
defaultRoles: ['HOST'],
|
||||
};
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
if (status === 'CREATE') return '신규';
|
||||
if (status === 'UPDATE') return '수정';
|
||||
if (status === 'ERROR') return '오류';
|
||||
return status;
|
||||
};
|
||||
|
||||
interface UploadPanelProps {
|
||||
title: string;
|
||||
templateUrl: string;
|
||||
file: File | null;
|
||||
result: AdminExcelImportResult | null;
|
||||
busy: boolean;
|
||||
onFileChange: (file: File | null) => void;
|
||||
onPreview: () => void;
|
||||
onApply: () => void;
|
||||
}
|
||||
|
||||
const ExcelUploadPanel: React.FC<UploadPanelProps> = ({
|
||||
title,
|
||||
templateUrl,
|
||||
file,
|
||||
result,
|
||||
busy,
|
||||
onFileChange,
|
||||
onPreview,
|
||||
onApply,
|
||||
}) => (
|
||||
<div className="card admin-upload-panel">
|
||||
<div className="admin-upload-head">
|
||||
<h3>{title}</h3>
|
||||
<a className="btn-ghost" href={templateUrl}>양식 다운로드</a>
|
||||
</div>
|
||||
<div className="admin-upload-controls">
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
onChange={(e) => onFileChange(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button className="btn-secondary" type="button" disabled={busy || !file} onClick={onPreview}>검증</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
type="button"
|
||||
disabled={busy || !file || !result?.success}
|
||||
onClick={onApply}
|
||||
>
|
||||
적용
|
||||
</button>
|
||||
</div>
|
||||
{result && (
|
||||
<div className="admin-upload-result">
|
||||
<div className="admin-upload-summary">
|
||||
<span>전체 {result.totalRows}건</span>
|
||||
<span>신규 {result.createCount}건</span>
|
||||
<span>수정 {result.updateCount}건</span>
|
||||
<span className={result.errorCount > 0 ? 'text-danger' : ''}>오류 {result.errorCount}건</span>
|
||||
<span>경고 {result.warningCount}건</span>
|
||||
{result.applied && <span>적용 완료</span>}
|
||||
</div>
|
||||
{result.rows.length > 0 && (
|
||||
<table className="table admin-preview-table">
|
||||
<thead><tr><th>행</th><th>상태</th><th>키</th><th>내용</th><th>검증 결과</th></tr></thead>
|
||||
<tbody>
|
||||
{result.rows.map((row) => (
|
||||
<tr key={`${row.rowNumber}-${row.key}`} className={row.errors.length > 0 ? 'row-error' : ''}>
|
||||
<td>{row.rowNumber}</td>
|
||||
<td>{statusLabel(row.status)}</td>
|
||||
<td>{row.key || '-'}</td>
|
||||
<td>{row.summary || '-'}</td>
|
||||
<td>
|
||||
{row.errors.length === 0 && row.warnings.length === 0 && '정상'}
|
||||
{row.errors.map((message) => <div key={message} className="text-danger">{message}</div>)}
|
||||
{row.warnings.map((message) => <div key={message}>{message}</div>)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AdminManagementPage: React.FC = () => {
|
||||
const [tab, setTab] = useState<Tab>('watcher');
|
||||
const [watcher, setWatcher] = useState<Watcher1Settings>({ name: '', team: '', contact: '' });
|
||||
const [purposes, setPurposes] = useState<PurposeCode[]>([]);
|
||||
const [purposeForm, setPurposeForm] = useState<Omit<PurposeCode, 'id'>>(EMPTY_PURPOSE);
|
||||
const [editingPurposeId, setEditingPurposeId] = useState<number | null>(null);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [teamForm, setTeamForm] = useState<Omit<Team, 'id'>>(EMPTY_TEAM);
|
||||
const [editingTeamId, setEditingTeamId] = useState<number | null>(null);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [userQuery, setUserQuery] = useState('');
|
||||
const [teamFilter, setTeamFilter] = useState('');
|
||||
const [teamUploadFile, setTeamUploadFile] = useState<File | null>(null);
|
||||
const [teamUploadResult, setTeamUploadResult] = useState<AdminExcelImportResult | null>(null);
|
||||
const [userRoleUploadFile, setUserRoleUploadFile] = useState<File | null>(null);
|
||||
const [userRoleUploadResult, setUserRoleUploadResult] = useState<AdminExcelImportResult | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
setError(null);
|
||||
Promise.all([getWatcher1Settings(), listAdminPurposeCodes(), listAdminTeams(), listAdminUsers()])
|
||||
.then(([w, p, t, u]) => {
|
||||
setWatcher(w);
|
||||
setPurposes(p);
|
||||
setTeams(t);
|
||||
setUsers(u);
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '관리 정보를 불러오지 못했습니다.'));
|
||||
};
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
const saveWatcher = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setWatcher(await updateWatcher1Settings(watcher));
|
||||
setNotice('현장감시자1 정보가 저장되었습니다.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '저장 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const editPurpose = (p: PurposeCode) => {
|
||||
setEditingPurposeId(p.id);
|
||||
setPurposeForm({
|
||||
code: p.code,
|
||||
name: p.name,
|
||||
sortOrder: p.sortOrder,
|
||||
active: p.active,
|
||||
customAllowed: p.customAllowed,
|
||||
});
|
||||
};
|
||||
|
||||
const resetPurposeForm = () => {
|
||||
setEditingPurposeId(null);
|
||||
setPurposeForm(EMPTY_PURPOSE);
|
||||
};
|
||||
|
||||
const editTeam = (team: Team) => {
|
||||
setEditingTeamId(team.id);
|
||||
setTeamForm({
|
||||
code: team.code,
|
||||
name: team.name,
|
||||
active: team.active,
|
||||
defaultRoles: team.defaultRoles,
|
||||
});
|
||||
};
|
||||
|
||||
const resetTeamForm = () => {
|
||||
setEditingTeamId(null);
|
||||
setTeamForm(EMPTY_TEAM);
|
||||
};
|
||||
|
||||
const toggleTeamDefaultRole = (role: Role) => {
|
||||
const next = teamForm.defaultRoles.includes(role)
|
||||
? teamForm.defaultRoles.filter((r) => r !== role)
|
||||
: [...teamForm.defaultRoles, role];
|
||||
setTeamForm({ ...teamForm, defaultRoles: next });
|
||||
};
|
||||
|
||||
const saveTeam = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (teamForm.defaultRoles.length === 0) {
|
||||
setError('팀 기본권한은 최소 1개 이상 필요합니다.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = editingTeamId == null
|
||||
? await createAdminTeam(teamForm)
|
||||
: await updateAdminTeam(editingTeamId, teamForm);
|
||||
setTeams((prev) => {
|
||||
const others = prev.filter((t) => t.id !== saved.id);
|
||||
return [...others, saved].sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
resetTeamForm();
|
||||
setNotice('팀 정보가 저장되었습니다.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '저장 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const savePurpose = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = editingPurposeId == null
|
||||
? await createAdminPurposeCode(purposeForm)
|
||||
: await updateAdminPurposeCode(editingPurposeId, purposeForm);
|
||||
setPurposes((prev) => {
|
||||
const others = prev.filter((p) => p.id !== saved.id);
|
||||
return [...others, saved].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
});
|
||||
resetPurposeForm();
|
||||
setNotice('출입목적 코드가 저장되었습니다.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '저장 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRole = (user: AdminUser, role: Role) => {
|
||||
const next = user.roles.includes(role)
|
||||
? user.roles.filter((r) => r !== role)
|
||||
: [...user.roles, role];
|
||||
if (next.length === 0) {
|
||||
setError('권한은 최소 1개 이상 필요합니다.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, roles: next } : u)));
|
||||
};
|
||||
|
||||
const changeUserTeam = (user: AdminUser, teamId: number) => {
|
||||
const team = teams.find((t) => t.id === teamId);
|
||||
setError(null);
|
||||
setUsers((prev) => prev.map((u) => (u.id === user.id
|
||||
? { ...u, teamId, teamCode: team?.code, teamName: team?.name, department: team?.name ?? u.department }
|
||||
: u)));
|
||||
};
|
||||
|
||||
const saveUser = async (user: AdminUser) => {
|
||||
if (user.roles.length === 0) {
|
||||
setError('권한은 최소 1개 이상 필요합니다.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
let updated = user;
|
||||
if (user.teamId) {
|
||||
updated = await updateAdminUserTeam(user.id, user.teamId, false);
|
||||
}
|
||||
updated = await updateAdminUserRoles(user.id, user.roles);
|
||||
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)));
|
||||
setNotice(`${user.username} 정보가 저장되었습니다.`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '사용자 권한 저장 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyTeamDefaults = async (team: Team) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const message = await applyTeamDefaultRolesToMembers(team.id);
|
||||
setUsers(await listAdminUsers());
|
||||
setNotice(message);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '기본권한 적용 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewTeamUpload = async () => {
|
||||
if (!teamUploadFile) {
|
||||
setError('업로드할 엑셀 파일을 선택하세요.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setTeamUploadResult(await uploadAdminTeams(teamUploadFile, true));
|
||||
setNotice('팀 엑셀 검증이 완료되었습니다.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '팀 엑셀 검증 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyTeamUpload = async () => {
|
||||
if (!teamUploadFile || !teamUploadResult || !teamUploadResult.success) {
|
||||
setError('오류가 없는 미리보기 결과가 있어야 적용할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await uploadAdminTeams(teamUploadFile, false);
|
||||
setTeamUploadResult(result);
|
||||
setTeams(await listAdminTeams());
|
||||
setUsers(await listAdminUsers());
|
||||
setNotice(`팀 엑셀 적용 완료: 신규 ${result.createCount}건, 수정 ${result.updateCount}건`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '팀 엑셀 적용 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewUserRoleUpload = async () => {
|
||||
if (!userRoleUploadFile) {
|
||||
setError('업로드할 엑셀 파일을 선택하세요.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setUserRoleUploadResult(await uploadAdminUserRoles(userRoleUploadFile, true));
|
||||
setNotice('권한 엑셀 검증이 완료되었습니다.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '권한 엑셀 검증 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyUserRoleUpload = async () => {
|
||||
if (!userRoleUploadFile || !userRoleUploadResult || !userRoleUploadResult.success) {
|
||||
setError('오류가 없는 미리보기 결과가 있어야 적용할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await uploadAdminUserRoles(userRoleUploadFile, false);
|
||||
setUserRoleUploadResult(result);
|
||||
setTeams(await listAdminTeams());
|
||||
setUsers(await listAdminUsers());
|
||||
setNotice(`권한 엑셀 적용 완료: 수정 ${result.updateCount}건`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '권한 엑셀 적용 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter((u) => {
|
||||
const q = userQuery.trim().toLowerCase();
|
||||
const matchesQuery = !q
|
||||
|| u.username.toLowerCase().includes(q)
|
||||
|| u.fullName.toLowerCase().includes(q)
|
||||
|| (u.teamName ?? u.department ?? '').toLowerCase().includes(q);
|
||||
const matchesTeam = !teamFilter || String(u.teamId ?? '') === teamFilter;
|
||||
return matchesQuery && matchesTeam;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>시스템 관리</h2></div>
|
||||
{notice && <div className="alert alert-info">{notice}</div>}
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div className="admin-tabs">
|
||||
<button className={tab === 'watcher' ? 'tab-active' : ''} onClick={() => setTab('watcher')}>현장감시자1</button>
|
||||
<button className={tab === 'purpose' ? 'tab-active' : ''} onClick={() => setTab('purpose')}>출입목적 코드</button>
|
||||
<button className={tab === 'teams' ? 'tab-active' : ''} onClick={() => setTab('teams')}>팀 관리</button>
|
||||
<button className={tab === 'roles' ? 'tab-active' : ''} onClick={() => setTab('roles')}>권한관리</button>
|
||||
</div>
|
||||
|
||||
{tab === 'watcher' && (
|
||||
<form className="card form-grid" onSubmit={saveWatcher}>
|
||||
<label className="field"><span>이름</span><input value={watcher.name} onChange={(e) => setWatcher({ ...watcher, name: e.target.value })} /></label>
|
||||
<label className="field"><span>소속</span><input value={watcher.team} onChange={(e) => setWatcher({ ...watcher, team: e.target.value })} /></label>
|
||||
<label className="field"><span>연락처</span><input value={watcher.contact} onChange={(e) => setWatcher({ ...watcher, contact: e.target.value })} /></label>
|
||||
<div className="form-actions span-2">
|
||||
<button className="btn-primary" disabled={busy}>저장</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{tab === 'purpose' && (
|
||||
<>
|
||||
<form className="card form-grid" onSubmit={savePurpose}>
|
||||
<label className="field"><span>코드</span><input className="ime-en" value={purposeForm.code} onChange={(e) => setPurposeForm({ ...purposeForm, code: e.target.value })} placeholder="WORK" /></label>
|
||||
<label className="field"><span>표시명</span><input className="ime-ko" value={purposeForm.name} onChange={(e) => setPurposeForm({ ...purposeForm, name: e.target.value })} placeholder="작업" /></label>
|
||||
<label className="field"><span>정렬순서</span><input type="number" value={purposeForm.sortOrder} onChange={(e) => setPurposeForm({ ...purposeForm, sortOrder: Number(e.target.value) })} /></label>
|
||||
<label className="checkbox-inline"><input type="checkbox" checked={purposeForm.active} onChange={(e) => setPurposeForm({ ...purposeForm, active: e.target.checked })} /><span>사용</span></label>
|
||||
<label className="checkbox-inline"><input type="checkbox" checked={purposeForm.customAllowed} onChange={(e) => setPurposeForm({ ...purposeForm, customAllowed: e.target.checked })} /><span>기타 입력 허용</span></label>
|
||||
<div className="form-actions span-2">
|
||||
<button type="button" className="btn-ghost" onClick={resetPurposeForm}>초기화</button>
|
||||
<button className="btn-primary" disabled={busy}>{editingPurposeId == null ? '추가' : '저장'}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="card">
|
||||
<table className="table">
|
||||
<thead><tr><th>코드</th><th>표시명</th><th>순서</th><th>사용</th><th>기타</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{purposes.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.code}</td>
|
||||
<td>{p.name}</td>
|
||||
<td>{p.sortOrder}</td>
|
||||
<td>{p.active ? 'Y' : 'N'}</td>
|
||||
<td>{p.customAllowed ? 'Y' : 'N'}</td>
|
||||
<td><button className="btn-link" onClick={() => editPurpose(p)}>수정</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'teams' && (
|
||||
<>
|
||||
<ExcelUploadPanel
|
||||
title="팀 엑셀 업로드"
|
||||
templateUrl={adminTeamTemplateUrl()}
|
||||
file={teamUploadFile}
|
||||
result={teamUploadResult}
|
||||
busy={busy}
|
||||
onFileChange={(file) => {
|
||||
setTeamUploadFile(file);
|
||||
setTeamUploadResult(null);
|
||||
}}
|
||||
onPreview={() => void previewTeamUpload()}
|
||||
onApply={() => void applyTeamUpload()}
|
||||
/>
|
||||
|
||||
<form className="card form-grid" onSubmit={saveTeam}>
|
||||
<label className="field"><span>팀 코드</span><input className="ime-en" value={teamForm.code} onChange={(e) => setTeamForm({ ...teamForm, code: e.target.value })} placeholder="DEV1" /></label>
|
||||
<label className="field"><span>팀명</span><input className="ime-ko" value={teamForm.name} onChange={(e) => setTeamForm({ ...teamForm, name: e.target.value })} placeholder="개발1팀" /></label>
|
||||
<label className="checkbox-inline"><input type="checkbox" checked={teamForm.active} onChange={(e) => setTeamForm({ ...teamForm, active: e.target.checked })} /><span>사용</span></label>
|
||||
<div className="field span-2">
|
||||
<span>기본권한</span>
|
||||
<div className="checkbox-row">
|
||||
{ALL_ROLES.map((role) => (
|
||||
<label key={role} className="checkbox-inline">
|
||||
<input type="checkbox" checked={teamForm.defaultRoles.includes(role)} onChange={() => toggleTeamDefaultRole(role)} />
|
||||
<span>{role === 'HOST' ? 'USER' : role}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-actions span-2">
|
||||
<button type="button" className="btn-ghost" onClick={resetTeamForm}>초기화</button>
|
||||
<button className="btn-primary" disabled={busy}>{editingTeamId == null ? '추가' : '저장'}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="card">
|
||||
<table className="table">
|
||||
<thead><tr><th>팀 코드</th><th>팀명</th><th>기본권한</th><th>사용</th><th></th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{teams.map((team) => (
|
||||
<tr key={team.id}>
|
||||
<td>{team.code}</td>
|
||||
<td>{team.name}</td>
|
||||
<td>{team.defaultRoles.map((r) => (r === 'HOST' ? 'USER' : r)).join(', ') || '-'}</td>
|
||||
<td>{team.active ? 'Y' : 'N'}</td>
|
||||
<td><button className="btn-link" onClick={() => editTeam(team)}>수정</button></td>
|
||||
<td><button className="btn-link" disabled={busy} onClick={() => void applyTeamDefaults(team)}>팀원 기본권한 적용</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'roles' && (
|
||||
<>
|
||||
<ExcelUploadPanel
|
||||
title="권한 엑셀 업로드"
|
||||
templateUrl={adminUserRolesTemplateUrl()}
|
||||
file={userRoleUploadFile}
|
||||
result={userRoleUploadResult}
|
||||
busy={busy}
|
||||
onFileChange={(file) => {
|
||||
setUserRoleUploadFile(file);
|
||||
setUserRoleUploadResult(null);
|
||||
}}
|
||||
onPreview={() => void previewUserRoleUpload()}
|
||||
onApply={() => void applyUserRoleUpload()}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<div className="admin-filter-row">
|
||||
<input value={userQuery} onChange={(e) => setUserQuery(e.target.value)} placeholder="아이디, 이름, 팀 검색" />
|
||||
<select value={teamFilter} onChange={(e) => setTeamFilter(e.target.value)}>
|
||||
<option value="">전체 팀</option>
|
||||
{teams.map((team) => <option key={team.id} value={team.id}>{team.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead><tr><th>아이디</th><th>이름</th><th>팀</th><th>권한</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.username}</td>
|
||||
<td>{u.fullName}</td>
|
||||
<td>
|
||||
<select
|
||||
value={u.teamId ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const teamId = Number(e.target.value);
|
||||
if (teamId) changeUserTeam(u, teamId);
|
||||
}}
|
||||
>
|
||||
<option value="">팀 미지정</option>
|
||||
{teams.map((team) => <option key={team.id} value={team.id}>{team.name}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<div className="checkbox-row">
|
||||
{ALL_ROLES.map((role) => (
|
||||
<label key={role} className="checkbox-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={busy}
|
||||
checked={u.roles.includes(role)}
|
||||
onChange={() => toggleRole(u, role)}
|
||||
/>
|
||||
<span>{role === 'HOST' ? 'USER' : role}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn-link" disabled={busy || u.roles.length === 0} onClick={() => void saveUser(u)}>
|
||||
저장
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ const ACTION_LABEL: Record<AuditLog['action'], string> = {
|
||||
REJECT: '반려',
|
||||
BLACKLIST_ADD: '블랙리스트 등록',
|
||||
BLACKLIST_REMOVE: '블랙리스트 해제',
|
||||
ADMIN_CONFIG_UPDATE: '시스템 설정',
|
||||
};
|
||||
|
||||
const ACTION_CLASS: Record<AuditLog['action'], string> = {
|
||||
@@ -15,6 +16,7 @@ const ACTION_CLASS: Record<AuditLog['action'], string> = {
|
||||
REJECT: 'red',
|
||||
BLACKLIST_ADD: 'red',
|
||||
BLACKLIST_REMOVE: 'gray',
|
||||
ADMIN_CONFIG_UPDATE: 'blue',
|
||||
};
|
||||
|
||||
export const AuditLogPage: React.FC = () => {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getStatsSummary, listInside, listVisitRequests } from '../api';
|
||||
import { getStatsSummary, listInside, listTodayAccess, listVisitRequests } from '../api';
|
||||
import { StatsSummary, VisitRequestView } from '../types';
|
||||
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
|
||||
import { VisitRequestDetailDialog } from '../components/VisitRequestDetailDialog';
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null);
|
||||
const [insideIds, setInsideIds] = useState<Set<number>>(new Set());
|
||||
const [exitedIds, setExitedIds] = useState<Set<number>>(new Set());
|
||||
const [checkOutById, setCheckOutById] = useState<Map<number, string>>(new Map());
|
||||
const [detailId, setDetailId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -17,6 +21,19 @@ export const DashboardPage: React.FC = () => {
|
||||
listInside()
|
||||
.then((rows) => setInsideIds(new Set(rows.map((r) => r.visitRequestId))))
|
||||
.catch(() => setInsideIds(new Set()));
|
||||
listTodayAccess()
|
||||
.then((rows) => {
|
||||
setExitedIds(new Set(rows
|
||||
.filter((r) => !r.inside && r.checkOutAt)
|
||||
.map((r) => r.visitRequestId)));
|
||||
setCheckOutById(new Map(rows
|
||||
.filter((r) => r.checkOutAt)
|
||||
.map((r) => [r.visitRequestId, r.checkOutAt as string])));
|
||||
})
|
||||
.catch(() => {
|
||||
setExitedIds(new Set());
|
||||
setCheckOutById(new Map());
|
||||
});
|
||||
listVisitRequests()
|
||||
.then(setItems)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
||||
@@ -51,19 +68,33 @@ export const DashboardPage: 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<tr
|
||||
key={r.id}
|
||||
className="clickable-row"
|
||||
tabIndex={0}
|
||||
onClick={() => setDetailId(r.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setDetailId(r.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td>{r.visitorName}</td>
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>{formatDateTime(r.visitFrom)}</td>
|
||||
<td>{checkOutById.has(r.id) ? formatDateTime(checkOutById.get(r.id)!) : '-'}</td>
|
||||
<td>
|
||||
{insideIds.has(r.id) ? (
|
||||
<span className="badge badge-green">재실중</span>
|
||||
) : exitedIds.has(r.id) ? (
|
||||
<span className="badge badge-gray">퇴장</span>
|
||||
) : (
|
||||
<span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span>
|
||||
)}
|
||||
@@ -74,6 +105,10 @@ export const DashboardPage: React.FC = () => {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detailId != null && (
|
||||
<VisitRequestDetailDialog requestId={detailId} onClose={() => setDetailId(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ export const LoginPage: React.FC = () => {
|
||||
<button className="btn-primary full" type="submit" disabled={busy || !username || !password}>
|
||||
{busy ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { reportDownloadUrl } from '../api';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { listReportVisits, reportDownloadUrl } from '../api';
|
||||
import { DatePickerField } from '../components/DatePickerField';
|
||||
import { VisitRequestView } from '../types';
|
||||
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
@@ -18,6 +20,27 @@ function firstOfMonthISO(): string {
|
||||
export const ReportPage: React.FC = () => {
|
||||
const [from, setFrom] = useState(firstOfMonthISO());
|
||||
const [to, setTo] = useState(todayISO());
|
||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
listReportVisits(from, to)
|
||||
.then(setItems)
|
||||
.catch((e) => {
|
||||
setItems([]);
|
||||
setError(e instanceof Error ? e.message : '보고서 조회에 실패했습니다.');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// Load the default month range once; explicit 조회 handles later date changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onDownload = () => {
|
||||
// Trigger a download in-place via a temporary anchor. Using window.open left
|
||||
@@ -37,7 +60,6 @@ export const ReportPage: React.FC = () => {
|
||||
<div className="page-head"><h2>출입관리 보고서</h2></div>
|
||||
|
||||
<div className="card">
|
||||
<p className="muted">기간을 선택하고 [엑셀 다운로드] 버튼을 클릭하면, 엑셀(.xlsx) 파일로 내려받습니다. (방문 시작일 기준)</p>
|
||||
<div className="report-row">
|
||||
<label className="field">
|
||||
<span>시작일</span>
|
||||
@@ -47,9 +69,57 @@ export const ReportPage: React.FC = () => {
|
||||
<span>종료일</span>
|
||||
<DatePickerField value={to} onChange={setTo} />
|
||||
</label>
|
||||
<button className="btn-ghost" onClick={load} disabled={loading}>조회</button>
|
||||
<button className="btn-primary" onClick={onDownload}>엑셀 다운로드</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
<div className="page-head">
|
||||
<h3>조회 결과 ({items.length})</h3>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="muted">불러오는 중...</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="muted">조회된 출입 신청이 없습니다.</p>
|
||||
) : (
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.visitorName}</td>
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.contact || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>{r.hostName}</td>
|
||||
<td>{r.purpose || '-'}</td>
|
||||
<td>{r.workName || '-'}</td>
|
||||
<td>{formatDateTime(r.visitFrom)}</td>
|
||||
<td>{formatDateTime(r.visitTo)}</td>
|
||||
<td><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -140,6 +140,48 @@ a { color: inherit; text-decoration: none; }
|
||||
.modal-message { margin: 0 0 12px; color: var(--muted); font-size: 14px; }
|
||||
.modal-input { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; resize: vertical; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
.detail-modal {
|
||||
width: min(1120px, calc(100vw - 48px));
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: auto;
|
||||
}
|
||||
.detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 12px; }
|
||||
.detail-status { margin-bottom: 12px; }
|
||||
.detail-form {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.detail-form .form-group {
|
||||
min-width: 0;
|
||||
}
|
||||
.detail-form .group-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.detail-form .field {
|
||||
grid-template-columns: 104px minmax(0, 1fr);
|
||||
}
|
||||
.detail-form .field input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.detail-form .checkbox-row {
|
||||
min-width: 0;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 16px;
|
||||
}
|
||||
.detail-item {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.detail-item span { color: var(--muted); font-size: 12px; font-weight: 700; }
|
||||
.detail-item strong { font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
|
||||
/* Public entrance kiosk */
|
||||
.kiosk { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; background: var(--bg, #f1f5f9); }
|
||||
@@ -175,7 +217,7 @@ a { color: inherit; text-decoration: none; }
|
||||
.form-grid .span-2 { grid-column: 1 / -1; }
|
||||
.visit-form { gap: 8px; padding: 12px; margin-bottom: 0; }
|
||||
.form-group {
|
||||
border: 1px solid #cbd5e1;
|
||||
border: 2px solid #94a3b8;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px 0;
|
||||
margin: 0;
|
||||
@@ -244,6 +286,7 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
.consent-label { display: flex; gap: 10px; align-items: flex-start; cursor: pointer; font-size: 12px; color: #334155; line-height: 1.35; }
|
||||
.consent-label input { margin-top: 2px; width: 16px; height: 16px; flex-shrink: 0; }
|
||||
.consent-retention-warning { color: var(--red); }
|
||||
|
||||
/* Inline checkbox group (e.g. 전산실 다중 선택) */
|
||||
.checkbox-row { display: flex; flex-wrap: wrap; gap: 10px; padding: 4px 2px; }
|
||||
@@ -290,6 +333,8 @@ button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
}
|
||||
.table th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: none; }
|
||||
.table tbody tr:hover { background: #f8fafc; }
|
||||
.clickable-row { cursor: pointer; }
|
||||
.clickable-row:focus-visible { outline: 2px solid #bfdbfe; outline-offset: -2px; }
|
||||
|
||||
/* ===== Badges ===== */
|
||||
.badge {
|
||||
@@ -325,6 +370,81 @@ button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.btn-link { background: none; border: none; color: var(--primary); font-weight: 600; }
|
||||
.row-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* ===== Admin management ===== */
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.admin-tabs button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 10px 12px;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.admin-tabs button.tab-active {
|
||||
color: var(--primary);
|
||||
border-bottom: 2px solid var(--primary);
|
||||
}
|
||||
.admin-filter-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.admin-filter-row input,
|
||||
.admin-filter-row select,
|
||||
.table select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
.admin-filter-row input {
|
||||
min-width: 260px;
|
||||
}
|
||||
.admin-upload-panel {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.admin-upload-head,
|
||||
.admin-upload-controls,
|
||||
.admin-upload-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.admin-upload-head {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.admin-upload-head h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.admin-upload-controls input[type="file"] {
|
||||
min-width: 280px;
|
||||
}
|
||||
.admin-upload-summary {
|
||||
margin: 12px 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-preview-table {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.admin-preview-table td {
|
||||
vertical-align: top;
|
||||
}
|
||||
.row-error {
|
||||
background: #fff7f7;
|
||||
}
|
||||
.text-danger {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* ===== Report ===== */
|
||||
.report-row { display: flex; align-items: flex-end; gap: 16px; }
|
||||
.report-row .field { margin-bottom: 0; }
|
||||
@@ -362,6 +482,10 @@ button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.group-grid { grid-template-columns: 1fr; }
|
||||
.detail-grid,
|
||||
.detail-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.visit-form .field,
|
||||
.visit-form .field.span-2 {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -33,6 +33,43 @@ export interface Zone {
|
||||
securityLevel: number;
|
||||
}
|
||||
|
||||
export interface PurposeCode {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
active: boolean;
|
||||
customAllowed: boolean;
|
||||
}
|
||||
|
||||
export interface Watcher1Settings {
|
||||
name: string;
|
||||
team: string;
|
||||
contact: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
defaultRoles: Role[];
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
fullName: string;
|
||||
department?: string;
|
||||
teamId?: number;
|
||||
teamCode?: string;
|
||||
teamName?: string;
|
||||
email?: string;
|
||||
enabled: boolean;
|
||||
locked: boolean;
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
export type VisitStatus =
|
||||
| 'DRAFT'
|
||||
| 'PENDING'
|
||||
@@ -52,6 +89,8 @@ export interface VisitRequestCreate {
|
||||
/** Detail room (콤보박스, 기타 자유 입력) — auxiliary, no separate QR. */
|
||||
roomZone?: string;
|
||||
purpose: string;
|
||||
purposeCode?: string;
|
||||
purposeDetail?: string;
|
||||
/** 작업명 — optional concrete task detail, stored separately from purpose. */
|
||||
workName?: string;
|
||||
/** 현장감시자2 (담당자 입력). 담당자·감시자1은 서버가 채운다. */
|
||||
@@ -67,12 +106,15 @@ export interface VisitRequestView {
|
||||
visitorName: string;
|
||||
company?: string;
|
||||
contact?: string;
|
||||
email?: string;
|
||||
vehicleNo?: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
hostDepartment?: string;
|
||||
zoneName?: string;
|
||||
purpose: string;
|
||||
purposeCode?: string;
|
||||
purposeDetail?: string;
|
||||
workName?: string;
|
||||
controlName?: string;
|
||||
controlTeam?: string;
|
||||
@@ -109,6 +151,26 @@ export interface ExcelImportResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface AdminExcelImportRow {
|
||||
rowNumber: number;
|
||||
status: 'CREATE' | 'UPDATE' | 'ERROR' | string;
|
||||
key?: string;
|
||||
summary?: string;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface AdminExcelImportResult {
|
||||
totalRows: number;
|
||||
createCount: number;
|
||||
updateCount: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
applied: boolean;
|
||||
success: boolean;
|
||||
rows: AdminExcelImportRow[];
|
||||
}
|
||||
|
||||
export interface InsideVisitor {
|
||||
visitRequestId: number;
|
||||
visitorName: string;
|
||||
@@ -170,7 +232,7 @@ export interface AuditLog {
|
||||
at: string;
|
||||
actorId?: number;
|
||||
actorUsername?: string;
|
||||
action: 'APPROVE' | 'REJECT' | 'BLACKLIST_ADD' | 'BLACKLIST_REMOVE';
|
||||
action: 'APPROVE' | 'REJECT' | 'BLACKLIST_ADD' | 'BLACKLIST_REMOVE' | 'ADMIN_CONFIG_UPDATE';
|
||||
targetType?: string;
|
||||
targetId?: number;
|
||||
detail?: string;
|
||||
|
||||
Reference in New Issue
Block a user