feat: 급여관리 개선 + 설비관리 신규 + 팝업관리/카드관리/가격표 개선

- 급여관리: 상세/등록 다이얼로그 리팩토링, actions/types 확장
- 설비관리: 설비현황/점검/수리 4개 페이지 신규 추가
- 팝업관리: PopupDetail/PopupForm 개선
- 카드관리: CardForm 개선
- IntegratedListTemplateV2, SearchFilter, useColumnSettings 개선
- CLAUDE.md: 페이지 모드 라우팅 패턴 규칙 추가
- 공통 페이지 패턴 가이드 확장
This commit is contained in:
유병철
2026-03-12 21:48:37 +09:00
parent 945a371cdf
commit ca5a9325c6
40 changed files with 10284 additions and 1867 deletions

View File

@@ -15,7 +15,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { CreditCard, ArrowLeft, Save } from 'lucide-react';
import { CreditCard, X, Save } from 'lucide-react';
import { useMenuStore } from '@/stores/menuStore';
import type { Card as CardType, CardFormData, CardCompany, CardStatus } from './types';
import { CARD_COMPANIES, CARD_STATUS_LABELS } from './types';
import { getActiveEmployees } from './actions';
@@ -28,6 +29,7 @@ interface CardFormProps {
export function CardForm({ mode, card, onSubmit }: CardFormProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const [formData, setFormData] = useState<CardFormData>({
cardCompany: '',
cardType: '',
@@ -245,18 +247,19 @@ export function CardForm({ mode, card, onSubmit }: CardFormProps) {
</CardContent>
</Card>
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
<Button type="button" variant="outline" onClick={handleBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<Button type="submit">
<Save className="w-4 h-4 mr-2" />
{mode === 'create' ? '등록' : '저장'}
</Button>
</div>
</form>
{/* 하단 버튼 (sticky 하단 바) */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
<Button type="button" variant="outline" onClick={handleBack}>
<X className="w-4 h-4 mr-1" />
</Button>
<Button type="button" onClick={() => onSubmit(formData)}>
<Save className="w-4 h-4 mr-1" />
{mode === 'create' ? '등록' : '저장'}
</Button>
</div>
</PageLayout>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useCallback, useMemo, useRef } from 'react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
@@ -9,9 +10,10 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { CurrencyInput } from '@/components/ui/currency-input';
import { DatePicker } from '@/components/ui/date-picker';
import {
Select,
SelectContent,
@@ -20,101 +22,30 @@ import {
SelectValue,
} from '@/components/ui/select';
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
import { Save, Search, UserPlus } from 'lucide-react';
import { Save, Search, UserPlus, Plus, X, RefreshCw, Loader2 } from 'lucide-react';
import { getEmployees } from '@/components/hr/EmployeeManagement/actions';
import type { Employee } from '@/components/hr/EmployeeManagement/types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { createPayroll, getPayrollSettings } from './actions';
import type { NameAmountItem, PayrollSettings } from './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;
defaultYear: number;
defaultMonth: number;
onSaved: () => void;
}
// ===== 컴포넌트 =====
export function SalaryRegistrationDialog({
open,
onOpenChange,
onSave,
defaultYear,
defaultMonth,
onSaved,
}: SalaryRegistrationDialogProps) {
// 사원 선택
const [employeeSearchOpen, setEmployeeSearchOpen] = useState(false);
@@ -122,19 +53,40 @@ export function SalaryRegistrationDialog({
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 [year, setYear] = useState(defaultYear);
const [month, setMonth] = useState(defaultMonth);
const [baseSalary, setBaseSalary] = useState(0);
const [overtimePay, setOvertimePay] = useState(0);
const [mealAllowance, setMealAllowance] = useState(200000); // 식대(비과세) 기본 20만원
// 수당
const [allowances, setAllowances] = useState({ ...DEFAULT_ALLOWANCES });
// 추가 수당
const [allowances, setAllowances] = useState<NameAmountItem[]>([]);
// 공제
const [deductions, setDeductions] = useState({ ...DEFAULT_DEDUCTIONS });
// 법정 공제
const [pension, setPension] = useState(0);
const [healthInsurance, setHealthInsurance] = useState(0);
const [longTermCare, setLongTermCare] = useState(0);
const [employmentInsurance, setEmploymentInsurance] = useState(0);
const [incomeTax, setIncomeTax] = useState(0);
const [residentTax, setResidentTax] = useState(0);
// 검색 모달 열기/닫기 (ref 동기화 - 닫힘 전파 방지를 위해 해제 지연)
// 추가 공제
const [deductions, setDeductions] = useState<NameAmountItem[]>([]);
// 공제대상가족수
const [dependents, setDependents] = useState(1);
// 비고
const [note, setNote] = useState('');
// 로딩
const [isSaving, setIsSaving] = useState(false);
const [isCalculating, setIsCalculating] = useState(false);
// 설정 (재계산용)
const [settings, setSettings] = useState<PayrollSettings | null>(null);
// 검색 모달 열기/닫기
const handleSearchOpenChange = useCallback((isOpen: boolean) => {
if (isOpen) {
searchOpenRef.current = true;
@@ -144,8 +96,64 @@ export function SalaryRegistrationDialog({
setEmployeeSearchOpen(isOpen);
}, []);
// 설정 로드 (재계산용)
const loadSettings = useCallback(async () => {
if (settings) return settings;
const result = await getPayrollSettings();
if (result.success && result.data) {
setSettings(result.data);
return result.data;
}
return null;
}, [settings]);
// 공제 자동 계산 (백엔드 PayrollSetting 계산 로직과 동일)
const calculateDeductions = useCallback(async (salary: number) => {
setIsCalculating(true);
try {
const s = await loadSettings();
if (!s) {
toast.error('급여 설정을 불러올 수 없습니다.');
return;
}
// 국민연금: baseSalary 기준, 상/하한액 반영 (round)
const pensionBase = Math.min(Math.max(salary, s.pensionMinSalary), s.pensionMaxSalary);
const newPension = Math.round(pensionBase * s.pensionRate / 100);
// 건강보험 (round)
const newHealth = Math.round(salary * s.healthInsuranceRate / 100);
// 장기요양보험 (건강보험료의 %) — 화면에는 분리 표시
const newLongTermCare = Math.round(newHealth * s.longTermCareRate / 100);
// 고용보험 (round)
const newEmployment = Math.round(salary * s.employmentInsuranceRate / 100);
// 근로소득세 (기본 요율 0%, 수동 입력 방식)
const newIncomeTax = Math.round(salary * s.incomeTaxRate / 100);
// 지방소득세 (근로소득세의 %)
const newResidentTax = Math.round(newIncomeTax * s.residentTaxRate / 100);
setPension(newPension);
setHealthInsurance(newHealth);
setLongTermCare(newLongTermCare);
setEmploymentInsurance(newEmployment);
setIncomeTax(newIncomeTax);
setResidentTax(newResidentTax);
toast.success('공제 항목이 재계산되었습니다.');
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('공제 계산 중 오류가 발생했습니다.');
} finally {
setIsCalculating(false);
}
}, [loadSettings]);
// 사원 선택 시 기본값 세팅
const handleSelectEmployee = useCallback((employee: Employee) => {
const handleSelectEmployee = useCallback(async (employee: Employee) => {
setSelectedEmployee(employee);
handleSearchOpenChange(false);
@@ -153,11 +161,11 @@ export function SalaryRegistrationDialog({
const monthlySalary = employee.salary ? Math.round(employee.salary / 12) : 0;
setBaseSalary(monthlySalary);
// 기본 공제 자동 계산
// baseSalary 기준으로 공제 자동 계산 (백엔드 로직과 동일)
if (monthlySalary > 0) {
setDeductions(calculateDefaultDeductions(monthlySalary));
await calculateDeductions(monthlySalary);
}
}, []);
}, [handleSearchOpenChange, calculateDeductions]);
// 사원 검색 fetch
const handleFetchEmployees = useCallback(async (query: string) => {
@@ -175,99 +183,152 @@ export function SalaryRegistrationDialog({
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),
// ===== 합계 계산 =====
const allowancesTotal = useMemo(
() => allowances.reduce((sum, a) => sum + a.amount, 0),
[allowances]
);
const totalDeduction = useMemo(() =>
Object.values(deductions).reduce((sum, v) => sum + v, 0),
const grossSalary = useMemo(
() => baseSalary + overtimePay + mealAllowance + allowancesTotal,
[baseSalary, overtimePay, mealAllowance, allowancesTotal]
);
const taxBase = useMemo(
() => Math.max(0, grossSalary - mealAllowance),
[grossSalary, mealAllowance]
);
const legalDeductionsTotal = useMemo(
() => pension + healthInsurance + longTermCare + employmentInsurance + incomeTax + residentTax,
[pension, healthInsurance, longTermCare, employmentInsurance, incomeTax, residentTax]
);
const extraDeductionsTotal = useMemo(
() => deductions.reduce((sum, d) => sum + d.amount, 0),
[deductions]
);
const netPayment = useMemo(() =>
baseSalary + totalAllowance - totalDeduction,
[baseSalary, totalAllowance, totalDeduction]
);
const totalDeductions = legalDeductionsTotal + extraDeductionsTotal;
const netSalary = grossSalary - totalDeductions;
// ===== 수당 추가/삭제 =====
const addAllowance = useCallback(() => {
setAllowances(prev => [...prev, { name: '', amount: 0 }]);
}, []);
const removeAllowance = useCallback((idx: number) => {
setAllowances(prev => prev.filter((_, i) => i !== idx));
}, []);
const updateAllowance = useCallback((idx: number, field: 'name' | 'amount', value: string | number) => {
setAllowances(prev => prev.map((item, i) =>
i === idx ? { ...item, [field]: value } : item
));
}, []);
// ===== 공제 추가/삭제 =====
const addDeduction = useCallback(() => {
setDeductions(prev => [...prev, { name: '', amount: 0 }]);
}, []);
const removeDeduction = useCallback((idx: number) => {
setDeductions(prev => prev.filter((_, i) => i !== idx));
}, []);
const updateDeduction = useCallback((idx: number, field: 'name' | 'amount', value: string | number) => {
setDeductions(prev => prev.map((item, i) =>
i === idx ? { ...item, [field]: value } : item
));
}, []);
// ===== 재계산 (baseSalary 기준 — 백엔드 동일) =====
const handleRecalculate = useCallback(() => {
calculateDeductions(baseSalary);
}, [calculateDeductions, baseSalary]);
// 저장 가능 여부
const canSave = selectedEmployee && baseSalary > 0 && year > 0 && month > 0;
const canSave = selectedEmployee && baseSalary > 0;
// 저장
const handleSave = useCallback(() => {
const handleSave = useCallback(async () => {
if (!selectedEmployee || !canSave) return;
setIsSaving(true);
const dept = selectedEmployee.departmentPositions?.[0];
try {
const result = await createPayroll({
user_id: selectedEmployee.userId || parseInt(selectedEmployee.id, 10),
pay_year: year,
pay_month: month,
base_salary: baseSalary,
overtime_pay: overtimePay,
bonus: mealAllowance,
allowances: allowances.filter(a => a.name && a.amount > 0),
pension,
health_insurance: healthInsurance,
long_term_care: longTermCare,
employment_insurance: employmentInsurance,
income_tax: incomeTax,
resident_tax: residentTax,
deductions: deductions.filter(d => d.name && d.amount > 0),
note: note || undefined,
});
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]);
if (result.success) {
toast.success('급여가 등록되었습니다.');
onSaved();
onOpenChange(false);
} else {
toast.error(result.error || '급여 등록에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('급여 등록에 실패했습니다.');
} finally {
setIsSaving(false);
}
}, [
selectedEmployee, canSave, year, month, baseSalary, overtimePay, mealAllowance,
allowances, pension, healthInsurance, longTermCare, employmentInsurance,
incomeTax, residentTax, deductions, note, onSaved, onOpenChange,
]);
// 다이얼로그 닫힐 때 초기화 (검색 모달 닫힘 전파 방지)
// 다이얼로그 닫힐 때 초기화
const handleOpenChange = useCallback((isOpen: boolean) => {
if (!isOpen && searchOpenRef.current) return;
if (!isOpen) {
setSelectedEmployee(null);
setBaseSalary(0);
setPaymentDate('');
setAllowances({ ...DEFAULT_ALLOWANCES });
setDeductions({ ...DEFAULT_DEDUCTIONS });
setOvertimePay(0);
setMealAllowance(200000);
setAllowances([]);
setPension(0);
setHealthInsurance(0);
setLongTermCare(0);
setEmploymentInsurance(0);
setIncomeTax(0);
setResidentTax(0);
setDeductions([]);
setDependents(1);
setNote('');
} else {
setYear(defaultYear);
setMonth(defaultMonth);
}
onOpenChange(isOpen);
}, [onOpenChange]);
}, [onOpenChange, defaultYear, defaultMonth]);
// 년도 옵션
// 년도/월 옵션
const yearOptions = useMemo(() => {
const currentYear = new Date().getFullYear();
return Array.from({ length: 3 }, (_, i) => currentYear - 1 + i);
const cy = new Date().getFullYear();
return Array.from({ length: 3 }, (_, i) => cy - 1 + i);
}, []);
// 월 옵션
const monthOptions = useMemo(() =>
Array.from({ length: 12 }, (_, i) => i + 1),
[]
);
const monthOptions = 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">
<DialogContent className="sm:max-w-[750px] 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" />
@@ -275,12 +336,12 @@ export function SalaryRegistrationDialog({
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 사원 선택 */}
<div className="space-y-5">
{/* ===== 기본 정보 ===== */}
<div className="bg-muted/50 rounded-lg p-3 sm:p-4">
<h3 className="font-semibold mb-3"> </h3>
{/* 사원 선택 버튼 */}
{/* 사원 선택 */}
{!selectedEmployee ? (
<Button
variant="outline"
@@ -311,12 +372,6 @@ export function SalaryRegistrationDialog({
<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"
@@ -329,8 +384,8 @@ export function SalaryRegistrationDialog({
</div>
)}
{/* 지급월 / 지급일 */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-4">
{/* 지급월 */}
<div className="grid grid-cols-2 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))}>
@@ -353,161 +408,242 @@ export function SalaryRegistrationDialog({
</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="border rounded-lg p-3 sm:p-4">
<h3 className="font-semibold mb-3 text-blue-600"> </h3>
<div className="space-y-3">
{/* 기본급 */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-32 shrink-0"></span>
<CurrencyInput
value={baseSalary}
onChange={(v) => setBaseSalary(v ?? 0)}
className="flex-1 h-8 text-right"
/>
</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">
<p className="text-xs text-muted-foreground ml-32">
{formatCurrency(selectedEmployee.salary)}
</span>
</p>
)}
</div>
</div>
{/* 고정연장근로수당 */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-32 shrink-0"></span>
<CurrencyInput
value={overtimePay}
onChange={(v) => setOvertimePay(v ?? 0)}
className="flex-1 h-8 text-right"
/>
</div>
{/* 식대(비과세) */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-32 shrink-0">()</span>
<CurrencyInput
value={mealAllowance}
onChange={(v) => setMealAllowance(v ?? 0)}
className="flex-1 h-8 text-right"
/>
</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>
<Separator />
{/* 공제 내역 */}
<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 className="flex items-center justify-between">
<span className="text-sm font-medium"> </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addAllowance}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</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>
{allowances.map((item, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
placeholder="수당명"
value={item.name}
onChange={(e) => updateAllowance(idx, 'name', e.target.value)}
className="w-32 h-8 text-sm"
/>
<CurrencyInput
value={item.amount}
onChange={(v) => updateAllowance(idx, 'amount', v ?? 0)}
className="flex-1 h-8 text-right"
/>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => removeAllowance(idx)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
<Separator />
{/* 합계 */}
<div className="flex items-center justify-between font-semibold text-blue-600">
<span> </span>
<span>{formatCurrency(grossSalary)}</span>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span> ( )</span>
<span>{formatCurrency(taxBase)}</span>
</div>
</div>
</div>
{/* 합계 */}
{/* ===== 공제 항목 ===== */}
<div className="border rounded-lg p-3 sm:p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-red-600"> </h3>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={handleRecalculate}
disabled={isCalculating || !selectedEmployee}
>
{isCalculating ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<RefreshCw className="h-3 w-3 mr-1" />
)}
</Button>
</div>
<div className="space-y-3">
{/* 공제대상가족수 */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-32 shrink-0"> </span>
<Input
type="number"
min={1}
value={dependents}
onChange={(e) => setDependents(parseInt(e.target.value) || 1)}
className="w-20 h-8 text-right text-sm"
/>
<span className="text-xs text-muted-foreground"> ( )</span>
</div>
<Separator />
{/* 4대보험 */}
<p className="text-xs text-muted-foreground font-medium">4</p>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-32 shrink-0"></span>
<CurrencyInput value={pension} onChange={(v) => setPension(v ?? 0)} className="flex-1 h-8 text-right" />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-32 shrink-0"></span>
<CurrencyInput value={healthInsurance} onChange={(v) => setHealthInsurance(v ?? 0)} className="flex-1 h-8 text-right" />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-32 shrink-0"></span>
<CurrencyInput value={longTermCare} onChange={(v) => setLongTermCare(v ?? 0)} className="flex-1 h-8 text-right" />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-32 shrink-0"></span>
<CurrencyInput value={employmentInsurance} onChange={(v) => setEmploymentInsurance(v ?? 0)} className="flex-1 h-8 text-right" />
</div>
<Separator />
{/* 세금 */}
<p className="text-xs text-muted-foreground font-medium"></p>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-32 shrink-0"></span>
<CurrencyInput value={incomeTax} onChange={(v) => setIncomeTax(v ?? 0)} className="flex-1 h-8 text-right" />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-32 shrink-0"></span>
<CurrencyInput value={residentTax} onChange={(v) => setResidentTax(v ?? 0)} className="flex-1 h-8 text-right" />
</div>
<Separator />
{/* 추가 공제 */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium"> </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addDeduction}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{deductions.map((item, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
placeholder="공제명"
value={item.name}
onChange={(e) => updateDeduction(idx, 'name', e.target.value)}
className="w-32 h-8 text-sm"
/>
<CurrencyInput
value={item.amount}
onChange={(v) => updateDeduction(idx, 'amount', v ?? 0)}
className="flex-1 h-8 text-right"
/>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => removeDeduction(idx)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
<Separator />
{/* 공제 합계 */}
<div className="flex items-center justify-between font-semibold text-red-600">
<span> </span>
<span>-{formatCurrency(totalDeductions)}</span>
</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-xs sm:text-sm text-muted-foreground block"> </span>
<span className="text-sm sm:text-lg font-semibold text-blue-600">
{formatCurrency(baseSalary + totalAllowance)}
{formatCurrency(grossSalary)}
</span>
</div>
<div>
<span className="text-xs sm:text-sm text-muted-foreground block"> </span>
<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)}
-{formatCurrency(totalDeductions)}
</span>
</div>
<div>
<span className="text-xs sm:text-sm text-muted-foreground block"></span>
<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)}
{formatCurrency(netSalary)}
</span>
</div>
</div>
</div>
{/* ===== 비고 ===== */}
<div>
<span className="text-sm text-muted-foreground block mb-1"></span>
<Textarea
placeholder="비고 입력..."
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
/>
</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 onClick={handleSave} disabled={!canSave || isSaving} className="gap-2">
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</Button>
</DialogFooter>

View File

@@ -1,156 +1,204 @@
'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';
import type {
PayrollRecord,
PayrollDetail,
PayrollSummary,
PayrollSettings,
PayrollStatus,
NameAmountItem,
} from './types';
// API 응답 타입
interface SalaryApiData {
// ===== API 응답 타입 =====
interface PayrollApiData {
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?: {
user_id: number;
pay_year: number;
pay_month: number;
base_salary: string | number;
overtime_pay: string | number;
bonus: string | number;
allowances: { name: string; amount: number }[] | null;
gross_salary: string | number;
income_tax: string | number;
resident_tax: string | number;
health_insurance: string | number;
long_term_care: string | number;
pension: string | number;
employment_insurance: string | number;
deductions: { name: string; amount: number }[] | null;
total_deductions: string | number;
net_salary: string | number;
status: PayrollStatus;
confirmed_at: string | null;
confirmed_by: number | null;
paid_at: string | null;
withdrawal_id: number | null;
note: string | null;
options: Record<string, unknown> | null;
user?: {
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;
employee_profile?: {
department?: { id: number; name: string } | null;
position_label?: string | null;
job_title_label?: string | null;
rank?: string | null;
} | null;
} | null;
created_at: string;
updated_at: string;
}
interface SalaryPaginationData {
data: SalaryApiData[];
interface PaginationApiData {
data: PayrollApiData[];
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;
interface SummaryApiData {
year: number;
month: number;
total_count: number;
draft_count: number;
confirmed_count: number;
paid_count: number;
total_gross: number;
total_deductions: number;
total_net: number;
}
// API → Frontend 변환 (목록용)
function transformApiToFrontend(apiData: SalaryApiData): SalaryRecord {
const profile = apiData.employee_profile;
interface SettingsApiData {
id?: number;
health_insurance_rate: number;
long_term_care_rate: number;
pension_rate: number;
employment_insurance_rate: number;
income_tax_rate: number;
resident_tax_rate: number;
pension_max_salary: number;
pension_min_salary: number;
pay_day: number;
auto_calculate: boolean;
allowance_types: { code: string; name: string; is_taxable?: boolean }[] | null;
deduction_types: { code: string; name: string }[] | null;
}
// ===== 변환 함수 =====
function toNum(v: string | number | null | undefined): number {
if (v == null) return 0;
return typeof v === 'string' ? parseFloat(v) || 0 : v;
}
function transformToRecord(d: PayrollApiData): PayrollRecord {
const profile = d.user?.employee_profile;
return {
id: String(apiData.id),
employeeId: apiData.employee?.user_id || `EMP${String(apiData.employee_id).padStart(3, '0')}`,
employeeName: apiData.employee?.name || '-',
id: d.id,
userId: d.user_id,
userName: d.user?.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,
baseSalary: toNum(d.base_salary),
overtimePay: toNum(d.overtime_pay),
bonus: toNum(d.bonus),
allowancesTotal: (d.allowances || []).reduce((s, a) => s + a.amount, 0),
grossSalary: toNum(d.gross_salary),
totalDeductions: toNum(d.total_deductions),
netSalary: toNum(d.net_salary),
status: d.status,
payYear: d.pay_year,
payMonth: d.pay_month,
note: d.note,
createdAt: d.created_at,
};
}
// API → Frontend 변환 (상세용)
function transformApiToDetail(apiData: SalaryApiData): SalaryDetail {
const allowanceDetails = apiData.allowance_details || {};
const deductionDetails = apiData.deduction_details || {};
const profile = apiData.employee_profile;
function transformToDetail(d: PayrollApiData): PayrollDetail {
const profile = d.user?.employee_profile;
return {
employeeId: apiData.employee?.user_id || `EMP${String(apiData.employee_id).padStart(3, '0')}`,
employeeName: apiData.employee?.name || '-',
id: d.id,
userId: d.user_id,
userName: d.user?.name || '-',
userEmail: d.user?.email || '',
department: profile?.department?.name || '-',
position: profile?.job_title_label || '-',
position: profile?.job_title_label || profile?.position_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,
baseSalary: toNum(d.base_salary),
overtimePay: toNum(d.overtime_pay),
bonus: toNum(d.bonus),
allowances: d.allowances || [],
grossSalary: toNum(d.gross_salary),
pension: toNum(d.pension),
healthInsurance: toNum(d.health_insurance),
longTermCare: toNum(d.long_term_care),
employmentInsurance: toNum(d.employment_insurance),
incomeTax: toNum(d.income_tax),
residentTax: toNum(d.resident_tax),
deductions: d.deductions || [],
totalDeductions: toNum(d.total_deductions),
netSalary: toNum(d.net_salary),
status: d.status,
payYear: d.pay_year,
payMonth: d.pay_month,
note: d.note,
confirmedAt: d.confirmed_at,
paidAt: d.paid_at,
};
}
// ===== 급여 목록 조회 =====
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;
function transformSettings(d: SettingsApiData): PayrollSettings {
return {
id: d.id,
healthInsuranceRate: d.health_insurance_rate,
longTermCareRate: d.long_term_care_rate,
pensionRate: d.pension_rate,
employmentInsuranceRate: d.employment_insurance_rate,
incomeTaxRate: d.income_tax_rate,
residentTaxRate: d.resident_tax_rate,
pensionMaxSalary: d.pension_max_salary,
pensionMinSalary: d.pension_min_salary,
payDay: d.pay_day,
autoCalculate: d.auto_calculate,
allowanceTypes: d.allowance_types || [],
deductionTypes: d.deduction_types || [],
};
}
// ============================
// 급여 목록 조회
// ============================
export async function getPayrolls(params?: {
year?: number;
month?: number;
search?: string;
status?: string;
user_id?: number;
sort_by?: string;
sort_dir?: string;
page?: number;
per_page?: number;
}): Promise<{
success: boolean;
data?: SalaryRecord[];
data?: PayrollRecord[];
pagination?: { total: number; currentPage: number; lastPage: number };
error?: string
error?: string;
}> {
const result = await executeServerAction<SalaryPaginationData>({
url: buildApiUrl('/api/v1/salaries', {
search: params?.search,
const result = await executeServerAction<PaginationApiData>({
url: buildApiUrl('/api/v1/payrolls', {
year: params?.year,
month: params?.month,
search: params?.search,
status: params?.status && params.status !== 'all' ? params.status : undefined,
employee_id: params?.employee_id,
start_date: params?.start_date,
end_date: params?.end_date,
user_id: params?.user_id,
sort_by: params?.sort_by,
sort_dir: params?.sort_dir,
page: params?.page,
per_page: params?.per_page,
}),
@@ -161,7 +209,7 @@ export async function getSalaries(params?: {
return {
success: true,
data: result.data.data.map(transformApiToFrontend),
data: result.data.data.map(transformToRecord),
pagination: {
total: result.data.total,
currentPage: result.data.current_page,
@@ -170,138 +218,400 @@ export async function getSalaries(params?: {
};
}
// ===== 급여 상세 조회 =====
export async function getSalary(id: string): Promise<{
success: boolean; data?: SalaryDetail; error?: string
// ============================
// 급여 상세 조회
// ============================
export async function getPayroll(id: number): Promise<{
success: boolean;
data?: PayrollDetail;
error?: string;
}> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/salaries/${id}`),
transform: (data: SalaryApiData) => transformApiToDetail(data),
errorMessage: '급여 정보를 불러오는데 실패했습니다.',
url: buildApiUrl(`/api/v1/payrolls/${id}`),
transform: (d: PayrollApiData) => transformToDetail(d),
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;
// ============================
// 월간 요약
// ============================
export async function getPayrollSummary(params: {
year: number;
month: number;
}): Promise<{
success: boolean;
data?: PayrollSummary;
error?: string;
}> {
const result = await executeServerAction<SummaryApiData>({
url: buildApiUrl('/api/v1/payrolls/summary', {
year: params.year,
month: params.month,
}),
errorMessage: '급여 요약을 불러오는데 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
const d = result.data;
return {
success: true,
data: {
year: d.year,
month: d.month,
totalCount: d.total_count,
draftCount: d.draft_count,
confirmedCount: d.confirmed_count,
paidCount: d.paid_count,
totalGross: d.total_gross,
totalDeductions: d.total_deductions,
totalNet: d.total_net,
},
};
}
// ============================
// 급여 등록
// ============================
export async function createPayroll(data: {
user_id: number;
pay_year: number;
pay_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 }> {
overtime_pay?: number;
bonus?: number;
allowances?: NameAmountItem[];
income_tax?: number;
resident_tax?: number;
health_insurance?: number;
long_term_care?: number;
pension?: number;
employment_insurance?: number;
deductions?: NameAmountItem[];
note?: string;
}): Promise<{ success: boolean; data?: PayrollDetail; error?: string }> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/salaries'),
url: buildApiUrl('/api/v1/payrolls'),
method: 'POST',
body: data,
transform: (d: SalaryApiData) => transformApiToDetail(d),
transform: (d: PayrollApiData) => transformToDetail(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<{
// ============================
// 급여 수정 (draft만)
// ============================
export async function updatePayroll(
id: number,
data: {
base_salary?: number;
overtime_pay?: number;
bonus?: number;
allowances?: NameAmountItem[];
income_tax?: number;
resident_tax?: number;
health_insurance?: number;
long_term_care?: number;
pension?: number;
employment_insurance?: number;
deductions?: NameAmountItem[];
note?: string;
}
): Promise<{ success: boolean; data?: PayrollDetail; error?: string }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/payrolls/${id}`),
method: 'PUT',
body: data,
transform: (d: PayrollApiData) => transformToDetail(d),
errorMessage: '급여 수정에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
// ============================
// 급여 삭제 (draft만)
// ============================
export async function deletePayroll(id: number): Promise<{
success: boolean;
data?: {
totalNetPayment: number; totalBaseSalary: number; totalAllowance: number;
totalOvertime: number; totalBonus: number; totalDeduction: number;
count: number; scheduledCount: number; completedCount: number;
};
error?: string
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: '통계 정보를 불러오는데 실패했습니다.',
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/payrolls/${id}`),
method: 'DELETE',
errorMessage: '급여 삭제에 실패했습니다.',
});
return { success: result.success, error: result.error };
}
// ============================
// 확정
// ============================
export async function confirmPayroll(id: number): Promise<{
success: boolean;
data?: PayrollDetail;
error?: string;
}> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/payrolls/${id}/confirm`),
method: 'POST',
transform: (d: PayrollApiData) => transformToDetail(d),
errorMessage: '급여 확정에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
// ============================
// 확정 취소
// ============================
export async function unconfirmPayroll(id: number): Promise<{
success: boolean;
data?: PayrollDetail;
error?: string;
}> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/payrolls/${id}/unconfirm`),
method: 'POST',
transform: (d: PayrollApiData) => transformToDetail(d),
errorMessage: '확정 취소에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
// ============================
// 지급 처리
// ============================
export async function payPayroll(
id: number,
withdrawalId?: number
): Promise<{ success: boolean; data?: PayrollDetail; error?: string }> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/payrolls/${id}/pay`),
method: 'POST',
body: withdrawalId ? { withdrawal_id: withdrawalId } : undefined,
transform: (d: PayrollApiData) => transformToDetail(d),
errorMessage: '지급 처리에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
// ============================
// 지급 취소 (슈퍼관리자)
// ============================
export async function unpayPayroll(id: number): Promise<{
success: boolean;
data?: PayrollDetail;
error?: string;
}> {
const result = await executeServerAction({
url: buildApiUrl(`/api/v1/payrolls/${id}/unpay`),
method: 'POST',
transform: (d: PayrollApiData) => transformToDetail(d),
errorMessage: '지급 취소에 실패했습니다.',
});
return { success: result.success, data: result.data, error: result.error };
}
// ============================
// 일괄 계산 (draft 재계산)
// ============================
export async function calculatePayrolls(params: {
year: number;
month: number;
user_ids?: number[];
}): Promise<{ success: boolean; data?: PayrollRecord[]; error?: string }> {
const result = await executeServerAction<PayrollApiData[]>({
url: buildApiUrl('/api/v1/payrolls/calculate'),
method: 'POST',
body: params,
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,
},
};
return { success: true, data: result.data.map(transformToRecord) };
}
// ===== 급여 엑셀 내보내기 (native fetch - keep as-is) =====
export async function exportSalaryExcel(params?: {
// ============================
// 일괄 확정
// ============================
export async function bulkConfirmPayrolls(params: {
year: number;
month: number;
}): Promise<{ success: boolean; count?: number; error?: string }> {
const result = await executeServerAction<{ count: number }>({
url: buildApiUrl('/api/v1/payrolls/bulk-confirm'),
method: 'POST',
body: params,
errorMessage: '일괄 확정에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
return { success: true, count: result.data.count };
}
// ============================
// 재직사원 일괄 생성
// ============================
export async function bulkGeneratePayrolls(params: {
year: number;
month: number;
}): Promise<{ success: boolean; count?: number; error?: string }> {
const result = await executeServerAction<{ count: number } | PayrollApiData[]>({
url: buildApiUrl('/api/v1/payrolls/bulk-generate'),
method: 'POST',
body: params,
errorMessage: '일괄 생성에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
const count = Array.isArray(result.data)
? result.data.length
: (result.data as { count: number }).count;
return { success: true, count };
}
// ============================
// 전월 급여 복사
// ============================
export async function copyFromPrevious(params: {
year: number;
month: number;
}): Promise<{ success: boolean; count?: number; error?: string }> {
const result = await executeServerAction<{ count: number } | PayrollApiData[]>({
url: buildApiUrl('/api/v1/payrolls/copy-from-previous'),
method: 'POST',
body: params,
errorMessage: '전월 복사에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
const count = Array.isArray(result.data)
? result.data.length
: (result.data as { count: number }).count;
return { success: true, count };
}
// ============================
// 급여명세서
// ============================
export async function getPayslip(id: number): Promise<{
success: boolean;
data?: {
period: string;
employee: { id: number; name: string; email: string };
earnings: {
base_salary: number;
overtime_pay: number;
bonus: number;
allowances: NameAmountItem[];
allowances_total: number;
gross_total: number;
};
deductions: {
income_tax: number;
resident_tax: number;
health_insurance: number;
pension: number;
employment_insurance: number;
other_deductions: NameAmountItem[];
other_total: number;
total: number;
};
net_salary: number;
status: string;
status_label: string;
paid_at: string | null;
};
error?: string;
}> {
interface PayslipApiData {
period: string;
employee: { id: number; name: string; email: string };
earnings: {
base_salary: number;
overtime_pay: number;
bonus: number;
allowances: NameAmountItem[];
allowances_total: number;
gross_total: number;
};
deductions: {
income_tax: number;
resident_tax: number;
health_insurance: number;
pension: number;
employment_insurance: number;
other_deductions: NameAmountItem[];
other_total: number;
total: number;
};
net_salary: number;
status: string;
status_label: string;
paid_at: string | null;
}
const result = await executeServerAction<PayslipApiData>({
url: buildApiUrl(`/api/v1/payrolls/${id}/payslip`),
errorMessage: '급여명세서를 불러오는데 실패했습니다.',
});
return { success: result.success, data: result.data ?? undefined, error: result.error };
}
// ============================
// 급여 설정 조회
// ============================
export async function getPayrollSettings(): Promise<{
success: boolean;
data?: PayrollSettings;
error?: string;
}> {
const result = await executeServerAction<SettingsApiData>({
url: buildApiUrl('/api/v1/settings/payroll'),
errorMessage: '급여 설정을 불러오는데 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
return { success: true, data: transformSettings(result.data) };
}
// ============================
// 급여 설정 수정
// ============================
export async function updatePayrollSettings(data: {
health_insurance_rate?: number;
long_term_care_rate?: number;
pension_rate?: number;
employment_insurance_rate?: number;
income_tax_rate?: number;
resident_tax_rate?: number;
pension_max_salary?: number;
pension_min_salary?: number;
pay_day?: number;
auto_calculate?: boolean;
allowance_types?: { code: string; name: string; is_taxable?: boolean }[];
deduction_types?: { code: string; name: string }[];
}): Promise<{ success: boolean; data?: PayrollSettings; error?: string }> {
const result = await executeServerAction<SettingsApiData>({
url: buildApiUrl('/api/v1/settings/payroll'),
method: 'PUT',
body: data,
errorMessage: '급여 설정 저장에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
return { success: true, data: transformSettings(result.data) };
}
// ============================
// 엑셀 내보내기
// ============================
export async function exportPayrollExcel(params?: {
year?: number;
month?: number;
status?: string;
employee_id?: number;
start_date?: string;
end_date?: string;
}): Promise<{
success: boolean;
data?: Blob;
@@ -318,19 +628,13 @@ export async function exportSalaryExcel(params?: {
'X-API-KEY': process.env.API_KEY || '',
};
const url = buildApiUrl('/api/v1/salaries/export', {
const url = buildApiUrl('/api/v1/payrolls/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,
});
const response = await fetch(url, { method: 'GET', headers });
if (!response.ok) {
return { success: false, error: `API 오류: ${response.status}` };
@@ -339,7 +643,7 @@ export async function exportSalaryExcel(params?: {
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`;
const filename = filenameMatch?.[1] || `급여_${params?.year || 'all'}_${params?.month || 'all'}.xlsx`;
return { success: true, data: blob, filename };
} catch (error) {
@@ -347,3 +651,19 @@ export async function exportSalaryExcel(params?: {
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ============================
// 전표 생성
// ============================
export async function createJournalEntries(params: {
year: number;
month: number;
entry_date?: string;
}): Promise<{ success: boolean; error?: string; data?: unknown }> {
return executeServerAction({
url: buildApiUrl('/api/v1/payrolls/journal-entries'),
method: 'POST',
body: params,
errorMessage: '전표 생성에 실패했습니다.',
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,100 +1,144 @@
/**
* 급여관리 타입 정의
* 급여관리 타입 정의 (payrolls API 기준)
*/
// 급여 상태 타입
export type PaymentStatus = 'scheduled' | 'completed';
// ===== 상태 =====
export type PayrollStatus = 'draft' | 'confirmed' | 'paid';
// 정렬 옵션 타입
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 NameAmountItem {
name: string;
amount: number;
}
// 급여 상세 정보 인터페이스
export interface SalaryDetail {
// 기본 정보
employeeId: string;
employeeName: string;
// ===== 급여 목록 레코드 =====
export interface PayrollRecord {
id: number;
userId: number;
userName: string;
department: string;
baseSalary: number;
overtimePay: number;
bonus: number;
allowancesTotal: number;
grossSalary: number;
totalDeductions: number;
netSalary: number;
status: PayrollStatus;
payYear: number;
payMonth: number;
note: string | null;
createdAt: string;
}
// ===== 급여 상세 =====
export interface PayrollDetail {
id: number;
userId: number;
userName: string;
userEmail: string;
department: string;
position: string;
rank: string;
// 지급 항목
baseSalary: number;
overtimePay: number;
bonus: number;
allowances: NameAmountItem[];
grossSalary: number;
// 법정 공제
pension: number;
healthInsurance: number;
longTermCare: number;
employmentInsurance: number;
incomeTax: number;
residentTax: number;
// 추가 공제
deductions: NameAmountItem[];
totalDeductions: number;
netSalary: number;
// 상태/메타
status: PayrollStatus;
payYear: number;
payMonth: number;
note: string | null;
confirmedAt: string | null;
paidAt: string | null;
}
// 급여 정보
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;
// ===== 월간 요약 =====
export interface PayrollSummary {
year: number;
month: number;
totalCount: number;
draftCount: number;
confirmedCount: number;
paidCount: number;
totalGross: number;
totalDeductions: number;
totalNet: number;
}
// 상태 라벨
export const PAYMENT_STATUS_LABELS: Record<PaymentStatus, string> = {
scheduled: '지급예정',
completed: '지급완료',
// ===== 급여 설정 =====
export interface PayrollSettings {
id?: number;
healthInsuranceRate: number;
longTermCareRate: number;
pensionRate: number;
employmentInsuranceRate: number;
incomeTaxRate: number;
residentTaxRate: number;
pensionMaxSalary: number;
pensionMinSalary: number;
payDay: number;
autoCalculate: boolean;
allowanceTypes: { code: string; name: string; is_taxable?: boolean }[];
deductionTypes: { code: string; name: string }[];
}
// ===== 급여명세서 =====
export interface Payslip {
payroll: PayrollDetail;
period: string;
employee: { id: number; name: string; email: string };
earnings: {
baseSalary: number;
overtimePay: number;
bonus: number;
allowances: NameAmountItem[];
allowancesTotal: number;
grossTotal: number;
};
deductions: {
incomeTax: number;
residentTax: number;
healthInsurance: number;
pension: number;
employmentInsurance: number;
otherDeductions: NameAmountItem[];
otherTotal: number;
total: number;
};
netSalary: number;
status: PayrollStatus;
statusLabel: string;
paidAt: string | null;
}
// ===== 상수 =====
export const PAYROLL_STATUS_LABELS: Record<PayrollStatus, string> = {
draft: '작성중',
confirmed: '확정',
paid: '지급완료',
};
// 상태 색상
export const PAYMENT_STATUS_COLORS: Record<PaymentStatus, string> = {
scheduled: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
export const PAYROLL_STATUS_COLORS: Record<PayrollStatus, string> = {
draft: 'bg-gray-100 text-gray-800',
confirmed: 'bg-blue-100 text-blue-800',
paid: '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);
};
return new Intl.NumberFormat('ko-KR').format(Math.round(amount));
};

View File

@@ -0,0 +1,524 @@
'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>
);
}

View 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: _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>
)}
/>
</>
);
}

View File

@@ -0,0 +1,349 @@
'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: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -0,0 +1,793 @@
'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}
/>
);
}

View File

@@ -0,0 +1,100 @@
/**
* 급여관리 타입 정의
*/
// 급여 상태 타입
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);
};