diff --git a/claudedocs/[DESIGN-2026-01-14] universal-list-component.md b/claudedocs/[DESIGN-2026-01-14] universal-list-component.md new file mode 100644 index 00000000..88bb2f34 --- /dev/null +++ b/claudedocs/[DESIGN-2026-01-14] universal-list-component.md @@ -0,0 +1,515 @@ +# 통합 리스트 컴포넌트 설계안 + +> **목표**: 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>(new Set()); +const [currentPage, setCurrentPage] = useState(1); +const [isLoading, setIsLoading] = useState(false); +const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); +const [deleteTargetId, setDeleteTargetId] = useState(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 + + + + XXX 삭제 + ... + + + 취소 + 삭제 + + + +``` + +--- + +## 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 { + // === 기본 정보 === + title: string; + description?: string; + icon?: LucideIcon; + basePath: string; // 라우팅 기본 경로 + + // === 데이터 === + idField: keyof T | ((item: T) => string); + + // === API Actions (Server Actions) === + actions: { + getList: (params?: ListParams) => Promise>; + getStats?: () => Promise; + deleteItem?: (id: string) => Promise; + deleteItems?: (ids: string[]) => Promise; + }; + + // === 테이블 === + 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; + filterFn?: (item: T, filters: Record) => 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({ config }: { config: UniversalListConfig }) { + const router = useRouter(); + + // ===== 상태 관리 (모두 자동화) ===== + const [data, setData] = useState([]); + const [stats, setStats] = useState(null); + const [searchValue, setSearchValue] = useState(''); + const [filters, setFilters] = useState(config.initialFilters); + const [activeTab, setActiveTab] = useState('all'); + const [selectedItems, setSelectedItems] = useState>(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 ( + <> + 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={{ ... }} + /> + + {/* 삭제 다이얼로그 - 자동 생성 */} + + + + {config.entityName ?? '항목'} 삭제 + + 선택한 {config.entityName ?? '항목'}을 삭제하시겠습니까? + + + + 취소 + 삭제 + + + + + ); +} +``` + +### 4.3 사용 예시 + +```tsx +// src/components/business/construction/bidding/config.ts + +export const biddingListConfig: UniversalListConfig = { + 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 }) => ( + + ), + + renderTableRow: (item, index, globalIndex, isSelected, handlers) => ( + handlers.onRowClick(item)}> + + {globalIndex} + {item.biddingCode} + {/* ... */} + + ), + + renderMobileCard: (item, index, globalIndex, isSelected, onToggle, handlers) => ( + handlers.onRowClick(item)} + details={[...]} + /> + ), +}; + +// src/components/business/construction/bidding/BiddingListClient.tsx (마이그레이션 후) +export default function BiddingListClient() { + return ; +} +``` + +--- + +## 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 | 설계안 초안 작성 | diff --git a/claudedocs/[IMPL-2026-01-13] mobile-filter-migration-checklist.md b/claudedocs/[IMPL-2026-01-13] mobile-filter-migration-checklist.md index 978500fd..e40e25d2 100644 --- a/claudedocs/[IMPL-2026-01-13] mobile-filter-migration-checklist.md +++ b/claudedocs/[IMPL-2026-01-13] mobile-filter-migration-checklist.md @@ -42,13 +42,13 @@ --- -## 👥 HR 도메인 (5개) +## 👥 HR 도메인 (5개) ✅ 완료 -- [ ] 급여관리 (`hr/SalaryManagement/index.tsx`) -- [ ] 사원관리 (`hr/EmployeeManagement/index.tsx`) -- [ ] 휴가관리 (`hr/VacationManagement/index.tsx`) -- [ ] 근태관리 (`hr/AttendanceManagement/index.tsx`) -- [ ] 카드관리 (`hr/CardManagement/index.tsx`) +- [x] 급여관리 (`hr/SalaryManagement/index.tsx`) +- [x] 사원관리 (`hr/EmployeeManagement/index.tsx`) +- [x] 휴가관리 (`hr/VacationManagement/index.tsx`) +- [x] 근태관리 (`hr/AttendanceManagement/index.tsx`) +- [x] 카드관리 (`hr/CardManagement/index.tsx`) - 필터 없음, 변경 불필요 --- @@ -116,13 +116,13 @@ |--------|------|------|--------| | 건설 (기완료) | 6 | 6 | 100% | | 건설 (마이그레이션) | 12 | 12 | 100% ✅ | -| HR | 0 | 5 | 0% | +| HR | 5 | 5 | 100% ✅ | | 회계 | 0 | 11 | 0% | | 생산/자재/품질/출고 | 0 | 6 | 0% | | 전자결재 | 0 | 3 | 0% | | 설정 | 0 | 4 | 0% | | 기타 | 0 | 9 | 0% | -| **총계** | **18** | **56** | **32%** | +| **총계** | **23** | **56** | **41%** | --- diff --git a/next.config.ts b/next.config.ts index 2b0991c0..7a28a123 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,7 +4,7 @@ import createNextIntlPlugin from 'next-intl/plugin'; const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const nextConfig: NextConfig = { - reactStrictMode: true, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트 + reactStrictMode: false, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트 turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility images: { remotePatterns: [ diff --git a/public/font/PretendardVariable.woff2 b/public/font/PretendardVariable.woff2 new file mode 100644 index 00000000..49c54b51 Binary files /dev/null and b/public/font/PretendardVariable.woff2 differ diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index be888cad..68164b77 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import localFont from 'next/font/local'; import { NextIntlClientProvider } from 'next-intl'; import { getMessages } from 'next-intl/server'; import { notFound } from 'next/navigation'; @@ -7,6 +8,15 @@ import { ThemeProvider } from '@/contexts/ThemeContext'; import { Toaster } from 'sonner'; import "../globals.css"; +// 🔧 Pretendard Variable 폰트 - FOUT 완전 방지 +const pretendard = localFont({ + src: '../../../public/font/PretendardVariable.woff2', + variable: '--font-pretendard', + display: 'swap', + preload: true, + weight: '100 900', // Variable 폰트 weight 범위 +}); + export const metadata: Metadata = { title: { default: "ERP System - Enterprise Resource Planning", @@ -64,8 +74,8 @@ export default async function RootLayout({ const messages = await getMessages(); return ( - - + + {children} diff --git a/src/app/globals.css b/src/app/globals.css index e462fa44..cfd3a9bc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,7 @@ -@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css'); @import "tailwindcss"; +/* 🔧 Pretendard 폰트는 next/font/local로 로드됨 (layout.tsx) */ + @variant dark (&:is(.dark *)); @variant senior (&:is(.senior *)); @@ -201,7 +202,7 @@ @layer base { * { @apply border-border outline-ring/50; - font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif; + font-family: var(--font-pretendard), -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif; } html { @@ -211,7 +212,7 @@ body { @apply bg-background text-foreground; - font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif; + font-family: var(--font-pretendard), -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-feature-settings: "kern" 1, "liga" 1, "calt" 1; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; diff --git a/src/components/accounting/VendorManagement/index.tsx b/src/components/accounting/VendorManagement/index.tsx index 0d1cd1be..956dc7cc 100644 --- a/src/components/accounting/VendorManagement/index.tsx +++ b/src/components/accounting/VendorManagement/index.tsx @@ -22,17 +22,12 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { TableRow, TableCell } from '@/components/ui/table'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { IntegratedListTemplateV2, type TableColumn, type StatCard, + type FilterFieldConfig, + type FilterValues, } from '@/components/templates/IntegratedListTemplateV2'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; import type { @@ -370,80 +365,96 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement ); }, [handleRowClick, handleEdit, handleDeleteClick]); - // ===== 테이블 헤더 액션 (5개 필터) ===== - const tableHeaderActions = ( -
- {/* 구분 필터 */} - + // ===== filterConfig 방식 모바일 필터 ===== + const filterConfig: FilterFieldConfig[] = useMemo(() => [ + { + key: 'category', + label: '구분', + type: 'single', + options: VENDOR_CATEGORY_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + allOptionLabel: '전체', + }, + { + key: 'creditRating', + label: '신용등급', + type: 'single', + options: CREDIT_RATING_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + allOptionLabel: '전체', + }, + { + key: 'transactionGrade', + label: '거래등급', + type: 'single', + options: TRANSACTION_GRADE_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + allOptionLabel: '전체', + }, + { + key: 'badDebt', + label: '악성채권', + type: 'single', + options: BAD_DEBT_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + allOptionLabel: '전체', + }, + { + key: 'sort', + label: '정렬', + type: 'single', + options: SORT_OPTIONS.map(o => ({ + value: o.value, + label: o.label, + })), + }, + ], []); - {/* 신용등급 필터 */} - + const filterValues: FilterValues = useMemo(() => ({ + category: categoryFilter, + creditRating: creditRatingFilter, + transactionGrade: transactionGradeFilter, + badDebt: badDebtFilter, + sort: sortOption, + }), [categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption]); - {/* 거래등급 필터 */} - + const handleFilterChange = useCallback((key: string, value: string | string[]) => { + switch (key) { + case 'category': + setCategoryFilter(value as string); + break; + case 'creditRating': + setCreditRatingFilter(value as string); + break; + case 'transactionGrade': + setTransactionGradeFilter(value as string); + break; + case 'badDebt': + setBadDebtFilter(value as string); + break; + case 'sort': + setSortOption(value as SortOption); + break; + } + setCurrentPage(1); + }, []); - {/* 악성채권 필터 */} - - - {/* 정렬 */} - -
- ); + const handleFilterReset = useCallback(() => { + setCategoryFilter('all'); + setCreditRatingFilter('all'); + setTransactionGradeFilter('all'); + setBadDebtFilter('all'); + setSortOption('latest'); + setCurrentPage(1); + }, []); return ( <> diff --git a/src/components/approval/ReferenceBox/index.tsx b/src/components/approval/ReferenceBox/index.tsx index 597b0623..b9901d9e 100644 --- a/src/components/approval/ReferenceBox/index.tsx +++ b/src/components/approval/ReferenceBox/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useCallback, useEffect, useTransition } from 'react'; +import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react'; import { Files, Eye, @@ -152,19 +152,44 @@ export function ReferenceBox() { } }, []); - // ===== 초기 로드 및 필터 변경 시 데이터 재로드 ===== - useEffect(() => { - loadData(); - }, [loadData]); - + // ===== 초기 로드 ===== + // 마운트 시 1회만 실행 (summary 로드) useEffect(() => { loadSummary(); - }, [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(() => { - setCurrentPage(1); - }, [searchQuery, filterOption, sortOption, activeTab]); + 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) => { diff --git a/src/components/hr/AttendanceManagement/index.tsx b/src/components/hr/AttendanceManagement/index.tsx index 90a1265c..94eb79b7 100644 --- a/src/components/hr/AttendanceManagement/index.tsx +++ b/src/components/hr/AttendanceManagement/index.tsx @@ -17,13 +17,6 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { TableRow, TableCell } from '@/components/ui/table'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { format } from 'date-fns'; import { ko } from 'date-fns/locale'; import { @@ -31,6 +24,8 @@ import { type TableColumn, type StatCard, type TabOption, + type FilterFieldConfig, + type FilterValues, } from '@/components/templates/IntegratedListTemplateV2'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; @@ -507,38 +502,51 @@ export function AttendanceManagement() { ); - // 테이블 헤더 액션 (필터 + 정렬 셀렉트) - 사원관리와 동일한 위치 - const tableHeaderActions = ( -
- {/* 필터 셀렉트박스 */} - + // ===== filterConfig 기반 통합 필터 시스템 ===== + const filterConfig: FilterFieldConfig[] = useMemo(() => [ + { + key: 'filter', + label: '필터', + type: 'single', + options: FILTER_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + allOptionLabel: '전체', + }, + { + key: 'sort', + label: '정렬', + type: 'single', + options: SORT_OPTIONS.map(o => ({ + value: o.value, + label: o.label, + })), + }, + ], []); - {/* 정렬 셀렉트박스 */} - -
- ); + const filterValues: FilterValues = useMemo(() => ({ + filter: filterOption, + sort: sortOption, + }), [filterOption, sortOption]); + + const handleFilterChange = useCallback((key: string, value: string | string[]) => { + switch (key) { + case 'filter': + setFilterOption(value as FilterOption); + break; + case 'sort': + setSortOption(value as SortOption); + break; + } + setCurrentPage(1); + }, []); + + const handleFilterReset = useCallback(() => { + setFilterOption('all'); + setSortOption('dateDesc'); + setCurrentPage(1); + }, []); // 검색 옆 추가 필터 (사유 등록 버튼) const extraFilters = ( @@ -570,7 +578,11 @@ export function AttendanceManagement() { onSearchChange={setSearchValue} searchPlaceholder="이름, 부서 검색..." extraFilters={extraFilters} - tableHeaderActions={tableHeaderActions} + filterConfig={filterConfig} + filterValues={filterValues} + onFilterChange={handleFilterChange} + onFilterReset={handleFilterReset} + filterTitle="근태 필터" tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab} diff --git a/src/components/hr/EmployeeManagement/index.tsx b/src/components/hr/EmployeeManagement/index.tsx index 20089926..234f045d 100644 --- a/src/components/hr/EmployeeManagement/index.tsx +++ b/src/components/hr/EmployeeManagement/index.tsx @@ -8,13 +8,6 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { TableRow, TableCell } from '@/components/ui/table'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { AlertDialog, AlertDialogAction, @@ -30,6 +23,8 @@ import { type TabOption, type TableColumn, type StatCard, + type FilterFieldConfig, + type FilterValues, } from '@/components/templates/IntegratedListTemplateV2'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; @@ -552,38 +547,51 @@ export function EmployeeManagement() { ); - // 테이블 헤더 액션 (필터/정렬 셀렉트박스) - const tableHeaderActions = ( -
- {/* 필터 셀렉트박스 */} - + // ===== filterConfig 기반 통합 필터 시스템 ===== + const filterConfig: FilterFieldConfig[] = useMemo(() => [ + { + key: 'filter', + label: '필터', + type: 'single', + options: FILTER_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + allOptionLabel: '전체', + }, + { + key: 'sort', + label: '정렬', + type: 'single', + options: SORT_OPTIONS.map(o => ({ + value: o.value, + label: o.label, + })), + }, + ], []); - {/* 정렬 셀렉트박스 */} - -
- ); + const filterValues: FilterValues = useMemo(() => ({ + filter: filterOption, + sort: sortOption, + }), [filterOption, sortOption]); + + const handleFilterChange = useCallback((key: string, value: string | string[]) => { + switch (key) { + case 'filter': + setFilterOption(value as FilterOption); + break; + case 'sort': + setSortOption(value as SortOption); + break; + } + setCurrentPage(1); + }, []); + + const handleFilterReset = useCallback(() => { + setFilterOption('all'); + setSortOption('rank'); + setCurrentPage(1); + }, []); // 페이지네이션 설정 const totalPages = Math.ceil(filteredEmployees.length / itemsPerPage); @@ -602,7 +610,11 @@ export function EmployeeManagement() { tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab} - tableHeaderActions={tableHeaderActions} + filterConfig={filterConfig} + filterValues={filterValues} + onFilterChange={handleFilterChange} + onFilterReset={handleFilterReset} + filterTitle="사원 필터" tableColumns={tableColumns} data={paginatedData} totalCount={filteredEmployees.length} diff --git a/src/components/hr/SalaryManagement/index.tsx b/src/components/hr/SalaryManagement/index.tsx index fc6e1e77..38f22a5b 100644 --- a/src/components/hr/SalaryManagement/index.tsx +++ b/src/components/hr/SalaryManagement/index.tsx @@ -20,18 +20,13 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { TableRow, TableCell } from '@/components/ui/table'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { IntegratedListTemplateV2, type TableColumn, type StatCard, type TabOption, + type FilterFieldConfig, + type FilterValues, } from '@/components/templates/IntegratedListTemplateV2'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; import { SalaryDetailDialog } from './SalaryDetailDialog'; @@ -451,19 +446,36 @@ export function SalaryManagement() { ); - // ===== 정렬 필터 ===== - const extraFilters = ( - - ); + // ===== filterConfig 기반 통합 필터 시스템 ===== + const filterConfig: FilterFieldConfig[] = useMemo(() => [ + { + key: 'sort', + label: '정렬', + type: 'single', + options: Object.entries(SORT_OPTIONS).map(([value, label]) => ({ + value, + label, + })), + }, + ], []); + + const filterValues: FilterValues = useMemo(() => ({ + sort: sortOption, + }), [sortOption]); + + const handleFilterChange = useCallback((key: string, value: string | string[]) => { + switch (key) { + case 'sort': + setSortOption(value as SortOption); + break; + } + setCurrentPage(1); + }, []); + + const handleFilterReset = useCallback(() => { + setSortOption('rank'); + setCurrentPage(1); + }, []); return ( <> @@ -476,7 +488,11 @@ export function SalaryManagement() { searchValue={searchQuery} onSearchChange={setSearchQuery} searchPlaceholder="이름, 부서 검색..." - extraFilters={extraFilters} + filterConfig={filterConfig} + filterValues={filterValues} + onFilterChange={handleFilterChange} + onFilterReset={handleFilterReset} + filterTitle="급여 필터" tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab} diff --git a/src/components/hr/VacationManagement/index.tsx b/src/components/hr/VacationManagement/index.tsx index 1f9b1fda..abe6c845 100644 --- a/src/components/hr/VacationManagement/index.tsx +++ b/src/components/hr/VacationManagement/index.tsx @@ -29,13 +29,6 @@ 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 { AlertDialog, AlertDialogAction, @@ -51,6 +44,8 @@ import { type TableColumn, type StatCard, type TabOption, + type FilterFieldConfig, + type FilterValues, } from '@/components/templates/IntegratedListTemplateV2'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; @@ -666,38 +661,51 @@ export function VacationManagement() { ); - // ===== 테이블 헤더 액션 (필터 + 정렬 셀렉트) - 사원관리와 동일한 위치 ===== - const tableHeaderActions = ( -
- {/* 필터 셀렉트박스 */} - + // ===== filterConfig 기반 통합 필터 시스템 ===== + const filterConfig: FilterFieldConfig[] = useMemo(() => [ + { + key: 'filter', + label: '필터', + type: 'single', + options: FILTER_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + allOptionLabel: '전체', + }, + { + key: 'sort', + label: '정렬', + type: 'single', + options: SORT_OPTIONS.map(o => ({ + value: o.value, + label: o.label, + })), + }, + ], []); - {/* 정렬 셀렉트박스 */} - -
- ); + const filterValues: FilterValues = useMemo(() => ({ + filter: filterOption, + sort: sortOption, + }), [filterOption, sortOption]); + + const handleFilterChange = useCallback((key: string, value: string | string[]) => { + switch (key) { + case 'filter': + setFilterOption(value as FilterOption); + break; + case 'sort': + setSortOption(value as SortOption); + break; + } + setCurrentPage(1); + }, []); + + const handleFilterReset = useCallback(() => { + setFilterOption('all'); + setSortOption('rank'); + setCurrentPage(1); + }, []); return ( <> @@ -711,7 +719,11 @@ export function VacationManagement() { searchValue={searchQuery} onSearchChange={setSearchQuery} searchPlaceholder="이름, 부서 검색..." - tableHeaderActions={tableHeaderActions} + filterConfig={filterConfig} + filterValues={filterValues} + onFilterChange={handleFilterChange} + onFilterReset={handleFilterReset} + filterTitle="휴가 필터" tabs={tabs} activeTab={mainTab} onTabChange={handleMainTabChange} diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index 0400218f..16067b11 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -93,6 +93,14 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro // 회사 선택 상태 (목업) const [selectedCompany, setSelectedCompany] = useState("all"); + // 🔧 클라이언트 마운트 상태 (새로고침 시 스피너 표시용) + const [isMounted, setIsMounted] = useState(false); + + // 🔧 클라이언트 마운트 확인 (새로고침 시 스피너 표시) + useEffect(() => { + setIsMounted(true); + }, []); + // 메뉴 폴링 (30초마다 메뉴 변경 확인) // 백엔드 GET /api/v1/menus API 준비되면 자동 동작 const { restartAfterAuth } = useMenuPolling({ @@ -309,6 +317,18 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro // By removing this check, we allow the component to render immediately with default values // and update once hydration completes through the useEffect above. + // 🔧 새로고침 시 스피너 표시 (hydration 전) + if (!isMounted) { + return ( +
+
+
+

로딩 중...

+
+
+ ); + } + // 현재 페이지가 대시보드인지 확인 const isDashboard = pathname?.includes('/dashboard') || activeMenu === 'dashboard'; diff --git a/src/middleware.ts b/src/middleware.ts index a8f8710f..c0d49557 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -191,6 +191,14 @@ export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const userAgent = request.headers.get('user-agent') || ''; + // 🚨 -1️⃣ Next.js 내부 요청 필터링 + // 동적 라우트 세그먼트가 리터럴로 포함된 요청은 Next.js 내부 컴파일/prefetch + // 예: /[locale]/settings/... 형태의 요청은 실제 사용자 요청이 아님 + if (pathname.includes('[') && pathname.includes(']')) { + // console.log(`[Internal Request Skip] Dynamic segment in path: ${pathname}`); + return NextResponse.next(); + } + // 🚨 0️⃣ Internet Explorer Detection (최우선 처리) // IE 사용자는 지원 안내 페이지로 리다이렉트 if (isInternetExplorer(userAgent)) {