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:
byeongcheolryu
2026-01-14 13:46:56 +09:00
parent ea8d701a8d
commit b08366c3f7
14 changed files with 881 additions and 239 deletions

View File

@@ -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 (
<>

View File

@@ -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) => {

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}