share job
This commit is contained in:
248
frontend/src/app/(main)/tam/0030/page.tsx
Normal file
248
frontend/src/app/(main)/tam/0030/page.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user