- 전자결재: 다양식 지원(11종), 완료함, 동적폼 렌더러, QA 보고서 - 회계: 계정과목 검색모달 리팩토링, 거래처/세금계산서 개선 - HR: 근태/휴가/직원 소소한 수정 - vehicle/quality/pricing 마이너 수정 - approval_backup_v1 백업 보관
699 lines
24 KiB
TypeScript
699 lines
24 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
|
|
import { useDateRange } from '@/hooks';
|
|
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 { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
import {
|
|
UniversalListPage,
|
|
type UniversalListConfig,
|
|
type StatCard,
|
|
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, DynamicDocumentData } from '@/components/approval/DocumentDetail/types';
|
|
import { getFieldLabels, filterVisibleFields, getFormName } from '@/components/approval/DocumentDetail/field-labels';
|
|
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
|
|
import { DEDICATED_FORM_CODES } from '@/components/approval/DocumentCreate/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 [, 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, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
|
|
|
|
// 다이얼로그 상태
|
|
const [markReadDialogOpen, setMarkReadDialogOpen] = useState(false);
|
|
const [markUnreadDialogOpen, setMarkUnreadDialogOpen] = useState(false);
|
|
|
|
// ===== 문서 상세 모달 상태 =====
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isModalLoading, setIsModalLoading] = useState(false);
|
|
const [selectedDocument, setSelectedDocument] = useState<ReferenceRecord | null>(null);
|
|
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | DynamicDocumentData | null>(null);
|
|
const [modalDocType, setModalDocType] = useState<DocumentType>('proposal');
|
|
|
|
// API 데이터
|
|
const [data, setData] = useState<ReferenceRecord[]>([]);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const isInitialLoadDone = useRef(false);
|
|
|
|
// 통계 데이터
|
|
const [summary, setSummary] = useState<ReferenceSummary | null>(null);
|
|
|
|
// ===== 데이터 로드 =====
|
|
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' };
|
|
}
|
|
})();
|
|
|
|
// 탭에 따른 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);
|
|
isInitialLoadDone.current = true;
|
|
}
|
|
}, [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();
|
|
|
|
}, []);
|
|
|
|
// ===== 데이터 로드 (의존성 명시적 관리) =====
|
|
// currentPage, searchQuery, filterOption, sortOption, activeTab 변경 시 데이터 재로드
|
|
useEffect(() => {
|
|
loadData();
|
|
|
|
}, [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(async (item: ReferenceRecord) => {
|
|
setSelectedDocument(item);
|
|
setIsModalLoading(true);
|
|
setIsModalOpen(true);
|
|
|
|
try {
|
|
const result = await getApprovalById(parseInt(item.id));
|
|
if (result.success && result.data) {
|
|
const formData = result.data;
|
|
const docTypeCode = formData.basicInfo.documentType;
|
|
|
|
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: index === 0 ? ('approved' as const) : ('none' as const),
|
|
}));
|
|
|
|
// 전용 양식 또는 동적 양식 → DynamicDocumentData
|
|
const isBuiltin = ['proposal', 'expenseReport', 'expense_report', 'expenseEstimate', 'expense_estimate'].includes(docTypeCode);
|
|
|
|
if (!isBuiltin) {
|
|
const dedicatedDataMap: Record<string, unknown> = {
|
|
officialDocument: formData.officialDocumentData,
|
|
resignation: formData.resignationData,
|
|
employmentCert: formData.employmentCertData,
|
|
careerCert: formData.careerCertData,
|
|
appointmentCert: formData.appointmentCertData,
|
|
sealUsage: formData.sealUsageData,
|
|
leaveNotice1st: formData.leaveNotice1stData,
|
|
leaveNotice2nd: formData.leaveNotice2ndData,
|
|
powerOfAttorney: formData.powerOfAttorneyData,
|
|
boardMinutes: formData.boardMinutesData,
|
|
quotation: formData.quotationData,
|
|
};
|
|
const dedicatedData = dedicatedDataMap[docTypeCode];
|
|
const fields = dedicatedData
|
|
? filterVisibleFields(dedicatedData as Record<string, unknown>)
|
|
: (formData.dynamicFormData || {});
|
|
|
|
setModalDocType('dynamic');
|
|
setModalData({
|
|
documentNo: formData.basicInfo.documentNo,
|
|
createdAt: formData.basicInfo.draftDate,
|
|
formName: formData.basicInfo.formName || getFormName(docTypeCode),
|
|
fields,
|
|
fieldLabels: getFieldLabels(docTypeCode),
|
|
approvers,
|
|
drafter,
|
|
});
|
|
} else if (docTypeCode === 'expenseEstimate' || docTypeCode === 'expense_estimate') {
|
|
setModalDocType('expenseEstimate');
|
|
setModalData({
|
|
documentNo: formData.basicInfo.documentNo,
|
|
createdAt: formData.basicInfo.draftDate,
|
|
items: formData.expenseEstimateData?.items.map(i => ({
|
|
id: i.id, expectedPaymentDate: i.expectedPaymentDate, category: i.category,
|
|
amount: i.amount, vendor: i.vendor, account: i.memo || '',
|
|
})) || [],
|
|
totalExpense: formData.expenseEstimateData?.totalExpense || 0,
|
|
accountBalance: formData.expenseEstimateData?.accountBalance || 0,
|
|
finalDifference: formData.expenseEstimateData?.finalDifference || 0,
|
|
approvers, drafter,
|
|
});
|
|
} else if (docTypeCode === 'expenseReport' || docTypeCode === 'expense_report') {
|
|
setModalDocType('expenseReport');
|
|
setModalData({
|
|
documentNo: formData.basicInfo.documentNo,
|
|
createdAt: formData.basicInfo.draftDate,
|
|
requestDate: formData.expenseReportData?.requestDate || '',
|
|
paymentDate: formData.expenseReportData?.paymentDate || '',
|
|
items: formData.expenseReportData?.items.map((i, idx) => ({
|
|
id: i.id, no: idx + 1, description: i.description, amount: i.amount, note: i.note,
|
|
})) || [],
|
|
cardInfo: formData.expenseReportData?.cardId || '-',
|
|
totalAmount: formData.expenseReportData?.totalAmount || 0,
|
|
attachments: [], approvers, drafter,
|
|
});
|
|
} else {
|
|
// 품의서
|
|
setModalDocType('proposal');
|
|
const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f =>
|
|
`/api/proxy/files/${f.id}/download`
|
|
);
|
|
setModalData({
|
|
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,
|
|
});
|
|
}
|
|
} 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 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: '문서번호', copyable: true },
|
|
{ key: 'approvalType', label: '문서유형', copyable: true },
|
|
{ key: 'title', label: '제목', copyable: true },
|
|
{ key: 'drafter', label: '기안자', copyable: true },
|
|
{ key: 'draftDate', label: '기안일시', copyable: true },
|
|
{ key: 'status', label: '상태', className: 'text-center' },
|
|
], []);
|
|
|
|
// ===== 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,
|
|
|
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
|
hideSearch: true,
|
|
searchValue: searchQuery,
|
|
onSearchChange: setSearchQuery,
|
|
|
|
dateRangeSelector: {
|
|
enabled: true,
|
|
showPresets: false,
|
|
startDate,
|
|
endDate,
|
|
onStartDateChange: setStartDate,
|
|
onEndDateChange: setEndDate,
|
|
},
|
|
|
|
searchPlaceholder: '제목, 기안자, 부서 검색...',
|
|
searchFilter: (item: ReferenceRecord, 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: '참조함 필터',
|
|
|
|
selectionActions: () => (
|
|
<>
|
|
<Button size="sm" variant="default" onClick={handleMarkReadClick}>
|
|
<Eye className="h-4 w-4 mr-2" />
|
|
열람
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={handleMarkUnreadClick}>
|
|
<EyeOff className="h-4 w-4 mr-2" />
|
|
미열람
|
|
</Button>
|
|
</>
|
|
),
|
|
|
|
renderTableRow: (item, index, globalIndex, handlers) => {
|
|
const { isSelected, onToggle, onRowClick: _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: () => (
|
|
<>
|
|
{/* 열람 처리 확인 다이얼로그 */}
|
|
<ConfirmDialog
|
|
open={markReadDialogOpen}
|
|
onOpenChange={setMarkReadDialogOpen}
|
|
onConfirm={handleMarkReadConfirm}
|
|
title="열람 처리"
|
|
description={`정말 ${selectedItems.size}건을 열람 처리하시겠습니까?`}
|
|
/>
|
|
|
|
{/* 미열람 처리 확인 다이얼로그 */}
|
|
<ConfirmDialog
|
|
open={markUnreadDialogOpen}
|
|
onOpenChange={setMarkUnreadDialogOpen}
|
|
onConfirm={handleMarkUnreadConfirm}
|
|
title="미열람 처리"
|
|
description={`정말 ${selectedItems.size}건을 미열람 처리하시겠습니까?`}
|
|
/>
|
|
|
|
{/* 문서 상세 모달 */}
|
|
{selectedDocument && modalData && (
|
|
<DocumentDetailModal
|
|
open={isModalOpen}
|
|
onOpenChange={(open) => {
|
|
setIsModalOpen(open);
|
|
if (!open) setModalData(null);
|
|
}}
|
|
documentType={modalDocType}
|
|
data={modalData}
|
|
mode="reference"
|
|
/>
|
|
)}
|
|
</>
|
|
),
|
|
}), [
|
|
data,
|
|
totalCount,
|
|
totalPages,
|
|
tableColumns,
|
|
tabs,
|
|
activeTab,
|
|
statCards,
|
|
startDate,
|
|
endDate,
|
|
filterOption,
|
|
sortOption,
|
|
handleMarkReadClick,
|
|
handleMarkUnreadClick,
|
|
handleDocumentClick,
|
|
markReadDialogOpen,
|
|
markUnreadDialogOpen,
|
|
selectedItems.size,
|
|
handleMarkReadConfirm,
|
|
handleMarkUnreadConfirm,
|
|
selectedDocument,
|
|
isModalOpen,
|
|
modalData,
|
|
modalDocType,
|
|
]);
|
|
|
|
// 모바일 필터 변경 핸들러
|
|
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}
|
|
externalIsLoading={isLoading}
|
|
/>
|
|
);
|
|
} |