- 회계: 매출/청구/입출금 관리 UI 개선 - 급여: SalaryDetailDialog 대폭 개선, SalaryRegistrationDialog 신규 - 공통: IntegratedDetailTemplate, UniversalListPage 보강 - UI: currency-input 컴포넌트 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
525 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|