Files
sam-react-prod/src/components/approval/ApprovalBox/index.tsx
유병철 181352d7a9 feat: [전자결재] 결재함 기능 확장 및 연결문서 기능 추가
- ApprovalBox actions/타입 확장
- DocumentDetailModalV2 개선
- LinkedDocumentContent 신규 추가
- 결재 문서 타입 보강

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:20:33 +09:00

907 lines
30 KiB
TypeScript

'use client';
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useDateRange } from '@/hooks';
import {
FileCheck,
Check,
X,
Clock,
FileX,
Files,
} from 'lucide-react';
import { toast } from 'sonner';
import {
getInbox,
getInboxSummary,
approveDocument,
rejectDocument,
approveDocumentsBulk,
rejectDocumentsBulk,
getDocumentApprovalById,
} from './actions';
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { TableRow, TableCell } from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import {
UniversalListPage,
type UniversalListConfig,
type TabOption,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
// import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
import type {
DocumentType,
ProposalDocumentData,
ExpenseReportDocumentData,
ExpenseEstimateDocumentData,
LinkedDocumentData,
} from '@/components/approval/DocumentDetail/types';
import type {
ApprovalTabType,
ApprovalRecord,
ApprovalType,
SortOption,
FilterOption,
} from './types';
import {
APPROVAL_TAB_LABELS,
SORT_OPTIONS,
FILTER_OPTIONS,
APPROVAL_TYPE_LABELS,
APPROVAL_STATUS_LABELS,
APPROVAL_STATUS_COLORS,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal';
// ===== 통계 타입 =====
interface InboxSummary {
total: number;
pending: number;
approved: number;
rejected: number;
}
export function ApprovalBox() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const { canApprove } = usePermission();
// ===== 상태 관리 =====
const [activeTab, setActiveTab] = useState<ApprovalTabType>('all');
const [searchQuery, setSearchQuery] = useState('');
const [filterOption, setFilterOption] = useState<FilterOption>('all');
const [sortOption, setSortOption] = useState<SortOption>('latest');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 날짜 범위 상태
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
// 다이얼로그 상태
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
const [rejectComment, setRejectComment] = useState('');
const [pendingSelectedItems, setPendingSelectedItems] = useState<Set<string>>(new Set());
const [pendingClearSelection, setPendingClearSelection] = useState<(() => void) | null>(null);
// ===== 문서 상세 모달 상태 =====
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<ApprovalRecord | null>(null);
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData | null>(null);
const [isModalLoading, setIsModalLoading] = useState(false);
// ===== 검사성적서 모달 상태 (work_order 연결 문서용) =====
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
const [inspectionWorkOrderId, setInspectionWorkOrderId] = useState<string | null>(null);
// API 데이터
const [data, setData] = useState<ApprovalRecord[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const isInitialLoadDone = useRef(false);
// 통계 데이터
const [fixedStats, setFixedStats] = useState({ all: 0, pending: 0, approved: 0, rejected: 0 });
// ===== 데이터 로드 =====
const loadData = useCallback(async () => {
if (!isInitialLoadDone.current) {
setIsLoading(true);
}
try {
const sortConfig: { sort_by: string; sort_dir: 'asc' | 'desc' } = (() => {
switch (sortOption) {
case 'latest':
return { sort_by: 'created_at', sort_dir: 'desc' };
case 'oldest':
return { sort_by: 'created_at', sort_dir: 'asc' };
case 'draftDateAsc':
return { sort_by: 'created_at', sort_dir: 'asc' };
case 'draftDateDesc':
return { sort_by: 'created_at', sort_dir: 'desc' };
default:
return { sort_by: 'created_at', sort_dir: 'desc' };
}
})();
const result = await getInbox({
page: currentPage,
per_page: itemsPerPage,
search: searchQuery || undefined,
status: activeTab !== 'all' ? activeTab : undefined,
approval_type: filterOption !== 'all' ? filterOption : undefined,
...sortConfig,
});
setData(result.data);
setTotalCount(result.total);
setTotalPages(result.lastPage);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to load inbox:', error);
toast.error('결재함 목록을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
isInitialLoadDone.current = true;
}
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab]);
// ===== 초기 로드 =====
useEffect(() => {
loadData();
}, [loadData]);
// ===== 검색어/필터/탭 변경 시 페이지 초기화 =====
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, filterOption, sortOption, activeTab]);
// ===== 탭 변경 핸들러 =====
const handleTabChange = useCallback((value: string) => {
setActiveTab(value as ApprovalTabType);
setSearchQuery('');
}, []);
// ===== 전체 탭일 때만 통계 업데이트 =====
useEffect(() => {
if (activeTab === 'all' && data.length > 0) {
const pending = data.filter((item) => item.status === 'pending').length;
const approved = data.filter((item) => item.status === 'approved').length;
const rejected = data.filter((item) => item.status === 'rejected').length;
setFixedStats({
all: totalCount,
pending,
approved,
rejected,
});
}
}, [data, totalCount, activeTab]);
// ===== 승인/반려 핸들러 =====
const handleApproveClick = useCallback(
(selectedItems: Set<string>, onClearSelection: () => void) => {
if (selectedItems.size === 0) return;
setPendingSelectedItems(selectedItems);
setPendingClearSelection(() => onClearSelection);
setApproveDialogOpen(true);
},
[]
);
const handleApproveConfirm = useCallback(async () => {
const ids = Array.from(pendingSelectedItems);
startTransition(async () => {
try {
const result = await approveDocumentsBulk(ids);
if (result.success) {
toast.success('승인 완료', {
description: '결재 승인이 완료되었습니다.',
});
pendingClearSelection?.();
loadData();
} else {
toast.error(result.error || '승인 처리에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Approve error:', error);
toast.error('승인 처리 중 오류가 발생했습니다.');
}
});
setApproveDialogOpen(false);
setPendingSelectedItems(new Set());
setPendingClearSelection(null);
}, [pendingSelectedItems, pendingClearSelection, loadData]);
const handleRejectClick = useCallback(
(selectedItems: Set<string>, onClearSelection: () => void) => {
if (selectedItems.size === 0) return;
setPendingSelectedItems(selectedItems);
setPendingClearSelection(() => onClearSelection);
setRejectComment('');
setRejectDialogOpen(true);
},
[]
);
const handleRejectConfirm = useCallback(async () => {
if (!rejectComment.trim()) {
toast.error('반려 사유를 입력해주세요.');
return;
}
const ids = Array.from(pendingSelectedItems);
startTransition(async () => {
try {
const result = await rejectDocumentsBulk(ids, rejectComment);
if (result.success) {
toast.success('반려 완료', {
description: '결재 반려가 완료되었습니다.',
});
pendingClearSelection?.();
setRejectComment('');
loadData();
} else {
toast.error(result.error || '반려 처리에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Reject error:', error);
toast.error('반려 처리 중 오류가 발생했습니다.');
}
});
setRejectDialogOpen(false);
setPendingSelectedItems(new Set());
setPendingClearSelection(null);
}, [pendingSelectedItems, rejectComment, pendingClearSelection, loadData]);
// ===== 문서 클릭 핸들러 =====
const handleDocumentClick = useCallback(async (item: ApprovalRecord) => {
setSelectedDocument(item);
setIsModalLoading(true);
setIsModalOpen(true);
try {
// 문서 결재(document) 타입은 별도 API로 연결 문서 데이터 조회
if (item.approvalType === 'document') {
const result = await getDocumentApprovalById(parseInt(item.id));
if (result.success && result.data) {
// work_order 연결 문서 → InspectionReportModal로 열기
if (result.data.workOrderId) {
setIsModalOpen(false);
setIsModalLoading(false);
setInspectionWorkOrderId(String(result.data.workOrderId));
setIsInspectionModalOpen(true);
return;
}
setModalData(result.data as LinkedDocumentData);
} else {
toast.error(result.error || '문서 조회에 실패했습니다.');
setIsModalOpen(false);
}
return;
}
// 기존 결재 문서 타입 (품의서, 지출결의서, 지출예상내역서)
const result = await getApprovalById(parseInt(item.id));
if (result.success && result.data) {
const formData = result.data;
const docType = getDocumentType(item.approvalType);
// 기안자 정보
const drafter = {
id: 'drafter-1',
name: formData.basicInfo.drafter,
position: formData.basicInfo.drafterPosition || '',
department: formData.basicInfo.drafterDepartment || '',
status: 'approved' as const,
};
// 결재자 정보
const approvers = formData.approvalLine.map((person, index) => ({
id: person.id,
name: person.name,
position: person.position,
department: person.department,
status:
item.status === 'approved'
? ('approved' as const)
: item.status === 'rejected'
? ('rejected' as const)
: index === 0
? ('pending' as const)
: ('none' as const),
}));
let convertedData: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData;
switch (docType) {
case 'expenseEstimate':
convertedData = {
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
items: formData.expenseEstimateData?.items.map(item => ({
id: item.id,
expectedPaymentDate: item.expectedPaymentDate,
category: item.category,
amount: item.amount,
vendor: item.vendor,
account: item.memo || '',
})) || [],
totalExpense: formData.expenseEstimateData?.totalExpense || 0,
accountBalance: formData.expenseEstimateData?.accountBalance || 0,
finalDifference: formData.expenseEstimateData?.finalDifference || 0,
approvers,
drafter,
};
break;
case 'expenseReport':
convertedData = {
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
requestDate: formData.expenseReportData?.requestDate || '',
paymentDate: formData.expenseReportData?.paymentDate || '',
items: formData.expenseReportData?.items.map((item, index) => ({
id: item.id,
no: index + 1,
description: item.description,
amount: item.amount,
note: item.note,
})) || [],
cardInfo: formData.expenseReportData?.cardId || '-',
totalAmount: formData.expenseReportData?.totalAmount || 0,
attachments: formData.expenseReportData?.uploadedFiles?.map(f => f.name) || [],
approvers,
drafter,
};
break;
default:
// 품의서
const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f =>
`/api/proxy/files/${f.id}/download`
);
convertedData = {
documentNo: formData.basicInfo.documentNo,
createdAt: formData.basicInfo.draftDate,
vendor: formData.proposalData?.vendor || '-',
vendorPaymentDate: formData.proposalData?.vendorPaymentDate || '',
title: formData.proposalData?.title || item.title,
description: formData.proposalData?.description || '-',
reason: formData.proposalData?.reason || '-',
estimatedCost: formData.proposalData?.estimatedCost || 0,
attachments: uploadedFileUrls,
approvers,
drafter,
};
break;
}
setModalData(convertedData);
} else {
toast.error(result.error || '문서 조회에 실패했습니다.');
setIsModalOpen(false);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to load document:', error);
toast.error('문서를 불러오는데 실패했습니다.');
setIsModalOpen(false);
} finally {
setIsModalLoading(false);
}
}, []);
const handleModalEdit = useCallback(() => {
if (selectedDocument) {
router.push(`/ko/approval/draft/new?id=${selectedDocument.id}&mode=edit`);
setIsModalOpen(false);
}
}, [selectedDocument, router]);
const handleModalCopy = useCallback(() => {
toast.info('문서 복제 기능은 준비 중입니다.');
setIsModalOpen(false);
}, []);
const handleModalApprove = useCallback(async () => {
if (!selectedDocument?.id) return;
const result = await approveDocument(selectedDocument.id);
if (result.success) {
toast.success('문서가 승인되었습니다.');
loadData();
} else {
toast.error(result.error || '승인에 실패했습니다.');
}
setIsModalOpen(false);
}, [selectedDocument, loadData]);
const handleModalReject = useCallback(async () => {
if (!selectedDocument?.id) return;
const result = await rejectDocument(selectedDocument.id, '반려');
if (result.success) {
toast.success('문서가 반려되었습니다.');
loadData();
} else {
toast.error(result.error || '반려에 실패했습니다.');
}
setIsModalOpen(false);
}, [selectedDocument, loadData]);
// ===== 문서 타입 변환 =====
const getDocumentType = (approvalType: ApprovalType): DocumentType => {
switch (approvalType) {
case 'expense_estimate':
return 'expenseEstimate';
case 'expense_report':
return 'expenseReport';
case 'document':
return 'document';
default:
return 'proposal';
}
};
// ===== 탭 옵션 =====
const tabs: TabOption[] = useMemo(
() => [
{
value: 'all',
label: APPROVAL_TAB_LABELS.all,
count: fixedStats.all,
color: 'blue',
},
{
value: 'pending',
label: APPROVAL_TAB_LABELS.pending,
count: fixedStats.pending,
color: 'yellow',
},
{
value: 'approved',
label: APPROVAL_TAB_LABELS.approved,
count: fixedStats.approved,
color: 'green',
},
{
value: 'rejected',
label: APPROVAL_TAB_LABELS.rejected,
count: fixedStats.rejected,
color: 'red',
},
],
[fixedStats]
);
// ===== UniversalListPage 설정 =====
const approvalBoxConfig: UniversalListConfig<ApprovalRecord> = useMemo(
() => ({
title: '결재함',
description: '결재 문서를 관리합니다',
icon: FileCheck,
basePath: '/approval/inbox',
idField: 'id',
actions: {
getList: async () => ({
success: true,
data: data,
totalCount: totalCount,
totalPages: totalPages,
}),
},
columns: [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'documentNo', label: '문서번호' },
{ key: 'approvalType', label: '문서유형' },
{ key: 'title', label: '제목' },
{ key: 'drafter', label: '기안자' },
{ key: 'approver', label: '결재자' },
{ key: 'draftDate', label: '기안일시' },
{ key: 'status', label: '상태', className: 'text-center' },
],
tabs: tabs,
defaultTab: activeTab,
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
dateRangeSelector: {
enabled: true,
showPresets: false,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
searchPlaceholder: '제목, 기안자, 부서 검색...',
searchFilter: (item: ApprovalRecord, search: string) => {
const s = search.toLowerCase();
return (
item.title?.toLowerCase().includes(s) ||
item.drafter?.toLowerCase().includes(s) ||
item.drafterDepartment?.toLowerCase().includes(s) ||
false
);
},
itemsPerPage: itemsPerPage,
// 모바일 필터 설정
filterConfig: [
{
key: 'approvalType',
label: '문서유형',
type: 'single',
options: FILTER_OPTIONS.filter((o) => o.value !== 'all'),
},
{
key: 'sort',
label: '정렬',
type: 'single',
options: SORT_OPTIONS,
},
],
initialFilters: {
approvalType: filterOption,
sort: sortOption,
},
filterTitle: '결재함 필터',
computeStats: () => [
{
label: '전체결재',
value: `${fixedStats.all}`,
icon: Files,
iconColor: 'text-blue-500',
},
{
label: '미결재',
value: `${fixedStats.pending}`,
icon: Clock,
iconColor: 'text-yellow-500',
},
{
label: '결재완료',
value: `${fixedStats.approved}`,
icon: FileCheck,
iconColor: 'text-green-500',
},
{
label: '결재반려',
value: `${fixedStats.rejected}`,
icon: FileX,
iconColor: 'text-red-500',
},
],
selectionActions: ({ selectedItems, onClearSelection }) => canApprove ? (
<>
<Button
size="sm"
variant="default"
onClick={() => handleApproveClick(selectedItems, onClearSelection)}
>
<Check className="h-4 w-4 mr-2" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleRejectClick(selectedItems, onClearSelection)}
>
<X className="h-4 w-4 mr-2" />
</Button>
</>
) : null,
tableHeaderActions: (
<div className="flex items-center gap-2">
<Select
value={filterOption}
onValueChange={(value) => setFilterOption(value as FilterOption)}
>
<SelectTrigger className="min-w-[140px] w-auto">
<SelectValue placeholder="필터 선택" />
</SelectTrigger>
<SelectContent>
{FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={sortOption}
onValueChange={(value) => setSortOption(value as SortOption)}
>
<SelectTrigger className="min-w-[140px] w-auto">
<SelectValue placeholder="정렬 선택" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
),
renderTableRow: (item, index, globalIndex, handlers) => {
const { isSelected, onToggle } = handlers;
return (
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => handleDocumentClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell className="font-mono text-sm">{item.documentNo}</TableCell>
<TableCell>
<Badge variant="outline">{APPROVAL_TYPE_LABELS[item.approvalType]}</Badge>
</TableCell>
<TableCell className="font-medium max-w-[200px] truncate">
{item.title}
</TableCell>
<TableCell>{item.drafter}</TableCell>
<TableCell>{item.approver || '-'}</TableCell>
<TableCell>{item.draftDate}</TableCell>
<TableCell className="text-center">
<Badge className={APPROVAL_STATUS_COLORS[item.status]}>
{APPROVAL_STATUS_LABELS[item.status]}
</Badge>
</TableCell>
</TableRow>
);
},
renderMobileCard: (item, index, globalIndex, handlers) => {
const { isSelected, onToggle } = handlers;
return (
<ListMobileCard
id={item.id}
title={item.title}
headerBadges={
<div className="flex gap-1">
<Badge variant="outline">{APPROVAL_TYPE_LABELS[item.approvalType]}</Badge>
<Badge className={APPROVAL_STATUS_COLORS[item.status]}>
{APPROVAL_STATUS_LABELS[item.status]}
</Badge>
</div>
}
isSelected={isSelected}
onToggleSelection={onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="문서번호" value={item.documentNo} />
<InfoField label="기안자" value={item.drafter} />
<InfoField label="부서" value={item.drafterDepartment} />
<InfoField label="직급" value={item.drafterPosition} />
<InfoField label="기안일" value={item.draftDate} />
<InfoField label="결재일" value={item.approvalDate || '-'} />
</div>
}
actions={
item.status === 'pending' && isSelected && canApprove ? (
<div className="flex gap-2">
<Button
variant="default"
className="flex-1"
onClick={() =>
handleApproveClick(new Set([item.id]), () => {})
}
>
<Check className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="flex-1"
onClick={() =>
handleRejectClick(new Set([item.id]), () => {})
}
>
<X className="w-4 h-4 mr-2" />
</Button>
</div>
) : undefined
}
onClick={() => handleDocumentClick(item)}
/>
);
},
renderDialogs: () => (
<>
{/* 승인 확인 다이얼로그 */}
<ConfirmDialog
open={approveDialogOpen}
onOpenChange={setApproveDialogOpen}
onConfirm={handleApproveConfirm}
title="결재 승인"
description={`정말 ${pendingSelectedItems.size}건을 승인하시겠습니까?`}
variant="success"
confirmText="승인"
/>
{/* 반려 확인 다이얼로그 */}
<AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{pendingSelectedItems.size} .
.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="py-4">
<Label htmlFor="reject-comment" className="text-sm font-medium">
<span className="text-destructive">*</span>
</Label>
<Textarea
id="reject-comment"
placeholder="반려 사유를 입력해주세요..."
value={rejectComment}
onChange={(e) => setRejectComment(e.target.value)}
className="mt-2 min-h-[100px]"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setRejectComment('')}>
</AlertDialogCancel>
<AlertDialogAction
onClick={handleRejectConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={!rejectComment.trim()}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 문서 상세 모달 */}
{selectedDocument && modalData && (
<DocumentDetailModal
open={isModalOpen}
onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) {
setModalData(null);
}
}}
documentType={getDocumentType(selectedDocument.approvalType)}
data={modalData}
mode="inbox"
onEdit={handleModalEdit}
onCopy={handleModalCopy}
onApprove={canApprove ? handleModalApprove : undefined}
onReject={canApprove ? handleModalReject : undefined}
/>
)}
{/* 검사성적서 모달 (work_order 연결 문서) */}
<InspectionReportModal
open={isInspectionModalOpen}
onOpenChange={(open) => {
setIsInspectionModalOpen(open);
if (!open) {
setInspectionWorkOrderId(null);
}
}}
workOrderId={inspectionWorkOrderId}
readOnly={true}
/>
</>
),
}),
[
data,
totalCount,
totalPages,
tabs,
activeTab,
startDate,
endDate,
fixedStats,
handleApproveClick,
handleRejectClick,
filterOption,
sortOption,
handleDocumentClick,
approveDialogOpen,
pendingSelectedItems,
handleApproveConfirm,
rejectDialogOpen,
rejectComment,
handleRejectConfirm,
selectedDocument,
isModalOpen,
modalData,
handleModalEdit,
handleModalCopy,
handleModalApprove,
handleModalReject,
canApprove,
isInspectionModalOpen,
inspectionWorkOrderId,
]
);
// 모바일 필터 변경 핸들러
const handleMobileFilterChange = useCallback((filters: Record<string, string | string[]>) => {
if (filters.approvalType) {
setFilterOption(filters.approvalType as FilterOption);
}
if (filters.sort) {
setSortOption(filters.sort as SortOption);
}
}, []);
return (
<UniversalListPage<ApprovalRecord>
config={approvalBoxConfig}
initialData={data}
initialTotalCount={totalCount}
externalPagination={{
currentPage,
totalPages,
totalItems: totalCount,
itemsPerPage,
onPageChange: setCurrentPage,
}}
onTabChange={handleTabChange}
onSearchChange={setSearchQuery}
onFilterChange={handleMobileFilterChange}
externalIsLoading={isLoading}
/>
);
}