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>
This commit is contained in:
515
claudedocs/[DESIGN-2026-01-14] universal-list-component.md
Normal file
515
claudedocs/[DESIGN-2026-01-14] universal-list-component.md
Normal file
@@ -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<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 | 설계안 초안 작성 |
|
||||||
@@ -42,13 +42,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 👥 HR 도메인 (5개)
|
## 👥 HR 도메인 (5개) ✅ 완료
|
||||||
|
|
||||||
- [ ] 급여관리 (`hr/SalaryManagement/index.tsx`)
|
- [x] 급여관리 (`hr/SalaryManagement/index.tsx`)
|
||||||
- [ ] 사원관리 (`hr/EmployeeManagement/index.tsx`)
|
- [x] 사원관리 (`hr/EmployeeManagement/index.tsx`)
|
||||||
- [ ] 휴가관리 (`hr/VacationManagement/index.tsx`)
|
- [x] 휴가관리 (`hr/VacationManagement/index.tsx`)
|
||||||
- [ ] 근태관리 (`hr/AttendanceManagement/index.tsx`)
|
- [x] 근태관리 (`hr/AttendanceManagement/index.tsx`)
|
||||||
- [ ] 카드관리 (`hr/CardManagement/index.tsx`)
|
- [x] 카드관리 (`hr/CardManagement/index.tsx`) - 필터 없음, 변경 불필요
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,13 +116,13 @@
|
|||||||
|--------|------|------|--------|
|
|--------|------|------|--------|
|
||||||
| 건설 (기완료) | 6 | 6 | 100% |
|
| 건설 (기완료) | 6 | 6 | 100% |
|
||||||
| 건설 (마이그레이션) | 12 | 12 | 100% ✅ |
|
| 건설 (마이그레이션) | 12 | 12 | 100% ✅ |
|
||||||
| HR | 0 | 5 | 0% |
|
| HR | 5 | 5 | 100% ✅ |
|
||||||
| 회계 | 0 | 11 | 0% |
|
| 회계 | 0 | 11 | 0% |
|
||||||
| 생산/자재/품질/출고 | 0 | 6 | 0% |
|
| 생산/자재/품질/출고 | 0 | 6 | 0% |
|
||||||
| 전자결재 | 0 | 3 | 0% |
|
| 전자결재 | 0 | 3 | 0% |
|
||||||
| 설정 | 0 | 4 | 0% |
|
| 설정 | 0 | 4 | 0% |
|
||||||
| 기타 | 0 | 9 | 0% |
|
| 기타 | 0 | 9 | 0% |
|
||||||
| **총계** | **18** | **56** | **32%** |
|
| **총계** | **23** | **56** | **41%** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import createNextIntlPlugin from 'next-intl/plugin';
|
|||||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트
|
reactStrictMode: false, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트
|
||||||
turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility
|
turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
|||||||
BIN
public/font/PretendardVariable.woff2
Normal file
BIN
public/font/PretendardVariable.woff2
Normal file
Binary file not shown.
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import localFont from 'next/font/local';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
@@ -7,6 +8,15 @@ import { ThemeProvider } from '@/contexts/ThemeContext';
|
|||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import "../globals.css";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: "ERP System - Enterprise Resource Planning",
|
default: "ERP System - Enterprise Resource Planning",
|
||||||
@@ -64,8 +74,8 @@ export default async function RootLayout({
|
|||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} className={pretendard.variable} suppressHydrationWarning>
|
||||||
<body className="antialiased">
|
<body className={`${pretendard.className} antialiased`}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css');
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* 🔧 Pretendard 폰트는 next/font/local로 로드됨 (layout.tsx) */
|
||||||
|
|
||||||
@variant dark (&:is(.dark *));
|
@variant dark (&:is(.dark *));
|
||||||
@variant senior (&:is(.senior *));
|
@variant senior (&:is(.senior *));
|
||||||
|
|
||||||
@@ -201,7 +202,7 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@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 {
|
html {
|
||||||
@@ -211,7 +212,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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;
|
font-feature-settings: "kern" 1, "liga" 1, "calt" 1;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|||||||
@@ -22,17 +22,12 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import {
|
import {
|
||||||
IntegratedListTemplateV2,
|
IntegratedListTemplateV2,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
type StatCard,
|
type StatCard,
|
||||||
|
type FilterFieldConfig,
|
||||||
|
type FilterValues,
|
||||||
} from '@/components/templates/IntegratedListTemplateV2';
|
} from '@/components/templates/IntegratedListTemplateV2';
|
||||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||||
import type {
|
import type {
|
||||||
@@ -370,80 +365,96 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
|
|||||||
);
|
);
|
||||||
}, [handleRowClick, handleEdit, handleDeleteClick]);
|
}, [handleRowClick, handleEdit, handleDeleteClick]);
|
||||||
|
|
||||||
// ===== 테이블 헤더 액션 (5개 필터) =====
|
// ===== filterConfig 방식 모바일 필터 =====
|
||||||
const tableHeaderActions = (
|
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
{
|
||||||
{/* 구분 필터 */}
|
key: 'category',
|
||||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
label: '구분',
|
||||||
<SelectTrigger className="w-[120px]">
|
type: 'single',
|
||||||
<SelectValue placeholder="구분" />
|
options: VENDOR_CATEGORY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||||
</SelectTrigger>
|
value: o.value,
|
||||||
<SelectContent>
|
label: o.label,
|
||||||
{VENDOR_CATEGORY_OPTIONS.map((option) => (
|
})),
|
||||||
<SelectItem key={option.value} value={option.value}>
|
allOptionLabel: '전체',
|
||||||
{option.label}
|
},
|
||||||
</SelectItem>
|
{
|
||||||
))}
|
key: 'creditRating',
|
||||||
</SelectContent>
|
label: '신용등급',
|
||||||
</Select>
|
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(() => ({
|
||||||
<Select value={creditRatingFilter} onValueChange={setCreditRatingFilter}>
|
category: categoryFilter,
|
||||||
<SelectTrigger className="w-[110px]">
|
creditRating: creditRatingFilter,
|
||||||
<SelectValue placeholder="신용등급" />
|
transactionGrade: transactionGradeFilter,
|
||||||
</SelectTrigger>
|
badDebt: badDebtFilter,
|
||||||
<SelectContent>
|
sort: sortOption,
|
||||||
{CREDIT_RATING_OPTIONS.map((option) => (
|
}), [categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption]);
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 거래등급 필터 */}
|
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||||
<Select value={transactionGradeFilter} onValueChange={setTransactionGradeFilter}>
|
switch (key) {
|
||||||
<SelectTrigger className="w-[120px]">
|
case 'category':
|
||||||
<SelectValue placeholder="거래등급" />
|
setCategoryFilter(value as string);
|
||||||
</SelectTrigger>
|
break;
|
||||||
<SelectContent>
|
case 'creditRating':
|
||||||
{TRANSACTION_GRADE_OPTIONS.map((option) => (
|
setCreditRatingFilter(value as string);
|
||||||
<SelectItem key={option.value} value={option.value}>
|
break;
|
||||||
{option.label}
|
case 'transactionGrade':
|
||||||
</SelectItem>
|
setTransactionGradeFilter(value as string);
|
||||||
))}
|
break;
|
||||||
</SelectContent>
|
case 'badDebt':
|
||||||
</Select>
|
setBadDebtFilter(value as string);
|
||||||
|
break;
|
||||||
|
case 'sort':
|
||||||
|
setSortOption(value as SortOption);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
{/* 악성채권 필터 */}
|
const handleFilterReset = useCallback(() => {
|
||||||
<Select value={badDebtFilter} onValueChange={setBadDebtFilter}>
|
setCategoryFilter('all');
|
||||||
<SelectTrigger className="w-[110px]">
|
setCreditRatingFilter('all');
|
||||||
<SelectValue placeholder="악성채권" />
|
setTransactionGradeFilter('all');
|
||||||
</SelectTrigger>
|
setBadDebtFilter('all');
|
||||||
<SelectContent>
|
setSortOption('latest');
|
||||||
{BAD_DEBT_STATUS_OPTIONS.map((option) => (
|
setCurrentPage(1);
|
||||||
<SelectItem key={option.value} value={option.value}>
|
}, []);
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 정렬 */}
|
|
||||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
|
||||||
<SelectTrigger className="w-[150px]">
|
|
||||||
<SelectValue placeholder="정렬" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{SORT_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect, useTransition } from 'react';
|
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Files,
|
Files,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -152,19 +152,44 @@ export function ReferenceBox() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ===== 초기 로드 및 필터 변경 시 데이터 재로드 =====
|
// ===== 초기 로드 =====
|
||||||
useEffect(() => {
|
// 마운트 시 1회만 실행 (summary 로드)
|
||||||
loadData();
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSummary();
|
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(() => {
|
useEffect(() => {
|
||||||
|
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);
|
setCurrentPage(1);
|
||||||
}, [searchQuery, filterOption, sortOption, activeTab]);
|
}
|
||||||
|
prevSearchRef.current = searchQuery;
|
||||||
|
prevFilterRef.current = filterOption;
|
||||||
|
prevSortRef.current = sortOption;
|
||||||
|
prevTabRef.current = activeTab;
|
||||||
|
}
|
||||||
|
}, [searchQuery, filterOption, sortOption, activeTab, currentPage]);
|
||||||
|
|
||||||
// ===== 탭 변경 핸들러 =====
|
// ===== 탭 변경 핸들러 =====
|
||||||
const handleTabChange = useCallback((value: string) => {
|
const handleTabChange = useCallback((value: string) => {
|
||||||
|
|||||||
@@ -17,13 +17,6 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { ko } from 'date-fns/locale';
|
import { ko } from 'date-fns/locale';
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +24,8 @@ import {
|
|||||||
type TableColumn,
|
type TableColumn,
|
||||||
type StatCard,
|
type StatCard,
|
||||||
type TabOption,
|
type TabOption,
|
||||||
|
type FilterFieldConfig,
|
||||||
|
type FilterValues,
|
||||||
} from '@/components/templates/IntegratedListTemplateV2';
|
} from '@/components/templates/IntegratedListTemplateV2';
|
||||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||||
@@ -507,38 +502,51 @@ export function AttendanceManagement() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 테이블 헤더 액션 (필터 + 정렬 셀렉트) - 사원관리와 동일한 위치
|
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||||
const tableHeaderActions = (
|
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||||
<div className="flex items-center gap-2">
|
{
|
||||||
{/* 필터 셀렉트박스 */}
|
key: 'filter',
|
||||||
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
|
label: '필터',
|
||||||
<SelectTrigger className="w-[140px]">
|
type: 'single',
|
||||||
<SelectValue placeholder="필터 선택" />
|
options: FILTER_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||||
</SelectTrigger>
|
value: o.value,
|
||||||
<SelectContent>
|
label: o.label,
|
||||||
{FILTER_OPTIONS.map((option) => (
|
})),
|
||||||
<SelectItem key={option.value} value={option.value}>
|
allOptionLabel: '전체',
|
||||||
{option.label}
|
},
|
||||||
</SelectItem>
|
{
|
||||||
))}
|
key: 'sort',
|
||||||
</SelectContent>
|
label: '정렬',
|
||||||
</Select>
|
type: 'single',
|
||||||
|
options: SORT_OPTIONS.map(o => ({
|
||||||
|
value: o.value,
|
||||||
|
label: o.label,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
{/* 정렬 셀렉트박스 */}
|
const filterValues: FilterValues = useMemo(() => ({
|
||||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
filter: filterOption,
|
||||||
<SelectTrigger className="w-[140px]">
|
sort: sortOption,
|
||||||
<SelectValue placeholder="정렬 선택" />
|
}), [filterOption, sortOption]);
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||||
{SORT_OPTIONS.map((option) => (
|
switch (key) {
|
||||||
<SelectItem key={option.value} value={option.value}>
|
case 'filter':
|
||||||
{option.label}
|
setFilterOption(value as FilterOption);
|
||||||
</SelectItem>
|
break;
|
||||||
))}
|
case 'sort':
|
||||||
</SelectContent>
|
setSortOption(value as SortOption);
|
||||||
</Select>
|
break;
|
||||||
</div>
|
}
|
||||||
);
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFilterReset = useCallback(() => {
|
||||||
|
setFilterOption('all');
|
||||||
|
setSortOption('dateDesc');
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 검색 옆 추가 필터 (사유 등록 버튼)
|
// 검색 옆 추가 필터 (사유 등록 버튼)
|
||||||
const extraFilters = (
|
const extraFilters = (
|
||||||
@@ -570,7 +578,11 @@ export function AttendanceManagement() {
|
|||||||
onSearchChange={setSearchValue}
|
onSearchChange={setSearchValue}
|
||||||
searchPlaceholder="이름, 부서 검색..."
|
searchPlaceholder="이름, 부서 검색..."
|
||||||
extraFilters={extraFilters}
|
extraFilters={extraFilters}
|
||||||
tableHeaderActions={tableHeaderActions}
|
filterConfig={filterConfig}
|
||||||
|
filterValues={filterValues}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onFilterReset={handleFilterReset}
|
||||||
|
filterTitle="근태 필터"
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
|
|||||||
@@ -8,13 +8,6 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -30,6 +23,8 @@ import {
|
|||||||
type TabOption,
|
type TabOption,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
type StatCard,
|
type StatCard,
|
||||||
|
type FilterFieldConfig,
|
||||||
|
type FilterValues,
|
||||||
} from '@/components/templates/IntegratedListTemplateV2';
|
} from '@/components/templates/IntegratedListTemplateV2';
|
||||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||||
@@ -552,38 +547,51 @@ export function EmployeeManagement() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 테이블 헤더 액션 (필터/정렬 셀렉트박스)
|
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||||
const tableHeaderActions = (
|
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||||
<div className="flex items-center gap-2">
|
{
|
||||||
{/* 필터 셀렉트박스 */}
|
key: 'filter',
|
||||||
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
|
label: '필터',
|
||||||
<SelectTrigger className="w-[180px]">
|
type: 'single',
|
||||||
<SelectValue placeholder="필터 선택" />
|
options: FILTER_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||||
</SelectTrigger>
|
value: o.value,
|
||||||
<SelectContent>
|
label: o.label,
|
||||||
{FILTER_OPTIONS.map((option) => (
|
})),
|
||||||
<SelectItem key={option.value} value={option.value}>
|
allOptionLabel: '전체',
|
||||||
{option.label}
|
},
|
||||||
</SelectItem>
|
{
|
||||||
))}
|
key: 'sort',
|
||||||
</SelectContent>
|
label: '정렬',
|
||||||
</Select>
|
type: 'single',
|
||||||
|
options: SORT_OPTIONS.map(o => ({
|
||||||
|
value: o.value,
|
||||||
|
label: o.label,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
{/* 정렬 셀렉트박스 */}
|
const filterValues: FilterValues = useMemo(() => ({
|
||||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
filter: filterOption,
|
||||||
<SelectTrigger className="w-[150px]">
|
sort: sortOption,
|
||||||
<SelectValue placeholder="정렬 선택" />
|
}), [filterOption, sortOption]);
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||||
{SORT_OPTIONS.map((option) => (
|
switch (key) {
|
||||||
<SelectItem key={option.value} value={option.value}>
|
case 'filter':
|
||||||
{option.label}
|
setFilterOption(value as FilterOption);
|
||||||
</SelectItem>
|
break;
|
||||||
))}
|
case 'sort':
|
||||||
</SelectContent>
|
setSortOption(value as SortOption);
|
||||||
</Select>
|
break;
|
||||||
</div>
|
}
|
||||||
);
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFilterReset = useCallback(() => {
|
||||||
|
setFilterOption('all');
|
||||||
|
setSortOption('rank');
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 페이지네이션 설정
|
// 페이지네이션 설정
|
||||||
const totalPages = Math.ceil(filteredEmployees.length / itemsPerPage);
|
const totalPages = Math.ceil(filteredEmployees.length / itemsPerPage);
|
||||||
@@ -602,7 +610,11 @@ export function EmployeeManagement() {
|
|||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
tableHeaderActions={tableHeaderActions}
|
filterConfig={filterConfig}
|
||||||
|
filterValues={filterValues}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onFilterReset={handleFilterReset}
|
||||||
|
filterTitle="사원 필터"
|
||||||
tableColumns={tableColumns}
|
tableColumns={tableColumns}
|
||||||
data={paginatedData}
|
data={paginatedData}
|
||||||
totalCount={filteredEmployees.length}
|
totalCount={filteredEmployees.length}
|
||||||
|
|||||||
@@ -20,18 +20,13 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import {
|
import {
|
||||||
IntegratedListTemplateV2,
|
IntegratedListTemplateV2,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
type StatCard,
|
type StatCard,
|
||||||
type TabOption,
|
type TabOption,
|
||||||
|
type FilterFieldConfig,
|
||||||
|
type FilterValues,
|
||||||
} from '@/components/templates/IntegratedListTemplateV2';
|
} from '@/components/templates/IntegratedListTemplateV2';
|
||||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||||
import { SalaryDetailDialog } from './SalaryDetailDialog';
|
import { SalaryDetailDialog } from './SalaryDetailDialog';
|
||||||
@@ -451,19 +446,36 @@ export function SalaryManagement() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===== 정렬 필터 =====
|
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||||
const extraFilters = (
|
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||||
<Select value={sortOption} onValueChange={(v) => setSortOption(v as SortOption)}>
|
{
|
||||||
<SelectTrigger className="w-[120px]">
|
key: 'sort',
|
||||||
<SelectValue />
|
label: '정렬',
|
||||||
</SelectTrigger>
|
type: 'single',
|
||||||
<SelectContent>
|
options: Object.entries(SORT_OPTIONS).map(([value, label]) => ({
|
||||||
{Object.entries(SORT_OPTIONS).map(([key, label]) => (
|
value,
|
||||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
label,
|
||||||
))}
|
})),
|
||||||
</SelectContent>
|
},
|
||||||
</Select>
|
], []);
|
||||||
);
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -476,7 +488,11 @@ export function SalaryManagement() {
|
|||||||
searchValue={searchQuery}
|
searchValue={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
searchPlaceholder="이름, 부서 검색..."
|
searchPlaceholder="이름, 부서 검색..."
|
||||||
extraFilters={extraFilters}
|
filterConfig={filterConfig}
|
||||||
|
filterValues={filterValues}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onFilterReset={handleFilterReset}
|
||||||
|
filterTitle="급여 필터"
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
|
|||||||
@@ -29,13 +29,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -51,6 +44,8 @@ import {
|
|||||||
type TableColumn,
|
type TableColumn,
|
||||||
type StatCard,
|
type StatCard,
|
||||||
type TabOption,
|
type TabOption,
|
||||||
|
type FilterFieldConfig,
|
||||||
|
type FilterValues,
|
||||||
} from '@/components/templates/IntegratedListTemplateV2';
|
} from '@/components/templates/IntegratedListTemplateV2';
|
||||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||||
@@ -666,38 +661,51 @@ export function VacationManagement() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===== 테이블 헤더 액션 (필터 + 정렬 셀렉트) - 사원관리와 동일한 위치 =====
|
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||||
const tableHeaderActions = (
|
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||||
<div className="flex items-center gap-2">
|
{
|
||||||
{/* 필터 셀렉트박스 */}
|
key: 'filter',
|
||||||
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
|
label: '필터',
|
||||||
<SelectTrigger className="w-[140px]">
|
type: 'single',
|
||||||
<SelectValue placeholder="필터 선택" />
|
options: FILTER_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||||
</SelectTrigger>
|
value: o.value,
|
||||||
<SelectContent>
|
label: o.label,
|
||||||
{FILTER_OPTIONS.map((option) => (
|
})),
|
||||||
<SelectItem key={option.value} value={option.value}>
|
allOptionLabel: '전체',
|
||||||
{option.label}
|
},
|
||||||
</SelectItem>
|
{
|
||||||
))}
|
key: 'sort',
|
||||||
</SelectContent>
|
label: '정렬',
|
||||||
</Select>
|
type: 'single',
|
||||||
|
options: SORT_OPTIONS.map(o => ({
|
||||||
|
value: o.value,
|
||||||
|
label: o.label,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
{/* 정렬 셀렉트박스 */}
|
const filterValues: FilterValues = useMemo(() => ({
|
||||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
filter: filterOption,
|
||||||
<SelectTrigger className="w-[140px]">
|
sort: sortOption,
|
||||||
<SelectValue placeholder="정렬 선택" />
|
}), [filterOption, sortOption]);
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||||
{SORT_OPTIONS.map((option) => (
|
switch (key) {
|
||||||
<SelectItem key={option.value} value={option.value}>
|
case 'filter':
|
||||||
{option.label}
|
setFilterOption(value as FilterOption);
|
||||||
</SelectItem>
|
break;
|
||||||
))}
|
case 'sort':
|
||||||
</SelectContent>
|
setSortOption(value as SortOption);
|
||||||
</Select>
|
break;
|
||||||
</div>
|
}
|
||||||
);
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFilterReset = useCallback(() => {
|
||||||
|
setFilterOption('all');
|
||||||
|
setSortOption('rank');
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -711,7 +719,11 @@ export function VacationManagement() {
|
|||||||
searchValue={searchQuery}
|
searchValue={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
searchPlaceholder="이름, 부서 검색..."
|
searchPlaceholder="이름, 부서 검색..."
|
||||||
tableHeaderActions={tableHeaderActions}
|
filterConfig={filterConfig}
|
||||||
|
filterValues={filterValues}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onFilterReset={handleFilterReset}
|
||||||
|
filterTitle="휴가 필터"
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
activeTab={mainTab}
|
activeTab={mainTab}
|
||||||
onTabChange={handleMainTabChange}
|
onTabChange={handleMainTabChange}
|
||||||
|
|||||||
@@ -93,6 +93,14 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
|||||||
// 회사 선택 상태 (목업)
|
// 회사 선택 상태 (목업)
|
||||||
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
||||||
|
|
||||||
|
// 🔧 클라이언트 마운트 상태 (새로고침 시 스피너 표시용)
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
// 🔧 클라이언트 마운트 확인 (새로고침 시 스피너 표시)
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 메뉴 폴링 (30초마다 메뉴 변경 확인)
|
// 메뉴 폴링 (30초마다 메뉴 변경 확인)
|
||||||
// 백엔드 GET /api/v1/menus API 준비되면 자동 동작
|
// 백엔드 GET /api/v1/menus API 준비되면 자동 동작
|
||||||
const { restartAfterAuth } = useMenuPolling({
|
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
|
// By removing this check, we allow the component to render immediately with default values
|
||||||
// and update once hydration completes through the useEffect above.
|
// and update once hydration completes through the useEffect above.
|
||||||
|
|
||||||
|
// 🔧 새로고침 시 스피너 표시 (hydration 전)
|
||||||
|
if (!isMounted) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||||
|
<p className="text-muted-foreground text-sm">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 현재 페이지가 대시보드인지 확인
|
// 현재 페이지가 대시보드인지 확인
|
||||||
const isDashboard = pathname?.includes('/dashboard') || activeMenu === 'dashboard';
|
const isDashboard = pathname?.includes('/dashboard') || activeMenu === 'dashboard';
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,14 @@ export function middleware(request: NextRequest) {
|
|||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
const userAgent = request.headers.get('user-agent') || '';
|
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 (최우선 처리)
|
// 🚨 0️⃣ Internet Explorer Detection (최우선 처리)
|
||||||
// IE 사용자는 지원 안내 페이지로 리다이렉트
|
// IE 사용자는 지원 안내 페이지로 리다이렉트
|
||||||
if (isInternetExplorer(userAgent)) {
|
if (isInternetExplorer(userAgent)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user