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:
@@ -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