Files
sam-react-prod/src/components/hr/SalaryManagement/SalaryDetailDialog.tsx
유병철 9d66d554ec feat: 회계/급여 관리 개선 및 공통 템플릿 보강
- 회계: 매출/청구/입출금 관리 UI 개선
- 급여: SalaryDetailDialog 대폭 개선, SalaryRegistrationDialog 신규
- 공통: IntegratedDetailTemplate, UniversalListPage 보강
- UI: currency-input 컴포넌트 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:26:15 +09:00

525 lines
20 KiB
TypeScript

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