- 숫자/통화/전화번호/사업자번호 등 특수 입력 컴포넌트 추가 - MobileCard 컴포넌트 통합 (ListMobileCard 제거) - IntegratedListTemplateV2 페이지네이션 버그 수정 (NaN 이슈) - IntegratedDetailTemplate 타이틀 중복 수정 - 문서 시스템 컴포넌트 추가 - 헤더 벨 아이콘 포커스 스타일 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
703 lines
23 KiB
TypeScript
703 lines
23 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
|
|
import {
|
|
Files,
|
|
Eye,
|
|
EyeOff,
|
|
BookOpen,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
getReferences,
|
|
getReferenceSummary,
|
|
markAsReadBulk,
|
|
markAsUnreadBulk,
|
|
} from './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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import {
|
|
UniversalListPage,
|
|
type UniversalListConfig,
|
|
type StatCard,
|
|
type TabOption,
|
|
} from '@/components/templates/UniversalListPage';
|
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
|
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 {
|
|
ReferenceTabType,
|
|
ReferenceRecord,
|
|
SortOption,
|
|
FilterOption,
|
|
ApprovalType,
|
|
} from './types';
|
|
import {
|
|
REFERENCE_TAB_LABELS,
|
|
SORT_OPTIONS,
|
|
FILTER_OPTIONS,
|
|
APPROVAL_TYPE_LABELS,
|
|
READ_STATUS_LABELS,
|
|
READ_STATUS_COLORS,
|
|
} from './types';
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
|
|
// ===== 통계 타입 =====
|
|
interface ReferenceSummary {
|
|
all: number;
|
|
read: number;
|
|
unread: number;
|
|
}
|
|
|
|
export function ReferenceBox() {
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
// ===== 상태 관리 =====
|
|
const [activeTab, setActiveTab] = useState<ReferenceTabType>('all');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [filterOption, setFilterOption] = useState<FilterOption>('all');
|
|
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const itemsPerPage = 20;
|
|
|
|
// 날짜 범위 상태
|
|
const [startDate, setStartDate] = useState('2025-09-01');
|
|
const [endDate, setEndDate] = useState('2025-09-03');
|
|
|
|
// 다이얼로그 상태
|
|
const [markReadDialogOpen, setMarkReadDialogOpen] = useState(false);
|
|
const [markUnreadDialogOpen, setMarkUnreadDialogOpen] = useState(false);
|
|
|
|
// ===== 문서 상세 모달 상태 =====
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [selectedDocument, setSelectedDocument] = useState<ReferenceRecord | null>(null);
|
|
|
|
// API 데이터
|
|
const [data, setData] = useState<ReferenceRecord[]>([]);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
// 통계 데이터
|
|
const [summary, setSummary] = useState<ReferenceSummary | 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 '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' };
|
|
}
|
|
})();
|
|
|
|
// 탭에 따른 is_read 파라미터
|
|
const isReadParam = activeTab === 'all' ? undefined : activeTab === 'read';
|
|
|
|
const result = await getReferences({
|
|
page: currentPage,
|
|
per_page: itemsPerPage,
|
|
search: searchQuery || undefined,
|
|
is_read: isReadParam,
|
|
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 references:', error);
|
|
toast.error('참조함 목록을 불러오는데 실패했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab]);
|
|
|
|
// ===== 통계 로드 =====
|
|
const loadSummary = useCallback(async () => {
|
|
try {
|
|
const result = await getReferenceSummary();
|
|
setSummary(result);
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('Failed to load summary:', error);
|
|
}
|
|
}, []);
|
|
|
|
// ===== 초기 로드 =====
|
|
// 마운트 시 1회만 실행 (summary 로드)
|
|
useEffect(() => {
|
|
loadSummary();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// ===== 데이터 로드 (의존성 명시적 관리) =====
|
|
// currentPage, searchQuery, filterOption, sortOption, activeTab 변경 시 데이터 재로드
|
|
useEffect(() => {
|
|
loadData();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [currentPage, searchQuery, filterOption, sortOption, activeTab]);
|
|
|
|
// ===== 검색어/필터/탭 변경 시 페이지 초기화 =====
|
|
// ref로 이전 값 추적하여 불필요한 상태 변경 방지 (무한 루프 방지)
|
|
const prevSearchRef = useRef(searchQuery);
|
|
const prevFilterRef = useRef(filterOption);
|
|
const prevSortRef = useRef(sortOption);
|
|
const prevTabRef = useRef(activeTab);
|
|
|
|
useEffect(() => {
|
|
const searchChanged = prevSearchRef.current !== searchQuery;
|
|
const filterChanged = prevFilterRef.current !== filterOption;
|
|
const sortChanged = prevSortRef.current !== sortOption;
|
|
const tabChanged = prevTabRef.current !== activeTab;
|
|
|
|
if (searchChanged || filterChanged || sortChanged || tabChanged) {
|
|
// 페이지가 1이 아닐 때만 리셋 (불필요한 상태 변경 방지)
|
|
if (currentPage !== 1) {
|
|
setCurrentPage(1);
|
|
}
|
|
prevSearchRef.current = searchQuery;
|
|
prevFilterRef.current = filterOption;
|
|
prevSortRef.current = sortOption;
|
|
prevTabRef.current = activeTab;
|
|
}
|
|
}, [searchQuery, filterOption, sortOption, activeTab, currentPage]);
|
|
|
|
// ===== 탭 변경 핸들러 =====
|
|
const handleTabChange = useCallback((value: string) => {
|
|
setActiveTab(value as ReferenceTabType);
|
|
setSelectedItems(new Set());
|
|
setSearchQuery('');
|
|
}, []);
|
|
|
|
// ===== 체크박스 핸들러 =====
|
|
const toggleSelection = useCallback((id: string) => {
|
|
setSelectedItems(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(id)) newSet.delete(id);
|
|
else newSet.add(id);
|
|
return newSet;
|
|
});
|
|
}, []);
|
|
|
|
const toggleSelectAll = useCallback(() => {
|
|
if (selectedItems.size === data.length && data.length > 0) {
|
|
setSelectedItems(new Set());
|
|
} else {
|
|
setSelectedItems(new Set(data.map(item => item.id)));
|
|
}
|
|
}, [selectedItems.size, data]);
|
|
|
|
// ===== 통계 데이터 (API summary 사용) =====
|
|
const stats = useMemo(() => {
|
|
return {
|
|
all: summary?.all ?? 0,
|
|
read: summary?.read ?? 0,
|
|
unread: summary?.unread ?? 0,
|
|
};
|
|
}, [summary]);
|
|
|
|
// ===== 열람/미열람 처리 핸들러 =====
|
|
const handleMarkReadClick = useCallback(() => {
|
|
if (selectedItems.size === 0) return;
|
|
setMarkReadDialogOpen(true);
|
|
}, [selectedItems.size]);
|
|
|
|
const handleMarkReadConfirm = useCallback(async () => {
|
|
const ids = Array.from(selectedItems);
|
|
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await markAsReadBulk(ids);
|
|
if (result.success) {
|
|
toast.success('열람 처리 완료', {
|
|
description: '열람 처리가 완료되었습니다.',
|
|
});
|
|
setSelectedItems(new Set());
|
|
loadData();
|
|
loadSummary();
|
|
} else {
|
|
toast.error(result.error || '열람 처리에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('Mark read error:', error);
|
|
toast.error('열람 처리 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
|
|
setMarkReadDialogOpen(false);
|
|
}, [selectedItems, loadData, loadSummary]);
|
|
|
|
const handleMarkUnreadClick = useCallback(() => {
|
|
if (selectedItems.size === 0) return;
|
|
setMarkUnreadDialogOpen(true);
|
|
}, [selectedItems.size]);
|
|
|
|
const handleMarkUnreadConfirm = useCallback(async () => {
|
|
const ids = Array.from(selectedItems);
|
|
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await markAsUnreadBulk(ids);
|
|
if (result.success) {
|
|
toast.success('미열람 처리 완료', {
|
|
description: '미열람 처리가 완료되었습니다.',
|
|
});
|
|
setSelectedItems(new Set());
|
|
loadData();
|
|
loadSummary();
|
|
} else {
|
|
toast.error(result.error || '미열람 처리에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('Mark unread error:', error);
|
|
toast.error('미열람 처리 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
|
|
setMarkUnreadDialogOpen(false);
|
|
}, [selectedItems, loadData, loadSummary]);
|
|
|
|
// ===== 문서 클릭/상세 보기 핸들러 =====
|
|
const handleDocumentClick = useCallback((item: ReferenceRecord) => {
|
|
setSelectedDocument(item);
|
|
setIsModalOpen(true);
|
|
}, []);
|
|
|
|
// ===== ApprovalType → DocumentType 변환 =====
|
|
const getDocumentType = (approvalType: ApprovalType): DocumentType => {
|
|
switch (approvalType) {
|
|
case 'expense_estimate': return 'expenseEstimate';
|
|
case 'expense_report': return 'expenseReport';
|
|
default: return 'proposal';
|
|
}
|
|
};
|
|
|
|
// ===== ReferenceRecord → 모달용 데이터 변환 =====
|
|
const convertToModalData = (item: ReferenceRecord): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
|
const docType = getDocumentType(item.approvalType);
|
|
const drafter = {
|
|
id: 'drafter-1',
|
|
name: item.drafter,
|
|
position: item.drafterPosition,
|
|
department: item.drafterDepartment,
|
|
status: 'approved' as const,
|
|
};
|
|
const approvers = [{
|
|
id: 'approver-1',
|
|
name: '결재자',
|
|
position: '부장',
|
|
department: '경영지원팀',
|
|
status: 'approved' as const,
|
|
}];
|
|
|
|
switch (docType) {
|
|
case 'expenseEstimate':
|
|
return {
|
|
documentNo: item.documentNo,
|
|
createdAt: item.draftDate,
|
|
items: [
|
|
{ id: '1', expectedPaymentDate: '2025-11-05', category: '통신 서비스', amount: 550000, vendor: 'KT', account: '국민 123-456-789012 홍길동' },
|
|
{ id: '2', expectedPaymentDate: '2025-11-12', category: '인건 대행', amount: 2500000, vendor: '에이치알코리아', account: '신한 110-123-456789 (주)에이치알' },
|
|
],
|
|
totalExpense: 3050000,
|
|
accountBalance: 25000000,
|
|
finalDifference: 21950000,
|
|
approvers,
|
|
drafter,
|
|
};
|
|
case 'expenseReport':
|
|
return {
|
|
documentNo: item.documentNo,
|
|
createdAt: item.draftDate,
|
|
requestDate: item.draftDate,
|
|
paymentDate: item.draftDate,
|
|
items: [
|
|
{ id: '1', no: 1, description: '업무용 택시비', amount: 50000, note: '고객사 미팅' },
|
|
{ id: '2', no: 2, description: '식대', amount: 30000, note: '팀 회식' },
|
|
],
|
|
cardInfo: '삼성카드 **** 1234',
|
|
totalAmount: 80000,
|
|
attachments: [],
|
|
approvers,
|
|
drafter,
|
|
};
|
|
default:
|
|
return {
|
|
documentNo: item.documentNo,
|
|
createdAt: item.draftDate,
|
|
vendor: '거래처',
|
|
vendorPaymentDate: item.draftDate,
|
|
title: item.title,
|
|
description: item.title,
|
|
reason: '업무상 필요',
|
|
estimatedCost: 1000000,
|
|
attachments: [],
|
|
approvers,
|
|
drafter,
|
|
};
|
|
}
|
|
};
|
|
|
|
// ===== 통계 카드 =====
|
|
const statCards: StatCard[] = useMemo(() => [
|
|
{ label: '전체', value: `${stats.all}건`, icon: Files, iconColor: 'text-blue-500' },
|
|
{ label: '열람', value: `${stats.read}건`, icon: Eye, iconColor: 'text-green-500' },
|
|
{ label: '미열람', value: `${stats.unread}건`, icon: EyeOff, iconColor: 'text-red-500' },
|
|
], [stats]);
|
|
|
|
// ===== 탭 옵션 (열람/미열람 토글 버튼 형태) =====
|
|
const tabs: TabOption[] = useMemo(() => [
|
|
{ value: 'all', label: REFERENCE_TAB_LABELS.all, count: stats.all, color: 'blue' },
|
|
{ value: 'read', label: REFERENCE_TAB_LABELS.read, count: stats.read, color: 'green' },
|
|
{ value: 'unread', label: REFERENCE_TAB_LABELS.unread, count: stats.unread, color: 'red' },
|
|
], [stats]);
|
|
|
|
// ===== 테이블 컬럼 =====
|
|
// 문서번호, 문서유형, 제목, 기안자, 기안일시, 상태
|
|
const tableColumns = useMemo(() => [
|
|
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
|
{ key: 'documentNo', label: '문서번호' },
|
|
{ key: 'approvalType', label: '문서유형' },
|
|
{ key: 'title', label: '제목' },
|
|
{ key: 'drafter', label: '기안자' },
|
|
{ key: 'draftDate', label: '기안일시' },
|
|
{ key: 'status', label: '상태', className: 'text-center' },
|
|
], []);
|
|
|
|
// ===== 테이블 헤더 액션 (필터 + 정렬 셀렉트) =====
|
|
const tableHeaderActions = useMemo(() => (
|
|
<div className="flex items-center gap-2">
|
|
{/* 필터 셀렉트박스 */}
|
|
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
|
|
<SelectTrigger className="w-[140px]">
|
|
<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-[140px]">
|
|
<SelectValue placeholder="정렬 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SORT_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
), [filterOption, sortOption]);
|
|
|
|
// ===== UniversalListPage 설정 =====
|
|
const referenceBoxConfig: UniversalListConfig<ReferenceRecord> = useMemo(() => ({
|
|
title: '참조함',
|
|
description: '참조로 지정된 문서를 확인합니다.',
|
|
icon: BookOpen,
|
|
basePath: '/approval/reference',
|
|
|
|
idField: 'id',
|
|
|
|
actions: {
|
|
getList: async () => ({
|
|
success: true,
|
|
data: data,
|
|
totalCount: totalCount,
|
|
totalPages: totalPages,
|
|
}),
|
|
},
|
|
|
|
columns: tableColumns,
|
|
|
|
tabs: tabs,
|
|
defaultTab: activeTab,
|
|
|
|
computeStats: () => statCards,
|
|
|
|
dateRangeSelector: {
|
|
enabled: true,
|
|
showPresets: false,
|
|
startDate,
|
|
endDate,
|
|
onStartDateChange: setStartDate,
|
|
onEndDateChange: setEndDate,
|
|
},
|
|
|
|
searchPlaceholder: '제목, 기안자, 부서 검색...',
|
|
|
|
itemsPerPage: itemsPerPage,
|
|
|
|
tableHeaderActions: tableHeaderActions,
|
|
|
|
// 모바일 필터 설정
|
|
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: '참조함 필터',
|
|
|
|
headerActions: ({ selectedItems: selected, onClearSelection }) => (
|
|
selected.size > 0 ? (
|
|
<div className="ml-auto flex gap-2">
|
|
<Button variant="default" onClick={handleMarkReadClick}>
|
|
<Eye className="h-4 w-4 mr-2" />
|
|
열람
|
|
</Button>
|
|
<Button variant="outline" onClick={handleMarkUnreadClick}>
|
|
<EyeOff className="h-4 w-4 mr-2" />
|
|
미열람
|
|
</Button>
|
|
</div>
|
|
) : null
|
|
),
|
|
|
|
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="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.draftDate}</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge className={READ_STATUS_COLORS[item.readStatus]}>
|
|
{READ_STATUS_LABELS[item.readStatus]}
|
|
</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={READ_STATUS_COLORS[item.readStatus]}>
|
|
{READ_STATUS_LABELS[item.readStatus]}
|
|
</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.readAt || '-'} />
|
|
</div>
|
|
}
|
|
actions={
|
|
<div className="flex gap-2">
|
|
{item.readStatus === 'unread' ? (
|
|
<Button
|
|
variant="default"
|
|
className="flex-1"
|
|
onClick={() => {
|
|
setSelectedItems(new Set([item.id]));
|
|
setMarkReadDialogOpen(true);
|
|
}}
|
|
>
|
|
<Eye className="w-4 h-4 mr-2" /> 열람 처리
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={() => {
|
|
setSelectedItems(new Set([item.id]));
|
|
setMarkUnreadDialogOpen(true);
|
|
}}
|
|
>
|
|
<EyeOff className="w-4 h-4 mr-2" /> 미열람 처리
|
|
</Button>
|
|
)}
|
|
</div>
|
|
}
|
|
/>
|
|
);
|
|
},
|
|
|
|
renderDialogs: () => (
|
|
<>
|
|
{/* 열람 처리 확인 다이얼로그 */}
|
|
<AlertDialog open={markReadDialogOpen} onOpenChange={setMarkReadDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>열람 처리</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
정말 {selectedItems.size}건을 열람 처리하시겠습니까?
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleMarkReadConfirm}>
|
|
확인
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* 미열람 처리 확인 다이얼로그 */}
|
|
<AlertDialog open={markUnreadDialogOpen} onOpenChange={setMarkUnreadDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>미열람 처리</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
정말 {selectedItems.size}건을 미열람 처리하시겠습니까?
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleMarkUnreadConfirm}>
|
|
확인
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* 문서 상세 모달 */}
|
|
{selectedDocument && (
|
|
<DocumentDetailModal
|
|
open={isModalOpen}
|
|
onOpenChange={setIsModalOpen}
|
|
documentType={getDocumentType(selectedDocument.approvalType)}
|
|
data={convertToModalData(selectedDocument)}
|
|
mode="reference"
|
|
/>
|
|
)}
|
|
</>
|
|
),
|
|
}), [
|
|
data,
|
|
totalCount,
|
|
totalPages,
|
|
tableColumns,
|
|
tabs,
|
|
activeTab,
|
|
statCards,
|
|
startDate,
|
|
endDate,
|
|
tableHeaderActions,
|
|
handleMarkReadClick,
|
|
handleMarkUnreadClick,
|
|
handleDocumentClick,
|
|
markReadDialogOpen,
|
|
markUnreadDialogOpen,
|
|
selectedItems.size,
|
|
handleMarkReadConfirm,
|
|
handleMarkUnreadConfirm,
|
|
selectedDocument,
|
|
isModalOpen,
|
|
getDocumentType,
|
|
convertToModalData,
|
|
]);
|
|
|
|
// 모바일 필터 변경 핸들러
|
|
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<ReferenceRecord>
|
|
config={referenceBoxConfig}
|
|
initialData={data}
|
|
initialTotalCount={totalCount}
|
|
externalPagination={{
|
|
currentPage,
|
|
totalPages,
|
|
totalItems: totalCount,
|
|
itemsPerPage,
|
|
onPageChange: setCurrentPage,
|
|
}}
|
|
externalSelection={{
|
|
selectedItems,
|
|
onToggleSelection: toggleSelection,
|
|
onToggleSelectAll: toggleSelectAll,
|
|
getItemId: (item) => item.id,
|
|
}}
|
|
onTabChange={handleTabChange}
|
|
onSearchChange={setSearchQuery}
|
|
onFilterChange={handleMobileFilterChange}
|
|
/>
|
|
);
|
|
} |