diff --git a/src/components/hr/SalaryManagement/SalaryDetailDialog.tsx b/src/components/hr/SalaryManagement/SalaryDetailDialog.tsx index 93d74b0d..f9800ec0 100644 --- a/src/components/hr/SalaryManagement/SalaryDetailDialog.tsx +++ b/src/components/hr/SalaryManagement/SalaryDetailDialog.tsx @@ -10,7 +10,6 @@ import { } from '@/components/ui/dialog'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; import { CurrencyInput } from '@/components/ui/currency-input'; import { @@ -36,11 +35,59 @@ interface AllowanceEdits { otherAllowance: number; } +interface DeductionEdits { + nationalPension: number; + healthInsurance: number; + longTermCare: number; + employmentInsurance: number; + incomeTax: number; + localIncomeTax: number; + otherDeduction: number; +} + interface SalaryDetailDialogProps { open: boolean; onOpenChange: (open: boolean) => void; salaryDetail: SalaryDetail | null; - onSave?: (updatedDetail: SalaryDetail, allowanceDetails?: Record) => void; + onSave?: (updatedDetail: SalaryDetail, allowanceDetails?: Record, deductionDetails?: Record) => void; +} + +// 행 컴포넌트: 라벨 고정폭 + 값/인풋 오른쪽 정렬 +function DetailRow({ + label, + value, + isEditing, + editValue, + onChange, + color, + prefix, +}: { + label: string; + value: string; + isEditing?: boolean; + editValue?: number; + onChange?: (value: number) => void; + color?: string; + prefix?: string; +}) { + const editing = isEditing && onChange !== undefined; + + return ( +
+ {label} +
+ {editing ? ( + onChange!(v ?? 0)} + className="w-full h-7 text-sm" + /> + ) : ( + {prefix}{value} + )} +
+
+ ); } export function SalaryDetailDialog({ @@ -51,7 +98,7 @@ export function SalaryDetailDialog({ }: SalaryDetailDialogProps) { const [editedStatus, setEditedStatus] = useState('scheduled'); const [hasChanges, setHasChanges] = useState(false); - const [isEditingAllowances, setIsEditingAllowances] = useState(false); + const [isEditing, setIsEditing] = useState(false); const [editedAllowances, setEditedAllowances] = useState({ positionAllowance: 0, overtimeAllowance: 0, @@ -59,6 +106,15 @@ export function SalaryDetailDialog({ transportAllowance: 0, otherAllowance: 0, }); + const [editedDeductions, setEditedDeductions] = useState({ + nationalPension: 0, + healthInsurance: 0, + longTermCare: 0, + employmentInsurance: 0, + incomeTax: 0, + localIncomeTax: 0, + otherDeduction: 0, + }); // 다이얼로그가 열릴 때 상태 초기화 useEffect(() => { @@ -71,8 +127,17 @@ export function SalaryDetailDialog({ transportAllowance: salaryDetail.allowances.transportAllowance, otherAllowance: salaryDetail.allowances.otherAllowance, }); + setEditedDeductions({ + nationalPension: salaryDetail.deductions.nationalPension, + healthInsurance: salaryDetail.deductions.healthInsurance, + longTermCare: salaryDetail.deductions.longTermCare, + employmentInsurance: salaryDetail.deductions.employmentInsurance, + incomeTax: salaryDetail.deductions.incomeTax, + localIncomeTax: salaryDetail.deductions.localIncomeTax, + otherDeduction: salaryDetail.deductions.otherDeduction, + }); setHasChanges(false); - setIsEditingAllowances(false); + setIsEditing(false); } }, [salaryDetail]); @@ -87,9 +152,17 @@ export function SalaryDetailDialog({ editedAllowances.mealAllowance !== salaryDetail.allowances.mealAllowance || editedAllowances.transportAllowance !== salaryDetail.allowances.transportAllowance || editedAllowances.otherAllowance !== salaryDetail.allowances.otherAllowance; + const deductionsChanged = + editedDeductions.nationalPension !== salaryDetail.deductions.nationalPension || + editedDeductions.healthInsurance !== salaryDetail.deductions.healthInsurance || + editedDeductions.longTermCare !== salaryDetail.deductions.longTermCare || + editedDeductions.employmentInsurance !== salaryDetail.deductions.employmentInsurance || + editedDeductions.incomeTax !== salaryDetail.deductions.incomeTax || + editedDeductions.localIncomeTax !== salaryDetail.deductions.localIncomeTax || + editedDeductions.otherDeduction !== salaryDetail.deductions.otherDeduction; - return statusChanged || allowancesChanged; - }, [salaryDetail, editedStatus, editedAllowances]); + return statusChanged || allowancesChanged || deductionsChanged; + }, [salaryDetail, editedStatus, editedAllowances, editedDeductions]); useEffect(() => { setHasChanges(checkForChanges()); @@ -97,16 +170,12 @@ export function SalaryDetailDialog({ if (!salaryDetail) return null; - const handleStatusChange = (newStatus: PaymentStatus) => { - setEditedStatus(newStatus); + const handleAllowanceChange = (field: keyof AllowanceEdits, value: number) => { + setEditedAllowances(prev => ({ ...prev, [field]: value })); }; - const handleAllowanceChange = (field: keyof AllowanceEdits, value: string) => { - const numValue = parseInt(value.replace(/,/g, ''), 10) || 0; - setEditedAllowances(prev => ({ - ...prev, - [field]: numValue, - })); + const handleDeductionChange = (field: keyof DeductionEdits, value: number) => { + setEditedDeductions(prev => ({ ...prev, [field]: value })); }; // 수당 합계 계산 @@ -114,14 +183,18 @@ export function SalaryDetailDialog({ return Object.values(editedAllowances).reduce((sum, val) => sum + val, 0); }; + // 공제 합계 계산 + const calculateTotalDeduction = () => { + return Object.values(editedDeductions).reduce((sum, val) => sum + val, 0); + }; + // 실지급액 계산 const calculateNetPayment = () => { - return salaryDetail.baseSalary + calculateTotalAllowance() - salaryDetail.totalDeduction; + return salaryDetail.baseSalary + calculateTotalAllowance() - calculateTotalDeduction(); }; const handleSave = () => { if (onSave && salaryDetail) { - // allowance_details를 백엔드 형식으로 변환 const allowanceDetails = { position_allowance: editedAllowances.positionAllowance, overtime_allowance: editedAllowances.overtimeAllowance, @@ -130,22 +203,34 @@ export function SalaryDetailDialog({ other_allowance: editedAllowances.otherAllowance, }; + const deductionDetails = { + national_pension: editedDeductions.nationalPension, + health_insurance: editedDeductions.healthInsurance, + long_term_care: editedDeductions.longTermCare, + employment_insurance: editedDeductions.employmentInsurance, + income_tax: editedDeductions.incomeTax, + local_income_tax: editedDeductions.localIncomeTax, + other_deduction: editedDeductions.otherDeduction, + }; + const updatedDetail: SalaryDetail = { ...salaryDetail, status: editedStatus, allowances: editedAllowances, + deductions: editedDeductions, totalAllowance: calculateTotalAllowance(), + totalDeduction: calculateTotalDeduction(), netPayment: calculateNetPayment(), }; - onSave(updatedDetail, allowanceDetails); + onSave(updatedDetail, allowanceDetails, deductionDetails); } setHasChanges(false); - setIsEditingAllowances(false); + setIsEditing(false); }; const handleToggleEdit = () => { - if (isEditingAllowances) { + if (isEditing) { // 편집 취소 - 원래 값으로 복원 setEditedAllowances({ positionAllowance: salaryDetail.allowances.positionAllowance, @@ -154,49 +239,75 @@ export function SalaryDetailDialog({ transportAllowance: salaryDetail.allowances.transportAllowance, otherAllowance: salaryDetail.allowances.otherAllowance, }); + setEditedDeductions({ + nationalPension: salaryDetail.deductions.nationalPension, + healthInsurance: salaryDetail.deductions.healthInsurance, + longTermCare: salaryDetail.deductions.longTermCare, + employmentInsurance: salaryDetail.deductions.employmentInsurance, + incomeTax: salaryDetail.deductions.incomeTax, + localIncomeTax: salaryDetail.deductions.localIncomeTax, + otherDeduction: salaryDetail.deductions.otherDeduction, + }); } - setIsEditingAllowances(!isEditingAllowances); + setIsEditing(!isEditing); }; return ( - + - -
- 급여 수정 - {salaryDetail.employeeName} + + {salaryDetail.employeeName} 급여 +
+ +
- {/* 상태 변경 셀렉트 박스 */} -
{/* 기본 정보 */} -
+

기본 정보

@@ -233,146 +344,140 @@ export function SalaryDetailDialog({ {/* 급여 항목 */}
{/* 수당 내역 */} -
-
-

수당 내역

- +
+

수당 내역

+
+ + + handleAllowanceChange('positionAllowance', v)} + /> + handleAllowanceChange('overtimeAllowance', v)} + /> + handleAllowanceChange('mealAllowance', v)} + /> + handleAllowanceChange('transportAllowance', v)} + /> + handleAllowanceChange('otherAllowance', v)} + />
-
-
- 본봉 - {formatCurrency(salaryDetail.baseSalary)}원 -
+
-
- 직책수당 - {isEditingAllowances ? ( - handleAllowanceChange('positionAllowance', String(value ?? 0))} - className="w-32 h-7 text-sm" - /> - ) : ( - {formatCurrency(editedAllowances.positionAllowance)}원 - )} -
-
- 초과근무수당 - {isEditingAllowances ? ( - handleAllowanceChange('overtimeAllowance', String(value ?? 0))} - className="w-32 h-7 text-sm" - /> - ) : ( - {formatCurrency(editedAllowances.overtimeAllowance)}원 - )} -
-
- 식대 - {isEditingAllowances ? ( - handleAllowanceChange('mealAllowance', String(value ?? 0))} - className="w-32 h-7 text-sm" - /> - ) : ( - {formatCurrency(editedAllowances.mealAllowance)}원 - )} -
-
- 교통비 - {isEditingAllowances ? ( - handleAllowanceChange('transportAllowance', String(value ?? 0))} - className="w-32 h-7 text-sm" - /> - ) : ( - {formatCurrency(editedAllowances.transportAllowance)}원 - )} -
-
- 기타수당 - {isEditingAllowances ? ( - handleAllowanceChange('otherAllowance', String(value ?? 0))} - className="w-32 h-7 text-sm" - /> - ) : ( - {formatCurrency(editedAllowances.otherAllowance)}원 - )} -
- -
- 수당 합계 - {formatCurrency(calculateTotalAllowance())}원 +
+ 수당 합계 + {formatCurrency(calculateTotalAllowance())}원
{/* 공제 내역 */} -
+

