Merge remote-tracking branch 'origin/master'

# Conflicts:
#	src/components/hr/SalaryManagement/index.tsx
#	src/components/production/WorkResults/WorkResultList.tsx
#	tsconfig.tsbuildinfo
This commit is contained in:
2026-01-16 15:47:13 +09:00
91 changed files with 21969 additions and 20128 deletions

View File

@@ -20,13 +20,13 @@ import { TableRow, TableCell } from '@/components/ui/table';
import { format } from 'date-fns';
import { ko } from 'date-fns/locale';
import {
IntegratedListTemplateV2,
type TableColumn,
UniversalListPage,
type UniversalListConfig,
type StatCard,
type TabOption,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
} from '@/components/templates/UniversalListPage';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import { AttendanceInfoDialog } from './AttendanceInfoDialog';
@@ -260,7 +260,7 @@ export function AttendanceManagement() {
], [mergedRecords.length, stats]);
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = useMemo(() => [
const tableColumns = useMemo(() => [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'department', label: '부서', className: 'min-w-[80px]' },
{ key: 'position', label: '직책', className: 'min-w-[100px]' },
@@ -361,147 +361,6 @@ export function AttendanceManagement() {
}
}, [router]);
// 테이블 행 렌더링
const renderTableRow = useCallback((item: AttendanceRecord, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
return (
<TableRow
key={item.id}
className="hover:bg-muted/50"
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelection(item.id)}
/>
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell>{item.department || '-'}</TableCell>
<TableCell>{item.position || '-'}</TableCell>
<TableCell>{item.employeeName}</TableCell>
<TableCell>{item.rank || '-'}</TableCell>
<TableCell>
{item.baseDate ? format(new Date(item.baseDate), 'yyyy-MM-dd (E)', { locale: ko }) : '-'}
</TableCell>
<TableCell>{item.checkIn ? item.checkIn.substring(0, 5) : '-'}</TableCell>
<TableCell>{item.checkOut ? item.checkOut.substring(0, 5) : '-'}</TableCell>
<TableCell>{item.breakTime || '-'}</TableCell>
<TableCell>{item.overtimeHours || '-'}</TableCell>
<TableCell>
{item.reason ? (
<Button
variant="link"
size="sm"
className="p-0 h-auto text-blue-600 hover:text-blue-800"
onClick={() => handleReasonClick(item)}
>
{item.reason.label}
</Button>
) : '-'}
</TableCell>
<TableCell className="text-center">
{isSelected && (
<Button
variant="ghost"
size="sm"
onClick={() => handleEditAttendance(item)}
title="수정"
>
<Edit className="w-4 h-4" />
</Button>
)}
</TableCell>
</TableRow>
);
}, [selectedItems, toggleSelection, handleEditAttendance, handleReasonClick]);
// 모바일 카드 렌더링
const renderMobileCard = useCallback((
item: AttendanceRecord,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
return (
<ListMobileCard
id={item.id}
title={item.employeeName}
headerBadges={
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="text-xs">
{item.department || '-'}
</Badge>
<Badge variant="outline" className="text-xs">
{item.rank || '-'}
</Badge>
</div>
}
statusBadge={
<Badge className={ATTENDANCE_STATUS_COLORS[item.status]}>
{ATTENDANCE_STATUS_LABELS[item.status]}
</Badge>
}
isSelected={isSelected}
onToggleSelection={onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="직책" value={item.position || '-'} />
<InfoField
label="기준일"
value={item.baseDate ? format(new Date(item.baseDate), 'yyyy-MM-dd') : '-'}
/>
<InfoField label="출근" value={item.checkIn ? item.checkIn.substring(0, 5) : '-'} />
<InfoField label="퇴근" value={item.checkOut ? item.checkOut.substring(0, 5) : '-'} />
<InfoField label="휴게" value={item.breakTime || '-'} />
<InfoField label="연장근무" value={item.overtimeHours || '-'} />
{item.reason && (
<InfoField label="사유" value={item.reason.label} />
)}
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleEditAttendance(item); }}
>
<Edit className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);
}, [handleEditAttendance]);
// 헤더 액션 (DateRangeSelector + 버튼들)
const headerActions = (
<>
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
<div className="ml-auto flex gap-2">
<Button variant="outline" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleAddAttendance}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</>
);
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
@@ -548,18 +407,271 @@ export function AttendanceManagement() {
setCurrentPage(1);
}, []);
// 검색 옆 추가 필터 (사유 등록 버튼)
const extraFilters = (
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" onClick={handleAddReason}>
<FileText className="w-4 h-4 mr-2" />
</Button>
</div>
);
// ===== UniversalListPage 설정 =====
const attendanceConfig: UniversalListConfig<AttendanceRecord> = useMemo(() => ({
title: '근태관리',
description: '직원 출퇴근 및 근태 정보를 관리합니다',
icon: Clock,
basePath: '/hr/attendance-management',
// 페이지네이션 설정
const totalPages = Math.ceil(filteredRecords.length / itemsPerPage);
idField: 'id',
actions: {
getList: async () => ({
success: true,
data: mergedRecords,
totalCount: mergedRecords.length,
}),
},
columns: tableColumns,
tabs: tabs,
defaultTab: activeTab,
filterConfig: filterConfig,
initialFilters: filterValues,
filterTitle: '근태 필터',
computeStats: () => statCards,
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
createButton: {
label: '근태 등록',
icon: Plus,
onClick: handleAddAttendance,
},
searchPlaceholder: '이름, 부서 검색...',
extraFilters: (
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleAddReason}>
<FileText className="w-4 h-4 mr-2" />
</Button>
</div>
),
itemsPerPage: itemsPerPage,
clientSideFiltering: true,
searchFilter: (item, searchValue) => {
const search = searchValue.toLowerCase();
return (
item.employeeName.toLowerCase().includes(search) ||
item.department.toLowerCase().includes(search)
);
},
tabFilter: (item, activeTab) => {
if (activeTab === 'all') return true;
return item.status === activeTab;
},
customFilterFn: (items, filterValues) => {
let filtered = items;
const filterOption = filterValues.filter as string;
if (filterOption && filterOption !== 'all') {
filtered = filtered.filter(r => r.status === filterOption);
}
return filtered;
},
customSortFn: (items, filterValues) => {
const sortOption = filterValues.sort as SortOption || 'dateDesc';
return [...items].sort((a, b) => {
switch (sortOption) {
case 'dateDesc':
return new Date(b.baseDate).getTime() - new Date(a.baseDate).getTime();
case 'dateAsc':
return new Date(a.baseDate).getTime() - new Date(b.baseDate).getTime();
case 'rank':
return a.rank.localeCompare(b.rank, 'ko');
case 'deptAsc':
return a.department.localeCompare(b.department, 'ko');
case 'deptDesc':
return b.department.localeCompare(a.department, 'ko');
case 'nameAsc':
return a.employeeName.localeCompare(b.employeeName, 'ko');
case 'nameDesc':
return b.employeeName.localeCompare(a.employeeName, 'ko');
default:
return 0;
}
});
},
renderTableRow: (item, index, globalIndex, handlers) => {
const { isSelected, onToggle } = handlers;
return (
<TableRow
key={item.id}
className="hover:bg-muted/50"
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={onToggle}
/>
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell>{item.department || '-'}</TableCell>
<TableCell>{item.position || '-'}</TableCell>
<TableCell>{item.employeeName}</TableCell>
<TableCell>{item.rank || '-'}</TableCell>
<TableCell>
{item.baseDate ? format(new Date(item.baseDate), 'yyyy-MM-dd (E)', { locale: ko }) : '-'}
</TableCell>
<TableCell>{item.checkIn ? item.checkIn.substring(0, 5) : '-'}</TableCell>
<TableCell>{item.checkOut ? item.checkOut.substring(0, 5) : '-'}</TableCell>
<TableCell>{item.breakTime || '-'}</TableCell>
<TableCell>{item.overtimeHours || '-'}</TableCell>
<TableCell>
{item.reason ? (
<Button
variant="link"
size="sm"
className="p-0 h-auto text-blue-600 hover:text-blue-800"
onClick={() => handleReasonClick(item)}
>
{item.reason.label}
</Button>
) : '-'}
</TableCell>
<TableCell className="text-center">
{isSelected && (
<Button
variant="ghost"
size="sm"
onClick={() => handleEditAttendance(item)}
title="수정"
>
<Edit className="w-4 h-4" />
</Button>
)}
</TableCell>
</TableRow>
);
},
renderMobileCard: (item, index, globalIndex, handlers) => {
const { isSelected, onToggle } = handlers;
return (
<ListMobileCard
id={item.id}
title={item.employeeName}
headerBadges={
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="text-xs">
{item.department || '-'}
</Badge>
<Badge variant="outline" className="text-xs">
{item.rank || '-'}
</Badge>
</div>
}
statusBadge={
<Badge className={ATTENDANCE_STATUS_COLORS[item.status]}>
{ATTENDANCE_STATUS_LABELS[item.status]}
</Badge>
}
isSelected={isSelected}
onToggleSelection={onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="직책" value={item.position || '-'} />
<InfoField
label="기준일"
value={item.baseDate ? format(new Date(item.baseDate), 'yyyy-MM-dd') : '-'}
/>
<InfoField label="출근" value={item.checkIn ? item.checkIn.substring(0, 5) : '-'} />
<InfoField label="퇴근" value={item.checkOut ? item.checkOut.substring(0, 5) : '-'} />
<InfoField label="휴게" value={item.breakTime || '-'} />
<InfoField label="연장근무" value={item.overtimeHours || '-'} />
{item.reason && (
<InfoField label="사유" value={item.reason.label} />
)}
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleEditAttendance(item); }}
>
<Edit className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);
},
renderDialogs: () => (
<>
{/* 근태 정보 다이얼로그 */}
<AttendanceInfoDialog
open={attendanceDialogOpen}
onOpenChange={setAttendanceDialogOpen}
mode={attendanceDialogMode}
attendance={selectedAttendance}
employees={employees}
onSave={handleSaveAttendance}
/>
{/* 사유 정보 다이얼로그 */}
<ReasonInfoDialog
open={reasonDialogOpen}
onOpenChange={setReasonDialogOpen}
employees={employees}
onSubmit={handleSubmitReason}
/>
</>
),
}), [
mergedRecords,
tableColumns,
tabs,
activeTab,
filterConfig,
filterValues,
statCards,
startDate,
endDate,
handleAddAttendance,
handleExcelDownload,
handleAddReason,
handleReasonClick,
handleEditAttendance,
attendanceDialogOpen,
attendanceDialogMode,
selectedAttendance,
employees,
handleSaveAttendance,
reasonDialogOpen,
handleSubmitReason,
]);
// 로딩 상태
if (isLoading) {
@@ -567,61 +679,10 @@ export function AttendanceManagement() {
}
return (
<>
<IntegratedListTemplateV2<AttendanceRecord>
title="근태관리"
description="직원 출퇴근 및 근태 정보를 관리합니다"
icon={Clock}
headerActions={headerActions}
stats={statCards}
searchValue={searchValue}
onSearchChange={setSearchValue}
searchPlaceholder="이름, 부서 검색..."
extraFilters={extraFilters}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="근태 필터"
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
tableColumns={tableColumns}
data={paginatedData}
totalCount={filteredRecords.length}
allData={filteredRecords}
selectedItems={selectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={{
currentPage,
totalPages,
totalItems: filteredRecords.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 근태 정보 다이얼로그 */}
<AttendanceInfoDialog
open={attendanceDialogOpen}
onOpenChange={setAttendanceDialogOpen}
mode={attendanceDialogMode}
attendance={selectedAttendance}
employees={employees}
onSave={handleSaveAttendance}
/>
{/* 사유 정보 다이얼로그 */}
<ReasonInfoDialog
open={reasonDialogOpen}
onOpenChange={setReasonDialogOpen}
employees={employees}
onSubmit={handleSubmitReason}
/>
</>
<UniversalListPage<AttendanceRecord>
config={attendanceConfig}
initialData={mergedRecords}
initialTotalCount={mergedRecords.length}
/>
);
}