feat: [급여] 급여관리 대폭 개선
- SalaryDetailDialog UI/기능 대폭 개선 - SalaryRegistrationDialog 신규 추가 - actions에 급여 업데이트 API 추가 - 급여 목록 페이지 API 연동 및 기능 강화 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, number>) => void;
|
||||
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({
|
||||
@@ -51,7 +98,7 @@ export function SalaryDetailDialog({
|
||||
}: SalaryDetailDialogProps) {
|
||||
const [editedStatus, setEditedStatus] = useState<PaymentStatus>('scheduled');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isEditingAllowances, setIsEditingAllowances] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedAllowances, setEditedAllowances] = useState<AllowanceEdits>({
|
||||
positionAllowance: 0,
|
||||
overtimeAllowance: 0,
|
||||
@@ -59,6 +106,15 @@ export function SalaryDetailDialog({
|
||||
transportAllowance: 0,
|
||||
otherAllowance: 0,
|
||||
});
|
||||
const [editedDeductions, setEditedDeductions] = useState<DeductionEdits>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
급여 수정 - {salaryDetail.employeeName}
|
||||
<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>
|
||||
{/* 상태 변경 셀렉트 박스 */}
|
||||
<Select
|
||||
value={editedStatus}
|
||||
onValueChange={(value) => handleStatusChange(value as PaymentStatus)}
|
||||
>
|
||||
<SelectTrigger className="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>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<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>
|
||||
@@ -233,146 +344,140 @@ export function SalaryDetailDialog({
|
||||
{/* 급여 항목 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 수당 내역 */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-blue-600">수당 내역</h3>
|
||||
<Button
|
||||
variant={isEditingAllowances ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleToggleEdit}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isEditingAllowances ? (
|
||||
<>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
편집 취소
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
지급항목 수정
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<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="space-y-2 text-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">본봉</span>
|
||||
<span className="font-medium">{formatCurrency(salaryDetail.baseSalary)}원</span>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">직책수당</span>
|
||||
{isEditingAllowances ? (
|
||||
<CurrencyInput
|
||||
value={editedAllowances.positionAllowance}
|
||||
onChange={(value) => handleAllowanceChange('positionAllowance', String(value ?? 0))}
|
||||
className="w-32 h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span>{formatCurrency(editedAllowances.positionAllowance)}원</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">초과근무수당</span>
|
||||
{isEditingAllowances ? (
|
||||
<CurrencyInput
|
||||
value={editedAllowances.overtimeAllowance}
|
||||
onChange={(value) => handleAllowanceChange('overtimeAllowance', String(value ?? 0))}
|
||||
className="w-32 h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span>{formatCurrency(editedAllowances.overtimeAllowance)}원</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">식대</span>
|
||||
{isEditingAllowances ? (
|
||||
<CurrencyInput
|
||||
value={editedAllowances.mealAllowance}
|
||||
onChange={(value) => handleAllowanceChange('mealAllowance', String(value ?? 0))}
|
||||
className="w-32 h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span>{formatCurrency(editedAllowances.mealAllowance)}원</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">교통비</span>
|
||||
{isEditingAllowances ? (
|
||||
<CurrencyInput
|
||||
value={editedAllowances.transportAllowance}
|
||||
onChange={(value) => handleAllowanceChange('transportAllowance', String(value ?? 0))}
|
||||
className="w-32 h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span>{formatCurrency(editedAllowances.transportAllowance)}원</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">기타수당</span>
|
||||
{isEditingAllowances ? (
|
||||
<CurrencyInput
|
||||
value={editedAllowances.otherAllowance}
|
||||
onChange={(value) => handleAllowanceChange('otherAllowance', String(value ?? 0))}
|
||||
className="w-32 h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span>{formatCurrency(editedAllowances.otherAllowance)}원</span>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between font-semibold text-blue-600">
|
||||
<span>수당 합계</span>
|
||||
<span>{formatCurrency(calculateTotalAllowance())}원</span>
|
||||
<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-4">
|
||||
<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">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">국민연금</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.nationalPension)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">건강보험</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.healthInsurance)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">장기요양보험</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.longTermCare)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">고용보험</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.employmentInsurance)}원</span>
|
||||
</div>
|
||||
<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 />
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">소득세</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.incomeTax)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">지방소득세</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.localIncomeTax)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">기타공제</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.otherDeduction)}원</span>
|
||||
</div>
|
||||
<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 justify-between font-semibold text-red-600">
|
||||
<span>공제 합계</span>
|
||||
<span>-{formatCurrency(salaryDetail.totalDeduction)}원</span>
|
||||
<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-4">
|
||||
<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>
|
||||
@@ -383,7 +488,7 @@ export function SalaryDetailDialog({
|
||||
<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(salaryDetail.totalDeduction)}원
|
||||
-{formatCurrency(calculateTotalDeduction())}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
571
src/components/hr/SalaryManagement/SalaryRegistrationDialog.tsx
Normal file
571
src/components/hr/SalaryManagement/SalaryRegistrationDialog.tsx
Normal file
@@ -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 (
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<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;
|
||||
|
||||
@@ -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<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 } = usePermission();
|
||||
@@ -68,6 +179,7 @@ export function SalaryManagement() {
|
||||
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[]>([]);
|
||||
@@ -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<string, number>
|
||||
allowanceDetails?: Record<string, number>,
|
||||
deductionDetails?: Record<string, number>
|
||||
) => {
|
||||
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<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);
|
||||
@@ -446,7 +663,12 @@ export function SalaryManagement() {
|
||||
</>
|
||||
),
|
||||
|
||||
headerActions: () => null,
|
||||
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;
|
||||
@@ -513,12 +735,19 @@ export function SalaryManagement() {
|
||||
},
|
||||
|
||||
renderDialogs: (_params) => (
|
||||
<SalaryDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
salaryDetail={selectedSalaryDetail}
|
||||
onSave={handleSaveDetail}
|
||||
/>
|
||||
<>
|
||||
<SalaryDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
salaryDetail={selectedSalaryDetail}
|
||||
onSave={handleSaveDetail}
|
||||
/>
|
||||
<SalaryRegistrationDialog
|
||||
open={registrationDialogOpen}
|
||||
onOpenChange={setRegistrationDialogOpen}
|
||||
onSave={handleCreateSalary}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
}), [
|
||||
salaryData,
|
||||
@@ -538,6 +767,8 @@ export function SalaryManagement() {
|
||||
selectedSalaryDetail,
|
||||
handleSaveDetail,
|
||||
handleAddPaymentItem,
|
||||
registrationDialogOpen,
|
||||
handleCreateSalary,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user