공제 내역

-
-
- 국민연금 - -{formatCurrency(salaryDetail.deductions.nationalPension)}원 -
-
- 건강보험 - -{formatCurrency(salaryDetail.deductions.healthInsurance)}원 -
-
- 장기요양보험 - -{formatCurrency(salaryDetail.deductions.longTermCare)}원 -
-
- 고용보험 - -{formatCurrency(salaryDetail.deductions.employmentInsurance)}원 -
+
+ handleDeductionChange('nationalPension', v)} + /> + handleDeductionChange('healthInsurance', v)} + /> + handleDeductionChange('longTermCare', v)} + /> + handleDeductionChange('employmentInsurance', v)} + /> -
- 소득세 - -{formatCurrency(salaryDetail.deductions.incomeTax)}원 -
-
- 지방소득세 - -{formatCurrency(salaryDetail.deductions.localIncomeTax)}원 -
-
- 기타공제 - -{formatCurrency(salaryDetail.deductions.otherDeduction)}원 -
+ handleDeductionChange('incomeTax', v)} + /> + handleDeductionChange('localIncomeTax', v)} + /> + handleDeductionChange('otherDeduction', v)} + /> +
+
-
- 공제 합계 - -{formatCurrency(salaryDetail.totalDeduction)}원 +
+ 공제 합계 + -{formatCurrency(calculateTotalDeduction())}원
{/* 지급 합계 */} -
+
급여 총액 @@ -383,7 +488,7 @@ export function SalaryDetailDialog({
공제 총액 - -{formatCurrency(salaryDetail.totalDeduction)}원 + -{formatCurrency(calculateTotalDeduction())}원
diff --git a/src/components/hr/SalaryManagement/SalaryRegistrationDialog.tsx b/src/components/hr/SalaryManagement/SalaryRegistrationDialog.tsx new file mode 100644 index 00000000..67681471 --- /dev/null +++ b/src/components/hr/SalaryManagement/SalaryRegistrationDialog.tsx @@ -0,0 +1,571 @@ +'use client'; + +import { useState, useCallback, useMemo, useRef } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { CurrencyInput } from '@/components/ui/currency-input'; +import { DatePicker } from '@/components/ui/date-picker'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal'; +import { Save, Search, UserPlus } from 'lucide-react'; +import { getEmployees } from '@/components/hr/EmployeeManagement/actions'; +import type { Employee } from '@/components/hr/EmployeeManagement/types'; +import { formatCurrency } from './types'; + +// ===== 기본값 상수 ===== +const DEFAULT_ALLOWANCES = { + positionAllowance: 0, + overtimeAllowance: 0, + mealAllowance: 150000, + transportAllowance: 100000, + otherAllowance: 0, +}; + +const DEFAULT_DEDUCTIONS = { + nationalPension: 0, + healthInsurance: 0, + longTermCare: 0, + employmentInsurance: 0, + incomeTax: 0, + localIncomeTax: 0, + otherDeduction: 0, +}; + +// 기본급 기준 4대보험 + 세금 자동 계산 +function calculateDefaultDeductions(baseSalary: number) { + const nationalPension = Math.round(baseSalary * 0.045); // 국민연금 4.5% + const healthInsurance = Math.round(baseSalary * 0.03545); // 건강보험 3.545% + const longTermCare = Math.round(healthInsurance * 0.1281); // 장기요양 12.81% of 건강보험 + const employmentInsurance = Math.round(baseSalary * 0.009); // 고용보험 0.9% + const totalIncome = baseSalary + DEFAULT_ALLOWANCES.mealAllowance + DEFAULT_ALLOWANCES.transportAllowance; + const incomeTax = Math.round(totalIncome * 0.05); // 소득세 (간이세액 근사) + const localIncomeTax = Math.round(incomeTax * 0.1); // 지방소득세 10% of 소득세 + + return { + nationalPension, + healthInsurance, + longTermCare, + employmentInsurance, + incomeTax, + localIncomeTax, + otherDeduction: 0, + }; +} + +// ===== 행 컴포넌트 ===== +function EditableRow({ + label, + value, + onChange, + prefix, +}: { + label: string; + value: number; + onChange: (value: number) => void; + prefix?: string; +}) { + return ( +
+ {label} +
+ onChange(v ?? 0)} + className="w-full h-7 text-sm text-right" + /> +
+
+ ); +} + +// ===== Props ===== +interface SalaryRegistrationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (data: { + employeeId: number; + employeeName: string; + department: string; + position: string; + rank: string; + year: number; + month: number; + baseSalary: number; + paymentDate: string; + allowances: Record; + deductions: Record; + }) => void; +} + +// ===== 컴포넌트 ===== +export function SalaryRegistrationDialog({ + open, + onOpenChange, + onSave, +}: SalaryRegistrationDialogProps) { + // 사원 선택 + const [employeeSearchOpen, setEmployeeSearchOpen] = useState(false); + const [selectedEmployee, setSelectedEmployee] = useState(null); + const searchOpenRef = useRef(false); + + // 급여 기본 정보 + const now = new Date(); + const [year, setYear] = useState(now.getFullYear()); + const [month, setMonth] = useState(now.getMonth() + 1); + const [paymentDate, setPaymentDate] = useState(''); + const [baseSalary, setBaseSalary] = useState(0); + + // 수당 + const [allowances, setAllowances] = useState({ ...DEFAULT_ALLOWANCES }); + + // 공제 + const [deductions, setDeductions] = useState({ ...DEFAULT_DEDUCTIONS }); + + // 검색 모달 열기/닫기 (ref 동기화 - 닫힘 전파 방지를 위해 해제 지연) + const handleSearchOpenChange = useCallback((isOpen: boolean) => { + if (isOpen) { + searchOpenRef.current = true; + } else { + setTimeout(() => { searchOpenRef.current = false; }, 300); + } + setEmployeeSearchOpen(isOpen); + }, []); + + // 사원 선택 시 기본값 세팅 + const handleSelectEmployee = useCallback((employee: Employee) => { + setSelectedEmployee(employee); + handleSearchOpenChange(false); + + // 기본급 세팅 (연봉 / 12) + const monthlySalary = employee.salary ? Math.round(employee.salary / 12) : 0; + setBaseSalary(monthlySalary); + + // 기본 공제 자동 계산 + if (monthlySalary > 0) { + setDeductions(calculateDefaultDeductions(monthlySalary)); + } + }, []); + + // 사원 검색 fetch + const handleFetchEmployees = useCallback(async (query: string) => { + const result = await getEmployees({ + q: query || undefined, + status: 'active', + per_page: 50, + }); + return result.data || []; + }, []); + + // 검색어 유효성 + const isValidSearch = useCallback((query: string) => { + if (!query || !query.trim()) return false; + return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query); + }, []); + + // 수당 변경 + const handleAllowanceChange = useCallback((field: string, value: number) => { + setAllowances(prev => ({ ...prev, [field]: value })); + }, []); + + // 공제 변경 + const handleDeductionChange = useCallback((field: string, value: number) => { + setDeductions(prev => ({ ...prev, [field]: value })); + }, []); + + // 합계 계산 + const totalAllowance = useMemo(() => + Object.values(allowances).reduce((sum, v) => sum + v, 0), + [allowances] + ); + + const totalDeduction = useMemo(() => + Object.values(deductions).reduce((sum, v) => sum + v, 0), + [deductions] + ); + + const netPayment = useMemo(() => + baseSalary + totalAllowance - totalDeduction, + [baseSalary, totalAllowance, totalDeduction] + ); + + // 저장 가능 여부 + const canSave = selectedEmployee && baseSalary > 0 && year > 0 && month > 0; + + // 저장 + const handleSave = useCallback(() => { + if (!selectedEmployee || !canSave) return; + + const dept = selectedEmployee.departmentPositions?.[0]; + + onSave({ + employeeId: selectedEmployee.userId || parseInt(selectedEmployee.id, 10), + employeeName: selectedEmployee.name, + department: dept?.departmentName || '-', + position: dept?.positionName || '-', + rank: selectedEmployee.rank || '-', + year, + month, + baseSalary, + paymentDate, + allowances: { + position_allowance: allowances.positionAllowance, + overtime_allowance: allowances.overtimeAllowance, + meal_allowance: allowances.mealAllowance, + transport_allowance: allowances.transportAllowance, + other_allowance: allowances.otherAllowance, + }, + deductions: { + national_pension: deductions.nationalPension, + health_insurance: deductions.healthInsurance, + long_term_care: deductions.longTermCare, + employment_insurance: deductions.employmentInsurance, + income_tax: deductions.incomeTax, + local_income_tax: deductions.localIncomeTax, + other_deduction: deductions.otherDeduction, + }, + }); + }, [selectedEmployee, canSave, year, month, baseSalary, paymentDate, allowances, deductions, onSave]); + + // 다이얼로그 닫힐 때 초기화 (검색 모달 닫힘 전파 방지) + const handleOpenChange = useCallback((isOpen: boolean) => { + if (!isOpen && searchOpenRef.current) return; + if (!isOpen) { + setSelectedEmployee(null); + setBaseSalary(0); + setPaymentDate(''); + setAllowances({ ...DEFAULT_ALLOWANCES }); + setDeductions({ ...DEFAULT_DEDUCTIONS }); + } + onOpenChange(isOpen); + }, [onOpenChange]); + + // 년도 옵션 + const yearOptions = useMemo(() => { + const currentYear = new Date().getFullYear(); + return Array.from({ length: 3 }, (_, i) => currentYear - 1 + i); + }, []); + + // 월 옵션 + const monthOptions = useMemo(() => + Array.from({ length: 12 }, (_, i) => i + 1), + [] + ); + + return ( + <> + + + + + + 급여 등록 + + + +
+ {/* 사원 선택 */} +
+

