feat(WEB): UniversalListPage 전체 마이그레이션 및 코드 정리
- UniversalListPage/IntegratedListTemplateV2 컴포넌트 기능 개선 - 회계, HR, 건설, 고객센터, 결재, 설정 등 전체 리스트 컴포넌트 마이그레이션 - 테스트 페이지 및 미사용 API 라우트 정리 (board-test, order-management-test 등) - 미들웨어 토큰 갱신 로직 개선 - AuthenticatedLayout 구조 개선 - claudedocs 문서 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -419,7 +419,7 @@ export function DocumentCreate() {
|
||||
default:
|
||||
// 이미 업로드된 파일 URL (Next.js 프록시 사용) + 새로 추가된 파일 미리보기 URL
|
||||
const uploadedFileUrls = (proposalData.uploadedFiles || []).map(f =>
|
||||
`/api/files/${f.id}/download`
|
||||
`/api/proxy/files/${f.id}/download`
|
||||
);
|
||||
const newFileUrls = proposalData.attachments.map(f => URL.createObjectURL(f));
|
||||
|
||||
|
||||
@@ -33,14 +33,17 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
type TableColumn,
|
||||
type StatCard,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types';
|
||||
import type {
|
||||
DocumentType,
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
} from '@/components/approval/DocumentDetail/types';
|
||||
import type {
|
||||
DraftRecord,
|
||||
DocumentStatus,
|
||||
@@ -54,7 +57,6 @@ import {
|
||||
FILTER_OPTIONS,
|
||||
DOCUMENT_STATUS_LABELS,
|
||||
DOCUMENT_STATUS_COLORS,
|
||||
APPROVER_STATUS_COLORS,
|
||||
} from './types';
|
||||
|
||||
// ===== 통계 타입 =====
|
||||
@@ -74,7 +76,6 @@ export function DraftBox() {
|
||||
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;
|
||||
|
||||
@@ -99,14 +100,18 @@ export function DraftBox() {
|
||||
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' };
|
||||
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' };
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -155,97 +160,83 @@ export function DraftBox() {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, filterOption, sortOption]);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
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]);
|
||||
|
||||
// ===== 액션 핸들러 =====
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const ids = Array.from(selectedItems);
|
||||
if (ids.length === 0) return;
|
||||
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}건의 문서를 상신했습니다.`);
|
||||
setSelectedItems(new Set());
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '상신에 실패했습니다.');
|
||||
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('상신 중 오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Submit error:', error);
|
||||
toast.error('상신 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}, [selectedItems, loadData, loadSummary]);
|
||||
});
|
||||
},
|
||||
[loadData, loadSummary]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedItems);
|
||||
if (ids.length === 0) return;
|
||||
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}건의 문서를 삭제했습니다.`);
|
||||
setSelectedItems(new Set());
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
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('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}, [selectedItems, loadData, loadSummary]);
|
||||
});
|
||||
},
|
||||
[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 || '삭제에 실패했습니다.');
|
||||
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('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}, [loadData, loadSummary]);
|
||||
});
|
||||
},
|
||||
[loadData, loadSummary]
|
||||
);
|
||||
|
||||
const handleNewDocument = useCallback(() => {
|
||||
router.push('/ko/approval/draft/new');
|
||||
}, [router]);
|
||||
|
||||
// ===== FCM 알림 발송 핸들러 =====
|
||||
const handleSendNotification = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
@@ -263,26 +254,23 @@ export function DraftBox() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ===== 문서 클릭/수정 핸들러 (조건부 로직) =====
|
||||
// 임시저장 → 문서 작성 페이지 (수정 모드)
|
||||
// 그 외 → 문서 상세 모달 (상세 API 호출하여 content 포함된 데이터 가져옴)
|
||||
const handleDocumentClick = useCallback(async (item: DraftRecord) => {
|
||||
if (item.status === 'draft') {
|
||||
// 임시저장 상태 → 문서 작성 페이지로 이동 (수정 모드)
|
||||
router.push(`/ko/approval/draft/new?id=${item.id}&mode=edit`);
|
||||
} else {
|
||||
// 그 외 상태 → 문서 상세 API 호출 후 모달 열기
|
||||
// 목록 API에서는 content가 포함되지 않을 수 있으므로 상세 조회 필요
|
||||
const detailData = await getDraftById(item.id);
|
||||
if (detailData) {
|
||||
setSelectedDocument(detailData);
|
||||
// ===== 문서 클릭 핸들러 =====
|
||||
const handleDocumentClick = useCallback(
|
||||
async (item: DraftRecord) => {
|
||||
if (item.status === 'draft') {
|
||||
router.push(`/ko/approval/draft/new?id=${item.id}&mode=edit`);
|
||||
} else {
|
||||
// 상세 조회 실패 시 기존 데이터 사용
|
||||
setSelectedDocument(item);
|
||||
const detailData = await getDraftById(item.id);
|
||||
if (detailData) {
|
||||
setSelectedDocument(detailData);
|
||||
} else {
|
||||
setSelectedDocument(item);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
}, [router]);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleModalEdit = useCallback(() => {
|
||||
if (selectedDocument) {
|
||||
@@ -292,30 +280,12 @@ export function DraftBox() {
|
||||
}, [selectedDocument, router]);
|
||||
|
||||
const handleModalCopy = useCallback(() => {
|
||||
console.log('[DraftBox] handleModalCopy 호출됨, selectedDocument:', selectedDocument);
|
||||
if (selectedDocument) {
|
||||
const copyUrl = `/ko/approval/draft/new?copyFrom=${selectedDocument.id}`;
|
||||
console.log('[DraftBox] 복제 URL로 이동:', copyUrl);
|
||||
router.push(copyUrl);
|
||||
router.push(`/ko/approval/draft/new?copyFrom=${selectedDocument.id}`);
|
||||
setIsModalOpen(false);
|
||||
} else {
|
||||
console.log('[DraftBox] selectedDocument가 없음');
|
||||
}
|
||||
}, [selectedDocument, router]);
|
||||
|
||||
const handleModalApprove = useCallback(() => {
|
||||
// 기안함에서는 본인 문서에 대한 승인 기능 없음 (결재함에서만 가능)
|
||||
toast.info('기안자는 본인 문서를 승인할 수 없습니다.');
|
||||
setIsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleModalReject = useCallback(() => {
|
||||
// 기안함에서는 본인 문서에 대한 반려 기능 없음 (결재함에서만 가능)
|
||||
toast.info('기안자는 본인 문서를 반려할 수 없습니다.');
|
||||
setIsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
// ===== 모달에서 상신 핸들러 =====
|
||||
const handleModalSubmit = useCallback(async () => {
|
||||
if (!selectedDocument) return;
|
||||
|
||||
@@ -339,21 +309,23 @@ export function DraftBox() {
|
||||
});
|
||||
}, [selectedDocument, loadData, loadSummary]);
|
||||
|
||||
// ===== DraftRecord → 모달용 데이터 변환 =====
|
||||
// ===== 문서 타입 판별 =====
|
||||
const getDocumentType = (item: DraftRecord): DocumentType => {
|
||||
// documentTypeCode 우선 사용, 없으면 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('지출') && item.documentType.includes('예상'))
|
||||
return 'expenseEstimate';
|
||||
if (item.documentType.includes('지출')) return 'expenseReport';
|
||||
return 'proposal';
|
||||
};
|
||||
|
||||
const convertToModalData = (item: DraftRecord): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||||
// ===== 모달용 데이터 변환 =====
|
||||
const convertToModalData = (
|
||||
item: DraftRecord
|
||||
): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||||
const docType = getDocumentType(item);
|
||||
const content = item.content || {};
|
||||
|
||||
@@ -364,7 +336,7 @@ export function DraftBox() {
|
||||
department: item.drafterDepartment || '',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
const approvers = item.approvers.map(a => ({
|
||||
const approvers = item.approvers.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
position: a.position,
|
||||
@@ -375,16 +347,16 @@ export function DraftBox() {
|
||||
|
||||
switch (docType) {
|
||||
case 'expenseEstimate': {
|
||||
// API content 구조: { items, totalExpense, accountBalance, finalDifference }
|
||||
const items = (content.items as Array<{
|
||||
id: string;
|
||||
checked?: boolean;
|
||||
expectedPaymentDate: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
vendor: string;
|
||||
memo?: string;
|
||||
}>) || [];
|
||||
const items =
|
||||
(content.items as Array<{
|
||||
id: string;
|
||||
checked?: boolean;
|
||||
expectedPaymentDate: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
vendor: string;
|
||||
memo?: string;
|
||||
}>) || [];
|
||||
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
@@ -395,7 +367,7 @@ export function DraftBox() {
|
||||
category: i.category || '',
|
||||
amount: i.amount || 0,
|
||||
vendor: i.vendor || '',
|
||||
account: i.memo || '', // memo를 account로 매핑
|
||||
account: i.memo || '',
|
||||
})),
|
||||
totalExpense: (content.totalExpense as number) || 0,
|
||||
accountBalance: (content.accountBalance as number) || 0,
|
||||
@@ -405,13 +377,13 @@ export function DraftBox() {
|
||||
};
|
||||
}
|
||||
case 'expenseReport': {
|
||||
// API content 구조: { requestDate, paymentDate, items, cardId, totalAmount }
|
||||
const items = (content.items as Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
note?: string;
|
||||
}>) || [];
|
||||
const items =
|
||||
(content.items as Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
note?: string;
|
||||
}>) || [];
|
||||
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
@@ -433,11 +405,9 @@ export function DraftBox() {
|
||||
};
|
||||
}
|
||||
default: {
|
||||
// proposal (품의서)
|
||||
// API content 구조: { vendor, vendorPaymentDate, title, description, reason, estimatedCost, files }
|
||||
const files = (content.files as Array<{ id: number; name: string; url?: string }>) || [];
|
||||
// Next.js 프록시 URL 사용 (인증된 요청 프록시)
|
||||
const attachmentUrls = files.map(f => `/api/files/${f.id}/download`);
|
||||
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,
|
||||
@@ -456,258 +426,355 @@ export function DraftBox() {
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 통계 카드 (API summary 사용) =====
|
||||
const statCards: StatCard[] = useMemo(() => {
|
||||
// API summary가 있으면 사용, 없으면 현재 데이터 기준으로 계산
|
||||
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' },
|
||||
];
|
||||
}, [summary]);
|
||||
|
||||
// ===== 테이블 컬럼 (스크린샷 기준) =====
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ 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' },
|
||||
], []);
|
||||
|
||||
// ===== 결재자 텍스트 포맷 (예: "강미영 외 2명") =====
|
||||
// ===== 결재자 텍스트 포맷 =====
|
||||
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}명`;
|
||||
};
|
||||
|
||||
// ===== 테이블 행 렌더링 (스크린샷 기준: 수정/삭제) =====
|
||||
const renderTableRow = useCallback((item: DraftRecord, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const draftBoxConfig: UniversalListConfig<DraftRecord> = useMemo(
|
||||
() => ({
|
||||
title: '기안함',
|
||||
description: '작성한 결재 문서를 관리합니다',
|
||||
icon: FileText,
|
||||
basePath: '/approval/draft',
|
||||
|
||||
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={() => toggleSelection(item.id)} />
|
||||
</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">
|
||||
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="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => handleDocumentClick(item)}
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
handleSubmit(selectedItems);
|
||||
onClearSelection();
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
상신
|
||||
</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)}
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
handleDelete(selectedItems);
|
||||
onClearSelection();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}, [selectedItems, toggleSelection, formatApprovers, handleDocumentClick, handleDeleteSingle]);
|
||||
<Button variant="outline" onClick={handleSendNotification}>
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
문서완료
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// ===== 모바일 카드 렌더링 =====
|
||||
const renderMobileCard = useCallback((
|
||||
item: DraftRecord,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}, [handleDocumentClick, handleDeleteSingle]);
|
||||
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>
|
||||
|
||||
// ===== 헤더 액션 (DateRangeSelector + 버튼들) =====
|
||||
const headerActions = (
|
||||
<>
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
<div className="ml-auto flex gap-2">
|
||||
{selectedItems.size > 0 && (
|
||||
<>
|
||||
<Button variant="default" onClick={handleSubmit}>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
상신
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleSendNotification}>
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
문서완료
|
||||
</Button>
|
||||
<Button onClick={handleNewDocument}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
문서 작성
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
<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 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>
|
||||
);
|
||||
// 모바일 필터 변경 핸들러
|
||||
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 (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="기안함"
|
||||
description="작성한 결재 문서를 관리합니다"
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statCards}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder="문서번호, 제목, 기안자 검색..."
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
tableColumns={tableColumns}
|
||||
data={data}
|
||||
totalCount={totalCount}
|
||||
allData={data}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(item: DraftRecord) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
isLoading={isLoading || isPending}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 문서 상세 모달 */}
|
||||
{selectedDocument && (
|
||||
<DocumentDetailModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
documentType={getDocumentType(selectedDocument)}
|
||||
data={convertToModalData(selectedDocument)}
|
||||
mode="draft"
|
||||
documentStatus={selectedDocument.status}
|
||||
onEdit={handleModalEdit}
|
||||
onCopy={handleModalCopy}
|
||||
onSubmit={handleModalSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<UniversalListPage<DraftRecord>
|
||||
config={draftBoxConfig}
|
||||
initialData={data}
|
||||
initialTotalCount={totalCount}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
onSearchChange={setSearchQuery}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,11 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
type TableColumn,
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type StatCard,
|
||||
type TabOption,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
@@ -385,7 +385,7 @@ export function ReferenceBox() {
|
||||
|
||||
// ===== 테이블 컬럼 =====
|
||||
// 문서번호, 문서유형, 제목, 기안자, 기안일시, 상태
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
const tableColumns = useMemo(() => [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'documentNo', label: '문서번호' },
|
||||
{ key: 'approvalType', label: '문서유형' },
|
||||
@@ -395,125 +395,8 @@ export function ReferenceBox() {
|
||||
{ key: 'status', label: '상태', className: 'text-center' },
|
||||
], []);
|
||||
|
||||
// ===== 테이블 행 렌더링 =====
|
||||
const renderTableRow = useCallback((item: ReferenceRecord, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
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={() => toggleSelection(item.id)} />
|
||||
</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>
|
||||
);
|
||||
}, [selectedItems, toggleSelection, handleDocumentClick]);
|
||||
|
||||
// ===== 모바일 카드 렌더링 =====
|
||||
const renderMobileCard = useCallback((
|
||||
item: ReferenceRecord,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ===== 헤더 액션 (DateRangeSelector + 열람/미열람 버튼) =====
|
||||
const headerActions = (
|
||||
<>
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
{selectedItems.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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// ===== 테이블 헤더 액션 (필터 + 정렬 셀렉트) =====
|
||||
const tableHeaderActions = (
|
||||
const tableHeaderActions = useMemo(() => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 필터 셀렉트박스 */}
|
||||
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
|
||||
@@ -543,89 +426,277 @@ export function ReferenceBox() {
|
||||
</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 (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="참조함"
|
||||
description="참조로 지정된 문서를 확인합니다."
|
||||
icon={BookOpen}
|
||||
headerActions={headerActions}
|
||||
stats={statCards}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder="제목, 기안자, 부서 검색..."
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
tableColumns={tableColumns}
|
||||
data={data}
|
||||
totalCount={totalCount}
|
||||
allData={data}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(item: ReferenceRecord) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
isLoading={isLoading || isPending}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 열람 처리 확인 다이얼로그 */}
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user