feat: 회계/급여 관리 개선 및 공통 템플릿 보강

- 회계: 매출/청구/입출금 관리 UI 개선
- 급여: SalaryDetailDialog 대폭 개선, SalaryRegistrationDialog 신규
- 공통: IntegratedDetailTemplate, UniversalListPage 보강
- UI: currency-input 컴포넌트 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-27 12:26:15 +09:00
parent b1686aaf66
commit 9d66d554ec
15 changed files with 1230 additions and 221 deletions

View File

@@ -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);

View File

@@ -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 };
},
},
// 테이블 컬럼

View File

@@ -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: '거래처를 선택해주세요.' };

View File

@@ -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,

View File

@@ -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 }> => {

View File

@@ -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로 대체 불가)

View File

@@ -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;
}

View File

@@ -45,7 +45,7 @@ const fields: FieldDefinition[] = [
{
key: 'withdrawalAmount',
label: '출금금액',
type: 'number',
type: 'currency',
placeholder: '출금금액을 입력해주세요',
disabled: (mode) => mode === 'view',
},

View File

@@ -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>

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,
}: {
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

@@ -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;

View File

@@ -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 (

View File

@@ -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] = '';

View File

@@ -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]);

View File

@@ -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",