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

16 KiB
Raw Blame History

통합 리스트 컴포넌트 설계안

목표: 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% 동일)

// 모든 파일에서 동일한 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% 동일)

// 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% 동일)

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% 동일)

const filterConfig: FilterFieldConfig[] = useMemo(() => [...], []);
const filterValues: FilterValues = useMemo(() => ({...}), []);
const handleFilterChange = useCallback((key, value) => { ... }, []);
const handleFilterReset = useCallback(() => { ... }, []);

AlertDialog 패턴 (100% 동일)

<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 인터페이스

// 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 핵심 컴포넌트

// 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 사용 예시

// 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 설계안 초안 작성