- Pretendard Variable 폰트 추가 및 전역 적용 - HR 모듈 모바일 필터 적용: - AttendanceManagement: MobileFilter 컴포넌트 적용 - EmployeeManagement: MobileFilter 컴포넌트 적용 - SalaryManagement: MobileFilter 컴포넌트 적용 - VacationManagement: MobileFilter 컴포넌트 적용 - 회계 모듈: - VendorManagement: MobileFilter 컴포넌트 적용 - 전자결재: - ReferenceBox: 모바일 UI 개선 - AuthenticatedLayout: 레이아웃 개선 - middleware: 설정 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
516 lines
16 KiB
Markdown
516 lines
16 KiB
Markdown
# 통합 리스트 컴포넌트 설계안
|
||
|
||
> **목표**: 56개 리스트 페이지를 하나의 `UniversalListPage` 컴포넌트로 통합
|
||
> **예상 효과**: 코드 중복 90% 제거, 유지보수 1개 파일만 수정
|
||
|
||
---
|
||
|
||
## 1. 현황 분석
|
||
|
||
### 분석된 파일 (4개 대표 샘플)
|
||
| 파일 | 줄 수 | 도메인 |
|
||
|------|-------|--------|
|
||
| BiddingListClient.tsx | 589줄 | 건설 |
|
||
| EmployeeManagement/index.tsx | 691줄 | HR |
|
||
| VendorManagement/index.tsx | 511줄 | 회계 |
|
||
| SiteManagementListClient.tsx | 568줄 | 건설 |
|
||
|
||
**평균 590줄 × 56개 = 약 33,000줄의 중복 코드**
|
||
|
||
---
|
||
|
||
## 2. 공통점 (90% 동일)
|
||
|
||
### 상태 관리 패턴 (100% 동일)
|
||
```tsx
|
||
// 모든 파일에서 동일한 useState 패턴
|
||
const [searchValue, setSearchValue] = useState('');
|
||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||
const itemsPerPage = 20;
|
||
```
|
||
|
||
### 필터링/정렬 로직 (95% 동일)
|
||
```tsx
|
||
// filteredData 계산
|
||
const filteredData = useMemo(() => {
|
||
let result = data;
|
||
// 탭 필터 적용
|
||
// 개별 필터 적용
|
||
// 검색 필터 적용
|
||
return result;
|
||
}, [dependencies]);
|
||
|
||
// paginatedData 계산
|
||
const paginatedData = useMemo(() => {
|
||
const start = (currentPage - 1) * itemsPerPage;
|
||
return filteredData.slice(start, start + itemsPerPage);
|
||
}, [filteredData, currentPage]);
|
||
```
|
||
|
||
### 핸들러 패턴 (100% 동일)
|
||
```tsx
|
||
const handleToggleSelection = useCallback((id: string) => { ... }, []);
|
||
const handleToggleSelectAll = useCallback(() => { ... }, []);
|
||
const handleRowClick = useCallback((item) => { router.push(...) }, [router]);
|
||
const handleEdit = useCallback((id) => { router.push(...) }, [router]);
|
||
const handleDeleteClick = useCallback((id) => { ... }, []);
|
||
const handleDeleteConfirm = useCallback(async () => { ... }, []);
|
||
const handleBulkDeleteClick = useCallback(() => { ... }, []);
|
||
const handleBulkDeleteConfirm = useCallback(async () => { ... }, []);
|
||
```
|
||
|
||
### filterConfig 패턴 (100% 동일)
|
||
```tsx
|
||
const filterConfig: FilterFieldConfig[] = useMemo(() => [...], []);
|
||
const filterValues: FilterValues = useMemo(() => ({...}), []);
|
||
const handleFilterChange = useCallback((key, value) => { ... }, []);
|
||
const handleFilterReset = useCallback(() => { ... }, []);
|
||
```
|
||
|
||
### AlertDialog 패턴 (100% 동일)
|
||
```tsx
|
||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>XXX 삭제</AlertDialogTitle>
|
||
<AlertDialogDescription>...</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 차이점 (설정으로 분리)
|
||
|
||
| 항목 | 설정 타입 | 예시 |
|
||
|------|----------|------|
|
||
| title | string | "입찰관리", "사원관리" |
|
||
| description | string? | "입찰을 관리합니다" |
|
||
| icon | LucideIcon | FileText, Users, Building2 |
|
||
| basePath | string | "/construction/project/bidding" |
|
||
| tableColumns | TableColumn[] | 페이지별 컬럼 정의 |
|
||
| filterConfig | FilterFieldConfig[] | 필터 항목 정의 |
|
||
| initialFilters | object | { status: 'all', sortBy: 'latest' } |
|
||
| tabs | TabOption[]? | 있거나 없음 |
|
||
| stats | StatCard[]? | 통계 카드 구성 |
|
||
| headerActions | ReactNode? | DateRangeSelector + 버튼들 |
|
||
| actions.getList | Function | API 함수들 |
|
||
| actions.deleteItem | Function? | 삭제 API |
|
||
| renderTableRow | Function | 행 렌더링 함수 |
|
||
| renderMobileCard | Function | 모바일 카드 렌더링 |
|
||
| searchFn | Function? | 검색 로직 커스텀 |
|
||
| sortFn | Function? | 정렬 로직 커스텀 |
|
||
|
||
---
|
||
|
||
## 4. 설계안: UniversalListPage
|
||
|
||
### 4.1 Config 인터페이스
|
||
|
||
```tsx
|
||
// src/components/templates/UniversalListPage/types.ts
|
||
|
||
export interface UniversalListConfig<T> {
|
||
// === 기본 정보 ===
|
||
title: string;
|
||
description?: string;
|
||
icon?: LucideIcon;
|
||
basePath: string; // 라우팅 기본 경로
|
||
|
||
// === 데이터 ===
|
||
idField: keyof T | ((item: T) => string);
|
||
|
||
// === API Actions (Server Actions) ===
|
||
actions: {
|
||
getList: (params?: ListParams) => Promise<ListResult<T>>;
|
||
getStats?: () => Promise<StatsResult>;
|
||
deleteItem?: (id: string) => Promise<DeleteResult>;
|
||
deleteItems?: (ids: string[]) => Promise<BulkDeleteResult>;
|
||
};
|
||
|
||
// === 테이블 ===
|
||
columns: TableColumn[];
|
||
renderTableRow: (
|
||
item: T,
|
||
index: number,
|
||
globalIndex: number,
|
||
isSelected: boolean,
|
||
handlers: RowHandlers
|
||
) => ReactNode;
|
||
renderMobileCard: (
|
||
item: T,
|
||
index: number,
|
||
globalIndex: number,
|
||
isSelected: boolean,
|
||
onToggle: () => void,
|
||
handlers: RowHandlers
|
||
) => ReactNode;
|
||
|
||
// === 필터 ===
|
||
filterConfig: FilterFieldConfig[];
|
||
initialFilters: Record<string, any>;
|
||
filterFn?: (item: T, filters: Record<string, any>) => boolean;
|
||
|
||
// === 검색 ===
|
||
searchPlaceholder?: string;
|
||
searchFn?: (item: T, query: string) => boolean;
|
||
|
||
// === 정렬 ===
|
||
sortOptions?: { value: string; label: string }[];
|
||
defaultSort?: string;
|
||
sortFn?: (data: T[], sortBy: string) => T[];
|
||
|
||
// === 탭 (선택) ===
|
||
tabs?: (data: T[], stats: any) => TabOption[];
|
||
tabFilterFn?: (item: T, activeTab: string) => boolean;
|
||
|
||
// === 통계 카드 (선택) ===
|
||
statsConfig?: (data: T[], stats: any) => StatCard[];
|
||
|
||
// === 헤더 액션 (선택) ===
|
||
headerActions?: (context: HeaderActionContext) => ReactNode;
|
||
|
||
// === 옵션 ===
|
||
itemsPerPage?: number; // 기본 20
|
||
showCheckbox?: boolean; // 기본 true
|
||
enableBulkDelete?: boolean; // 기본 true
|
||
entityName?: string; // "입찰", "사원" 등 (삭제 메시지용)
|
||
}
|
||
```
|
||
|
||
### 4.2 핵심 컴포넌트
|
||
|
||
```tsx
|
||
// src/components/templates/UniversalListPage/index.tsx
|
||
|
||
export function UniversalListPage<T>({ config }: { config: UniversalListConfig<T> }) {
|
||
const router = useRouter();
|
||
|
||
// ===== 상태 관리 (모두 자동화) =====
|
||
const [data, setData] = useState<T[]>([]);
|
||
const [stats, setStats] = useState<any>(null);
|
||
const [searchValue, setSearchValue] = useState('');
|
||
const [filters, setFilters] = useState(config.initialFilters);
|
||
const [activeTab, setActiveTab] = useState('all');
|
||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [deleteDialog, setDeleteDialog] = useState<{open: boolean; targetId: string | null}>({
|
||
open: false,
|
||
targetId: null
|
||
});
|
||
const itemsPerPage = config.itemsPerPage ?? 20;
|
||
|
||
// ===== 데이터 로드 =====
|
||
const loadData = useCallback(async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
const [listResult, statsResult] = await Promise.all([
|
||
config.actions.getList({ size: 1000 }),
|
||
config.actions.getStats?.() ?? Promise.resolve({ success: true, data: null }),
|
||
]);
|
||
if (listResult.success) setData(listResult.data?.items ?? []);
|
||
if (statsResult.success) setStats(statsResult.data);
|
||
} catch {
|
||
toast.error('데이터 로드에 실패했습니다.');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [config.actions]);
|
||
|
||
useEffect(() => { loadData(); }, [loadData]);
|
||
|
||
// ===== 필터링 (설정 기반 자동화) =====
|
||
const filteredData = useMemo(() => {
|
||
let result = data;
|
||
|
||
// 탭 필터
|
||
if (config.tabFilterFn && activeTab !== 'all') {
|
||
result = result.filter(item => config.tabFilterFn!(item, activeTab));
|
||
}
|
||
|
||
// 커스텀 필터 또는 기본 필터
|
||
if (config.filterFn) {
|
||
result = result.filter(item => config.filterFn!(item, filters));
|
||
}
|
||
|
||
// 검색
|
||
if (searchValue && config.searchFn) {
|
||
result = result.filter(item => config.searchFn!(item, searchValue));
|
||
}
|
||
|
||
// 정렬
|
||
if (config.sortFn && filters.sortBy) {
|
||
result = config.sortFn(result, filters.sortBy);
|
||
}
|
||
|
||
return result;
|
||
}, [data, activeTab, filters, searchValue, config]);
|
||
|
||
// ===== 페이지네이션 =====
|
||
const paginatedData = useMemo(() => {
|
||
const start = (currentPage - 1) * itemsPerPage;
|
||
return filteredData.slice(start, start + itemsPerPage);
|
||
}, [filteredData, currentPage, itemsPerPage]);
|
||
|
||
// ===== 핸들러 (모두 자동화) =====
|
||
const handlers: RowHandlers = useMemo(() => ({
|
||
onRowClick: (item: T) => {
|
||
const id = typeof config.idField === 'function'
|
||
? config.idField(item)
|
||
: String(item[config.idField]);
|
||
router.push(`${config.basePath}/${id}`);
|
||
},
|
||
onEdit: (id: string) => router.push(`${config.basePath}/${id}/edit`),
|
||
onDelete: (id: string) => setDeleteDialog({ open: true, targetId: id }),
|
||
}), [config.basePath, config.idField, router]);
|
||
|
||
const handleToggleSelection = useCallback((id: string) => {
|
||
setSelectedItems(prev => {
|
||
const newSet = new Set(prev);
|
||
if (newSet.has(id)) newSet.delete(id);
|
||
else newSet.add(id);
|
||
return newSet;
|
||
});
|
||
}, []);
|
||
|
||
const handleToggleSelectAll = useCallback(() => {
|
||
if (selectedItems.size === paginatedData.length) {
|
||
setSelectedItems(new Set());
|
||
} else {
|
||
const ids = paginatedData.map(item =>
|
||
typeof config.idField === 'function'
|
||
? config.idField(item)
|
||
: String(item[config.idField])
|
||
);
|
||
setSelectedItems(new Set(ids));
|
||
}
|
||
}, [selectedItems.size, paginatedData, config.idField]);
|
||
|
||
// ... 삭제 핸들러들도 동일하게 자동화
|
||
|
||
// ===== 렌더링 =====
|
||
return (
|
||
<>
|
||
<IntegratedListTemplateV2
|
||
title={config.title}
|
||
description={config.description}
|
||
icon={config.icon}
|
||
headerActions={config.headerActions?.(headerContext)}
|
||
stats={config.statsConfig?.(data, stats)}
|
||
tabs={config.tabs?.(data, stats)}
|
||
activeTab={activeTab}
|
||
onTabChange={setActiveTab}
|
||
filterConfig={config.filterConfig}
|
||
filterValues={filters}
|
||
onFilterChange={handleFilterChange}
|
||
onFilterReset={handleFilterReset}
|
||
filterTitle={`${config.entityName ?? ''} 필터`}
|
||
searchValue={searchValue}
|
||
onSearchChange={setSearchValue}
|
||
searchPlaceholder={config.searchPlaceholder}
|
||
tableColumns={config.columns}
|
||
data={paginatedData}
|
||
allData={filteredData}
|
||
getItemId={(item) => typeof config.idField === 'function'
|
||
? config.idField(item)
|
||
: String(item[config.idField])}
|
||
renderTableRow={(item, index, globalIndex) =>
|
||
config.renderTableRow(item, index, globalIndex, selectedItems.has(...), handlers)}
|
||
renderMobileCard={(item, index, globalIndex, isSelected, onToggle) =>
|
||
config.renderMobileCard(item, index, globalIndex, isSelected, onToggle, handlers)}
|
||
selectedItems={selectedItems}
|
||
onToggleSelection={handleToggleSelection}
|
||
onToggleSelectAll={handleToggleSelectAll}
|
||
onBulkDelete={config.enableBulkDelete !== false ? handleBulkDelete : undefined}
|
||
pagination={{ ... }}
|
||
/>
|
||
|
||
{/* 삭제 다이얼로그 - 자동 생성 */}
|
||
<AlertDialog open={deleteDialog.open} onOpenChange={...}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>{config.entityName ?? '항목'} 삭제</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
선택한 {config.entityName ?? '항목'}을 삭제하시겠습니까?
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 4.3 사용 예시
|
||
|
||
```tsx
|
||
// src/components/business/construction/bidding/config.ts
|
||
|
||
export const biddingListConfig: UniversalListConfig<Bidding> = {
|
||
title: '입찰관리',
|
||
description: '입찰을 관리합니다 (견적완료 시 자동 등록)',
|
||
icon: FileText,
|
||
basePath: '/ko/construction/project/bidding',
|
||
idField: 'id',
|
||
entityName: '입찰',
|
||
|
||
actions: {
|
||
getList: getBiddingList,
|
||
getStats: getBiddingStats,
|
||
deleteItem: deleteBidding,
|
||
deleteItems: deleteBiddings,
|
||
},
|
||
|
||
columns: [
|
||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||
{ key: 'biddingCode', label: '입찰번호', className: 'w-[120px]' },
|
||
// ...
|
||
],
|
||
|
||
filterConfig: [
|
||
{ key: 'partner', label: '거래처', type: 'multi', options: MOCK_PARTNERS },
|
||
{ key: 'status', label: '상태', type: 'single', options: STATUS_OPTIONS },
|
||
{ key: 'sortBy', label: '정렬', type: 'single', options: SORT_OPTIONS },
|
||
],
|
||
initialFilters: { partner: [], status: 'all', sortBy: 'biddingDateDesc' },
|
||
|
||
searchPlaceholder: '입찰번호, 거래처, 현장명 검색',
|
||
searchFn: (item, query) => {
|
||
const search = query.toLowerCase();
|
||
return (
|
||
item.projectName.toLowerCase().includes(search) ||
|
||
item.biddingCode.toLowerCase().includes(search) ||
|
||
item.partnerName.toLowerCase().includes(search)
|
||
);
|
||
},
|
||
|
||
sortFn: (data, sortBy) => {
|
||
const sorted = [...data];
|
||
switch (sortBy) {
|
||
case 'biddingDateDesc':
|
||
sorted.sort((a, b) => new Date(b.biddingDate).getTime() - new Date(a.biddingDate).getTime());
|
||
break;
|
||
// ...
|
||
}
|
||
return sorted;
|
||
},
|
||
|
||
statsConfig: (data, stats) => [
|
||
{ label: '전체 입찰', value: stats?.total ?? 0, icon: FileText, iconColor: 'text-blue-600' },
|
||
{ label: '입찰대기', value: stats?.waiting ?? 0, icon: Clock, iconColor: 'text-orange-500' },
|
||
{ label: '낙찰', value: stats?.awarded ?? 0, icon: Trophy, iconColor: 'text-green-600' },
|
||
],
|
||
|
||
headerActions: ({ startDate, endDate, setStartDate, setEndDate }) => (
|
||
<DateRangeSelector
|
||
startDate={startDate}
|
||
endDate={endDate}
|
||
onStartDateChange={setStartDate}
|
||
onEndDateChange={setEndDate}
|
||
/>
|
||
),
|
||
|
||
renderTableRow: (item, index, globalIndex, isSelected, handlers) => (
|
||
<TableRow key={item.id} onClick={() => handlers.onRowClick(item)}>
|
||
<TableCell><Checkbox checked={isSelected} /></TableCell>
|
||
<TableCell>{globalIndex}</TableCell>
|
||
<TableCell>{item.biddingCode}</TableCell>
|
||
{/* ... */}
|
||
</TableRow>
|
||
),
|
||
|
||
renderMobileCard: (item, index, globalIndex, isSelected, onToggle, handlers) => (
|
||
<MobileCard
|
||
title={item.projectName}
|
||
isSelected={isSelected}
|
||
onToggle={onToggle}
|
||
onClick={() => handlers.onRowClick(item)}
|
||
details={[...]}
|
||
/>
|
||
),
|
||
};
|
||
|
||
// src/components/business/construction/bidding/BiddingListClient.tsx (마이그레이션 후)
|
||
export default function BiddingListClient() {
|
||
return <UniversalListPage config={biddingListConfig} />;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 마이그레이션 계획
|
||
|
||
### Phase 1: 기반 구축 (1일)
|
||
- [ ] `UniversalListPage` 컴포넌트 생성
|
||
- [ ] 타입 정의 (`types.ts`)
|
||
- [ ] 헬퍼 훅 생성 (`useUniversalList.ts`)
|
||
|
||
### Phase 2: 파일럿 마이그레이션 (1일)
|
||
- [ ] `BiddingListClient.tsx` → config 방식으로 변환
|
||
- [ ] 기능 동작 검증 (PC/모바일)
|
||
- [ ] 패턴 확정
|
||
|
||
### Phase 3: 도메인별 마이그레이션 (3-4일)
|
||
- [ ] 건설 도메인 (12개)
|
||
- [ ] HR 도메인 (5개)
|
||
- [ ] 회계 도메인 (14개)
|
||
- [ ] 기타 도메인 (25개)
|
||
|
||
### Phase 4: 정리 (1일)
|
||
- [ ] 레거시 코드 삭제
|
||
- [ ] 문서화
|
||
- [ ] 테스트 정리
|
||
|
||
---
|
||
|
||
## 6. 예상 효과
|
||
|
||
### Before
|
||
```
|
||
56개 파일 × 평균 590줄 = 33,040줄
|
||
새 기능 추가 시: 56개 파일 수정
|
||
```
|
||
|
||
### After
|
||
```
|
||
1개 UniversalListPage + 56개 config = 약 8,000줄
|
||
새 기능 추가 시: 1개 파일 수정
|
||
```
|
||
|
||
### 절감 효과
|
||
- **코드량**: 75% 감소 (33,040줄 → 8,000줄)
|
||
- **유지보수**: 56배 효율화
|
||
- **일관성**: 100% 보장
|
||
- **버그 수정**: 1곳만 수정하면 전체 적용
|
||
|
||
---
|
||
|
||
## 7. 주의사항
|
||
|
||
1. **점진적 마이그레이션**: 한 번에 전체 변경하지 말고 파일럿 후 확장
|
||
2. **기능 동등성 검증**: 각 페이지 마이그레이션 후 PC/모바일 모두 테스트
|
||
3. **타입 안전성**: 제네릭으로 각 데이터 타입 체크 필수
|
||
4. **커스텀 로직 지원**: 특수한 경우를 위한 확장 포인트 제공
|
||
|
||
---
|
||
|
||
## 변경 이력
|
||
|
||
| 날짜 | 작업 |
|
||
|------|------|
|
||
| 2026-01-14 | 설계안 초안 작성 |
|