Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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 (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body className="antialiased">
|
||||
<html lang={locale} className={pretendard.variable} suppressHydrationWarning>
|
||||
<body className={`${pretendard.className} antialiased`}>
|
||||
<ThemeProvider>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{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";
|
||||
|
||||
/* 🔧 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;
|
||||
|
||||
@@ -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 = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 구분 필터 */}
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VENDOR_CATEGORY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
// ===== 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,
|
||||
})),
|
||||
},
|
||||
], []);
|
||||
|
||||
{/* 신용등급 필터 */}
|
||||
<Select value={creditRatingFilter} onValueChange={setCreditRatingFilter}>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue placeholder="신용등급" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CREDIT_RATING_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
category: categoryFilter,
|
||||
creditRating: creditRatingFilter,
|
||||
transactionGrade: transactionGradeFilter,
|
||||
badDebt: badDebtFilter,
|
||||
sort: sortOption,
|
||||
}), [categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption]);
|
||||
|
||||
{/* 거래등급 필터 */}
|
||||
<Select value={transactionGradeFilter} onValueChange={setTransactionGradeFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="거래등급" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRANSACTION_GRADE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
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);
|
||||
}, []);
|
||||
|
||||
{/* 악성채권 필터 */}
|
||||
<Select value={badDebtFilter} onValueChange={setBadDebtFilter}>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue placeholder="악성채권" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BAD_DEBT_STATUS_OPTIONS.map((option) => (
|
||||
<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>
|
||||
);
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setCategoryFilter('all');
|
||||
setCreditRatingFilter('all');
|
||||
setTransactionGradeFilter('all');
|
||||
setBadDebtFilter('all');
|
||||
setSortOption('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 필터 셀렉트박스 */}
|
||||
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="필터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
// ===== 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,
|
||||
})),
|
||||
},
|
||||
], []);
|
||||
|
||||
{/* 정렬 셀렉트박스 */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="정렬 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
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}
|
||||
|
||||
@@ -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 = (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 필터 셀렉트박스 */}
|
||||
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="필터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
// ===== 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,
|
||||
})),
|
||||
},
|
||||
], []);
|
||||
|
||||
{/* 정렬 셀렉트박스 */}
|
||||
<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>
|
||||
);
|
||||
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}
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// ===== 정렬 필터 =====
|
||||
const extraFilters = (
|
||||
<Select value={sortOption} onValueChange={(v) => setSortOption(v as SortOption)}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(SORT_OPTIONS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
// ===== 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}
|
||||
|
||||
@@ -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 = (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 필터 셀렉트박스 */}
|
||||
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="필터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
// ===== 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,
|
||||
})),
|
||||
},
|
||||
], []);
|
||||
|
||||
{/* 정렬 셀렉트박스 */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="정렬 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
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}
|
||||
|
||||
@@ -93,6 +93,14 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
// 회사 선택 상태 (목업)
|
||||
const [selectedCompany, setSelectedCompany] = useState<string>("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 (
|
||||
<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';
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user