기본 정보

+ + {/* 사원 선택 버튼 */} + {!selectedEmployee ? ( + + ) : ( +
+
+
+ 사번 +

{selectedEmployee.employeeCode || selectedEmployee.id}

+
+
+ 이름 +

{selectedEmployee.name}

+
+
+ 부서 +

+ {selectedEmployee.departmentPositions?.[0]?.departmentName || '-'} +

+
+
+ 직급 +

{selectedEmployee.rank || '-'}

+
+
+ 직책 +

+ {selectedEmployee.departmentPositions?.[0]?.positionName || '-'} +

+
+
+ +
+ )} + + {/* 지급월 / 지급일 */} +
+
+ 년도 + +
+
+ + +
+
+ 지급일 + +
+
+ + {/* 기본급 */} +
+ 기본급 (월) + { + const newSalary = v ?? 0; + setBaseSalary(newSalary); + if (newSalary > 0) { + setDeductions(calculateDefaultDeductions(newSalary)); + } + }} + className="w-full" + /> + {selectedEmployee?.salary && ( + + 연봉 {formatCurrency(selectedEmployee.salary)}원 기준 + + )} +
+
+ + {/* 수당 / 공제 */} +
+ {/* 수당 내역 */} +
+

수당 내역

+
+ handleAllowanceChange('positionAllowance', v)} + /> + handleAllowanceChange('overtimeAllowance', v)} + /> + handleAllowanceChange('mealAllowance', v)} + /> + handleAllowanceChange('transportAllowance', v)} + /> + handleAllowanceChange('otherAllowance', v)} + /> +
+
+ +
+ 수당 합계 + {formatCurrency(totalAllowance)}원 +
+
+
+ + {/* 공제 내역 */} +
+

