- 공통 레이아웃 패턴 적용: [달력] → [프리셋] → [검색창] → [버튼들] - beforeTableContent → headerActions + createButton 마이그레이션 - DateRangeSelector extraActions prop 활용하여 검색창 통합 - PricingListClient 테이블 행 클릭 → 상세 이동 기능 추가 - 회계 관련 페이지 (입금/출금/매입/매출/어음/카드/예상지출 등) 정리 - 건설 관련 페이지 검색 영역 정리 - 부모 메뉴 리다이렉트 컴포넌트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
566 lines
18 KiB
TypeScript
566 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
import {
|
|
Download,
|
|
DollarSign,
|
|
Check,
|
|
Clock,
|
|
Pencil,
|
|
Banknote,
|
|
Briefcase,
|
|
Timer,
|
|
Gift,
|
|
MinusCircle,
|
|
Loader2,
|
|
Search,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { TableRow, TableCell } from '@/components/ui/table';
|
|
import {
|
|
UniversalListPage,
|
|
type UniversalListConfig,
|
|
type StatCard,
|
|
type FilterFieldConfig,
|
|
type FilterValues,
|
|
} from '@/components/templates/UniversalListPage';
|
|
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
|
import { SalaryDetailDialog } from './SalaryDetailDialog';
|
|
import {
|
|
getSalaries,
|
|
getSalary,
|
|
bulkUpdateSalaryStatus,
|
|
updateSalaryStatus,
|
|
updateSalary,
|
|
} from './actions';
|
|
import type {
|
|
SalaryRecord,
|
|
SalaryDetail,
|
|
PaymentStatus,
|
|
SortOption,
|
|
} from './types';
|
|
import {
|
|
PAYMENT_STATUS_LABELS,
|
|
PAYMENT_STATUS_COLORS,
|
|
SORT_OPTIONS,
|
|
formatCurrency,
|
|
} from './types';
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
|
|
export function SalaryManagement() {
|
|
// ===== 상태 관리 =====
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [sortOption, setSortOption] = useState<SortOption>('rank');
|
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const itemsPerPage = 20;
|
|
|
|
// 날짜 범위 상태
|
|
const [startDate, setStartDate] = useState('2025-12-01');
|
|
const [endDate, setEndDate] = useState('2025-12-31');
|
|
|
|
// 다이얼로그 상태
|
|
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
|
const [selectedSalaryDetail, setSelectedSalaryDetail] = useState<SalaryDetail | null>(null);
|
|
const [selectedSalaryId, setSelectedSalaryId] = useState<string | null>(null);
|
|
|
|
// 데이터 상태
|
|
const [salaryData, setSalaryData] = useState<SalaryRecord[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isActionLoading, setIsActionLoading] = useState(false);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
|
|
// ===== 데이터 로드 =====
|
|
const loadSalaries = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await getSalaries({
|
|
search: searchQuery || undefined,
|
|
start_date: startDate || undefined,
|
|
end_date: endDate || undefined,
|
|
page: currentPage,
|
|
per_page: itemsPerPage,
|
|
});
|
|
|
|
if (result.success && result.data) {
|
|
setSalaryData(result.data);
|
|
setTotalCount(result.pagination?.total || result.data.length);
|
|
setTotalPages(result.pagination?.lastPage || 1);
|
|
} else {
|
|
toast.error(result.error || '급여 목록을 불러오는데 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('loadSalaries error:', error);
|
|
toast.error('급여 목록을 불러오는데 실패했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [searchQuery, startDate, endDate, currentPage, itemsPerPage]);
|
|
|
|
// 초기 데이터 로드 및 검색/필터 변경 시 재로드
|
|
useEffect(() => {
|
|
loadSalaries();
|
|
}, [loadSalaries]);
|
|
|
|
// ===== 체크박스 핸들러 =====
|
|
const toggleSelection = useCallback((id: string) => {
|
|
setSelectedItems(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(id)) newSet.delete(id);
|
|
else newSet.add(id);
|
|
return newSet;
|
|
});
|
|
}, []);
|
|
|
|
const toggleSelectAll = useCallback(() => {
|
|
if (selectedItems.size === salaryData.length && salaryData.length > 0) {
|
|
setSelectedItems(new Set());
|
|
} else {
|
|
setSelectedItems(new Set(salaryData.map(item => item.id)));
|
|
}
|
|
}, [selectedItems.size, salaryData]);
|
|
|
|
// ===== 지급완료 핸들러 =====
|
|
const handleMarkCompleted = useCallback(async () => {
|
|
if (selectedItems.size === 0) return;
|
|
|
|
setIsActionLoading(true);
|
|
try {
|
|
const result = await bulkUpdateSalaryStatus(
|
|
Array.from(selectedItems),
|
|
'completed'
|
|
);
|
|
|
|
if (result.success) {
|
|
toast.success(`${result.updatedCount || selectedItems.size}건이 지급완료 처리되었습니다.`);
|
|
setSelectedItems(new Set());
|
|
await loadSalaries();
|
|
} else {
|
|
toast.error(result.error || '상태 변경에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('handleMarkCompleted error:', error);
|
|
toast.error('상태 변경에 실패했습니다.');
|
|
} finally {
|
|
setIsActionLoading(false);
|
|
}
|
|
}, [selectedItems, loadSalaries]);
|
|
|
|
// ===== 지급예정 핸들러 =====
|
|
const handleMarkScheduled = useCallback(async () => {
|
|
if (selectedItems.size === 0) return;
|
|
|
|
setIsActionLoading(true);
|
|
try {
|
|
const result = await bulkUpdateSalaryStatus(
|
|
Array.from(selectedItems),
|
|
'scheduled'
|
|
);
|
|
|
|
if (result.success) {
|
|
toast.success(`${result.updatedCount || selectedItems.size}건이 지급예정 처리되었습니다.`);
|
|
setSelectedItems(new Set());
|
|
await loadSalaries();
|
|
} else {
|
|
toast.error(result.error || '상태 변경에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('handleMarkScheduled error:', error);
|
|
toast.error('상태 변경에 실패했습니다.');
|
|
} finally {
|
|
setIsActionLoading(false);
|
|
}
|
|
}, [selectedItems, loadSalaries]);
|
|
|
|
// ===== 상세보기 핸들러 =====
|
|
const handleViewDetail = useCallback(async (record: SalaryRecord) => {
|
|
setSelectedSalaryId(record.id);
|
|
setIsActionLoading(true);
|
|
try {
|
|
const result = await getSalary(record.id);
|
|
if (result.success && result.data) {
|
|
setSelectedSalaryDetail(result.data);
|
|
setDetailDialogOpen(true);
|
|
} else {
|
|
toast.error(result.error || '급여 상세 정보를 불러오는데 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('handleViewDetail error:', error);
|
|
toast.error('급여 상세 정보를 불러오는데 실패했습니다.');
|
|
} finally {
|
|
setIsActionLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// ===== 급여 상세 저장 핸들러 =====
|
|
const handleSaveDetail = useCallback(async (
|
|
updatedDetail: SalaryDetail,
|
|
allowanceDetails?: Record<string, number>
|
|
) => {
|
|
if (!selectedSalaryId) return;
|
|
|
|
setIsActionLoading(true);
|
|
try {
|
|
// 수당 정보가 변경된 경우 updateSalary API 호출
|
|
if (allowanceDetails) {
|
|
const result = await updateSalary(selectedSalaryId, {
|
|
allowance_details: allowanceDetails,
|
|
status: updatedDetail.status,
|
|
});
|
|
if (result.success) {
|
|
toast.success('급여 정보가 저장되었습니다.');
|
|
setDetailDialogOpen(false);
|
|
await loadSalaries();
|
|
} else {
|
|
toast.error(result.error || '저장에 실패했습니다.');
|
|
}
|
|
} else {
|
|
// 상태만 변경된 경우 기존 API 호출
|
|
const result = await updateSalaryStatus(selectedSalaryId, updatedDetail.status);
|
|
if (result.success) {
|
|
toast.success('급여 정보가 저장되었습니다.');
|
|
setDetailDialogOpen(false);
|
|
await loadSalaries();
|
|
} else {
|
|
toast.error(result.error || '저장에 실패했습니다.');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('handleSaveDetail error:', error);
|
|
toast.error('저장에 실패했습니다.');
|
|
} finally {
|
|
setIsActionLoading(false);
|
|
}
|
|
}, [selectedSalaryId, loadSalaries]);
|
|
|
|
// ===== 지급항목 추가 핸들러 =====
|
|
const handleAddPaymentItem = useCallback(() => {
|
|
// TODO: 지급항목 추가 다이얼로그 또는 로직 구현
|
|
toast.info('지급항목 추가 기능은 준비 중입니다.');
|
|
}, []);
|
|
|
|
// ===== 통계 카드 (총 실지급액, 총 기본급, 총 수당, 초과근무, 상여, 총공제) =====
|
|
const statCards: StatCard[] = useMemo(() => {
|
|
const totalNetPayment = salaryData.reduce((sum, s) => sum + s.netPayment, 0);
|
|
const totalBaseSalary = salaryData.reduce((sum, s) => sum + s.baseSalary, 0);
|
|
const totalAllowance = salaryData.reduce((sum, s) => sum + s.allowance, 0);
|
|
const totalOvertime = salaryData.reduce((sum, s) => sum + s.overtime, 0);
|
|
const totalBonus = salaryData.reduce((sum, s) => sum + s.bonus, 0);
|
|
const totalDeduction = salaryData.reduce((sum, s) => sum + s.deduction, 0);
|
|
|
|
return [
|
|
{
|
|
label: '총 실지급액',
|
|
value: `${formatCurrency(totalNetPayment)}원`,
|
|
icon: DollarSign,
|
|
iconColor: 'text-green-500',
|
|
},
|
|
{
|
|
label: '총 기본급',
|
|
value: `${formatCurrency(totalBaseSalary)}원`,
|
|
icon: Banknote,
|
|
iconColor: 'text-blue-500',
|
|
},
|
|
{
|
|
label: '총 수당',
|
|
value: `${formatCurrency(totalAllowance)}원`,
|
|
icon: Briefcase,
|
|
iconColor: 'text-purple-500',
|
|
},
|
|
{
|
|
label: '초과근무',
|
|
value: `${formatCurrency(totalOvertime)}원`,
|
|
icon: Timer,
|
|
iconColor: 'text-orange-500',
|
|
},
|
|
{
|
|
label: '상여',
|
|
value: `${formatCurrency(totalBonus)}원`,
|
|
icon: Gift,
|
|
iconColor: 'text-pink-500',
|
|
},
|
|
{
|
|
label: '총 공제',
|
|
value: `${formatCurrency(totalDeduction)}원`,
|
|
icon: MinusCircle,
|
|
iconColor: 'text-red-500',
|
|
},
|
|
];
|
|
}, [salaryData]);
|
|
|
|
// ===== 테이블 컬럼 (부서, 직책, 이름, 직급, 기본급, 수당, 초과근무, 상여, 공제, 실지급액, 일자, 상태, 작업) =====
|
|
const tableColumns = useMemo(() => [
|
|
{ key: 'department', label: '부서' },
|
|
{ key: 'position', label: '직책' },
|
|
{ key: 'name', label: '이름' },
|
|
{ key: 'rank', label: '직급' },
|
|
{ key: 'baseSalary', label: '기본급', className: 'text-right' },
|
|
{ key: 'allowance', label: '수당', className: 'text-right' },
|
|
{ key: 'overtime', label: '초과근무', className: 'text-right' },
|
|
{ key: 'bonus', label: '상여', className: 'text-right' },
|
|
{ key: 'deduction', label: '공제', className: 'text-right' },
|
|
{ key: 'netPayment', label: '실지급액', className: 'text-right' },
|
|
{ key: 'paymentDate', label: '일자', className: 'text-center' },
|
|
{ key: 'status', label: '상태', className: 'text-center' },
|
|
{ key: 'action', label: '작업', className: 'text-center w-[80px]' },
|
|
], []);
|
|
|
|
// ===== 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);
|
|
}, []);
|
|
|
|
// ===== UniversalListPage 설정 =====
|
|
const salaryConfig: UniversalListConfig<SalaryRecord> = useMemo(() => ({
|
|
title: '급여관리',
|
|
description: '직원들의 급여 현황을 관리합니다',
|
|
icon: DollarSign,
|
|
basePath: '/hr/salary-management',
|
|
|
|
idField: 'id',
|
|
|
|
actions: {
|
|
getList: async () => ({
|
|
success: true,
|
|
data: salaryData,
|
|
totalCount: totalCount,
|
|
totalPages: totalPages,
|
|
}),
|
|
},
|
|
|
|
columns: tableColumns,
|
|
|
|
filterConfig: filterConfig,
|
|
initialFilters: filterValues,
|
|
filterTitle: '급여 필터',
|
|
|
|
computeStats: () => statCards,
|
|
|
|
searchPlaceholder: '이름, 부서 검색...',
|
|
|
|
itemsPerPage: itemsPerPage,
|
|
|
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
|
hideSearch: true,
|
|
searchValue: searchQuery,
|
|
onSearchChange: setSearchQuery,
|
|
|
|
// 날짜 범위 선택 (DateRangeSelector 사용)
|
|
dateRangeSelector: {
|
|
enabled: true,
|
|
showPresets: false,
|
|
startDate,
|
|
endDate,
|
|
onStartDateChange: setStartDate,
|
|
onEndDateChange: setEndDate,
|
|
},
|
|
|
|
headerActions: ({ selectedItems: selected }) => (
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{/* 지급완료/지급예정 버튼 - 선택된 항목이 있을 때만 표시 */}
|
|
{selected.size > 0 && (
|
|
<>
|
|
<Button
|
|
variant="default"
|
|
onClick={handleMarkCompleted}
|
|
disabled={isActionLoading}
|
|
>
|
|
{isActionLoading ? (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Check className="h-4 w-4 mr-2" />
|
|
)}
|
|
지급완료
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleMarkScheduled}
|
|
disabled={isActionLoading}
|
|
>
|
|
{isActionLoading ? (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Clock className="h-4 w-4 mr-2" />
|
|
)}
|
|
지급예정
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
<Button variant="outline" onClick={() => toast.info('엑셀 다운로드 기능은 준비 중입니다.')}>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
엑셀 다운로드
|
|
</Button>
|
|
</div>
|
|
),
|
|
|
|
renderTableRow: (item, index, globalIndex, handlers) => {
|
|
const { isSelected, onToggle } = handlers;
|
|
|
|
return (
|
|
<TableRow key={item.id} className="hover:bg-muted/50">
|
|
<TableCell className="text-center">
|
|
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
|
|
</TableCell>
|
|
<TableCell>{item.department}</TableCell>
|
|
<TableCell>{item.position}</TableCell>
|
|
<TableCell className="font-medium">{item.employeeName}</TableCell>
|
|
<TableCell>{item.rank}</TableCell>
|
|
<TableCell className="text-right">{formatCurrency(item.baseSalary)}원</TableCell>
|
|
<TableCell className="text-right">{formatCurrency(item.allowance)}원</TableCell>
|
|
<TableCell className="text-right">{formatCurrency(item.overtime)}원</TableCell>
|
|
<TableCell className="text-right">{formatCurrency(item.bonus)}원</TableCell>
|
|
<TableCell className="text-right text-red-600">-{formatCurrency(item.deduction)}원</TableCell>
|
|
<TableCell className="text-right font-medium text-green-600">{formatCurrency(item.netPayment)}원</TableCell>
|
|
<TableCell className="text-center">{item.paymentDate}</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge className={PAYMENT_STATUS_COLORS[item.status]}>
|
|
{PAYMENT_STATUS_LABELS[item.status]}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleViewDetail(item)}
|
|
title="수정"
|
|
disabled={isActionLoading}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
},
|
|
|
|
renderMobileCard: (item, index, globalIndex, handlers) => {
|
|
const { isSelected, onToggle } = handlers;
|
|
|
|
return (
|
|
<ListMobileCard
|
|
id={item.id}
|
|
title={item.employeeName}
|
|
headerBadges={
|
|
<Badge className={PAYMENT_STATUS_COLORS[item.status]}>
|
|
{PAYMENT_STATUS_LABELS[item.status]}
|
|
</Badge>
|
|
}
|
|
isSelected={isSelected}
|
|
onToggleSelection={onToggle}
|
|
infoGrid={
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<InfoField label="부서" value={item.department} />
|
|
<InfoField label="직급" value={item.rank} />
|
|
<InfoField label="기본급" value={`${formatCurrency(item.baseSalary)}원`} />
|
|
<InfoField label="수당" value={`${formatCurrency(item.allowance)}원`} />
|
|
<InfoField label="초과근무" value={`${formatCurrency(item.overtime)}원`} />
|
|
<InfoField label="상여" value={`${formatCurrency(item.bonus)}원`} />
|
|
<InfoField label="공제" value={`-${formatCurrency(item.deduction)}원`} />
|
|
<InfoField label="실지급액" value={`${formatCurrency(item.netPayment)}원`} />
|
|
<InfoField label="지급일" value={item.paymentDate} />
|
|
</div>
|
|
}
|
|
actions={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={() => handleViewDetail(item)}
|
|
disabled={isActionLoading}
|
|
>
|
|
<Pencil className="h-4 w-4 mr-2" />
|
|
수정
|
|
</Button>
|
|
}
|
|
/>
|
|
);
|
|
},
|
|
|
|
renderDialogs: () => (
|
|
<SalaryDetailDialog
|
|
open={detailDialogOpen}
|
|
onOpenChange={setDetailDialogOpen}
|
|
salaryDetail={selectedSalaryDetail}
|
|
onSave={handleSaveDetail}
|
|
onAddPaymentItem={handleAddPaymentItem}
|
|
/>
|
|
),
|
|
}), [
|
|
salaryData,
|
|
totalCount,
|
|
totalPages,
|
|
tableColumns,
|
|
filterConfig,
|
|
filterValues,
|
|
statCards,
|
|
startDate,
|
|
endDate,
|
|
handleMarkCompleted,
|
|
handleMarkScheduled,
|
|
isActionLoading,
|
|
handleViewDetail,
|
|
detailDialogOpen,
|
|
selectedSalaryDetail,
|
|
handleSaveDetail,
|
|
handleAddPaymentItem,
|
|
]);
|
|
|
|
return (
|
|
<UniversalListPage<SalaryRecord>
|
|
config={salaryConfig}
|
|
initialData={salaryData}
|
|
initialTotalCount={totalCount}
|
|
externalPagination={{
|
|
currentPage,
|
|
totalPages,
|
|
totalItems: totalCount,
|
|
itemsPerPage,
|
|
onPageChange: setCurrentPage,
|
|
}}
|
|
externalSelection={{
|
|
selectedItems,
|
|
onToggleSelection: toggleSelection,
|
|
onToggleSelectAll: toggleSelectAll,
|
|
getItemId: (item) => item.id,
|
|
}}
|
|
onSearchChange={setSearchQuery}
|
|
externalIsLoading={isLoading}
|
|
/>
|
|
);
|
|
} |