Files
sam-react-prod/claudedocs/[DESIGN-2026-01-14] universal-list-component.md
byeongcheolryu b08366c3f7 feat(WEB): Pretendard 폰트 적용 및 HR/회계 모바일 필터 마이그레이션
- 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>
2026-01-14 13:46:56 +09:00

516 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 통합 리스트 컴포넌트 설계안
> **목표**: 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 | 설계안 초안 작성 |