249 lines
9.2 KiB
TypeScript
249 lines
9.2 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
getApvappList, multiApprove, multiReject,
|
|
APRVL_STUS_LABEL, APRVL_KIND_LABEL, ApvreqListItem,
|
|
} from '@/lib/api/tam';
|
|
|
|
import { localYmd, localYmdDaysAgo } from '@/lib/utils/date';
|
|
|
|
const PAGE_SIZE = 20;
|
|
const TODAY = localYmd();
|
|
const MONTH_AGO = localYmdDaysAgo(30);
|
|
|
|
export default function Tam0030Page() {
|
|
const router = useRouter();
|
|
const qc = useQueryClient();
|
|
|
|
const [staYmd, setStaYmd] = useState(MONTH_AGO);
|
|
const [endYmd, setEndYmd] = useState(TODAY);
|
|
const [pageNo, setPageNo] = useState(1);
|
|
const [filter, setFilter] = useState({ staYmd: MONTH_AGO, endYmd: TODAY, pageNo: 1 });
|
|
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [batchComment, setBatchComment] = useState('');
|
|
|
|
const { data, isLoading, refetch } = useQuery({
|
|
queryKey: ['apvapp', filter],
|
|
queryFn: () => getApvappList({ ...filter, pageSize: PAGE_SIZE }),
|
|
});
|
|
|
|
const handleSearch = () => {
|
|
const f = { staYmd, endYmd, pageNo: 1 };
|
|
const same = JSON.stringify(f) === JSON.stringify(filter);
|
|
setFilter(f);
|
|
setPageNo(1);
|
|
setSelected(new Set());
|
|
if (same) refetch();
|
|
};
|
|
|
|
const handlePageChange = (p: number) => {
|
|
setPageNo(p);
|
|
setFilter((prev) => ({ ...prev, pageNo: p }));
|
|
setSelected(new Set());
|
|
};
|
|
|
|
// 결재중인 항목만 체크박스 대상
|
|
const appringItems = (data?.list ?? []).filter((item) => item.aprvlStusCd === '0002');
|
|
|
|
const toggleAll = () => {
|
|
if (selected.size === appringItems.length) {
|
|
setSelected(new Set());
|
|
} else {
|
|
setSelected(new Set(appringItems.map((i) => i.aprvlDocId)));
|
|
}
|
|
};
|
|
|
|
const toggleOne = (id: string) => {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
next.has(id) ? next.delete(id) : next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const getSelectedItems = () =>
|
|
(data?.list ?? [])
|
|
.filter((i) => selected.has(i.aprvlDocId))
|
|
.map((i) => ({ aprvlDocId: i.aprvlDocId, aprvlSno: i.aprvlCmplSno + 1 }));
|
|
|
|
const approveMut = useMutation({
|
|
mutationFn: () => multiApprove(getSelectedItems(), batchComment),
|
|
onSuccess: () => {
|
|
alert(`${selected.size}건 일괄 승인 처리되었습니다.`);
|
|
setSelected(new Set());
|
|
setBatchComment('');
|
|
qc.invalidateQueries({ queryKey: ['apvapp'] });
|
|
},
|
|
onError: (e: any) => alert(e?.response?.data?.message ?? '일괄 승인 오류'),
|
|
});
|
|
|
|
const rejectMut = useMutation({
|
|
mutationFn: () => multiReject(getSelectedItems(), batchComment),
|
|
onSuccess: () => {
|
|
alert(`${selected.size}건 일괄 반려 처리되었습니다.`);
|
|
setSelected(new Set());
|
|
setBatchComment('');
|
|
qc.invalidateQueries({ queryKey: ['apvapp'] });
|
|
},
|
|
onError: (e: any) => alert(e?.response?.data?.message ?? '일괄 반려 오류'),
|
|
});
|
|
|
|
const handleBatchApprove = () => {
|
|
if (selected.size === 0) { alert('선택된 항목이 없습니다.'); return; }
|
|
if (!confirm(`${selected.size}건을 일괄 승인하시겠습니까?`)) return;
|
|
approveMut.mutate();
|
|
};
|
|
|
|
const handleBatchReject = () => {
|
|
if (selected.size === 0) { alert('선택된 항목이 없습니다.'); return; }
|
|
if (!batchComment.trim()) { alert('반려 사유를 입력해주세요.'); return; }
|
|
if (!confirm(`${selected.size}건을 일괄 반려하시겠습니까?`)) return;
|
|
rejectMut.mutate();
|
|
};
|
|
|
|
const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 1;
|
|
const isPending = approveMut.isPending || rejectMut.isPending;
|
|
|
|
const statusBadge = (cd: string) => {
|
|
const colors: Record<string, string> = {
|
|
'0001': 'bg-gray-100 text-gray-600',
|
|
'0002': 'bg-blue-100 text-blue-700',
|
|
'0003': 'bg-green-100 text-green-700',
|
|
'0004': 'bg-red-100 text-red-700',
|
|
};
|
|
return (
|
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${colors[cd] ?? ''}`}>
|
|
{APRVL_STUS_LABEL[cd] ?? cd}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="p-4">
|
|
<h2 className="text-lg font-semibold mb-4">결재 처리</h2>
|
|
|
|
{/* 검색 */}
|
|
<div className="flex flex-wrap gap-2 mb-4 items-center">
|
|
<input type="date" value={staYmd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
|
|
onChange={(e) => setStaYmd(e.target.value.replace(/-/g, ''))}
|
|
className="border rounded px-2 py-1.5 text-sm"
|
|
/>
|
|
<span className="text-sm">~</span>
|
|
<input type="date" value={endYmd.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
|
|
onChange={(e) => setEndYmd(e.target.value.replace(/-/g, ''))}
|
|
className="border rounded px-2 py-1.5 text-sm"
|
|
/>
|
|
<button onClick={handleSearch}
|
|
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
|
|
>
|
|
검색
|
|
</button>
|
|
</div>
|
|
|
|
{/* 일괄처리 바 */}
|
|
{selected.size > 0 && (
|
|
<div className="flex items-center gap-2 mb-3 p-2 bg-yellow-50 border border-yellow-200 rounded">
|
|
<span className="text-sm font-medium text-yellow-800">{selected.size}건 선택됨</span>
|
|
<input
|
|
type="text"
|
|
value={batchComment}
|
|
onChange={(e) => setBatchComment(e.target.value)}
|
|
placeholder="의견 (반려 시 필수)"
|
|
className="flex-1 border rounded px-2 py-1 text-sm"
|
|
/>
|
|
<button onClick={handleBatchApprove} disabled={isPending}
|
|
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
|
|
>
|
|
일괄 승인
|
|
</button>
|
|
<button onClick={handleBatchReject} disabled={isPending}
|
|
className="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 disabled:opacity-50"
|
|
>
|
|
일괄 반려
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 테이블 */}
|
|
<table className="w-full text-sm border-collapse">
|
|
<thead>
|
|
<tr className="bg-gray-100 text-center">
|
|
<th className="border px-2 py-2 w-8">
|
|
<input type="checkbox"
|
|
checked={appringItems.length > 0 && selected.size === appringItems.length}
|
|
onChange={toggleAll}
|
|
/>
|
|
</th>
|
|
<th className="border px-3 py-2">신청자</th>
|
|
<th className="border px-3 py-2">종류</th>
|
|
<th className="border px-3 py-2">상태</th>
|
|
<th className="border px-3 py-2">신청일</th>
|
|
<th className="border px-3 py-2">완료일</th>
|
|
<th className="border px-3 py-2 w-16">진행</th>
|
|
<th className="border px-3 py-2 w-24">처리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{isLoading ? (
|
|
<tr><td colSpan={8} className="text-center py-8 text-gray-400">불러오는 중...</td></tr>
|
|
) : !data?.list?.length ? (
|
|
<tr><td colSpan={8} className="text-center py-8 text-gray-400">처리할 문서가 없습니다.</td></tr>
|
|
) : (
|
|
data.list.map((item: ApvreqListItem) => {
|
|
const isAppring = item.aprvlStusCd === '0002';
|
|
const isChecked = selected.has(item.aprvlDocId);
|
|
return (
|
|
<tr key={item.aprvlDocId}
|
|
className={`hover:bg-gray-50 text-center ${isChecked ? 'bg-blue-50' : ''}`}
|
|
>
|
|
<td className="border px-2 py-2">
|
|
{isAppring && (
|
|
<input type="checkbox" checked={isChecked}
|
|
onChange={() => toggleOne(item.aprvlDocId)}
|
|
/>
|
|
)}
|
|
</td>
|
|
<td className="border px-3 py-2">{item.aplntId}</td>
|
|
<td className="border px-3 py-2">{APRVL_KIND_LABEL[item.aprvlKindCd] ?? item.aprvlKindCd}</td>
|
|
<td className="border px-3 py-2">{statusBadge(item.aprvlStusCd)}</td>
|
|
<td className="border px-3 py-2">{item.offerDt}</td>
|
|
<td className="border px-3 py-2">{item.cmplDt || '-'}</td>
|
|
<td className="border px-3 py-2">{item.aprvlCmplSno} / {item.finalAprvlSno}</td>
|
|
<td className="border px-3 py-2">
|
|
<button
|
|
onClick={() => router.push(`/tam/0030/${item.aprvlDocId}`)}
|
|
className="px-2 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600"
|
|
>
|
|
처리
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
|
|
{/* 페이지네이션 */}
|
|
{totalPages > 1 && (
|
|
<div className="flex justify-center gap-1 mt-4">
|
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
|
<button key={p} onClick={() => handlePageChange(p)}
|
|
className={`px-3 py-1 text-sm rounded border ${
|
|
p === pageNo ? 'bg-blue-600 text-white border-blue-600' : 'hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
{p}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="mt-2 text-sm text-gray-500 text-right">전체 {data?.total ?? 0}건</div>
|
|
</div>
|
|
);
|
|
}
|