chore: 백업/레거시 파일 정리 (-9,927줄)
- approval_backup_v1/ 전체 삭제 (27파일) - SalaryManagement_backup_20260312/ 삭제 (5파일) - AccountManagement/_legacy/ 삭제 - vehicle/types.ts 삭제
This commit is contained in:
@@ -1,524 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Save, Pencil, X } from 'lucide-react';
|
||||
import type { SalaryDetail, PaymentStatus } from './types';
|
||||
import {
|
||||
PAYMENT_STATUS_LABELS,
|
||||
PAYMENT_STATUS_COLORS,
|
||||
formatCurrency,
|
||||
} from './types';
|
||||
|
||||
interface AllowanceEdits {
|
||||
positionAllowance: number;
|
||||
overtimeAllowance: number;
|
||||
mealAllowance: number;
|
||||
transportAllowance: number;
|
||||
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<string, number>, deductionDetails?: Record<string, number>) => 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 (
|
||||
<div className={editing ? 'flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2' : 'flex items-center gap-2'}>
|
||||
<span className={editing ? 'text-muted-foreground whitespace-nowrap text-xs sm:text-sm sm:w-24 sm:shrink-0' : 'text-muted-foreground whitespace-nowrap w-20 sm:w-24 shrink-0'}>{label}</span>
|
||||
<div className="flex-1 text-right">
|
||||
{editing ? (
|
||||
<CurrencyInput
|
||||
value={editValue ?? 0}
|
||||
onChange={(v) => onChange!(v ?? 0)}
|
||||
className="w-full h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className={color}>{prefix}{value}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SalaryDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
salaryDetail,
|
||||
onSave,
|
||||
}: SalaryDetailDialogProps) {
|
||||
const [editedStatus, setEditedStatus] = useState<PaymentStatus>('scheduled');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedAllowances, setEditedAllowances] = useState<AllowanceEdits>({
|
||||
positionAllowance: 0,
|
||||
overtimeAllowance: 0,
|
||||
mealAllowance: 0,
|
||||
transportAllowance: 0,
|
||||
otherAllowance: 0,
|
||||
});
|
||||
const [editedDeductions, setEditedDeductions] = useState<DeductionEdits>({
|
||||
nationalPension: 0,
|
||||
healthInsurance: 0,
|
||||
longTermCare: 0,
|
||||
employmentInsurance: 0,
|
||||
incomeTax: 0,
|
||||
localIncomeTax: 0,
|
||||
otherDeduction: 0,
|
||||
});
|
||||
|
||||
// 다이얼로그가 열릴 때 상태 초기화
|
||||
useEffect(() => {
|
||||
if (salaryDetail) {
|
||||
setEditedStatus(salaryDetail.status);
|
||||
setEditedAllowances({
|
||||
positionAllowance: salaryDetail.allowances.positionAllowance,
|
||||
overtimeAllowance: salaryDetail.allowances.overtimeAllowance,
|
||||
mealAllowance: salaryDetail.allowances.mealAllowance,
|
||||
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);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [salaryDetail]);
|
||||
|
||||
// 변경 사항 확인
|
||||
const checkForChanges = useCallback(() => {
|
||||
if (!salaryDetail) return false;
|
||||
|
||||
const statusChanged = editedStatus !== salaryDetail.status;
|
||||
const allowancesChanged =
|
||||
editedAllowances.positionAllowance !== salaryDetail.allowances.positionAllowance ||
|
||||
editedAllowances.overtimeAllowance !== salaryDetail.allowances.overtimeAllowance ||
|
||||
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 || deductionsChanged;
|
||||
}, [salaryDetail, editedStatus, editedAllowances, editedDeductions]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasChanges(checkForChanges());
|
||||
}, [checkForChanges]);
|
||||
|
||||
if (!salaryDetail) return null;
|
||||
|
||||
const handleAllowanceChange = (field: keyof AllowanceEdits, value: number) => {
|
||||
setEditedAllowances(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleDeductionChange = (field: keyof DeductionEdits, value: number) => {
|
||||
setEditedDeductions(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 수당 합계 계산
|
||||
const calculateTotalAllowance = () => {
|
||||
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() - calculateTotalDeduction();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave && salaryDetail) {
|
||||
const allowanceDetails = {
|
||||
position_allowance: editedAllowances.positionAllowance,
|
||||
overtime_allowance: editedAllowances.overtimeAllowance,
|
||||
meal_allowance: editedAllowances.mealAllowance,
|
||||
transport_allowance: editedAllowances.transportAllowance,
|
||||
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, deductionDetails);
|
||||
}
|
||||
setHasChanges(false);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleToggleEdit = () => {
|
||||
if (isEditing) {
|
||||
// 편집 취소 - 원래 값으로 복원
|
||||
setEditedAllowances({
|
||||
positionAllowance: salaryDetail.allowances.positionAllowance,
|
||||
overtimeAllowance: salaryDetail.allowances.overtimeAllowance,
|
||||
mealAllowance: salaryDetail.allowances.mealAllowance,
|
||||
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,
|
||||
});
|
||||
}
|
||||
setIsEditing(!isEditing);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<span className="truncate">{salaryDetail.employeeName} 급여</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant={isEditing ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleToggleEdit}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
편집 취소
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
급여 수정
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Select
|
||||
value={editedStatus}
|
||||
onValueChange={(value) => setEditedStatus(value as PaymentStatus)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[100px] sm:min-w-[140px] w-auto">
|
||||
<SelectValue>
|
||||
<Badge className={PAYMENT_STATUS_COLORS[editedStatus]}>
|
||||
{PAYMENT_STATUS_LABELS[editedStatus]}
|
||||
</Badge>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="scheduled">
|
||||
<Badge className={PAYMENT_STATUS_COLORS.scheduled}>
|
||||
{PAYMENT_STATUS_LABELS.scheduled}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
<SelectItem value="completed">
|
||||
<Badge className={PAYMENT_STATUS_COLORS.completed}>
|
||||
{PAYMENT_STATUS_LABELS.completed}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-muted/50 rounded-lg p-3 sm:p-4">
|
||||
<h3 className="font-semibold mb-3">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">사번</span>
|
||||
<p className="font-medium">{salaryDetail.employeeId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">이름</span>
|
||||
<p className="font-medium">{salaryDetail.employeeName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">부서</span>
|
||||
<p className="font-medium">{salaryDetail.department}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직급</span>
|
||||
<p className="font-medium">{salaryDetail.rank}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직책</span>
|
||||
<p className="font-medium">{salaryDetail.position}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">지급월</span>
|
||||
<p className="font-medium">{salaryDetail.year}년 {salaryDetail.month}월</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">지급일</span>
|
||||
<p className="font-medium">{salaryDetail.paymentDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 급여 항목 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 수당 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-blue-600">수당 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<DetailRow
|
||||
label="본봉"
|
||||
value={`${formatCurrency(salaryDetail.baseSalary)}원`}
|
||||
/>
|
||||
<Separator />
|
||||
<DetailRow
|
||||
label="직책수당"
|
||||
value={`${formatCurrency(editedAllowances.positionAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.positionAllowance}
|
||||
onChange={(v) => handleAllowanceChange('positionAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="초과근무수당"
|
||||
value={`${formatCurrency(editedAllowances.overtimeAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.overtimeAllowance}
|
||||
onChange={(v) => handleAllowanceChange('overtimeAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="식대"
|
||||
value={`${formatCurrency(editedAllowances.mealAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.mealAllowance}
|
||||
onChange={(v) => handleAllowanceChange('mealAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="교통비"
|
||||
value={`${formatCurrency(editedAllowances.transportAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.transportAllowance}
|
||||
onChange={(v) => handleAllowanceChange('transportAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="기타수당"
|
||||
value={`${formatCurrency(editedAllowances.otherAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.otherAllowance}
|
||||
onChange={(v) => handleAllowanceChange('otherAllowance', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-blue-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">수당 합계</span>
|
||||
<span className="flex-1 text-right">{formatCurrency(calculateTotalAllowance())}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공제 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-red-600">공제 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<DetailRow
|
||||
label="국민연금"
|
||||
value={`${formatCurrency(editedDeductions.nationalPension)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.nationalPension}
|
||||
onChange={(v) => handleDeductionChange('nationalPension', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="건강보험"
|
||||
value={`${formatCurrency(editedDeductions.healthInsurance)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.healthInsurance}
|
||||
onChange={(v) => handleDeductionChange('healthInsurance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="장기요양보험"
|
||||
value={`${formatCurrency(editedDeductions.longTermCare)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.longTermCare}
|
||||
onChange={(v) => handleDeductionChange('longTermCare', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="고용보험"
|
||||
value={`${formatCurrency(editedDeductions.employmentInsurance)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.employmentInsurance}
|
||||
onChange={(v) => handleDeductionChange('employmentInsurance', v)}
|
||||
/>
|
||||
<Separator />
|
||||
<DetailRow
|
||||
label="소득세"
|
||||
value={`${formatCurrency(editedDeductions.incomeTax)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.incomeTax}
|
||||
onChange={(v) => handleDeductionChange('incomeTax', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="지방소득세"
|
||||
value={`${formatCurrency(editedDeductions.localIncomeTax)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.localIncomeTax}
|
||||
onChange={(v) => handleDeductionChange('localIncomeTax', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="기타공제"
|
||||
value={`${formatCurrency(editedDeductions.otherDeduction)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.otherDeduction}
|
||||
onChange={(v) => handleDeductionChange('otherDeduction', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-red-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">공제 합계</span>
|
||||
<span className="flex-1 text-right">-{formatCurrency(calculateTotalDeduction())}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지급 합계 */}
|
||||
<div className="bg-primary/5 border-2 border-primary/20 rounded-lg p-3 sm:p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4 text-center">
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">급여 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-blue-600">
|
||||
{formatCurrency(salaryDetail.baseSalary + calculateTotalAllowance())}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">공제 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-red-600">
|
||||
-{formatCurrency(calculateTotalDeduction())}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">실지급액</span>
|
||||
<span className="text-base sm:text-xl font-bold text-primary">
|
||||
{formatCurrency(calculateNetPayment())}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges}
|
||||
className="gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,571 +0,0 @@
|
||||
'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: _prefix,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
prefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
|
||||
<span className="text-muted-foreground whitespace-nowrap text-xs sm:text-sm sm:w-24 sm:shrink-0">{label}</span>
|
||||
<div className="flex-1">
|
||||
<CurrencyInput
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? 0)}
|
||||
className="w-full h-7 text-sm text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 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<string, number>;
|
||||
deductions: Record<string, number>;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// ===== 컴포넌트 =====
|
||||
export function SalaryRegistrationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: SalaryRegistrationDialogProps) {
|
||||
// 사원 선택
|
||||
const [employeeSearchOpen, setEmployeeSearchOpen] = useState(false);
|
||||
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(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 (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
급여 등록
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 사원 선택 */}
|
||||
<div className="bg-muted/50 rounded-lg p-3 sm:p-4">
|
||||
<h3 className="font-semibold mb-3">기본 정보</h3>
|
||||
|
||||
{/* 사원 선택 버튼 */}
|
||||
{!selectedEmployee ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-12 border-dashed"
|
||||
onClick={() => handleSearchOpenChange(true)}
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
사원 검색 (클릭하여 선택)
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">사번</span>
|
||||
<p className="font-medium">{selectedEmployee.employeeCode || selectedEmployee.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">이름</span>
|
||||
<p className="font-medium">{selectedEmployee.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">부서</span>
|
||||
<p className="font-medium">
|
||||
{selectedEmployee.departmentPositions?.[0]?.departmentName || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직급</span>
|
||||
<p className="font-medium">{selectedEmployee.rank || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직책</span>
|
||||
<p className="font-medium">
|
||||
{selectedEmployee.departmentPositions?.[0]?.positionName || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={() => handleSearchOpenChange(true)}
|
||||
>
|
||||
다른 사원 선택
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 지급월 / 지급일 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block mb-1">년도</span>
|
||||
<Select value={String(year)} onValueChange={(v) => setYear(Number(v))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}년</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block mb-1">월</span>
|
||||
<Select value={String(month)} onValueChange={(v) => setMonth(Number(v))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthOptions.map(m => (
|
||||
<SelectItem key={m} value={String(m)}>{m}월</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<span className="text-sm text-muted-foreground block mb-1">지급일</span>
|
||||
<DatePicker
|
||||
value={paymentDate}
|
||||
onChange={setPaymentDate}
|
||||
placeholder="지급일 선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본급 */}
|
||||
<div className="mt-4">
|
||||
<span className="text-sm text-muted-foreground block mb-1">기본급 (월)</span>
|
||||
<CurrencyInput
|
||||
value={baseSalary}
|
||||
onChange={(v) => {
|
||||
const newSalary = v ?? 0;
|
||||
setBaseSalary(newSalary);
|
||||
if (newSalary > 0) {
|
||||
setDeductions(calculateDefaultDeductions(newSalary));
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
{selectedEmployee?.salary && (
|
||||
<span className="text-xs text-muted-foreground mt-1 block">
|
||||
연봉 {formatCurrency(selectedEmployee.salary)}원 기준
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수당 / 공제 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 수당 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-blue-600">수당 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<EditableRow
|
||||
label="직책수당"
|
||||
value={allowances.positionAllowance}
|
||||
onChange={(v) => handleAllowanceChange('positionAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="초과근무수당"
|
||||
value={allowances.overtimeAllowance}
|
||||
onChange={(v) => handleAllowanceChange('overtimeAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="식대"
|
||||
value={allowances.mealAllowance}
|
||||
onChange={(v) => handleAllowanceChange('mealAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="교통비"
|
||||
value={allowances.transportAllowance}
|
||||
onChange={(v) => handleAllowanceChange('transportAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="기타수당"
|
||||
value={allowances.otherAllowance}
|
||||
onChange={(v) => handleAllowanceChange('otherAllowance', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-blue-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">수당 합계</span>
|
||||
<span className="flex-1 text-right">{formatCurrency(totalAllowance)}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공제 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-red-600">공제 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<EditableRow
|
||||
label="국민연금"
|
||||
value={deductions.nationalPension}
|
||||
onChange={(v) => handleDeductionChange('nationalPension', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="건강보험"
|
||||
value={deductions.healthInsurance}
|
||||
onChange={(v) => handleDeductionChange('healthInsurance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="장기요양보험"
|
||||
value={deductions.longTermCare}
|
||||
onChange={(v) => handleDeductionChange('longTermCare', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="고용보험"
|
||||
value={deductions.employmentInsurance}
|
||||
onChange={(v) => handleDeductionChange('employmentInsurance', v)}
|
||||
/>
|
||||
<Separator />
|
||||
<EditableRow
|
||||
label="소득세"
|
||||
value={deductions.incomeTax}
|
||||
onChange={(v) => handleDeductionChange('incomeTax', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="지방소득세"
|
||||
value={deductions.localIncomeTax}
|
||||
onChange={(v) => handleDeductionChange('localIncomeTax', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="기타공제"
|
||||
value={deductions.otherDeduction}
|
||||
onChange={(v) => handleDeductionChange('otherDeduction', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-red-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">공제 합계</span>
|
||||
<span className="flex-1 text-right">-{formatCurrency(totalDeduction)}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="bg-primary/5 border-2 border-primary/20 rounded-lg p-3 sm:p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4 text-center">
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">급여 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-blue-600">
|
||||
{formatCurrency(baseSalary + totalAllowance)}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">공제 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-red-600">
|
||||
-{formatCurrency(totalDeduction)}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">실지급액</span>
|
||||
<span className="text-base sm:text-xl font-bold text-primary">
|
||||
{formatCurrency(netPayment)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!canSave} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 사원 검색 모달 */}
|
||||
<SearchableSelectionModal<Employee>
|
||||
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 ? (
|
||||
<span className="text-xs text-gray-400 text-right block">
|
||||
총 {items.length}명
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
mode="single"
|
||||
onSelect={handleSelectEmployee}
|
||||
renderItem={(employee) => (
|
||||
<div className="p-3 hover:bg-blue-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-900">{employee.name}</span>
|
||||
{employee.employeeCode && (
|
||||
<span className="text-xs text-gray-400 ml-2">({employee.employeeCode})</span>
|
||||
)}
|
||||
</div>
|
||||
{employee.rank && (
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{employee.rank}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{employee.departmentPositions.length > 0 && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{employee.departmentPositions
|
||||
.map(dp => `${dp.departmentName} / ${dp.positionName}`)
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{employee.salary && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
연봉: {Number(employee.salary).toLocaleString()}원
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { SalaryRecord, SalaryDetail, PaymentStatus } from './types';
|
||||
|
||||
// API 응답 타입
|
||||
interface SalaryApiData {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
employee_id: number;
|
||||
year: number;
|
||||
month: number;
|
||||
base_salary: string;
|
||||
total_allowance: string;
|
||||
total_overtime: string;
|
||||
total_bonus: string;
|
||||
total_deduction: string;
|
||||
net_payment: string;
|
||||
allowance_details: Record<string, number> | null;
|
||||
deduction_details: Record<string, number> | null;
|
||||
payment_date: string | null;
|
||||
status: 'scheduled' | 'completed';
|
||||
employee?: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_id?: string;
|
||||
email?: string;
|
||||
} | null;
|
||||
employee_profile?: {
|
||||
id: number;
|
||||
department_id: number | null;
|
||||
position_key: string | null;
|
||||
job_title_key: string | null;
|
||||
position_label: string | null;
|
||||
job_title_label: string | null;
|
||||
rank: string | null;
|
||||
department?: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
} | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface SalaryPaginationData {
|
||||
data: SalaryApiData[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface StatisticsApiData {
|
||||
total_net_payment: number;
|
||||
total_base_salary: number;
|
||||
total_allowance: number;
|
||||
total_overtime: number;
|
||||
total_bonus: number;
|
||||
total_deduction: number;
|
||||
count: number;
|
||||
scheduled_count: number;
|
||||
completed_count: number;
|
||||
}
|
||||
|
||||
// API → Frontend 변환 (목록용)
|
||||
function transformApiToFrontend(apiData: SalaryApiData): SalaryRecord {
|
||||
const profile = apiData.employee_profile;
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
employeeId: apiData.employee?.user_id || `EMP${String(apiData.employee_id).padStart(3, '0')}`,
|
||||
employeeName: apiData.employee?.name || '-',
|
||||
department: profile?.department?.name || '-',
|
||||
position: profile?.job_title_label || '-',
|
||||
rank: profile?.rank || '-',
|
||||
baseSalary: parseFloat(apiData.base_salary),
|
||||
allowance: parseFloat(apiData.total_allowance),
|
||||
overtime: parseFloat(apiData.total_overtime),
|
||||
bonus: parseFloat(apiData.total_bonus),
|
||||
deduction: parseFloat(apiData.total_deduction),
|
||||
netPayment: parseFloat(apiData.net_payment),
|
||||
paymentDate: apiData.payment_date || '',
|
||||
status: apiData.status as PaymentStatus,
|
||||
year: apiData.year,
|
||||
month: apiData.month,
|
||||
createdAt: apiData.created_at,
|
||||
updatedAt: apiData.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// API → Frontend 변환 (상세용)
|
||||
function transformApiToDetail(apiData: SalaryApiData): SalaryDetail {
|
||||
const allowanceDetails = apiData.allowance_details || {};
|
||||
const deductionDetails = apiData.deduction_details || {};
|
||||
const profile = apiData.employee_profile;
|
||||
|
||||
return {
|
||||
employeeId: apiData.employee?.user_id || `EMP${String(apiData.employee_id).padStart(3, '0')}`,
|
||||
employeeName: apiData.employee?.name || '-',
|
||||
department: profile?.department?.name || '-',
|
||||
position: profile?.job_title_label || '-',
|
||||
rank: profile?.rank || '-',
|
||||
baseSalary: parseFloat(apiData.base_salary),
|
||||
allowances: {
|
||||
positionAllowance: allowanceDetails.position_allowance || 0,
|
||||
overtimeAllowance: allowanceDetails.overtime_allowance || 0,
|
||||
mealAllowance: allowanceDetails.meal_allowance || 0,
|
||||
transportAllowance: allowanceDetails.transport_allowance || 0,
|
||||
otherAllowance: allowanceDetails.other_allowance || 0,
|
||||
},
|
||||
deductions: {
|
||||
nationalPension: deductionDetails.national_pension || 0,
|
||||
healthInsurance: deductionDetails.health_insurance || 0,
|
||||
longTermCare: deductionDetails.long_term_care || 0,
|
||||
employmentInsurance: deductionDetails.employment_insurance || 0,
|
||||
incomeTax: deductionDetails.income_tax || 0,
|
||||
localIncomeTax: deductionDetails.local_income_tax || 0,
|
||||
otherDeduction: deductionDetails.other_deduction || 0,
|
||||
},
|
||||
totalAllowance: parseFloat(apiData.total_allowance),
|
||||
totalDeduction: parseFloat(apiData.total_deduction),
|
||||
netPayment: parseFloat(apiData.net_payment),
|
||||
paymentDate: apiData.payment_date || '',
|
||||
status: apiData.status as PaymentStatus,
|
||||
year: apiData.year,
|
||||
month: apiData.month,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 급여 목록 조회 =====
|
||||
export async function getSalaries(params?: {
|
||||
search?: string; year?: number; month?: number; status?: string;
|
||||
employee_id?: number; start_date?: string; end_date?: string;
|
||||
page?: number; per_page?: number;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: SalaryRecord[];
|
||||
pagination?: { total: number; currentPage: number; lastPage: number };
|
||||
error?: string
|
||||
}> {
|
||||
const result = await executeServerAction<SalaryPaginationData>({
|
||||
url: buildApiUrl('/api/v1/salaries', {
|
||||
search: params?.search,
|
||||
year: params?.year,
|
||||
month: params?.month,
|
||||
status: params?.status && params.status !== 'all' ? params.status : undefined,
|
||||
employee_id: params?.employee_id,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
page: params?.page,
|
||||
per_page: params?.per_page,
|
||||
}),
|
||||
errorMessage: '급여 목록을 불러오는데 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
pagination: {
|
||||
total: result.data.total,
|
||||
currentPage: result.data.current_page,
|
||||
lastPage: result.data.last_page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 급여 상세 조회 =====
|
||||
export async function getSalary(id: string): Promise<{
|
||||
success: boolean; data?: SalaryDetail; error?: string
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/salaries/${id}`),
|
||||
transform: (data: SalaryApiData) => transformApiToDetail(data),
|
||||
errorMessage: '급여 정보를 불러오는데 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 상태 변경 =====
|
||||
export async function updateSalaryStatus(
|
||||
id: string,
|
||||
status: PaymentStatus
|
||||
): Promise<{ success: boolean; data?: SalaryRecord; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/salaries/${id}/status`),
|
||||
method: 'PATCH',
|
||||
body: { status },
|
||||
transform: (data: SalaryApiData) => transformApiToFrontend(data),
|
||||
errorMessage: '상태 변경에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 일괄 상태 변경 =====
|
||||
export async function bulkUpdateSalaryStatus(
|
||||
ids: string[],
|
||||
status: PaymentStatus
|
||||
): Promise<{ success: boolean; updatedCount?: number; error?: string }> {
|
||||
const result = await executeServerAction<{ updated_count: number }>({
|
||||
url: buildApiUrl('/api/v1/salaries/bulk-update-status'),
|
||||
method: 'POST',
|
||||
body: { ids: ids.map(id => parseInt(id, 10)), status },
|
||||
errorMessage: '일괄 상태 변경에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, updatedCount: result.data.updated_count };
|
||||
}
|
||||
|
||||
// ===== 급여 수정 =====
|
||||
export async function updateSalary(
|
||||
id: string,
|
||||
data: {
|
||||
base_salary?: number;
|
||||
allowance_details?: Record<string, number>;
|
||||
deduction_details?: Record<string, number>;
|
||||
status?: PaymentStatus;
|
||||
payment_date?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; data?: SalaryDetail; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/salaries/${id}`),
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
transform: (d: SalaryApiData) => transformApiToDetail(d),
|
||||
errorMessage: '급여 수정에 실패했습니다.',
|
||||
});
|
||||
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<string, number>;
|
||||
deduction_details?: Record<string, number>;
|
||||
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;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
totalNetPayment: number; totalBaseSalary: number; totalAllowance: number;
|
||||
totalOvertime: number; totalBonus: number; totalDeduction: number;
|
||||
count: number; scheduledCount: number; completedCount: number;
|
||||
};
|
||||
error?: string
|
||||
}> {
|
||||
const result = await executeServerAction<StatisticsApiData>({
|
||||
url: buildApiUrl('/api/v1/salaries/statistics', {
|
||||
year: params?.year,
|
||||
month: params?.month,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
}),
|
||||
errorMessage: '통계 정보를 불러오는데 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalNetPayment: result.data.total_net_payment,
|
||||
totalBaseSalary: result.data.total_base_salary,
|
||||
totalAllowance: result.data.total_allowance,
|
||||
totalOvertime: result.data.total_overtime,
|
||||
totalBonus: result.data.total_bonus,
|
||||
totalDeduction: result.data.total_deduction,
|
||||
count: result.data.count,
|
||||
scheduledCount: result.data.scheduled_count,
|
||||
completedCount: result.data.completed_count,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 급여 엑셀 내보내기 (native fetch - keep as-is) =====
|
||||
export async function exportSalaryExcel(params?: {
|
||||
year?: number;
|
||||
month?: number;
|
||||
status?: string;
|
||||
employee_id?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: Blob;
|
||||
filename?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
};
|
||||
|
||||
const url = buildApiUrl('/api/v1/salaries/export', {
|
||||
year: params?.year,
|
||||
month: params?.month,
|
||||
status: params?.status && params.status !== 'all' ? params.status : undefined,
|
||||
employee_id: params?.employee_id,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: `API 오류: ${response.status}` };
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filenameMatch = contentDisposition?.match(/filename="?(.+)"?/);
|
||||
const filename = filenameMatch?.[1] || `급여명세_${params?.year || 'all'}_${params?.month || 'all'}.xlsx`;
|
||||
|
||||
return { success: true, data: blob, filename };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -1,793 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
DollarSign,
|
||||
Check,
|
||||
Clock,
|
||||
Banknote,
|
||||
Briefcase,
|
||||
Timer,
|
||||
Gift,
|
||||
MinusCircle,
|
||||
Loader2,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { 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,
|
||||
} from './actions';
|
||||
import type {
|
||||
SalaryRecord,
|
||||
SalaryDetail,
|
||||
SortOption,
|
||||
} from './types';
|
||||
import {
|
||||
PAYMENT_STATUS_LABELS,
|
||||
PAYMENT_STATUS_COLORS,
|
||||
SORT_OPTIONS,
|
||||
formatCurrency,
|
||||
} 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<string, SalaryDetail> = {
|
||||
'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: _canExport } = usePermission();
|
||||
// ===== 상태 관리 =====
|
||||
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, endDate, setStartDate, setEndDate } = useDateRange('currentMonth');
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [selectedSalaryDetail, setSelectedSalaryDetail] = useState<SalaryDetail | null>(null);
|
||||
const [selectedSalaryId, setSelectedSalaryId] = useState<string | null>(null);
|
||||
const [registrationDialogOpen, setRegistrationDialogOpen] = useState(false);
|
||||
|
||||
// 데이터 상태
|
||||
const [salaryData, setSalaryData] = useState<SalaryRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const isInitialLoadDone = useRef(false);
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadSalaries = useCallback(async () => {
|
||||
if (!isInitialLoadDone.current) {
|
||||
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 && result.data.length > 0) {
|
||||
setSalaryData(result.data);
|
||||
setTotalCount(result.pagination?.total || result.data.length);
|
||||
setTotalPages(result.pagination?.lastPage || 1);
|
||||
} else {
|
||||
// API 데이터가 없으면 목 데이터 사용
|
||||
setSalaryData(MOCK_SALARY_RECORDS);
|
||||
setTotalCount(MOCK_SALARY_RECORDS.length);
|
||||
setTotalPages(1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('loadSalaries error:', error);
|
||||
// API 실패 시에도 목 데이터 사용
|
||||
setSalaryData(MOCK_SALARY_RECORDS);
|
||||
setTotalCount(MOCK_SALARY_RECORDS.length);
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadDone.current = true;
|
||||
}
|
||||
}, [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 {
|
||||
// 목 데이터인 경우 목 상세 데이터 사용
|
||||
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);
|
||||
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>,
|
||||
deductionDetails?: Record<string, number>
|
||||
) => {
|
||||
if (!selectedSalaryId) return;
|
||||
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
// 목 데이터인 경우 로컬 상태만 업데이트
|
||||
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) {
|
||||
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(() => {
|
||||
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<string, number>;
|
||||
deductions: Record<string, number>;
|
||||
}) => {
|
||||
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);
|
||||
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: '부서', sortable: true, copyable: true },
|
||||
{ key: 'position', label: '직책', sortable: true, copyable: true },
|
||||
{ key: 'name', label: '이름', sortable: true, copyable: true },
|
||||
{ key: 'rank', label: '직급', sortable: true, copyable: true },
|
||||
{ key: 'baseSalary', label: '기본급', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'allowance', label: '수당', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'overtime', label: '초과근무', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'bonus', label: '상여', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'deduction', label: '공제', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'netPayment', label: '실지급액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'paymentDate', label: '일자', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center', sortable: true },
|
||||
], []);
|
||||
|
||||
// ===== 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,
|
||||
|
||||
// 엑셀 다운로드 설정
|
||||
excelDownload: {
|
||||
columns: [
|
||||
{ header: '부서', key: 'department' },
|
||||
{ header: '직책', key: 'position' },
|
||||
{ header: '이름', key: 'employeeName' },
|
||||
{ header: '직급', key: 'rank' },
|
||||
{ header: '기본급', key: 'baseSalary' },
|
||||
{ header: '수당', key: 'allowance' },
|
||||
{ header: '초과근무', key: 'overtime' },
|
||||
{ header: '상여', key: 'bonus' },
|
||||
{ header: '공제', key: 'deduction' },
|
||||
{ header: '실지급액', key: 'netPayment' },
|
||||
{ header: '지급일', key: 'paymentDate' },
|
||||
{ header: '상태', key: 'status', transform: (value: unknown) => value === 'completed' ? '지급완료' : '지급예정' },
|
||||
],
|
||||
filename: '급여명세',
|
||||
sheetName: '급여',
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 날짜 범위 선택 (DateRangeSelector 사용)
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
selectionActions: () => (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
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
|
||||
size="sm"
|
||||
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>
|
||||
</>
|
||||
),
|
||||
|
||||
headerActions: () => (
|
||||
<Button size="sm" onClick={() => setRegistrationDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
급여 등록
|
||||
</Button>
|
||||
),
|
||||
|
||||
renderTableRow: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleViewDetail(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<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>
|
||||
</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}
|
||||
onClick={() => handleViewDetail(item)}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
renderDialogs: (_params) => (
|
||||
<>
|
||||
<SalaryDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
salaryDetail={selectedSalaryDetail}
|
||||
onSave={handleSaveDetail}
|
||||
/>
|
||||
<SalaryRegistrationDialog
|
||||
open={registrationDialogOpen}
|
||||
onOpenChange={setRegistrationDialogOpen}
|
||||
onSave={handleCreateSalary}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
}), [
|
||||
salaryData,
|
||||
totalCount,
|
||||
totalPages,
|
||||
tableColumns,
|
||||
filterConfig,
|
||||
filterValues,
|
||||
statCards,
|
||||
startDate,
|
||||
endDate,
|
||||
handleMarkCompleted,
|
||||
handleMarkScheduled,
|
||||
isActionLoading,
|
||||
handleViewDetail,
|
||||
detailDialogOpen,
|
||||
selectedSalaryDetail,
|
||||
handleSaveDetail,
|
||||
handleAddPaymentItem,
|
||||
registrationDialogOpen,
|
||||
handleCreateSalary,
|
||||
]);
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* 급여관리 타입 정의
|
||||
*/
|
||||
|
||||
// 급여 상태 타입
|
||||
export type PaymentStatus = 'scheduled' | 'completed';
|
||||
|
||||
// 정렬 옵션 타입
|
||||
export type SortOption = 'rank' | 'name' | 'department' | 'paymentDate';
|
||||
|
||||
// 급여 레코드 인터페이스
|
||||
export interface SalaryRecord {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
baseSalary: number; // 기본급
|
||||
allowance: number; // 수당
|
||||
overtime: number; // 초과근무
|
||||
bonus: number; // 상여
|
||||
deduction: number; // 공제
|
||||
netPayment: number; // 실지급액
|
||||
paymentDate: string; // 지급일
|
||||
status: PaymentStatus; // 상태
|
||||
year: number; // 년도
|
||||
month: number; // 월
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 급여 상세 정보 인터페이스
|
||||
export interface SalaryDetail {
|
||||
// 기본 정보
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
|
||||
// 급여 정보
|
||||
baseSalary: number; // 본봉
|
||||
|
||||
// 수당 내역
|
||||
allowances: {
|
||||
positionAllowance: number; // 직책수당
|
||||
overtimeAllowance: number; // 초과근무수당
|
||||
mealAllowance: number; // 식대
|
||||
transportAllowance: number; // 교통비
|
||||
otherAllowance: number; // 기타수당
|
||||
};
|
||||
|
||||
// 공제 내역
|
||||
deductions: {
|
||||
nationalPension: number; // 국민연금
|
||||
healthInsurance: number; // 건강보험
|
||||
longTermCare: number; // 장기요양보험
|
||||
employmentInsurance: number; // 고용보험
|
||||
incomeTax: number; // 소득세
|
||||
localIncomeTax: number; // 지방소득세
|
||||
otherDeduction: number; // 기타공제
|
||||
};
|
||||
|
||||
// 합계
|
||||
totalAllowance: number; // 수당 합계
|
||||
totalDeduction: number; // 공제 합계
|
||||
netPayment: number; // 실지급액
|
||||
|
||||
// 추가 정보
|
||||
paymentDate: string;
|
||||
status: PaymentStatus;
|
||||
year: number;
|
||||
month: number;
|
||||
}
|
||||
|
||||
// 상태 라벨
|
||||
export const PAYMENT_STATUS_LABELS: Record<PaymentStatus, string> = {
|
||||
scheduled: '지급예정',
|
||||
completed: '지급완료',
|
||||
};
|
||||
|
||||
// 상태 색상
|
||||
export const PAYMENT_STATUS_COLORS: Record<PaymentStatus, string> = {
|
||||
scheduled: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
// 정렬 옵션 라벨
|
||||
export const SORT_OPTIONS: Record<SortOption, string> = {
|
||||
rank: '직급순',
|
||||
name: '이름순',
|
||||
department: '부서순',
|
||||
paymentDate: '지급일순',
|
||||
};
|
||||
|
||||
// 금액 포맷 유틸리티
|
||||
export const formatCurrency = (amount: number): string => {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
};
|
||||
Reference in New Issue
Block a user