Files
sam-react-prod/src/components/approval/ReferenceBox/index.tsx
유병철 a5578bf669 feat: UniversalListPage 검색 기능 개선 및 리렌더링 버그 수정
- UniversalListPage 템플릿에 searchFilter, useClientSearch 지원 추가
- 검색 입력 시 리렌더링(포커스 유실) 버그 수정
- 29개 리스트 페이지에 searchFilter 함수 추가
- SiteBriefingListClient 누락된 searchFilter 추가
- IntegratedListTemplateV2 검색 로직 정리
- 검색 기능 수정내역 가이드 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:51:07 +09:00

695 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 { ConfirmDialog } from '@/components/ui/confirm-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 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();
// 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,
// 검색창 (공통 컴포넌트에서 자동 생성)
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,
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: () => (
<>
{/* 열람 처리 확인 다이얼로그 */}
<ConfirmDialog
open={markReadDialogOpen}
onOpenChange={setMarkReadDialogOpen}
onConfirm={handleMarkReadConfirm}
title="열람 처리"
description={`정말 ${selectedItems.size}건을 열람 처리하시겠습니까?`}
/>
{/* 미열람 처리 확인 다이얼로그 */}
<ConfirmDialog
open={markUnreadDialogOpen}
onOpenChange={setMarkUnreadDialogOpen}
onConfirm={handleMarkUnreadConfirm}
title="미열람 처리"
description={`정말 ${selectedItems.size}건을 미열람 처리하시겠습니까?`}
/>
{/* 문서 상세 모달 */}
{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}
externalIsLoading={isLoading}
/>
);
}