Files
sam-react-prod/src/components/hr/SalaryManagement/index.tsx
유병철 1f6b592b9f feat(WEB): 리스트 페이지 UI 레이아웃 표준화
- 공통 레이아웃 패턴 적용: [달력] → [프리셋] → [검색창] → [버튼들]
- beforeTableContent → headerActions + createButton 마이그레이션
- DateRangeSelector extraActions prop 활용하여 검색창 통합
- PricingListClient 테이블 행 클릭 → 상세 이동 기능 추가
- 회계 관련 페이지 (입금/출금/매입/매출/어음/카드/예상지출 등) 정리
- 건설 관련 페이지 검색 영역 정리
- 부모 메뉴 리다이렉트 컴포넌트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 22:04:36 +09:00

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}
/>
);
}