공제 내역

+
+ handleDeductionChange('nationalPension', v)} + /> + handleDeductionChange('healthInsurance', v)} + /> + handleDeductionChange('longTermCare', v)} + /> + handleDeductionChange('employmentInsurance', v)} + /> + + handleDeductionChange('incomeTax', v)} + /> + handleDeductionChange('localIncomeTax', v)} + /> + handleDeductionChange('otherDeduction', v)} + /> +
+
+ +
+ 공제 합계 + -{formatCurrency(totalDeduction)}원 +
+
+
+
+ + {/* 합계 */} +
+
+
+ 급여 총액 + + {formatCurrency(baseSalary + totalAllowance)}원 + +
+
+ 공제 총액 + + -{formatCurrency(totalDeduction)}원 + +
+
+ 실지급액 + + {formatCurrency(netPayment)}원 + +
+
+
+
+ + + + + +
+
+ + {/* 사원 검색 모달 */} + + open={employeeSearchOpen} + onOpenChange={handleSearchOpenChange} + title="사원 검색" + searchPlaceholder="사원명, 사원코드 검색..." + fetchData={handleFetchEmployees} + keyExtractor={(emp) => emp.id} + validateSearch={isValidSearch} + invalidSearchMessage="한글, 영문 또는 숫자 1자 이상 입력하세요" + emptyQueryMessage="사원명 또는 사원코드를 입력하세요" + loadingMessage="사원 검색 중..." + dialogClassName="sm:max-w-[500px]" + infoText={(items, isLoading) => + !isLoading ? ( + + 총 {items.length}명 + + ) : null + } + mode="single" + onSelect={handleSelectEmployee} + renderItem={(employee) => ( +
+
+
+ {employee.name} + {employee.employeeCode && ( + ({employee.employeeCode}) + )} +
+ {employee.rank && ( + + {employee.rank} + + )} +
+ {employee.departmentPositions.length > 0 && ( +

+ {employee.departmentPositions + .map(dp => `${dp.departmentName} / ${dp.positionName}`) + .join(', ')} +

+ )} + {employee.salary && ( +

+ 연봉: {Number(employee.salary).toLocaleString()}원 +

+ )} +
+ )} + /> + + ); +} diff --git a/src/components/hr/SalaryManagement/actions.ts b/src/components/hr/SalaryManagement/actions.ts index 8745b6a8..dba114aa 100644 --- a/src/components/hr/SalaryManagement/actions.ts +++ b/src/components/hr/SalaryManagement/actions.ts @@ -233,6 +233,27 @@ export async function updateSalary( return { success: result.success, data: result.data, error: result.error }; } +// ===== 급여 등록 ===== +export async function createSalary(data: { + employee_id: number; + year: number; + month: number; + base_salary: number; + allowance_details?: Record; + deduction_details?: Record; + payment_date?: string; + status?: PaymentStatus; +}): Promise<{ success: boolean; data?: SalaryDetail; error?: string }> { + const result = await executeServerAction({ + url: buildApiUrl('/api/v1/salaries'), + method: 'POST', + body: data, + transform: (d: SalaryApiData) => transformApiToDetail(d), + errorMessage: '급여 등록에 실패했습니다.', + }); + return { success: result.success, data: result.data, error: result.error }; +} + // ===== 급여 통계 조회 ===== export async function getSalaryStatistics(params?: { year?: number; month?: number; start_date?: string; end_date?: string; diff --git a/src/components/hr/SalaryManagement/index.tsx b/src/components/hr/SalaryManagement/index.tsx index db2fa45a..8f82085c 100644 --- a/src/components/hr/SalaryManagement/index.tsx +++ b/src/components/hr/SalaryManagement/index.tsx @@ -13,6 +13,7 @@ import { MinusCircle, Loader2, Search, + Plus, } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -30,9 +31,11 @@ import { import { usePermission } from '@/hooks/usePermission'; import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; import { SalaryDetailDialog } from './SalaryDetailDialog'; +import { SalaryRegistrationDialog } from './SalaryRegistrationDialog'; import { getSalaries, getSalary, + createSalary, bulkUpdateSalaryStatus, updateSalaryStatus, updateSalary, @@ -51,6 +54,114 @@ import { } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; +// ===== 목 데이터 (API 연동 전 테스트용) ===== +const MOCK_SALARY_RECORDS: SalaryRecord[] = [ + { + id: 'mock-1', + employeeId: 'EMP001', + employeeName: '김철수', + department: '개발팀', + position: '팀장', + rank: '과장', + baseSalary: 3500000, + allowance: 850000, + overtime: 320000, + bonus: 0, + deduction: 542000, + netPayment: 4128000, + paymentDate: '2026-02-25', + status: 'scheduled', + year: 2026, + month: 2, + createdAt: '2026-02-01', + updatedAt: '2026-02-01', + }, + { + id: 'mock-2', + employeeId: 'EMP002', + employeeName: '이영희', + department: '경영지원팀', + position: '사원', + rank: '대리', + baseSalary: 3000000, + allowance: 550000, + overtime: 0, + bonus: 500000, + deduction: 468000, + netPayment: 3582000, + paymentDate: '2026-02-25', + status: 'completed', + year: 2026, + month: 2, + createdAt: '2026-02-01', + updatedAt: '2026-02-20', + }, +]; + +const MOCK_SALARY_DETAILS: Record = { + 'mock-1': { + employeeId: 'EMP001', + employeeName: '김철수', + department: '개발팀', + position: '팀장', + rank: '과장', + baseSalary: 3500000, + allowances: { + positionAllowance: 300000, + overtimeAllowance: 320000, + mealAllowance: 150000, + transportAllowance: 100000, + otherAllowance: 0, + }, + deductions: { + nationalPension: 157500, + healthInsurance: 121450, + longTermCare: 14820, + employmentInsurance: 31500, + incomeTax: 185230, + localIncomeTax: 18520, + otherDeduction: 12980, + }, + totalAllowance: 870000, + totalDeduction: 542000, + netPayment: 4128000, + paymentDate: '2026-02-25', + status: 'scheduled', + year: 2026, + month: 2, + }, + 'mock-2': { + employeeId: 'EMP002', + employeeName: '이영희', + department: '경영지원팀', + position: '사원', + rank: '대리', + baseSalary: 3000000, + allowances: { + positionAllowance: 200000, + overtimeAllowance: 0, + mealAllowance: 150000, + transportAllowance: 100000, + otherAllowance: 100000, + }, + deductions: { + nationalPension: 135000, + healthInsurance: 104100, + longTermCare: 12700, + employmentInsurance: 27000, + incomeTax: 160200, + localIncomeTax: 16020, + otherDeduction: 12980, + }, + totalAllowance: 550000, + totalDeduction: 468000, + netPayment: 3582000, + paymentDate: '2026-02-25', + status: 'completed', + year: 2026, + month: 2, + }, +}; export function SalaryManagement() { const { canExport } = usePermission(); @@ -68,6 +179,7 @@ export function SalaryManagement() { const [detailDialogOpen, setDetailDialogOpen] = useState(false); const [selectedSalaryDetail, setSelectedSalaryDetail] = useState(null); const [selectedSalaryId, setSelectedSalaryId] = useState(null); + const [registrationDialogOpen, setRegistrationDialogOpen] = useState(false); // 데이터 상태 const [salaryData, setSalaryData] = useState([]); @@ -91,17 +203,23 @@ export function SalaryManagement() { per_page: itemsPerPage, }); - if (result.success && result.data) { + if (result.success && result.data && result.data.length > 0) { setSalaryData(result.data); setTotalCount(result.pagination?.total || result.data.length); setTotalPages(result.pagination?.lastPage || 1); } else { - toast.error(result.error || '급여 목록을 불러오는데 실패했습니다.'); + // API 데이터가 없으면 목 데이터 사용 + setSalaryData(MOCK_SALARY_RECORDS); + setTotalCount(MOCK_SALARY_RECORDS.length); + setTotalPages(1); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('loadSalaries error:', error); - toast.error('급여 목록을 불러오는데 실패했습니다.'); + // API 실패 시에도 목 데이터 사용 + setSalaryData(MOCK_SALARY_RECORDS); + setTotalCount(MOCK_SALARY_RECORDS.length); + setTotalPages(1); } finally { setIsLoading(false); isInitialLoadDone.current = true; @@ -190,6 +308,16 @@ export function SalaryManagement() { setSelectedSalaryId(record.id); setIsActionLoading(true); try { + // 목 데이터인 경우 목 상세 데이터 사용 + if (record.id.startsWith('mock-')) { + const mockDetail = MOCK_SALARY_DETAILS[record.id]; + if (mockDetail) { + setSelectedSalaryDetail(mockDetail); + setDetailDialogOpen(true); + } + return; + } + const result = await getSalary(record.id); if (result.success && result.data) { setSelectedSalaryDetail(result.data); @@ -209,16 +337,36 @@ export function SalaryManagement() { // ===== 급여 상세 저장 핸들러 ===== const handleSaveDetail = useCallback(async ( updatedDetail: SalaryDetail, - allowanceDetails?: Record + allowanceDetails?: Record, + deductionDetails?: Record ) => { if (!selectedSalaryId) return; setIsActionLoading(true); try { - // 수당 정보가 변경된 경우 updateSalary API 호출 - if (allowanceDetails) { + // 목 데이터인 경우 로컬 상태만 업데이트 + if (selectedSalaryId.startsWith('mock-')) { + setSalaryData(prev => prev.map(item => + item.id === selectedSalaryId + ? { + ...item, + status: updatedDetail.status, + allowance: updatedDetail.totalAllowance, + deduction: updatedDetail.totalDeduction, + netPayment: updatedDetail.netPayment, + } + : item + )); + toast.success('급여 정보가 저장되었습니다. (목 데이터)'); + setDetailDialogOpen(false); + return; + } + + // 수당/공제 정보가 변경된 경우 updateSalary API 호출 + if (allowanceDetails || deductionDetails) { const result = await updateSalary(selectedSalaryId, { allowance_details: allowanceDetails, + deduction_details: deductionDetails, status: updatedDetail.status, }); if (result.success) { @@ -250,10 +398,79 @@ export function SalaryManagement() { // ===== 지급항목 추가 핸들러 ===== const handleAddPaymentItem = useCallback(() => { - // TODO: 지급항목 추가 다이얼로그 또는 로직 구현 toast.info('지급항목 추가 기능은 준비 중입니다.'); }, []); + // ===== 급여 등록 핸들러 ===== + const handleCreateSalary = useCallback(async (data: { + employeeId: number; + employeeName: string; + department: string; + position: string; + rank: string; + year: number; + month: number; + baseSalary: number; + paymentDate: string; + allowances: Record; + deductions: Record; + }) => { + setIsActionLoading(true); + try { + const result = await createSalary({ + employee_id: data.employeeId, + year: data.year, + month: data.month, + base_salary: data.baseSalary, + allowance_details: data.allowances, + deduction_details: data.deductions, + payment_date: data.paymentDate || undefined, + status: 'scheduled', + }); + + if (result.success) { + toast.success('급여가 등록되었습니다.'); + setRegistrationDialogOpen(false); + await loadSalaries(); + } else { + // API 실패 시 목 데이터로 로컬 추가 + const totalAllowance = Object.values(data.allowances).reduce((s, v) => s + v, 0); + const totalDeduction = Object.values(data.deductions).reduce((s, v) => s + v, 0); + const mockId = `mock-${Date.now()}`; + const newRecord: SalaryRecord = { + id: mockId, + employeeId: String(data.employeeId), + employeeName: data.employeeName, + department: data.department, + position: data.position, + rank: data.rank, + baseSalary: data.baseSalary, + allowance: totalAllowance, + overtime: data.allowances.overtime_allowance || 0, + bonus: 0, + deduction: totalDeduction, + netPayment: data.baseSalary + totalAllowance - totalDeduction, + paymentDate: data.paymentDate, + status: 'scheduled', + year: data.year, + month: data.month, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + setSalaryData(prev => [...prev, newRecord]); + setTotalCount(prev => prev + 1); + toast.success('급여가 등록되었습니다. (목 데이터)'); + setRegistrationDialogOpen(false); + } + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('handleCreateSalary error:', error); + toast.error('급여 등록에 실패했습니다.'); + } finally { + setIsActionLoading(false); + } + }, [loadSalaries]); + // ===== 통계 카드 (총 실지급액, 총 기본급, 총 수당, 초과근무, 상여, 총공제) ===== const statCards: StatCard[] = useMemo(() => { const totalNetPayment = salaryData.reduce((sum, s) => sum + s.netPayment, 0); @@ -446,7 +663,12 @@ export function SalaryManagement() { ), - headerActions: () => null, + headerActions: () => ( + + ), renderTableRow: (item, index, globalIndex, handlers) => { const { isSelected, onToggle } = handlers; @@ -513,12 +735,19 @@ export function SalaryManagement() { }, renderDialogs: (_params) => ( - + <> + + + ), }), [ salaryData, @@ -538,6 +767,8 @@ export function SalaryManagement() { selectedSalaryDetail, handleSaveDetail, handleAddPaymentItem, + registrationDialogOpen, + handleCreateSalary, ]); return (