feat: 회계/급여 관리 개선 및 공통 템플릿 보강
- 회계: 매출/청구/입출금 관리 UI 개선 - 급여: SalaryDetailDialog 대폭 개선, SalaryRegistrationDialog 신규 - 공통: IntegratedDetailTemplate, UniversalListPage 보강 - UI: currency-input 컴포넌트 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,11 @@ export function BillManagementClient({
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 삭제 후 현재 페이지가 빈 페이지가 되면 마지막 유효 페이지로 이동
|
||||
if (result.data.length === 0 && result.pagination.lastPage > 0 && page > result.pagination.lastPage) {
|
||||
loadData(result.pagination.lastPage);
|
||||
return;
|
||||
}
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
setCurrentPage(result.pagination.currentPage);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { toast } from 'sonner';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { getBills, deleteBill, updateBillStatus } from './actions';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import { createDeleteItemHandler, extractUniqueOptions } from '../shared';
|
||||
import { extractUniqueOptions } from '../shared';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
@@ -244,7 +244,14 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
totalCount: pagination.total,
|
||||
};
|
||||
},
|
||||
deleteItem: createDeleteItemHandler(deleteBill, setBillData, '어음이 삭제되었습니다.'),
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteBill(id);
|
||||
if (result.success) {
|
||||
// 서버에서 재조회 (pagination 메타데이터 포함)
|
||||
await loadBills();
|
||||
}
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
|
||||
@@ -67,10 +67,9 @@ export default function DepositDetailClientV2({
|
||||
}, [depositId, initialMode]);
|
||||
|
||||
// ===== 저장/등록 핸들러 =====
|
||||
// IntegratedDetailTemplate이 config.transformSubmitData를 이미 적용한 데이터를 전달함
|
||||
const handleSubmit = useCallback(
|
||||
async (formData: Record<string, unknown>): Promise<{ success: boolean; error?: string }> => {
|
||||
const submitData = depositDetailConfig.transformSubmitData?.(formData) || formData;
|
||||
|
||||
async (submitData: Record<string, unknown>): Promise<{ success: boolean; error?: string }> => {
|
||||
if (!submitData.vendorId) {
|
||||
toast.error('거래처를 선택해주세요.');
|
||||
return { success: false, error: '거래처를 선택해주세요.' };
|
||||
|
||||
@@ -44,7 +44,7 @@ const fields: FieldDefinition[] = [
|
||||
{
|
||||
key: 'depositAmount',
|
||||
label: '입금금액',
|
||||
type: 'number',
|
||||
type: 'currency',
|
||||
placeholder: '입금금액을 입력해주세요',
|
||||
disabled: (mode) => mode === 'view',
|
||||
},
|
||||
@@ -82,6 +82,7 @@ const fields: FieldDefinition[] = [
|
||||
label: '입금 유형',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'unset',
|
||||
placeholder: '선택',
|
||||
options: DEPOSIT_TYPE_SELECTOR_OPTIONS.map((opt) => ({
|
||||
value: opt.value,
|
||||
|
||||
@@ -159,6 +159,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
salesDate,
|
||||
vendorId,
|
||||
salesType,
|
||||
items,
|
||||
totalSupplyAmount: totals.supplyAmount,
|
||||
totalVat: totals.vat,
|
||||
totalAmount: totals.total,
|
||||
@@ -188,7 +189,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [salesDate, vendorId, salesType, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]);
|
||||
}, [salesDate, vendorId, salesType, items, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]);
|
||||
|
||||
// ===== 삭제 (IntegratedDetailTemplate 호환) =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
|
||||
@@ -320,13 +320,13 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
|
||||
// 커스텀 필터 함수 (filterConfig 기반 - ULP의 filters state에서 값 전달)
|
||||
// 검색은 searchFilter에서 처리하므로 여기서는 필터만 처리
|
||||
// NOTE: salesType 필터는 API에서 매출유형을 제공하지 않아 비활성 (모든 데이터가 'other')
|
||||
customFilterFn: (items, fv) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
const issuanceVal = fv.issuance as string;
|
||||
|
||||
let result = applyFilters(items, [
|
||||
enumFilter('vendorName', fv.vendor as string),
|
||||
enumFilter('salesType', fv.salesType as string),
|
||||
]);
|
||||
|
||||
// 발행여부 필터 (특수 로직 - enumFilter로 대체 불가)
|
||||
|
||||
@@ -261,6 +261,20 @@ export function transformFrontendToApi(data: Partial<SalesRecord>): Record<strin
|
||||
if (data.taxInvoiceIssued !== undefined) apiData.tax_invoice_issued = data.taxInvoiceIssued;
|
||||
if (data.transactionStatementIssued !== undefined) apiData.transaction_statement_issued = data.transactionStatementIssued;
|
||||
|
||||
// 품목 배열 변환 (수정 시 items 포함)
|
||||
if (data.items !== undefined && data.items.length > 0) {
|
||||
apiData.items = data.items.map((item, index) => ({
|
||||
item_name: item.itemName,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unitPrice,
|
||||
supply_amount: item.supplyAmount,
|
||||
tax_amount: item.vat,
|
||||
total_amount: item.supplyAmount + item.vat,
|
||||
note: item.note || null,
|
||||
sort_order: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
return apiData;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const fields: FieldDefinition[] = [
|
||||
{
|
||||
key: 'withdrawalAmount',
|
||||
label: '출금금액',
|
||||
type: 'number',
|
||||
type: 'currency',
|
||||
placeholder: '출금금액을 입력해주세요',
|
||||
disabled: (mode) => mode === 'view',
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import {
|
||||
@@ -36,11 +35,59 @@ interface AllowanceEdits {
|
||||
otherAllowance: number;
|
||||
}
|
||||
|
||||
interface DeductionEdits {
|
||||
nationalPension: number;
|
||||
healthInsurance: number;
|
||||
longTermCare: number;
|
||||
employmentInsurance: number;
|
||||
incomeTax: number;
|
||||
localIncomeTax: number;
|
||||
otherDeduction: number;
|
||||
}
|
||||
|
||||
interface SalaryDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
salaryDetail: SalaryDetail | null;
|
||||
onSave?: (updatedDetail: SalaryDetail, allowanceDetails?: Record<string, number>) => void;
|
||||
onSave?: (updatedDetail: SalaryDetail, allowanceDetails?: Record<string, number>, deductionDetails?: Record<string, number>) => void;
|
||||
}
|
||||
|
||||
// 행 컴포넌트: 라벨 고정폭 + 값/인풋 오른쪽 정렬
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
isEditing,
|
||||
editValue,
|
||||
onChange,
|
||||
color,
|
||||
prefix,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
isEditing?: boolean;
|
||||
editValue?: number;
|
||||
onChange?: (value: number) => void;
|
||||
color?: string;
|
||||
prefix?: string;
|
||||
}) {
|
||||
const editing = isEditing && onChange !== undefined;
|
||||
|
||||
return (
|
||||
<div className={editing ? 'flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2' : 'flex items-center gap-2'}>
|
||||
<span className={editing ? 'text-muted-foreground whitespace-nowrap text-xs sm:text-sm sm:w-24 sm:shrink-0' : 'text-muted-foreground whitespace-nowrap w-20 sm:w-24 shrink-0'}>{label}</span>
|
||||
<div className="flex-1 text-right">
|
||||
{editing ? (
|
||||
<CurrencyInput
|
||||
value={editValue ?? 0}
|
||||
onChange={(v) => onChange!(v ?? 0)}
|
||||
className="w-full h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className={color}>{prefix}{value}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SalaryDetailDialog({
|
||||
@@ -51,7 +98,7 @@ export function SalaryDetailDialog({
|
||||
}: SalaryDetailDialogProps) {
|
||||
const [editedStatus, setEditedStatus] = useState<PaymentStatus>('scheduled');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isEditingAllowances, setIsEditingAllowances] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedAllowances, setEditedAllowances] = useState<AllowanceEdits>({
|
||||
positionAllowance: 0,
|
||||
overtimeAllowance: 0,
|
||||
@@ -59,6 +106,15 @@ export function SalaryDetailDialog({
|
||||
transportAllowance: 0,
|
||||
otherAllowance: 0,
|
||||
});
|
||||
const [editedDeductions, setEditedDeductions] = useState<DeductionEdits>({
|
||||
nationalPension: 0,
|
||||
healthInsurance: 0,
|
||||
longTermCare: 0,
|
||||
employmentInsurance: 0,
|
||||
incomeTax: 0,
|
||||
localIncomeTax: 0,
|
||||
otherDeduction: 0,
|
||||
});
|
||||
|
||||
// 다이얼로그가 열릴 때 상태 초기화
|
||||
useEffect(() => {
|
||||
@@ -71,8 +127,17 @@ export function SalaryDetailDialog({
|
||||
transportAllowance: salaryDetail.allowances.transportAllowance,
|
||||
otherAllowance: salaryDetail.allowances.otherAllowance,
|
||||
});
|
||||
setEditedDeductions({
|
||||
nationalPension: salaryDetail.deductions.nationalPension,
|
||||
healthInsurance: salaryDetail.deductions.healthInsurance,
|
||||
longTermCare: salaryDetail.deductions.longTermCare,
|
||||
employmentInsurance: salaryDetail.deductions.employmentInsurance,
|
||||
incomeTax: salaryDetail.deductions.incomeTax,
|
||||
localIncomeTax: salaryDetail.deductions.localIncomeTax,
|
||||
otherDeduction: salaryDetail.deductions.otherDeduction,
|
||||
});
|
||||
setHasChanges(false);
|
||||
setIsEditingAllowances(false);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [salaryDetail]);
|
||||
|
||||
@@ -87,9 +152,17 @@ export function SalaryDetailDialog({
|
||||
editedAllowances.mealAllowance !== salaryDetail.allowances.mealAllowance ||
|
||||
editedAllowances.transportAllowance !== salaryDetail.allowances.transportAllowance ||
|
||||
editedAllowances.otherAllowance !== salaryDetail.allowances.otherAllowance;
|
||||
const deductionsChanged =
|
||||
editedDeductions.nationalPension !== salaryDetail.deductions.nationalPension ||
|
||||
editedDeductions.healthInsurance !== salaryDetail.deductions.healthInsurance ||
|
||||
editedDeductions.longTermCare !== salaryDetail.deductions.longTermCare ||
|
||||
editedDeductions.employmentInsurance !== salaryDetail.deductions.employmentInsurance ||
|
||||
editedDeductions.incomeTax !== salaryDetail.deductions.incomeTax ||
|
||||
editedDeductions.localIncomeTax !== salaryDetail.deductions.localIncomeTax ||
|
||||
editedDeductions.otherDeduction !== salaryDetail.deductions.otherDeduction;
|
||||
|
||||
return statusChanged || allowancesChanged;
|
||||
}, [salaryDetail, editedStatus, editedAllowances]);
|
||||
return statusChanged || allowancesChanged || deductionsChanged;
|
||||
}, [salaryDetail, editedStatus, editedAllowances, editedDeductions]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasChanges(checkForChanges());
|
||||
@@ -97,16 +170,12 @@ export function SalaryDetailDialog({
|
||||
|
||||
if (!salaryDetail) return null;
|
||||
|
||||
const handleStatusChange = (newStatus: PaymentStatus) => {
|
||||
setEditedStatus(newStatus);
|
||||
const handleAllowanceChange = (field: keyof AllowanceEdits, value: number) => {
|
||||
setEditedAllowances(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleAllowanceChange = (field: keyof AllowanceEdits, value: string) => {
|
||||
const numValue = parseInt(value.replace(/,/g, ''), 10) || 0;
|
||||
setEditedAllowances(prev => ({
|
||||
...prev,
|
||||
[field]: numValue,
|
||||
}));
|
||||
const handleDeductionChange = (field: keyof DeductionEdits, value: number) => {
|
||||
setEditedDeductions(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 수당 합계 계산
|
||||
@@ -114,14 +183,18 @@ export function SalaryDetailDialog({
|
||||
return Object.values(editedAllowances).reduce((sum, val) => sum + val, 0);
|
||||
};
|
||||
|
||||
// 공제 합계 계산
|
||||
const calculateTotalDeduction = () => {
|
||||
return Object.values(editedDeductions).reduce((sum, val) => sum + val, 0);
|
||||
};
|
||||
|
||||
// 실지급액 계산
|
||||
const calculateNetPayment = () => {
|
||||
return salaryDetail.baseSalary + calculateTotalAllowance() - salaryDetail.totalDeduction;
|
||||
return salaryDetail.baseSalary + calculateTotalAllowance() - calculateTotalDeduction();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave && salaryDetail) {
|
||||
// allowance_details를 백엔드 형식으로 변환
|
||||
const allowanceDetails = {
|
||||
position_allowance: editedAllowances.positionAllowance,
|
||||
overtime_allowance: editedAllowances.overtimeAllowance,
|
||||
@@ -130,22 +203,34 @@ export function SalaryDetailDialog({
|
||||
other_allowance: editedAllowances.otherAllowance,
|
||||
};
|
||||
|
||||
const deductionDetails = {
|
||||
national_pension: editedDeductions.nationalPension,
|
||||
health_insurance: editedDeductions.healthInsurance,
|
||||
long_term_care: editedDeductions.longTermCare,
|
||||
employment_insurance: editedDeductions.employmentInsurance,
|
||||
income_tax: editedDeductions.incomeTax,
|
||||
local_income_tax: editedDeductions.localIncomeTax,
|
||||
other_deduction: editedDeductions.otherDeduction,
|
||||
};
|
||||
|
||||
const updatedDetail: SalaryDetail = {
|
||||
...salaryDetail,
|
||||
status: editedStatus,
|
||||
allowances: editedAllowances,
|
||||
deductions: editedDeductions,
|
||||
totalAllowance: calculateTotalAllowance(),
|
||||
totalDeduction: calculateTotalDeduction(),
|
||||
netPayment: calculateNetPayment(),
|
||||
};
|
||||
|
||||
onSave(updatedDetail, allowanceDetails);
|
||||
onSave(updatedDetail, allowanceDetails, deductionDetails);
|
||||
}
|
||||
setHasChanges(false);
|
||||
setIsEditingAllowances(false);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleToggleEdit = () => {
|
||||
if (isEditingAllowances) {
|
||||
if (isEditing) {
|
||||
// 편집 취소 - 원래 값으로 복원
|
||||
setEditedAllowances({
|
||||
positionAllowance: salaryDetail.allowances.positionAllowance,
|
||||
@@ -154,49 +239,75 @@ export function SalaryDetailDialog({
|
||||
transportAllowance: salaryDetail.allowances.transportAllowance,
|
||||
otherAllowance: salaryDetail.allowances.otherAllowance,
|
||||
});
|
||||
setEditedDeductions({
|
||||
nationalPension: salaryDetail.deductions.nationalPension,
|
||||
healthInsurance: salaryDetail.deductions.healthInsurance,
|
||||
longTermCare: salaryDetail.deductions.longTermCare,
|
||||
employmentInsurance: salaryDetail.deductions.employmentInsurance,
|
||||
incomeTax: salaryDetail.deductions.incomeTax,
|
||||
localIncomeTax: salaryDetail.deductions.localIncomeTax,
|
||||
otherDeduction: salaryDetail.deductions.otherDeduction,
|
||||
});
|
||||
}
|
||||
setIsEditingAllowances(!isEditingAllowances);
|
||||
setIsEditing(!isEditing);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
급여 수정 - {salaryDetail.employeeName}
|
||||
<DialogTitle className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<span className="truncate">{salaryDetail.employeeName} 급여</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant={isEditing ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleToggleEdit}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
편집 취소
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
급여 수정
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Select
|
||||
value={editedStatus}
|
||||
onValueChange={(value) => setEditedStatus(value as PaymentStatus)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[100px] sm:min-w-[140px] w-auto">
|
||||
<SelectValue>
|
||||
<Badge className={PAYMENT_STATUS_COLORS[editedStatus]}>
|
||||
{PAYMENT_STATUS_LABELS[editedStatus]}
|
||||
</Badge>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="scheduled">
|
||||
<Badge className={PAYMENT_STATUS_COLORS.scheduled}>
|
||||
{PAYMENT_STATUS_LABELS.scheduled}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
<SelectItem value="completed">
|
||||
<Badge className={PAYMENT_STATUS_COLORS.completed}>
|
||||
{PAYMENT_STATUS_LABELS.completed}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 상태 변경 셀렉트 박스 */}
|
||||
<Select
|
||||
value={editedStatus}
|
||||
onValueChange={(value) => handleStatusChange(value as PaymentStatus)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectValue>
|
||||
<Badge className={PAYMENT_STATUS_COLORS[editedStatus]}>
|
||||
{PAYMENT_STATUS_LABELS[editedStatus]}
|
||||
</Badge>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="scheduled">
|
||||
<Badge className={PAYMENT_STATUS_COLORS.scheduled}>
|
||||
{PAYMENT_STATUS_LABELS.scheduled}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
<SelectItem value="completed">
|
||||
<Badge className={PAYMENT_STATUS_COLORS.completed}>
|
||||
{PAYMENT_STATUS_LABELS.completed}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<div className="bg-muted/50 rounded-lg p-3 sm:p-4">
|
||||
<h3 className="font-semibold mb-3">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
@@ -233,146 +344,140 @@ export function SalaryDetailDialog({
|
||||
{/* 급여 항목 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 수당 내역 */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-blue-600">수당 내역</h3>
|
||||
<Button
|
||||
variant={isEditingAllowances ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleToggleEdit}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isEditingAllowances ? (
|
||||
<>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
편집 취소
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
지급항목 수정
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-blue-600">수당 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<DetailRow
|
||||
label="본봉"
|
||||
value={`${formatCurrency(salaryDetail.baseSalary)}원`}
|
||||
/>
|
||||
<Separator />
|
||||
<DetailRow
|
||||
label="직책수당"
|
||||
value={`${formatCurrency(editedAllowances.positionAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.positionAllowance}
|
||||
onChange={(v) => handleAllowanceChange('positionAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="초과근무수당"
|
||||
value={`${formatCurrency(editedAllowances.overtimeAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.overtimeAllowance}
|
||||
onChange={(v) => handleAllowanceChange('overtimeAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="식대"
|
||||
value={`${formatCurrency(editedAllowances.mealAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.mealAllowance}
|
||||
onChange={(v) => handleAllowanceChange('mealAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="교통비"
|
||||
value={`${formatCurrency(editedAllowances.transportAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.transportAllowance}
|
||||
onChange={(v) => handleAllowanceChange('transportAllowance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="기타수당"
|
||||
value={`${formatCurrency(editedAllowances.otherAllowance)}원`}
|
||||
isEditing={isEditing}
|
||||
editValue={editedAllowances.otherAllowance}
|
||||
onChange={(v) => handleAllowanceChange('otherAllowance', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">본봉</span>
|
||||
<span className="font-medium">{formatCurrency(salaryDetail.baseSalary)}원</span>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">직책수당</span>
|
||||
{isEditingAllowances ? (
|
||||
<CurrencyInput
|
||||
value={editedAllowances.positionAllowance}
|
||||
onChange={(value) => handleAllowanceChange('positionAllowance', String(value ?? 0))}
|
||||
className="w-32 h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span>{formatCurrency(editedAllowances.positionAllowance)}원</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">초과근무수당</span>
|
||||
{isEditingAllowances ? (
|
||||
<CurrencyInput
|
||||
value={editedAllowances.overtimeAllowance}
|
||||
onChange={(value) => handleAllowanceChange('overtimeAllowance', String(value ?? 0))}
|
||||
className="w-32 h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span>{formatCurrency(editedAllowances.overtimeAllowance)}원</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">식대</span>
|
||||
{isEditingAllowances ? (
|
||||
<CurrencyInput
|
||||
value={editedAllowances.mealAllowance}
|
||||
onChange={(value) => handleAllowanceChange('mealAllowance', String(value ?? 0))}
|
||||
className="w-32 h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span>{formatCurrency(editedAllowances.mealAllowance)}원</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">교통비</span>
|
||||
{isEditingAllowances ? (
|
||||
<CurrencyInput
|
||||
value={editedAllowances.transportAllowance}
|
||||
onChange={(value) => handleAllowanceChange('transportAllowance', String(value ?? 0))}
|
||||
className="w-32 h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span>{formatCurrency(editedAllowances.transportAllowance)}원</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">기타수당</span>
|
||||
{isEditingAllowances ? (
|
||||
<CurrencyInput
|
||||
value={editedAllowances.otherAllowance}
|
||||
onChange={(value) => handleAllowanceChange('otherAllowance', String(value ?? 0))}
|
||||
className="w-32 h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span>{formatCurrency(editedAllowances.otherAllowance)}원</span>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between font-semibold text-blue-600">
|
||||
<span>수당 합계</span>
|
||||
<span>{formatCurrency(calculateTotalAllowance())}원</span>
|
||||
<div className="flex items-center gap-2 font-semibold text-blue-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">수당 합계</span>
|
||||
<span className="flex-1 text-right">{formatCurrency(calculateTotalAllowance())}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공제 내역 */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-red-600">공제 내역</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">국민연금</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.nationalPension)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">건강보험</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.healthInsurance)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">장기요양보험</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.longTermCare)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">고용보험</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.employmentInsurance)}원</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<DetailRow
|
||||
label="국민연금"
|
||||
value={`${formatCurrency(editedDeductions.nationalPension)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.nationalPension}
|
||||
onChange={(v) => handleDeductionChange('nationalPension', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="건강보험"
|
||||
value={`${formatCurrency(editedDeductions.healthInsurance)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.healthInsurance}
|
||||
onChange={(v) => handleDeductionChange('healthInsurance', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="장기요양보험"
|
||||
value={`${formatCurrency(editedDeductions.longTermCare)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.longTermCare}
|
||||
onChange={(v) => handleDeductionChange('longTermCare', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="고용보험"
|
||||
value={`${formatCurrency(editedDeductions.employmentInsurance)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.employmentInsurance}
|
||||
onChange={(v) => handleDeductionChange('employmentInsurance', v)}
|
||||
/>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">소득세</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.incomeTax)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">지방소득세</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.localIncomeTax)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">기타공제</span>
|
||||
<span className="text-red-600">-{formatCurrency(salaryDetail.deductions.otherDeduction)}원</span>
|
||||
</div>
|
||||
<DetailRow
|
||||
label="소득세"
|
||||
value={`${formatCurrency(editedDeductions.incomeTax)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.incomeTax}
|
||||
onChange={(v) => handleDeductionChange('incomeTax', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="지방소득세"
|
||||
value={`${formatCurrency(editedDeductions.localIncomeTax)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.localIncomeTax}
|
||||
onChange={(v) => handleDeductionChange('localIncomeTax', v)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="기타공제"
|
||||
value={`${formatCurrency(editedDeductions.otherDeduction)}원`}
|
||||
color="text-red-600"
|
||||
prefix="-"
|
||||
isEditing={isEditing}
|
||||
editValue={editedDeductions.otherDeduction}
|
||||
onChange={(v) => handleDeductionChange('otherDeduction', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex justify-between font-semibold text-red-600">
|
||||
<span>공제 합계</span>
|
||||
<span>-{formatCurrency(salaryDetail.totalDeduction)}원</span>
|
||||
<div className="flex items-center gap-2 font-semibold text-red-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">공제 합계</span>
|
||||
<span className="flex-1 text-right">-{formatCurrency(calculateTotalDeduction())}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지급 합계 */}
|
||||
<div className="bg-primary/5 border-2 border-primary/20 rounded-lg p-4">
|
||||
<div className="bg-primary/5 border-2 border-primary/20 rounded-lg p-3 sm:p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4 text-center">
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">급여 총액</span>
|
||||
@@ -383,7 +488,7 @@ export function SalaryDetailDialog({
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">공제 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-red-600">
|
||||
-{formatCurrency(salaryDetail.totalDeduction)}원
|
||||
-{formatCurrency(calculateTotalDeduction())}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
571
src/components/hr/SalaryManagement/SalaryRegistrationDialog.tsx
Normal file
571
src/components/hr/SalaryManagement/SalaryRegistrationDialog.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
|
||||
import { Save, Search, UserPlus } from 'lucide-react';
|
||||
import { getEmployees } from '@/components/hr/EmployeeManagement/actions';
|
||||
import type { Employee } from '@/components/hr/EmployeeManagement/types';
|
||||
import { formatCurrency } from './types';
|
||||
|
||||
// ===== 기본값 상수 =====
|
||||
const DEFAULT_ALLOWANCES = {
|
||||
positionAllowance: 0,
|
||||
overtimeAllowance: 0,
|
||||
mealAllowance: 150000,
|
||||
transportAllowance: 100000,
|
||||
otherAllowance: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_DEDUCTIONS = {
|
||||
nationalPension: 0,
|
||||
healthInsurance: 0,
|
||||
longTermCare: 0,
|
||||
employmentInsurance: 0,
|
||||
incomeTax: 0,
|
||||
localIncomeTax: 0,
|
||||
otherDeduction: 0,
|
||||
};
|
||||
|
||||
// 기본급 기준 4대보험 + 세금 자동 계산
|
||||
function calculateDefaultDeductions(baseSalary: number) {
|
||||
const nationalPension = Math.round(baseSalary * 0.045); // 국민연금 4.5%
|
||||
const healthInsurance = Math.round(baseSalary * 0.03545); // 건강보험 3.545%
|
||||
const longTermCare = Math.round(healthInsurance * 0.1281); // 장기요양 12.81% of 건강보험
|
||||
const employmentInsurance = Math.round(baseSalary * 0.009); // 고용보험 0.9%
|
||||
const totalIncome = baseSalary + DEFAULT_ALLOWANCES.mealAllowance + DEFAULT_ALLOWANCES.transportAllowance;
|
||||
const incomeTax = Math.round(totalIncome * 0.05); // 소득세 (간이세액 근사)
|
||||
const localIncomeTax = Math.round(incomeTax * 0.1); // 지방소득세 10% of 소득세
|
||||
|
||||
return {
|
||||
nationalPension,
|
||||
healthInsurance,
|
||||
longTermCare,
|
||||
employmentInsurance,
|
||||
incomeTax,
|
||||
localIncomeTax,
|
||||
otherDeduction: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 행 컴포넌트 =====
|
||||
function EditableRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
prefix,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
prefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
|
||||
<span className="text-muted-foreground whitespace-nowrap text-xs sm:text-sm sm:w-24 sm:shrink-0">{label}</span>
|
||||
<div className="flex-1">
|
||||
<CurrencyInput
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? 0)}
|
||||
className="w-full h-7 text-sm text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Props =====
|
||||
interface SalaryRegistrationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (data: {
|
||||
employeeId: number;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
year: number;
|
||||
month: number;
|
||||
baseSalary: number;
|
||||
paymentDate: string;
|
||||
allowances: Record<string, number>;
|
||||
deductions: Record<string, number>;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// ===== 컴포넌트 =====
|
||||
export function SalaryRegistrationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: SalaryRegistrationDialogProps) {
|
||||
// 사원 선택
|
||||
const [employeeSearchOpen, setEmployeeSearchOpen] = useState(false);
|
||||
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null);
|
||||
const searchOpenRef = useRef(false);
|
||||
|
||||
// 급여 기본 정보
|
||||
const now = new Date();
|
||||
const [year, setYear] = useState(now.getFullYear());
|
||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||
const [paymentDate, setPaymentDate] = useState('');
|
||||
const [baseSalary, setBaseSalary] = useState(0);
|
||||
|
||||
// 수당
|
||||
const [allowances, setAllowances] = useState({ ...DEFAULT_ALLOWANCES });
|
||||
|
||||
// 공제
|
||||
const [deductions, setDeductions] = useState({ ...DEFAULT_DEDUCTIONS });
|
||||
|
||||
// 검색 모달 열기/닫기 (ref 동기화 - 닫힘 전파 방지를 위해 해제 지연)
|
||||
const handleSearchOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
searchOpenRef.current = true;
|
||||
} else {
|
||||
setTimeout(() => { searchOpenRef.current = false; }, 300);
|
||||
}
|
||||
setEmployeeSearchOpen(isOpen);
|
||||
}, []);
|
||||
|
||||
// 사원 선택 시 기본값 세팅
|
||||
const handleSelectEmployee = useCallback((employee: Employee) => {
|
||||
setSelectedEmployee(employee);
|
||||
handleSearchOpenChange(false);
|
||||
|
||||
// 기본급 세팅 (연봉 / 12)
|
||||
const monthlySalary = employee.salary ? Math.round(employee.salary / 12) : 0;
|
||||
setBaseSalary(monthlySalary);
|
||||
|
||||
// 기본 공제 자동 계산
|
||||
if (monthlySalary > 0) {
|
||||
setDeductions(calculateDefaultDeductions(monthlySalary));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 사원 검색 fetch
|
||||
const handleFetchEmployees = useCallback(async (query: string) => {
|
||||
const result = await getEmployees({
|
||||
q: query || undefined,
|
||||
status: 'active',
|
||||
per_page: 50,
|
||||
});
|
||||
return result.data || [];
|
||||
}, []);
|
||||
|
||||
// 검색어 유효성
|
||||
const isValidSearch = useCallback((query: string) => {
|
||||
if (!query || !query.trim()) return false;
|
||||
return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query);
|
||||
}, []);
|
||||
|
||||
// 수당 변경
|
||||
const handleAllowanceChange = useCallback((field: string, value: number) => {
|
||||
setAllowances(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 공제 변경
|
||||
const handleDeductionChange = useCallback((field: string, value: number) => {
|
||||
setDeductions(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 합계 계산
|
||||
const totalAllowance = useMemo(() =>
|
||||
Object.values(allowances).reduce((sum, v) => sum + v, 0),
|
||||
[allowances]
|
||||
);
|
||||
|
||||
const totalDeduction = useMemo(() =>
|
||||
Object.values(deductions).reduce((sum, v) => sum + v, 0),
|
||||
[deductions]
|
||||
);
|
||||
|
||||
const netPayment = useMemo(() =>
|
||||
baseSalary + totalAllowance - totalDeduction,
|
||||
[baseSalary, totalAllowance, totalDeduction]
|
||||
);
|
||||
|
||||
// 저장 가능 여부
|
||||
const canSave = selectedEmployee && baseSalary > 0 && year > 0 && month > 0;
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(() => {
|
||||
if (!selectedEmployee || !canSave) return;
|
||||
|
||||
const dept = selectedEmployee.departmentPositions?.[0];
|
||||
|
||||
onSave({
|
||||
employeeId: selectedEmployee.userId || parseInt(selectedEmployee.id, 10),
|
||||
employeeName: selectedEmployee.name,
|
||||
department: dept?.departmentName || '-',
|
||||
position: dept?.positionName || '-',
|
||||
rank: selectedEmployee.rank || '-',
|
||||
year,
|
||||
month,
|
||||
baseSalary,
|
||||
paymentDate,
|
||||
allowances: {
|
||||
position_allowance: allowances.positionAllowance,
|
||||
overtime_allowance: allowances.overtimeAllowance,
|
||||
meal_allowance: allowances.mealAllowance,
|
||||
transport_allowance: allowances.transportAllowance,
|
||||
other_allowance: allowances.otherAllowance,
|
||||
},
|
||||
deductions: {
|
||||
national_pension: deductions.nationalPension,
|
||||
health_insurance: deductions.healthInsurance,
|
||||
long_term_care: deductions.longTermCare,
|
||||
employment_insurance: deductions.employmentInsurance,
|
||||
income_tax: deductions.incomeTax,
|
||||
local_income_tax: deductions.localIncomeTax,
|
||||
other_deduction: deductions.otherDeduction,
|
||||
},
|
||||
});
|
||||
}, [selectedEmployee, canSave, year, month, baseSalary, paymentDate, allowances, deductions, onSave]);
|
||||
|
||||
// 다이얼로그 닫힐 때 초기화 (검색 모달 닫힘 전파 방지)
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen && searchOpenRef.current) return;
|
||||
if (!isOpen) {
|
||||
setSelectedEmployee(null);
|
||||
setBaseSalary(0);
|
||||
setPaymentDate('');
|
||||
setAllowances({ ...DEFAULT_ALLOWANCES });
|
||||
setDeductions({ ...DEFAULT_DEDUCTIONS });
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange]);
|
||||
|
||||
// 년도 옵션
|
||||
const yearOptions = useMemo(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
return Array.from({ length: 3 }, (_, i) => currentYear - 1 + i);
|
||||
}, []);
|
||||
|
||||
// 월 옵션
|
||||
const monthOptions = useMemo(() =>
|
||||
Array.from({ length: 12 }, (_, i) => i + 1),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
급여 등록
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 사원 선택 */}
|
||||
<div className="bg-muted/50 rounded-lg p-3 sm:p-4">
|
||||
<h3 className="font-semibold mb-3">기본 정보</h3>
|
||||
|
||||
{/* 사원 선택 버튼 */}
|
||||
{!selectedEmployee ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-12 border-dashed"
|
||||
onClick={() => handleSearchOpenChange(true)}
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
사원 검색 (클릭하여 선택)
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">사번</span>
|
||||
<p className="font-medium">{selectedEmployee.employeeCode || selectedEmployee.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">이름</span>
|
||||
<p className="font-medium">{selectedEmployee.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">부서</span>
|
||||
<p className="font-medium">
|
||||
{selectedEmployee.departmentPositions?.[0]?.departmentName || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직급</span>
|
||||
<p className="font-medium">{selectedEmployee.rank || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">직책</span>
|
||||
<p className="font-medium">
|
||||
{selectedEmployee.departmentPositions?.[0]?.positionName || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={() => handleSearchOpenChange(true)}
|
||||
>
|
||||
다른 사원 선택
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 지급월 / 지급일 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block mb-1">년도</span>
|
||||
<Select value={String(year)} onValueChange={(v) => setYear(Number(v))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}년</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block mb-1">월</span>
|
||||
<Select value={String(month)} onValueChange={(v) => setMonth(Number(v))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthOptions.map(m => (
|
||||
<SelectItem key={m} value={String(m)}>{m}월</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<span className="text-sm text-muted-foreground block mb-1">지급일</span>
|
||||
<DatePicker
|
||||
value={paymentDate}
|
||||
onChange={setPaymentDate}
|
||||
placeholder="지급일 선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본급 */}
|
||||
<div className="mt-4">
|
||||
<span className="text-sm text-muted-foreground block mb-1">기본급 (월)</span>
|
||||
<CurrencyInput
|
||||
value={baseSalary}
|
||||
onChange={(v) => {
|
||||
const newSalary = v ?? 0;
|
||||
setBaseSalary(newSalary);
|
||||
if (newSalary > 0) {
|
||||
setDeductions(calculateDefaultDeductions(newSalary));
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
{selectedEmployee?.salary && (
|
||||
<span className="text-xs text-muted-foreground mt-1 block">
|
||||
연봉 {formatCurrency(selectedEmployee.salary)}원 기준
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수당 / 공제 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 수당 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-blue-600">수당 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<EditableRow
|
||||
label="직책수당"
|
||||
value={allowances.positionAllowance}
|
||||
onChange={(v) => handleAllowanceChange('positionAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="초과근무수당"
|
||||
value={allowances.overtimeAllowance}
|
||||
onChange={(v) => handleAllowanceChange('overtimeAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="식대"
|
||||
value={allowances.mealAllowance}
|
||||
onChange={(v) => handleAllowanceChange('mealAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="교통비"
|
||||
value={allowances.transportAllowance}
|
||||
onChange={(v) => handleAllowanceChange('transportAllowance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="기타수당"
|
||||
value={allowances.otherAllowance}
|
||||
onChange={(v) => handleAllowanceChange('otherAllowance', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-blue-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">수당 합계</span>
|
||||
<span className="flex-1 text-right">{formatCurrency(totalAllowance)}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공제 내역 */}
|
||||
<div className="border rounded-lg p-3 sm:p-4 flex flex-col">
|
||||
<h3 className="font-semibold mb-3 text-red-600">공제 내역</h3>
|
||||
<div className="space-y-2 text-sm flex-1">
|
||||
<EditableRow
|
||||
label="국민연금"
|
||||
value={deductions.nationalPension}
|
||||
onChange={(v) => handleDeductionChange('nationalPension', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="건강보험"
|
||||
value={deductions.healthInsurance}
|
||||
onChange={(v) => handleDeductionChange('healthInsurance', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="장기요양보험"
|
||||
value={deductions.longTermCare}
|
||||
onChange={(v) => handleDeductionChange('longTermCare', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="고용보험"
|
||||
value={deductions.employmentInsurance}
|
||||
onChange={(v) => handleDeductionChange('employmentInsurance', v)}
|
||||
/>
|
||||
<Separator />
|
||||
<EditableRow
|
||||
label="소득세"
|
||||
value={deductions.incomeTax}
|
||||
onChange={(v) => handleDeductionChange('incomeTax', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="지방소득세"
|
||||
value={deductions.localIncomeTax}
|
||||
onChange={(v) => handleDeductionChange('localIncomeTax', v)}
|
||||
/>
|
||||
<EditableRow
|
||||
label="기타공제"
|
||||
value={deductions.otherDeduction}
|
||||
onChange={(v) => handleDeductionChange('otherDeduction', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2">
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 font-semibold text-red-600 mt-2">
|
||||
<span className="w-20 sm:w-24 shrink-0">공제 합계</span>
|
||||
<span className="flex-1 text-right">-{formatCurrency(totalDeduction)}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="bg-primary/5 border-2 border-primary/20 rounded-lg p-3 sm:p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4 text-center">
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">급여 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-blue-600">
|
||||
{formatCurrency(baseSalary + totalAllowance)}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">공제 총액</span>
|
||||
<span className="text-sm sm:text-lg font-semibold text-red-600">
|
||||
-{formatCurrency(totalDeduction)}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground block">실지급액</span>
|
||||
<span className="text-base sm:text-xl font-bold text-primary">
|
||||
{formatCurrency(netPayment)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!canSave} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 사원 검색 모달 */}
|
||||
<SearchableSelectionModal<Employee>
|
||||
open={employeeSearchOpen}
|
||||
onOpenChange={handleSearchOpenChange}
|
||||
title="사원 검색"
|
||||
searchPlaceholder="사원명, 사원코드 검색..."
|
||||
fetchData={handleFetchEmployees}
|
||||
keyExtractor={(emp) => emp.id}
|
||||
validateSearch={isValidSearch}
|
||||
invalidSearchMessage="한글, 영문 또는 숫자 1자 이상 입력하세요"
|
||||
emptyQueryMessage="사원명 또는 사원코드를 입력하세요"
|
||||
loadingMessage="사원 검색 중..."
|
||||
dialogClassName="sm:max-w-[500px]"
|
||||
infoText={(items, isLoading) =>
|
||||
!isLoading ? (
|
||||
<span className="text-xs text-gray-400 text-right block">
|
||||
총 {items.length}명
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
mode="single"
|
||||
onSelect={handleSelectEmployee}
|
||||
renderItem={(employee) => (
|
||||
<div className="p-3 hover:bg-blue-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-900">{employee.name}</span>
|
||||
{employee.employeeCode && (
|
||||
<span className="text-xs text-gray-400 ml-2">({employee.employeeCode})</span>
|
||||
)}
|
||||
</div>
|
||||
{employee.rank && (
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{employee.rank}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{employee.departmentPositions.length > 0 && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{employee.departmentPositions
|
||||
.map(dp => `${dp.departmentName} / ${dp.positionName}`)
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{employee.salary && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
연봉: {Number(employee.salary).toLocaleString()}원
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -233,6 +233,27 @@ export async function updateSalary(
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 등록 =====
|
||||
export async function createSalary(data: {
|
||||
employee_id: number;
|
||||
year: number;
|
||||
month: number;
|
||||
base_salary: number;
|
||||
allowance_details?: Record<string, number>;
|
||||
deduction_details?: Record<string, number>;
|
||||
payment_date?: string;
|
||||
status?: PaymentStatus;
|
||||
}): Promise<{ success: boolean; data?: SalaryDetail; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/salaries'),
|
||||
method: 'POST',
|
||||
body: data,
|
||||
transform: (d: SalaryApiData) => transformApiToDetail(d),
|
||||
errorMessage: '급여 등록에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 급여 통계 조회 =====
|
||||
export async function getSalaryStatistics(params?: {
|
||||
year?: number; month?: number; start_date?: string; end_date?: string;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
MinusCircle,
|
||||
Loader2,
|
||||
Search,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -30,9 +31,11 @@ import {
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { SalaryDetailDialog } from './SalaryDetailDialog';
|
||||
import { SalaryRegistrationDialog } from './SalaryRegistrationDialog';
|
||||
import {
|
||||
getSalaries,
|
||||
getSalary,
|
||||
createSalary,
|
||||
bulkUpdateSalaryStatus,
|
||||
updateSalaryStatus,
|
||||
updateSalary,
|
||||
@@ -51,6 +54,114 @@ import {
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== 목 데이터 (API 연동 전 테스트용) =====
|
||||
const MOCK_SALARY_RECORDS: SalaryRecord[] = [
|
||||
{
|
||||
id: 'mock-1',
|
||||
employeeId: 'EMP001',
|
||||
employeeName: '김철수',
|
||||
department: '개발팀',
|
||||
position: '팀장',
|
||||
rank: '과장',
|
||||
baseSalary: 3500000,
|
||||
allowance: 850000,
|
||||
overtime: 320000,
|
||||
bonus: 0,
|
||||
deduction: 542000,
|
||||
netPayment: 4128000,
|
||||
paymentDate: '2026-02-25',
|
||||
status: 'scheduled',
|
||||
year: 2026,
|
||||
month: 2,
|
||||
createdAt: '2026-02-01',
|
||||
updatedAt: '2026-02-01',
|
||||
},
|
||||
{
|
||||
id: 'mock-2',
|
||||
employeeId: 'EMP002',
|
||||
employeeName: '이영희',
|
||||
department: '경영지원팀',
|
||||
position: '사원',
|
||||
rank: '대리',
|
||||
baseSalary: 3000000,
|
||||
allowance: 550000,
|
||||
overtime: 0,
|
||||
bonus: 500000,
|
||||
deduction: 468000,
|
||||
netPayment: 3582000,
|
||||
paymentDate: '2026-02-25',
|
||||
status: 'completed',
|
||||
year: 2026,
|
||||
month: 2,
|
||||
createdAt: '2026-02-01',
|
||||
updatedAt: '2026-02-20',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_SALARY_DETAILS: Record<string, SalaryDetail> = {
|
||||
'mock-1': {
|
||||
employeeId: 'EMP001',
|
||||
employeeName: '김철수',
|
||||
department: '개발팀',
|
||||
position: '팀장',
|
||||
rank: '과장',
|
||||
baseSalary: 3500000,
|
||||
allowances: {
|
||||
positionAllowance: 300000,
|
||||
overtimeAllowance: 320000,
|
||||
mealAllowance: 150000,
|
||||
transportAllowance: 100000,
|
||||
otherAllowance: 0,
|
||||
},
|
||||
deductions: {
|
||||
nationalPension: 157500,
|
||||
healthInsurance: 121450,
|
||||
longTermCare: 14820,
|
||||
employmentInsurance: 31500,
|
||||
incomeTax: 185230,
|
||||
localIncomeTax: 18520,
|
||||
otherDeduction: 12980,
|
||||
},
|
||||
totalAllowance: 870000,
|
||||
totalDeduction: 542000,
|
||||
netPayment: 4128000,
|
||||
paymentDate: '2026-02-25',
|
||||
status: 'scheduled',
|
||||
year: 2026,
|
||||
month: 2,
|
||||
},
|
||||
'mock-2': {
|
||||
employeeId: 'EMP002',
|
||||
employeeName: '이영희',
|
||||
department: '경영지원팀',
|
||||
position: '사원',
|
||||
rank: '대리',
|
||||
baseSalary: 3000000,
|
||||
allowances: {
|
||||
positionAllowance: 200000,
|
||||
overtimeAllowance: 0,
|
||||
mealAllowance: 150000,
|
||||
transportAllowance: 100000,
|
||||
otherAllowance: 100000,
|
||||
},
|
||||
deductions: {
|
||||
nationalPension: 135000,
|
||||
healthInsurance: 104100,
|
||||
longTermCare: 12700,
|
||||
employmentInsurance: 27000,
|
||||
incomeTax: 160200,
|
||||
localIncomeTax: 16020,
|
||||
otherDeduction: 12980,
|
||||
},
|
||||
totalAllowance: 550000,
|
||||
totalDeduction: 468000,
|
||||
netPayment: 3582000,
|
||||
paymentDate: '2026-02-25',
|
||||
status: 'completed',
|
||||
year: 2026,
|
||||
month: 2,
|
||||
},
|
||||
};
|
||||
|
||||
export function SalaryManagement() {
|
||||
const { canExport } = usePermission();
|
||||
@@ -68,6 +179,7 @@ export function SalaryManagement() {
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [selectedSalaryDetail, setSelectedSalaryDetail] = useState<SalaryDetail | null>(null);
|
||||
const [selectedSalaryId, setSelectedSalaryId] = useState<string | null>(null);
|
||||
const [registrationDialogOpen, setRegistrationDialogOpen] = useState(false);
|
||||
|
||||
// 데이터 상태
|
||||
const [salaryData, setSalaryData] = useState<SalaryRecord[]>([]);
|
||||
@@ -91,17 +203,23 @@ export function SalaryManagement() {
|
||||
per_page: itemsPerPage,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
if (result.success && result.data && result.data.length > 0) {
|
||||
setSalaryData(result.data);
|
||||
setTotalCount(result.pagination?.total || result.data.length);
|
||||
setTotalPages(result.pagination?.lastPage || 1);
|
||||
} else {
|
||||
toast.error(result.error || '급여 목록을 불러오는데 실패했습니다.');
|
||||
// API 데이터가 없으면 목 데이터 사용
|
||||
setSalaryData(MOCK_SALARY_RECORDS);
|
||||
setTotalCount(MOCK_SALARY_RECORDS.length);
|
||||
setTotalPages(1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('loadSalaries error:', error);
|
||||
toast.error('급여 목록을 불러오는데 실패했습니다.');
|
||||
// API 실패 시에도 목 데이터 사용
|
||||
setSalaryData(MOCK_SALARY_RECORDS);
|
||||
setTotalCount(MOCK_SALARY_RECORDS.length);
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadDone.current = true;
|
||||
@@ -190,6 +308,16 @@ export function SalaryManagement() {
|
||||
setSelectedSalaryId(record.id);
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
// 목 데이터인 경우 목 상세 데이터 사용
|
||||
if (record.id.startsWith('mock-')) {
|
||||
const mockDetail = MOCK_SALARY_DETAILS[record.id];
|
||||
if (mockDetail) {
|
||||
setSelectedSalaryDetail(mockDetail);
|
||||
setDetailDialogOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await getSalary(record.id);
|
||||
if (result.success && result.data) {
|
||||
setSelectedSalaryDetail(result.data);
|
||||
@@ -209,16 +337,36 @@ export function SalaryManagement() {
|
||||
// ===== 급여 상세 저장 핸들러 =====
|
||||
const handleSaveDetail = useCallback(async (
|
||||
updatedDetail: SalaryDetail,
|
||||
allowanceDetails?: Record<string, number>
|
||||
allowanceDetails?: Record<string, number>,
|
||||
deductionDetails?: Record<string, number>
|
||||
) => {
|
||||
if (!selectedSalaryId) return;
|
||||
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
// 수당 정보가 변경된 경우 updateSalary API 호출
|
||||
if (allowanceDetails) {
|
||||
// 목 데이터인 경우 로컬 상태만 업데이트
|
||||
if (selectedSalaryId.startsWith('mock-')) {
|
||||
setSalaryData(prev => prev.map(item =>
|
||||
item.id === selectedSalaryId
|
||||
? {
|
||||
...item,
|
||||
status: updatedDetail.status,
|
||||
allowance: updatedDetail.totalAllowance,
|
||||
deduction: updatedDetail.totalDeduction,
|
||||
netPayment: updatedDetail.netPayment,
|
||||
}
|
||||
: item
|
||||
));
|
||||
toast.success('급여 정보가 저장되었습니다. (목 데이터)');
|
||||
setDetailDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 수당/공제 정보가 변경된 경우 updateSalary API 호출
|
||||
if (allowanceDetails || deductionDetails) {
|
||||
const result = await updateSalary(selectedSalaryId, {
|
||||
allowance_details: allowanceDetails,
|
||||
deduction_details: deductionDetails,
|
||||
status: updatedDetail.status,
|
||||
});
|
||||
if (result.success) {
|
||||
@@ -250,10 +398,79 @@ export function SalaryManagement() {
|
||||
|
||||
// ===== 지급항목 추가 핸들러 =====
|
||||
const handleAddPaymentItem = useCallback(() => {
|
||||
// TODO: 지급항목 추가 다이얼로그 또는 로직 구현
|
||||
toast.info('지급항목 추가 기능은 준비 중입니다.');
|
||||
}, []);
|
||||
|
||||
// ===== 급여 등록 핸들러 =====
|
||||
const handleCreateSalary = useCallback(async (data: {
|
||||
employeeId: number;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
year: number;
|
||||
month: number;
|
||||
baseSalary: number;
|
||||
paymentDate: string;
|
||||
allowances: Record<string, number>;
|
||||
deductions: Record<string, number>;
|
||||
}) => {
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
const result = await createSalary({
|
||||
employee_id: data.employeeId,
|
||||
year: data.year,
|
||||
month: data.month,
|
||||
base_salary: data.baseSalary,
|
||||
allowance_details: data.allowances,
|
||||
deduction_details: data.deductions,
|
||||
payment_date: data.paymentDate || undefined,
|
||||
status: 'scheduled',
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success('급여가 등록되었습니다.');
|
||||
setRegistrationDialogOpen(false);
|
||||
await loadSalaries();
|
||||
} else {
|
||||
// API 실패 시 목 데이터로 로컬 추가
|
||||
const totalAllowance = Object.values(data.allowances).reduce((s, v) => s + v, 0);
|
||||
const totalDeduction = Object.values(data.deductions).reduce((s, v) => s + v, 0);
|
||||
const mockId = `mock-${Date.now()}`;
|
||||
const newRecord: SalaryRecord = {
|
||||
id: mockId,
|
||||
employeeId: String(data.employeeId),
|
||||
employeeName: data.employeeName,
|
||||
department: data.department,
|
||||
position: data.position,
|
||||
rank: data.rank,
|
||||
baseSalary: data.baseSalary,
|
||||
allowance: totalAllowance,
|
||||
overtime: data.allowances.overtime_allowance || 0,
|
||||
bonus: 0,
|
||||
deduction: totalDeduction,
|
||||
netPayment: data.baseSalary + totalAllowance - totalDeduction,
|
||||
paymentDate: data.paymentDate,
|
||||
status: 'scheduled',
|
||||
year: data.year,
|
||||
month: data.month,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setSalaryData(prev => [...prev, newRecord]);
|
||||
setTotalCount(prev => prev + 1);
|
||||
toast.success('급여가 등록되었습니다. (목 데이터)');
|
||||
setRegistrationDialogOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleCreateSalary error:', error);
|
||||
toast.error('급여 등록에 실패했습니다.');
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
}, [loadSalaries]);
|
||||
|
||||
// ===== 통계 카드 (총 실지급액, 총 기본급, 총 수당, 초과근무, 상여, 총공제) =====
|
||||
const statCards: StatCard[] = useMemo(() => {
|
||||
const totalNetPayment = salaryData.reduce((sum, s) => sum + s.netPayment, 0);
|
||||
@@ -446,7 +663,12 @@ export function SalaryManagement() {
|
||||
</>
|
||||
),
|
||||
|
||||
headerActions: () => null,
|
||||
headerActions: () => (
|
||||
<Button size="sm" onClick={() => setRegistrationDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
급여 등록
|
||||
</Button>
|
||||
),
|
||||
|
||||
renderTableRow: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
@@ -513,12 +735,19 @@ export function SalaryManagement() {
|
||||
},
|
||||
|
||||
renderDialogs: (_params) => (
|
||||
<SalaryDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
salaryDetail={selectedSalaryDetail}
|
||||
onSave={handleSaveDetail}
|
||||
/>
|
||||
<>
|
||||
<SalaryDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
salaryDetail={selectedSalaryDetail}
|
||||
onSave={handleSaveDetail}
|
||||
/>
|
||||
<SalaryRegistrationDialog
|
||||
open={registrationDialogOpen}
|
||||
onOpenChange={setRegistrationDialogOpen}
|
||||
onSave={handleCreateSalary}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
}), [
|
||||
salaryData,
|
||||
@@ -538,6 +767,8 @@ export function SalaryManagement() {
|
||||
selectedSalaryDetail,
|
||||
handleSaveDetail,
|
||||
handleAddPaymentItem,
|
||||
registrationDialogOpen,
|
||||
handleCreateSalary,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -94,7 +94,9 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
} else {
|
||||
const defaultData: Record<string, unknown> = {};
|
||||
config.fields.forEach((field) => {
|
||||
if (field.type === 'checkbox') {
|
||||
if (field.defaultValue !== undefined) {
|
||||
defaultData[field.key] = field.defaultValue;
|
||||
} else if (field.type === 'checkbox') {
|
||||
defaultData[field.key] = false;
|
||||
} else {
|
||||
defaultData[field.key] = '';
|
||||
@@ -135,10 +137,12 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
: (initialData as Record<string, unknown>);
|
||||
setFormData(transformed);
|
||||
} else {
|
||||
// 기본값 설정
|
||||
// 기본값 설정 (field.defaultValue 우선 적용)
|
||||
const defaultData: Record<string, unknown> = {};
|
||||
config.fields.forEach((field) => {
|
||||
if (field.type === 'checkbox') {
|
||||
if (field.defaultValue !== undefined) {
|
||||
defaultData[field.key] = field.defaultValue;
|
||||
} else if (field.type === 'checkbox') {
|
||||
defaultData[field.key] = false;
|
||||
} else {
|
||||
defaultData[field.key] = '';
|
||||
|
||||
@@ -246,8 +246,8 @@ export function UniversalListPage<T>({
|
||||
|
||||
// 삭제 등으로 현재 페이지가 빈 페이지가 되면 마지막 유효 페이지로 이동
|
||||
useEffect(() => {
|
||||
if (totalPages > 0 && currentPage > totalPages) {
|
||||
setCurrentPage(totalPages);
|
||||
if (currentPage > 1 && (totalPages === 0 || currentPage > totalPages)) {
|
||||
setCurrentPage(Math.max(1, totalPages));
|
||||
}
|
||||
}, [totalPages, currentPage]);
|
||||
|
||||
@@ -328,8 +328,9 @@ export function UniversalListPage<T>({
|
||||
}, []);
|
||||
|
||||
// initialData prop 변경 감지 (부모 컴포넌트에서 데이터 로드 후 전달하는 경우)
|
||||
// 삭제 후 빈 배열도 동기화해야 빈 페이지가 올바르게 표시됨
|
||||
useEffect(() => {
|
||||
if (initialData && initialData.length > 0) {
|
||||
if (initialData) {
|
||||
setRawData(initialData);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
@@ -23,10 +23,11 @@ export interface CurrencyInputProps
|
||||
* CurrencyInput - 금액 입력 전용 컴포넌트
|
||||
*
|
||||
* 특징:
|
||||
* - 항상 천단위 콤마 표시
|
||||
* - 입력 중에도 실시간 천단위 콤마 표시
|
||||
* - 정수만 허용 (소수점 없음)
|
||||
* - 통화 기호 표시 (₩, $, ¥, €)
|
||||
* - Leading zero 자동 제거
|
||||
* - 커서 위치 자동 보정
|
||||
*
|
||||
* @example
|
||||
* // 기본 (원화)
|
||||
@@ -57,6 +58,38 @@ const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
|
||||
) => {
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
const [displayValue, setDisplayValue] = React.useState<string>("");
|
||||
const innerRef = React.useRef<HTMLInputElement>(null);
|
||||
const cursorPosRef = React.useRef<number | null>(null);
|
||||
|
||||
// 외부 ref와 내부 ref 결합
|
||||
const combinedRef = React.useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
innerRef.current = node;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
|
||||
},
|
||||
[ref]
|
||||
);
|
||||
|
||||
// 렌더 후 커서 위치 복원
|
||||
React.useLayoutEffect(() => {
|
||||
if (cursorPosRef.current !== null && innerRef.current && isFocused) {
|
||||
innerRef.current.setSelectionRange(cursorPosRef.current, cursorPosRef.current);
|
||||
cursorPosRef.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 숫자를 콤마 포맷 문자열로 변환
|
||||
const formatWithCommas = (sanitized: string): string => {
|
||||
if (!sanitized || sanitized === "-") return sanitized || "";
|
||||
const isNeg = sanitized.startsWith("-");
|
||||
const digits = isNeg ? sanitized.slice(1) : sanitized;
|
||||
if (!digits) return sanitized;
|
||||
const num = parseInt(digits, 10);
|
||||
if (isNaN(num)) return "";
|
||||
const formatted = formatNumber(num, { useComma: true });
|
||||
return isNeg ? `-${formatted}` : formatted;
|
||||
};
|
||||
|
||||
// 외부 value 변경 시 displayValue 동기화
|
||||
React.useEffect(() => {
|
||||
@@ -111,37 +144,53 @@ const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const rawValue = e.target.value;
|
||||
// 콤마 제거
|
||||
const cursorPos = e.target.selectionStart || 0;
|
||||
|
||||
// 커서 앞 숫자 개수 계산 (콤마 무시)
|
||||
const digitsBeforeCursor = rawValue.slice(0, cursorPos).replace(/[^\d]/g, "").length;
|
||||
|
||||
// 콤마 제거 후 정제
|
||||
const withoutComma = rawValue.replace(/,/g, "");
|
||||
const sanitized = sanitizeInput(withoutComma);
|
||||
|
||||
setDisplayValue(sanitized);
|
||||
|
||||
// 입력 중 "-"만 있으면 onChange 호출 안함
|
||||
if (sanitized === "-") return;
|
||||
if (sanitized === "-") {
|
||||
setDisplayValue(sanitized);
|
||||
cursorPosRef.current = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const numValue = parseValue(sanitized);
|
||||
onChange(numValue);
|
||||
|
||||
// 실시간 콤마 포맷
|
||||
const formatted = formatWithCommas(sanitized);
|
||||
setDisplayValue(formatted);
|
||||
|
||||
// 새 커서 위치 계산: 숫자 개수 기준으로 위치 복원
|
||||
let digitCount = 0;
|
||||
let newPos = formatted.length;
|
||||
for (let i = 0; i < formatted.length; i++) {
|
||||
if (/\d/.test(formatted[i])) {
|
||||
digitCount++;
|
||||
}
|
||||
if (digitCount === digitsBeforeCursor) {
|
||||
newPos = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
cursorPosRef.current = newPos;
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(true);
|
||||
|
||||
// 포커스 시 콤마 제거된 순수 숫자로 표시
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (!isNaN(numValue)) {
|
||||
setDisplayValue(String(Math.floor(numValue)));
|
||||
}
|
||||
}
|
||||
|
||||
// 포커스 시에도 콤마 유지 (실시간 포맷)
|
||||
onFocus?.(e);
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(false);
|
||||
|
||||
const sanitized = sanitizeInput(displayValue);
|
||||
const sanitized = sanitizeInput(displayValue.replace(/,/g, ""));
|
||||
const numValue = parseValue(sanitized);
|
||||
|
||||
onChange(numValue);
|
||||
@@ -193,7 +242,7 @@ const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
ref={ref}
|
||||
ref={combinedRef}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
|
||||
Reference in New Issue
Block a user