feat: [payroll] 공제항목 수동 수정 기능 추가 및 상여금→식대 변경
- 공제 6개 항목(국민연금/건강보험/장기요양/고용보험/소득세/지방소득세) 수동 수정 가능 - 수동 수정 시 노란색 배경으로 시각적 구분, 재계산 버튼으로 초기화 - 서버사이드 deduction_overrides 유효성 검증 및 적용 로직 추가 - 수정 모달에서 기존 공제값 복원 및 자동계산 비교로 수동 표시 - 상여금 → 식대 라벨 변경 (등록/상세/CSV)
This commit is contained in:
@@ -80,6 +80,13 @@ public function store(Request $request): JsonResponse
|
||||
'deductions' => 'nullable|array',
|
||||
'deductions.*.name' => 'required_with:deductions|string',
|
||||
'deductions.*.amount' => 'required_with:deductions|numeric|min:0',
|
||||
'deduction_overrides' => 'nullable|array',
|
||||
'deduction_overrides.pension' => 'nullable|numeric|min:0',
|
||||
'deduction_overrides.health_insurance' => 'nullable|numeric|min:0',
|
||||
'deduction_overrides.long_term_care' => 'nullable|numeric|min:0',
|
||||
'deduction_overrides.employment_insurance' => 'nullable|numeric|min:0',
|
||||
'deduction_overrides.income_tax' => 'nullable|numeric|min:0',
|
||||
'deduction_overrides.resident_tax' => 'nullable|numeric|min:0',
|
||||
'note' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
@@ -122,6 +129,13 @@ public function update(Request $request, int $id): JsonResponse
|
||||
'deductions' => 'nullable|array',
|
||||
'deductions.*.name' => 'required_with:deductions|string',
|
||||
'deductions.*.amount' => 'required_with:deductions|numeric|min:0',
|
||||
'deduction_overrides' => 'nullable|array',
|
||||
'deduction_overrides.pension' => 'nullable|numeric|min:0',
|
||||
'deduction_overrides.health_insurance' => 'nullable|numeric|min:0',
|
||||
'deduction_overrides.long_term_care' => 'nullable|numeric|min:0',
|
||||
'deduction_overrides.employment_insurance' => 'nullable|numeric|min:0',
|
||||
'deduction_overrides.income_tax' => 'nullable|numeric|min:0',
|
||||
'deduction_overrides.resident_tax' => 'nullable|numeric|min:0',
|
||||
'note' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
@@ -296,7 +310,7 @@ public function export(Request $request): StreamedResponse
|
||||
$file = fopen('php://output', 'w');
|
||||
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM
|
||||
|
||||
fputcsv($file, ['사원명', '부서', '기본급', '고정연장근로수당', '상여금', '총지급액', '국민연금', '건강보험', '장기요양보험', '고용보험', '근로소득세', '지방소득세', '총공제액', '실수령액', '상태']);
|
||||
fputcsv($file, ['사원명', '부서', '기본급', '고정연장근로수당', '식대', '총지급액', '국민연금', '건강보험', '장기요양보험', '고용보험', '근로소득세', '지방소득세', '총공제액', '실수령액', '상태']);
|
||||
|
||||
foreach ($payrolls as $payroll) {
|
||||
$profile = $payroll->user?->tenantProfiles?->first();
|
||||
|
||||
@@ -156,6 +156,7 @@ public function storePayroll(array $data): Payroll
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId) {
|
||||
$calculated = $this->calculateAmounts($data);
|
||||
$this->applyDeductionOverrides($calculated, $data['deduction_overrides'] ?? null);
|
||||
|
||||
$payroll = Payroll::create([
|
||||
'tenant_id' => $tenantId,
|
||||
@@ -203,6 +204,7 @@ public function updatePayroll(int $id, array $data): ?Payroll
|
||||
|
||||
$mergedData = array_merge($payroll->toArray(), $data);
|
||||
$calculated = $this->calculateAmounts($mergedData);
|
||||
$this->applyDeductionOverrides($calculated, $data['deduction_overrides'] ?? null);
|
||||
|
||||
$payroll->update([
|
||||
'base_salary' => $data['base_salary'] ?? $payroll->base_salary,
|
||||
@@ -432,6 +434,35 @@ public function calculateAmounts(array $data, ?PayrollSetting $settings = null):
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 수정된 공제 항목 반영
|
||||
*/
|
||||
private function applyDeductionOverrides(array &$calculated, ?array $overrides): void
|
||||
{
|
||||
if (empty($overrides)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 추가공제 금액 산출 (오버라이드 적용 전 법정공제 합계와 비교)
|
||||
$oldStatutory = $calculated['pension'] + $calculated['health_insurance'] + $calculated['long_term_care']
|
||||
+ $calculated['employment_insurance'] + $calculated['income_tax'] + $calculated['resident_tax'];
|
||||
$extraDeductions = max(0, $calculated['total_deductions'] - $oldStatutory);
|
||||
|
||||
// 수동 수정값 적용
|
||||
$fields = ['pension', 'health_insurance', 'long_term_care', 'employment_insurance', 'income_tax', 'resident_tax'];
|
||||
foreach ($fields as $field) {
|
||||
if (isset($overrides[$field])) {
|
||||
$calculated[$field] = (int) $overrides[$field];
|
||||
}
|
||||
}
|
||||
|
||||
// 총 공제액·실수령액 재계산
|
||||
$newStatutory = $calculated['pension'] + $calculated['health_insurance'] + $calculated['long_term_care']
|
||||
+ $calculated['employment_insurance'] + $calculated['income_tax'] + $calculated['resident_tax'];
|
||||
$calculated['total_deductions'] = (int) ($newStatutory + $extraDeductions);
|
||||
$calculated['net_salary'] = (int) max(0, $calculated['gross_salary'] - $calculated['total_deductions']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 근로소득세 계산 (간이세액표 기준, 부양가족 1인)
|
||||
*/
|
||||
|
||||
@@ -193,7 +193,7 @@ class="money-input w-full px-3 py-2 border border-gray-300 rounded-lg text-sm te
|
||||
class="money-input w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-right focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">상여금</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">식대</label>
|
||||
<input type="text" id="payrollBonus" name="bonus" inputmode="numeric" value="0"
|
||||
oninput="formatMoneyInput(this); recalculate()"
|
||||
onfocus="moneyFocus(this)" onblur="moneyBlur(this)"
|
||||
@@ -211,16 +211,55 @@ class="money-input w-full px-3 py-2 border border-gray-300 rounded-lg text-sm te
|
||||
<div id="allowancesContainer" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
{{-- 공제 요약 (자동 계산) --}}
|
||||
{{-- 공제 항목 (자동 계산 + 수동 수정 가능) --}}
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">공제 항목 (자동 계산)</h4>
|
||||
<div class="bg-gray-50 rounded-lg p-3 space-y-1 text-sm">
|
||||
<div class="flex justify-between"><span class="text-gray-500">국민연금</span><span id="calcPension" class="text-gray-700">0</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">건강보험</span><span id="calcHealth" class="text-gray-700">0</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">장기요양보험</span><span id="calcLongTermCare" class="text-gray-700">0</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">고용보험</span><span id="calcEmployment" class="text-gray-700">0</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">근로소득세</span><span id="calcIncomeTax" class="text-gray-700">0</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">지방소득세</span><span id="calcResidentTax" class="text-gray-700">0</span></div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-sm font-medium text-gray-700">공제 항목</h4>
|
||||
<button type="button" onclick="resetDeductionOverrides()" class="text-xs text-blue-600 hover:text-blue-800">재계산</button>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-3 space-y-1.5 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">국민연금</span>
|
||||
<input type="text" id="calcPension" inputmode="numeric" value="0"
|
||||
oninput="formatMoneyInput(this); markManualOverride(this); updateDeductionTotals()"
|
||||
onfocus="moneyFocus(this)" onblur="moneyBlur(this)"
|
||||
class="money-input w-28 px-2 py-1 border border-gray-200 rounded text-right text-sm text-gray-700 focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">건강보험</span>
|
||||
<input type="text" id="calcHealth" inputmode="numeric" value="0"
|
||||
oninput="formatMoneyInput(this); markManualOverride(this); updateDeductionTotals()"
|
||||
onfocus="moneyFocus(this)" onblur="moneyBlur(this)"
|
||||
class="money-input w-28 px-2 py-1 border border-gray-200 rounded text-right text-sm text-gray-700 focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">장기요양보험</span>
|
||||
<input type="text" id="calcLongTermCare" inputmode="numeric" value="0"
|
||||
oninput="formatMoneyInput(this); markManualOverride(this); updateDeductionTotals()"
|
||||
onfocus="moneyFocus(this)" onblur="moneyBlur(this)"
|
||||
class="money-input w-28 px-2 py-1 border border-gray-200 rounded text-right text-sm text-gray-700 focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">고용보험</span>
|
||||
<input type="text" id="calcEmployment" inputmode="numeric" value="0"
|
||||
oninput="formatMoneyInput(this); markManualOverride(this); updateDeductionTotals()"
|
||||
onfocus="moneyFocus(this)" onblur="moneyBlur(this)"
|
||||
class="money-input w-28 px-2 py-1 border border-gray-200 rounded text-right text-sm text-gray-700 focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">근로소득세</span>
|
||||
<input type="text" id="calcIncomeTax" inputmode="numeric" value="0"
|
||||
oninput="formatMoneyInput(this); markManualOverride(this); updateDeductionTotals()"
|
||||
onfocus="moneyFocus(this)" onblur="moneyBlur(this)"
|
||||
class="money-input w-28 px-2 py-1 border border-gray-200 rounded text-right text-sm text-gray-700 focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">지방소득세</span>
|
||||
<input type="text" id="calcResidentTax" inputmode="numeric" value="0"
|
||||
oninput="formatMoneyInput(this); markManualOverride(this); updateDeductionTotals()"
|
||||
onfocus="moneyFocus(this)" onblur="moneyBlur(this)"
|
||||
class="money-input w-28 px-2 py-1 border border-gray-200 rounded text-right text-sm text-gray-700 focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -404,7 +443,50 @@ function openEditPayrollModal(id, data) {
|
||||
data.deductions.forEach(d => addDeductionRow(d.name, d.amount));
|
||||
}
|
||||
|
||||
recalculate();
|
||||
// 법정공제 항목 값 복원 (서버 저장값으로 설정하고 수동 표시)
|
||||
const deductionFields = {
|
||||
'calcPension': data.pension, 'calcHealth': data.health_insurance,
|
||||
'calcLongTermCare': data.long_term_care, 'calcEmployment': data.employment_insurance,
|
||||
'calcIncomeTax': data.income_tax, 'calcResidentTax': data.resident_tax,
|
||||
};
|
||||
Object.entries(deductionFields).forEach(([id, val]) => {
|
||||
const el = document.getElementById(id);
|
||||
setMoneyValue(el, val || 0);
|
||||
});
|
||||
|
||||
// 자동 계산하여 서버 저장값과 다르면 수동 수정 표시
|
||||
const calcData = {
|
||||
base_salary: parseMoneyValue(document.getElementById('payrollBaseSalary')),
|
||||
overtime_pay: parseMoneyValue(document.getElementById('payrollOvertimePay')),
|
||||
bonus: parseMoneyValue(document.getElementById('payrollBonus')),
|
||||
};
|
||||
fetch('{{ route("api.admin.hr.payrolls.calculate") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(calcData),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
const d = result.data;
|
||||
const autoMap = {
|
||||
'calcPension': d.pension, 'calcHealth': d.health_insurance,
|
||||
'calcLongTermCare': d.long_term_care, 'calcEmployment': d.employment_insurance,
|
||||
'calcIncomeTax': d.income_tax, 'calcResidentTax': d.resident_tax,
|
||||
};
|
||||
Object.entries(autoMap).forEach(([id, autoVal]) => {
|
||||
const el = document.getElementById(id);
|
||||
const savedVal = parseMoneyValue(el);
|
||||
if (savedVal !== autoVal) {
|
||||
markManualOverride(el);
|
||||
}
|
||||
});
|
||||
document.getElementById('calcGross').textContent = numberFormat(d.gross_salary);
|
||||
updateDeductionTotals();
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
document.getElementById('payrollModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
@@ -498,26 +580,73 @@ function doRecalculate() {
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
const d = result.data;
|
||||
document.getElementById('calcIncomeTax').textContent = numberFormat(d.income_tax);
|
||||
document.getElementById('calcResidentTax').textContent = numberFormat(d.resident_tax);
|
||||
document.getElementById('calcHealth').textContent = numberFormat(d.health_insurance);
|
||||
document.getElementById('calcLongTermCare').textContent = numberFormat(d.long_term_care);
|
||||
document.getElementById('calcPension').textContent = numberFormat(d.pension);
|
||||
document.getElementById('calcEmployment').textContent = numberFormat(d.employment_insurance);
|
||||
const fields = {
|
||||
'calcPension': d.pension,
|
||||
'calcHealth': d.health_insurance,
|
||||
'calcLongTermCare': d.long_term_care,
|
||||
'calcEmployment': d.employment_insurance,
|
||||
'calcIncomeTax': d.income_tax,
|
||||
'calcResidentTax': d.resident_tax,
|
||||
};
|
||||
Object.entries(fields).forEach(([id, val]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el.dataset.manual) setMoneyValue(el, val);
|
||||
});
|
||||
document.getElementById('calcGross').textContent = numberFormat(d.gross_salary);
|
||||
document.getElementById('calcTotalDeductions').textContent = numberFormat(d.total_deductions);
|
||||
document.getElementById('calcNet').textContent = numberFormat(d.net_salary);
|
||||
updateDeductionTotals();
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function resetCalculation() {
|
||||
['calcIncomeTax','calcResidentTax','calcHealth','calcLongTermCare','calcPension','calcEmployment','calcGross','calcTotalDeductions','calcNet'].forEach(id => {
|
||||
['calcPension','calcHealth','calcLongTermCare','calcEmployment','calcIncomeTax','calcResidentTax'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
el.value = '0';
|
||||
delete el.dataset.manual;
|
||||
el.classList.remove('bg-yellow-50', 'border-yellow-300');
|
||||
el.classList.add('border-gray-200');
|
||||
});
|
||||
['calcGross','calcTotalDeductions','calcNet'].forEach(id => {
|
||||
document.getElementById(id).textContent = '0';
|
||||
});
|
||||
}
|
||||
|
||||
function markManualOverride(el) {
|
||||
el.dataset.manual = '1';
|
||||
el.classList.remove('border-gray-200');
|
||||
el.classList.add('bg-yellow-50', 'border-yellow-300');
|
||||
}
|
||||
|
||||
function resetDeductionOverrides() {
|
||||
['calcPension','calcHealth','calcLongTermCare','calcEmployment','calcIncomeTax','calcResidentTax'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
delete el.dataset.manual;
|
||||
el.classList.remove('bg-yellow-50', 'border-yellow-300');
|
||||
el.classList.add('border-gray-200');
|
||||
});
|
||||
recalculate();
|
||||
}
|
||||
|
||||
function updateDeductionTotals() {
|
||||
const ids = ['calcPension','calcHealth','calcLongTermCare','calcEmployment','calcIncomeTax','calcResidentTax'];
|
||||
let statutory = 0;
|
||||
ids.forEach(id => { statutory += parseMoneyValue(document.getElementById(id)); });
|
||||
|
||||
let extra = 0;
|
||||
document.querySelectorAll('#deductionsContainer .deduction-amount').forEach(el => {
|
||||
extra += parseMoneyValue(el);
|
||||
});
|
||||
|
||||
const totalDeductions = statutory + extra;
|
||||
const grossText = document.getElementById('calcGross').textContent;
|
||||
const gross = parseFloat(grossText.replace(/,/g, '')) || 0;
|
||||
const net = Math.max(0, gross - totalDeductions);
|
||||
|
||||
document.getElementById('calcTotalDeductions').textContent = numberFormat(totalDeductions);
|
||||
document.getElementById('calcNet').textContent = numberFormat(net);
|
||||
}
|
||||
|
||||
function numberFormat(n) {
|
||||
return Number(n).toLocaleString('ko-KR');
|
||||
}
|
||||
@@ -568,6 +697,18 @@ function submitPayroll() {
|
||||
if (name && amount > 0) deductions.push({name, amount});
|
||||
});
|
||||
|
||||
// 수동 수정된 공제 항목 override
|
||||
const overrides = {};
|
||||
const overrideMap = {
|
||||
'calcPension': 'pension', 'calcHealth': 'health_insurance',
|
||||
'calcLongTermCare': 'long_term_care', 'calcEmployment': 'employment_insurance',
|
||||
'calcIncomeTax': 'income_tax', 'calcResidentTax': 'resident_tax',
|
||||
};
|
||||
Object.entries(overrideMap).forEach(([elId, field]) => {
|
||||
const el = document.getElementById(elId);
|
||||
if (el.dataset.manual) overrides[field] = parseMoneyValue(el);
|
||||
});
|
||||
|
||||
const data = {
|
||||
user_id: parseInt(document.getElementById('payrollUserId').value),
|
||||
pay_year: parseInt(document.getElementById('payrollPayYear').value),
|
||||
@@ -577,6 +718,7 @@ function submitPayroll() {
|
||||
bonus: parseMoneyValue(document.getElementById('payrollBonus')),
|
||||
allowances: allowances.length > 0 ? allowances : null,
|
||||
deductions: deductions.length > 0 ? deductions : null,
|
||||
deduction_overrides: Object.keys(overrides).length > 0 ? overrides : null,
|
||||
note: document.getElementById('payrollNote').value || null,
|
||||
};
|
||||
|
||||
@@ -739,7 +881,7 @@ function openPayrollDetail(id, data) {
|
||||
html += '<tr class="bg-blue-50"><th class="px-4 py-2 text-left text-blue-800 font-medium" colspan="2">지급 항목</th></tr>';
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">기본급</td><td class="px-4 py-2 text-right">${numberFormat(data.base_salary)}</td></tr>`;
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">고정연장근로수당</td><td class="px-4 py-2 text-right">${numberFormat(data.overtime_pay)}</td></tr>`;
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">상여금</td><td class="px-4 py-2 text-right">${numberFormat(data.bonus)}</td></tr>`;
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">식대</td><td class="px-4 py-2 text-right">${numberFormat(data.bonus)}</td></tr>`;
|
||||
|
||||
if (data.allowances && data.allowances.length > 0) {
|
||||
data.allowances.forEach(a => {
|
||||
|
||||
Reference in New Issue
Block a user