- UniversalListPage에 externalIsLoading prop 추가 - CardTransactionDetailClient DevFill 자동입력 기능 추가 - 여러 컴포넌트 로딩 상태 처리 개선 - skeleton 컴포넌트 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
783 lines
24 KiB
TypeScript
783 lines
24 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useMemo, useCallback, useEffect, useTransition } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
FileText,
|
|
Send,
|
|
Trash2,
|
|
Plus,
|
|
Pencil,
|
|
Bell,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
getDrafts,
|
|
getDraftsSummary,
|
|
getDraftById,
|
|
deleteDraft,
|
|
deleteDrafts,
|
|
submitDraft,
|
|
submitDrafts,
|
|
} from './actions';
|
|
import { sendApprovalNotification } from '@/lib/actions/fcm';
|
|
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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
UniversalListPage,
|
|
type UniversalListConfig,
|
|
} 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,
|
|
} from '@/components/approval/DocumentDetail/types';
|
|
import type {
|
|
DraftRecord,
|
|
DocumentStatus,
|
|
Approver,
|
|
SortOption,
|
|
FilterOption,
|
|
} from './types';
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
import {
|
|
SORT_OPTIONS,
|
|
FILTER_OPTIONS,
|
|
DOCUMENT_STATUS_LABELS,
|
|
DOCUMENT_STATUS_COLORS,
|
|
} from './types';
|
|
|
|
// ===== 통계 타입 =====
|
|
interface DraftsSummary {
|
|
total: number;
|
|
draft: number;
|
|
pending: number;
|
|
approved: number;
|
|
rejected: number;
|
|
}
|
|
|
|
export function DraftBox() {
|
|
const router = useRouter();
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
// ===== 상태 관리 =====
|
|
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, setStartDate] = useState('2025-01-01');
|
|
const [endDate, setEndDate] = useState('2025-12-31');
|
|
|
|
// API 데이터
|
|
const [data, setData] = useState<DraftRecord[]>([]);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
// 통계 데이터
|
|
const [summary, setSummary] = useState<DraftsSummary | null>(null);
|
|
|
|
// ===== 문서 상세 모달 상태 =====
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [selectedDocument, setSelectedDocument] = useState<DraftRecord | null>(null);
|
|
|
|
// ===== 데이터 로드 =====
|
|
const loadData = useCallback(async () => {
|
|
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 'titleAsc':
|
|
return { sort_by: 'title', sort_dir: 'asc' };
|
|
case 'titleDesc':
|
|
return { sort_by: 'title', sort_dir: 'desc' };
|
|
default:
|
|
return { sort_by: 'created_at', sort_dir: 'desc' };
|
|
}
|
|
})();
|
|
|
|
const result = await getDrafts({
|
|
page: currentPage,
|
|
per_page: itemsPerPage,
|
|
search: searchQuery || undefined,
|
|
status: 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 drafts:', error);
|
|
toast.error('기안함 목록을 불러오는데 실패했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption]);
|
|
|
|
// ===== 통계 로드 =====
|
|
const loadSummary = useCallback(async () => {
|
|
try {
|
|
const result = await getDraftsSummary();
|
|
setSummary(result);
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('Failed to load summary:', error);
|
|
}
|
|
}, []);
|
|
|
|
// ===== 초기 로드 및 필터 변경 시 데이터 재로드 =====
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
useEffect(() => {
|
|
loadSummary();
|
|
}, [loadSummary]);
|
|
|
|
// ===== 검색어 변경 시 페이지 초기화 =====
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [searchQuery, filterOption, sortOption]);
|
|
|
|
// ===== 액션 핸들러 =====
|
|
const handleSubmit = useCallback(
|
|
async (selectedItems: Set<string>) => {
|
|
const ids = Array.from(selectedItems);
|
|
if (ids.length === 0) return;
|
|
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await submitDrafts(ids);
|
|
if (result.success) {
|
|
toast.success(`${ids.length}건의 문서를 상신했습니다.`);
|
|
loadData();
|
|
loadSummary();
|
|
} else {
|
|
toast.error(result.error || '상신에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('Submit error:', error);
|
|
toast.error('상신 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
},
|
|
[loadData, loadSummary]
|
|
);
|
|
|
|
const handleDelete = useCallback(
|
|
async (selectedItems: Set<string>) => {
|
|
const ids = Array.from(selectedItems);
|
|
if (ids.length === 0) return;
|
|
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await deleteDrafts(ids);
|
|
if (result.success) {
|
|
toast.success(`${ids.length}건의 문서를 삭제했습니다.`);
|
|
loadData();
|
|
loadSummary();
|
|
} else {
|
|
toast.error(result.error || '삭제에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('Delete error:', error);
|
|
toast.error('삭제 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
},
|
|
[loadData, loadSummary]
|
|
);
|
|
|
|
const handleDeleteSingle = useCallback(
|
|
async (id: string) => {
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await deleteDraft(id);
|
|
if (result.success) {
|
|
toast.success('문서를 삭제했습니다.');
|
|
loadData();
|
|
loadSummary();
|
|
} else {
|
|
toast.error(result.error || '삭제에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('Delete error:', error);
|
|
toast.error('삭제 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
},
|
|
[loadData, loadSummary]
|
|
);
|
|
|
|
const handleNewDocument = useCallback(() => {
|
|
router.push('/ko/approval/draft/new');
|
|
}, [router]);
|
|
|
|
const handleSendNotification = useCallback(async () => {
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await sendApprovalNotification();
|
|
if (result.success) {
|
|
toast.success(`결재 알림을 발송했습니다. (${result.sentCount || 0}건)`);
|
|
} else {
|
|
toast.error(result.error || '알림 발송에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('Notification error:', error);
|
|
toast.error('알림 발송 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
// ===== 문서 클릭 핸들러 =====
|
|
const handleDocumentClick = useCallback(
|
|
async (item: DraftRecord) => {
|
|
if (item.status === 'draft') {
|
|
router.push(`/ko/approval/draft/new?id=${item.id}&mode=edit`);
|
|
} else {
|
|
const detailData = await getDraftById(item.id);
|
|
if (detailData) {
|
|
setSelectedDocument(detailData);
|
|
} else {
|
|
setSelectedDocument(item);
|
|
}
|
|
setIsModalOpen(true);
|
|
}
|
|
},
|
|
[router]
|
|
);
|
|
|
|
const handleModalEdit = useCallback(() => {
|
|
if (selectedDocument) {
|
|
router.push(`/ko/approval/draft/new?id=${selectedDocument.id}&mode=edit`);
|
|
setIsModalOpen(false);
|
|
}
|
|
}, [selectedDocument, router]);
|
|
|
|
const handleModalCopy = useCallback(() => {
|
|
if (selectedDocument) {
|
|
router.push(`/ko/approval/draft/new?copyFrom=${selectedDocument.id}`);
|
|
setIsModalOpen(false);
|
|
}
|
|
}, [selectedDocument, router]);
|
|
|
|
const handleModalSubmit = useCallback(async () => {
|
|
if (!selectedDocument) return;
|
|
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await submitDraft(selectedDocument.id);
|
|
if (result.success) {
|
|
toast.success('문서를 상신했습니다.');
|
|
setIsModalOpen(false);
|
|
setSelectedDocument(null);
|
|
loadData();
|
|
loadSummary();
|
|
} else {
|
|
toast.error(result.error || '상신에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('Submit error:', error);
|
|
toast.error('상신 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
}, [selectedDocument, loadData, loadSummary]);
|
|
|
|
// ===== 문서 타입 판별 =====
|
|
const getDocumentType = (item: DraftRecord): DocumentType => {
|
|
if (item.documentTypeCode) {
|
|
if (item.documentTypeCode === 'expenseEstimate') return 'expenseEstimate';
|
|
if (item.documentTypeCode === 'expenseReport') return 'expenseReport';
|
|
if (item.documentTypeCode === 'proposal') return 'proposal';
|
|
}
|
|
if (item.documentType.includes('지출') && item.documentType.includes('예상'))
|
|
return 'expenseEstimate';
|
|
if (item.documentType.includes('지출')) return 'expenseReport';
|
|
return 'proposal';
|
|
};
|
|
|
|
// ===== 모달용 데이터 변환 =====
|
|
const convertToModalData = (
|
|
item: DraftRecord
|
|
): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
|
const docType = getDocumentType(item);
|
|
const content = item.content || {};
|
|
|
|
const drafter = {
|
|
id: 'drafter-1',
|
|
name: item.drafter,
|
|
position: item.drafterPosition || '',
|
|
department: item.drafterDepartment || '',
|
|
status: 'approved' as const,
|
|
};
|
|
const approvers = item.approvers.map((a) => ({
|
|
id: a.id,
|
|
name: a.name,
|
|
position: a.position,
|
|
department: a.department,
|
|
status: a.status,
|
|
approvedAt: a.approvedAt,
|
|
}));
|
|
|
|
switch (docType) {
|
|
case 'expenseEstimate': {
|
|
const items =
|
|
(content.items as Array<{
|
|
id: string;
|
|
checked?: boolean;
|
|
expectedPaymentDate: string;
|
|
category: string;
|
|
amount: number;
|
|
vendor: string;
|
|
memo?: string;
|
|
}>) || [];
|
|
|
|
return {
|
|
documentNo: item.documentNo,
|
|
createdAt: item.draftDate,
|
|
items: items.map((i, idx) => ({
|
|
id: i.id || String(idx + 1),
|
|
expectedPaymentDate: i.expectedPaymentDate || '',
|
|
category: i.category || '',
|
|
amount: i.amount || 0,
|
|
vendor: i.vendor || '',
|
|
account: i.memo || '',
|
|
})),
|
|
totalExpense: (content.totalExpense as number) || 0,
|
|
accountBalance: (content.accountBalance as number) || 0,
|
|
finalDifference: (content.finalDifference as number) || 0,
|
|
approvers,
|
|
drafter,
|
|
};
|
|
}
|
|
case 'expenseReport': {
|
|
const items =
|
|
(content.items as Array<{
|
|
id: string;
|
|
description: string;
|
|
amount: number;
|
|
note?: string;
|
|
}>) || [];
|
|
|
|
return {
|
|
documentNo: item.documentNo,
|
|
createdAt: item.draftDate,
|
|
requestDate: (content.requestDate as string) || item.draftDate,
|
|
paymentDate: (content.paymentDate as string) || item.draftDate,
|
|
items: items.map((i, idx) => ({
|
|
id: i.id || String(idx + 1),
|
|
no: idx + 1,
|
|
description: i.description || '',
|
|
amount: i.amount || 0,
|
|
note: i.note || '',
|
|
})),
|
|
cardInfo: (content.cardId as string) || '',
|
|
totalAmount: (content.totalAmount as number) || 0,
|
|
attachments: [],
|
|
approvers,
|
|
drafter,
|
|
};
|
|
}
|
|
default: {
|
|
const files =
|
|
(content.files as Array<{ id: number; name: string; url?: string }>) || [];
|
|
const attachmentUrls = files.map((f) => `/api/proxy/files/${f.id}/download`);
|
|
|
|
return {
|
|
documentNo: item.documentNo,
|
|
createdAt: item.draftDate,
|
|
vendor: (content.vendor as string) || '',
|
|
vendorPaymentDate: (content.vendorPaymentDate as string) || '',
|
|
title: (content.title as string) || item.title,
|
|
description: (content.description as string) || '',
|
|
reason: (content.reason as string) || '',
|
|
estimatedCost: (content.estimatedCost as number) || 0,
|
|
attachments: attachmentUrls,
|
|
approvers,
|
|
drafter,
|
|
};
|
|
}
|
|
}
|
|
};
|
|
|
|
// ===== 결재자 텍스트 포맷 =====
|
|
const formatApprovers = (approvers: Approver[]): string => {
|
|
if (approvers.length === 0) return '-';
|
|
if (approvers.length === 1) return approvers[0].name;
|
|
return `${approvers[0].name} 외 ${approvers.length - 1}명`;
|
|
};
|
|
|
|
// ===== UniversalListPage 설정 =====
|
|
const draftBoxConfig: UniversalListConfig<DraftRecord> = useMemo(
|
|
() => ({
|
|
title: '기안함',
|
|
description: '작성한 결재 문서를 관리합니다',
|
|
icon: FileText,
|
|
basePath: '/approval/draft',
|
|
|
|
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: 'documentType', label: '문서유형' },
|
|
{ key: 'title', label: '제목' },
|
|
{ key: 'approvers', label: '결재자' },
|
|
{ key: 'draftDate', label: '기안일시' },
|
|
{ key: 'status', label: '상태', className: 'text-center' },
|
|
{ key: 'actions', label: '작업', className: 'text-center' },
|
|
],
|
|
|
|
dateRangeSelector: {
|
|
enabled: true,
|
|
showPresets: false,
|
|
startDate,
|
|
endDate,
|
|
onStartDateChange: setStartDate,
|
|
onEndDateChange: setEndDate,
|
|
},
|
|
|
|
createButton: {
|
|
label: '문서 작성',
|
|
icon: Plus,
|
|
onClick: handleNewDocument,
|
|
},
|
|
|
|
searchPlaceholder: '문서번호, 제목, 기안자 검색...',
|
|
|
|
itemsPerPage: itemsPerPage,
|
|
|
|
// 모바일 필터 설정
|
|
filterConfig: [
|
|
{
|
|
key: 'status',
|
|
label: '상태',
|
|
type: 'single',
|
|
options: FILTER_OPTIONS.filter((o) => o.value !== 'all'),
|
|
},
|
|
{
|
|
key: 'sort',
|
|
label: '정렬',
|
|
type: 'single',
|
|
options: SORT_OPTIONS,
|
|
},
|
|
],
|
|
initialFilters: {
|
|
status: filterOption,
|
|
sort: sortOption,
|
|
},
|
|
filterTitle: '기안함 필터',
|
|
|
|
computeStats: () => {
|
|
const inProgressCount = summary ? summary.pending : 0;
|
|
const approvedCount = summary?.approved ?? 0;
|
|
const rejectedCount = summary?.rejected ?? 0;
|
|
const draftCount = summary?.draft ?? 0;
|
|
|
|
return [
|
|
{
|
|
label: '진행',
|
|
value: `${inProgressCount}건`,
|
|
icon: FileText,
|
|
iconColor: 'text-blue-500',
|
|
},
|
|
{
|
|
label: '완료',
|
|
value: `${approvedCount}건`,
|
|
icon: FileText,
|
|
iconColor: 'text-green-500',
|
|
},
|
|
{
|
|
label: '반려',
|
|
value: `${rejectedCount}건`,
|
|
icon: FileText,
|
|
iconColor: 'text-red-500',
|
|
},
|
|
{
|
|
label: '임시 저장',
|
|
value: `${draftCount}건`,
|
|
icon: FileText,
|
|
iconColor: 'text-gray-500',
|
|
},
|
|
];
|
|
},
|
|
|
|
headerActions: ({ selectedItems, onClearSelection }) => (
|
|
<div className="ml-auto flex gap-2">
|
|
{selectedItems.size > 0 && (
|
|
<>
|
|
<Button
|
|
variant="default"
|
|
onClick={() => {
|
|
handleSubmit(selectedItems);
|
|
onClearSelection();
|
|
}}
|
|
>
|
|
<Send className="h-4 w-4 mr-2" />
|
|
상신
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => {
|
|
handleDelete(selectedItems);
|
|
onClearSelection();
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
삭제
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button variant="outline" onClick={handleSendNotification}>
|
|
<Bell className="h-4 w-4 mr-2" />
|
|
문서완료
|
|
</Button>
|
|
</div>
|
|
),
|
|
|
|
tableHeaderActions: (
|
|
<div className="flex items-center gap-2">
|
|
<Select
|
|
value={filterOption}
|
|
onValueChange={(value) => setFilterOption(value as FilterOption)}
|
|
>
|
|
<SelectTrigger className="w-[120px]">
|
|
<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="w-[120px]">
|
|
<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, onRowClick } = 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="text-sm">{item.documentNo}</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline">{item.documentType}</Badge>
|
|
</TableCell>
|
|
<TableCell className="font-medium max-w-[250px] truncate">
|
|
{item.title}
|
|
</TableCell>
|
|
<TableCell>{formatApprovers(item.approvers)}</TableCell>
|
|
<TableCell>{item.draftDate}</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge className={DOCUMENT_STATUS_COLORS[item.status]}>
|
|
{DOCUMENT_STATUS_LABELS[item.status]}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
|
{isSelected && item.status === 'draft' && (
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
|
|
onClick={() => handleDocumentClick(item)}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
onClick={() => handleDeleteSingle(item.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
},
|
|
|
|
renderMobileCard: (item, index, globalIndex, handlers) => {
|
|
const { isSelected, onToggle } = handlers;
|
|
|
|
return (
|
|
<ListMobileCard
|
|
id={item.id}
|
|
title={item.title}
|
|
headerBadges={
|
|
<>
|
|
<Badge variant="outline">{item.documentType}</Badge>
|
|
<Badge className={DOCUMENT_STATUS_COLORS[item.status]}>
|
|
{DOCUMENT_STATUS_LABELS[item.status]}
|
|
</Badge>
|
|
</>
|
|
}
|
|
isSelected={isSelected}
|
|
onToggleSelection={onToggle}
|
|
infoGrid={
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<InfoField label="문서번호" value={item.documentNo} />
|
|
<InfoField label="기안일자" value={item.draftDate} />
|
|
<InfoField label="기안자" value={item.drafter} />
|
|
<InfoField
|
|
label="결재자"
|
|
value={item.approvers.map((a) => a.name).join(' → ') || '-'}
|
|
/>
|
|
</div>
|
|
}
|
|
actions={
|
|
isSelected && item.status === 'draft' ? (
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={() => handleDocumentClick(item)}
|
|
>
|
|
<Pencil className="w-4 h-4 mr-2" /> 수정
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1 text-red-600"
|
|
onClick={() => handleDeleteSingle(item.id)}
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" /> 삭제
|
|
</Button>
|
|
</div>
|
|
) : undefined
|
|
}
|
|
onClick={() => handleDocumentClick(item)}
|
|
/>
|
|
);
|
|
},
|
|
|
|
renderDialogs: () =>
|
|
selectedDocument && (
|
|
<DocumentDetailModal
|
|
open={isModalOpen}
|
|
onOpenChange={setIsModalOpen}
|
|
documentType={getDocumentType(selectedDocument)}
|
|
data={convertToModalData(selectedDocument)}
|
|
mode="draft"
|
|
documentStatus={selectedDocument.status}
|
|
onEdit={handleModalEdit}
|
|
onCopy={handleModalCopy}
|
|
onSubmit={handleModalSubmit}
|
|
/>
|
|
),
|
|
}),
|
|
[
|
|
data,
|
|
totalCount,
|
|
totalPages,
|
|
startDate,
|
|
endDate,
|
|
handleNewDocument,
|
|
summary,
|
|
handleSubmit,
|
|
handleDelete,
|
|
handleSendNotification,
|
|
filterOption,
|
|
sortOption,
|
|
handleDocumentClick,
|
|
handleDeleteSingle,
|
|
formatApprovers,
|
|
selectedDocument,
|
|
isModalOpen,
|
|
handleModalEdit,
|
|
handleModalCopy,
|
|
handleModalSubmit,
|
|
]
|
|
);
|
|
|
|
// 모바일 필터 변경 핸들러
|
|
const handleFilterChange = useCallback((filters: Record<string, string | string[]>) => {
|
|
if (filters.status) {
|
|
setFilterOption(filters.status as FilterOption);
|
|
}
|
|
if (filters.sort) {
|
|
setSortOption(filters.sort as SortOption);
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<UniversalListPage<DraftRecord>
|
|
config={draftBoxConfig}
|
|
initialData={data}
|
|
initialTotalCount={totalCount}
|
|
externalPagination={{
|
|
currentPage,
|
|
totalPages,
|
|
totalItems: totalCount,
|
|
itemsPerPage,
|
|
onPageChange: setCurrentPage,
|
|
}}
|
|
onSearchChange={setSearchQuery}
|
|
onFilterChange={handleFilterChange}
|
|
externalIsLoading={isLoading}
|
|
/>
|
|
);
|
|
}
|