'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('rank'); const [selectedItems, setSelectedItems] = useState>(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(null); const [selectedSalaryId, setSelectedSalaryId] = useState(null); // 데이터 상태 const [salaryData, setSalaryData] = useState([]); 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 ) => { 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 = 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 }) => (
{/* 지급완료/지급예정 버튼 - 선택된 항목이 있을 때만 표시 */} {selected.size > 0 && ( <> )}
), renderTableRow: (item, index, globalIndex, handlers) => { const { isSelected, onToggle } = handlers; return ( {item.department} {item.position} {item.employeeName} {item.rank} {formatCurrency(item.baseSalary)}원 {formatCurrency(item.allowance)}원 {formatCurrency(item.overtime)}원 {formatCurrency(item.bonus)}원 -{formatCurrency(item.deduction)}원 {formatCurrency(item.netPayment)}원 {item.paymentDate} {PAYMENT_STATUS_LABELS[item.status]} ); }, renderMobileCard: (item, index, globalIndex, handlers) => { const { isSelected, onToggle } = handlers; return ( {PAYMENT_STATUS_LABELS[item.status]} } isSelected={isSelected} onToggleSelection={onToggle} infoGrid={
} actions={ } /> ); }, renderDialogs: () => ( ), }), [ salaryData, totalCount, totalPages, tableColumns, filterConfig, filterValues, statCards, startDate, endDate, handleMarkCompleted, handleMarkScheduled, isActionLoading, handleViewDetail, detailDialogOpen, selectedSalaryDetail, handleSaveDetail, handleAddPaymentItem, ]); return ( 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} /> ); }