166 lines
5.5 KiB
TypeScript
166 lines
5.5 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { approveRequest, listPendingRequests, rejectRequest } from '../api';
|
|
import { VisitRequestView } from '../types';
|
|
import { formatVisitRange } from '../status';
|
|
import { Dialog } from '../components/Dialog';
|
|
|
|
type SortKey = 'visitorName' | 'company' | 'zoneName' | 'purpose' | 'visitFrom';
|
|
type BusyAction = 'approve' | 'reject';
|
|
|
|
const COLUMNS: { key: SortKey; label: string }[] = [
|
|
{ key: 'visitorName', label: '방문자' },
|
|
{ key: 'company', label: '회사' },
|
|
{ key: 'zoneName', label: '출입구역' },
|
|
{ key: 'purpose', label: '출입목적' },
|
|
{ key: 'visitFrom', label: '출입기간' },
|
|
];
|
|
|
|
export const ApprovalQueuePage: React.FC = () => {
|
|
const [items, setItems] = useState<VisitRequestView[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [busyAction, setBusyAction] = useState<{ id: number; action: BusyAction } | null>(null);
|
|
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
|
const [rejectingId, setRejectingId] = useState<number | null>(null);
|
|
|
|
const onSort = (key: SortKey) => {
|
|
if (sortKey === key) {
|
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
} else {
|
|
setSortKey(key);
|
|
setSortDir('asc');
|
|
}
|
|
};
|
|
|
|
const sortedItems = useMemo(() => {
|
|
if (!sortKey) return items;
|
|
const arr = [...items];
|
|
arr.sort((a, b) => {
|
|
const cmp = sortKey === 'visitFrom'
|
|
? new Date(a.visitFrom).getTime() - new Date(b.visitFrom).getTime()
|
|
: String(a[sortKey] ?? '').localeCompare(String(b[sortKey] ?? ''), 'ko');
|
|
return sortDir === 'asc' ? cmp : -cmp;
|
|
});
|
|
return arr;
|
|
}, [items, sortKey, sortDir]);
|
|
|
|
const load = () => {
|
|
setLoading(true);
|
|
listPendingRequests()
|
|
.then(setItems)
|
|
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
|
.finally(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(load, []);
|
|
|
|
const approve = async (id: number) => {
|
|
setError(null);
|
|
setBusyAction({ id, action: 'approve' });
|
|
try {
|
|
await approveRequest(id);
|
|
load();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : '처리 실패');
|
|
} finally {
|
|
setBusyAction(null);
|
|
}
|
|
};
|
|
|
|
const confirmReject = async (comment?: string) => {
|
|
const id = rejectingId;
|
|
setRejectingId(null);
|
|
if (id == null) return;
|
|
setError(null);
|
|
setBusyAction({ id, action: 'reject' });
|
|
try {
|
|
await rejectRequest(id, comment);
|
|
load();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : '처리 실패');
|
|
} finally {
|
|
setBusyAction(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="page-head"><h2>승인 대기 ({items.length})</h2></div>
|
|
{error && <div className="alert alert-error">{error}</div>}
|
|
|
|
<div className="card">
|
|
{loading ? (
|
|
<p className="muted">불러오는 중…</p>
|
|
) : items.length === 0 ? (
|
|
<p className="muted">승인 대기 중인 신청이 없습니다.</p>
|
|
) : (
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
{COLUMNS.map((c) => (
|
|
<th
|
|
key={c.key}
|
|
onClick={() => onSort(c.key)}
|
|
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
|
|
title="클릭하여 정렬"
|
|
>
|
|
{c.label}{sortKey === c.key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''}
|
|
</th>
|
|
))}
|
|
<th>처리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedItems.map((r) => (
|
|
<tr key={r.id}>
|
|
<td>{r.visitorName}</td>
|
|
<td>{r.company || '-'}</td>
|
|
<td>{r.zoneName || '-'}</td>
|
|
<td>{r.purpose}</td>
|
|
<td>{formatVisitRange(r.visitFrom, r.visitTo)}</td>
|
|
<td className="action-cell">
|
|
<button
|
|
className="btn-success"
|
|
disabled={busyAction?.id === r.id && busyAction.action === 'approve'}
|
|
onClick={() => {
|
|
if (busyAction?.id === r.id) return;
|
|
approve(r.id);
|
|
}}
|
|
>
|
|
{busyAction?.id === r.id && busyAction.action === 'approve' ? '처리 중...' : '승인'}
|
|
</button>
|
|
<button
|
|
className="btn-danger"
|
|
disabled={busyAction?.id === r.id && busyAction.action === 'reject'}
|
|
onClick={() => {
|
|
if (busyAction?.id === r.id) return;
|
|
setRejectingId(r.id);
|
|
}}
|
|
>
|
|
{busyAction?.id === r.id && busyAction.action === 'reject' ? '처리 중...' : '반려'}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{rejectingId != null && (
|
|
<Dialog
|
|
title="반려 처리"
|
|
message="반려 사유를 입력하세요 (선택)."
|
|
withInput
|
|
inputPlaceholder="반려 사유"
|
|
confirmLabel="반려"
|
|
danger
|
|
onConfirm={confirmReject}
|
|
onCancel={() => setRejectingId(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|