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

@@ -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>
);
};

View 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>
);
};

View File

@@ -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 = () => {

View File

@@ -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>
);
};

View File

@@ -57,6 +57,7 @@ export const LoginPage: React.FC = () => {
<button className="btn-primary full" type="submit" disabled={busy || !username || !password}>
{busy ? '로그인 중…' : '로그인'}
</button>
</form>
</div>
);

View File

@@ -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>
);
};

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>