feat: [payroll] 공제항목 수동 수정 기능 추가 및 상여금→식대 변경

- 공제 6개 항목(국민연금/건강보험/장기요양/고용보험/소득세/지방소득세) 수동 수정 가능
- 수동 수정 시 노란색 배경으로 시각적 구분, 재계산 버튼으로 초기화
- 서버사이드 deduction_overrides 유효성 검증 및 적용 로직 추가
- 수정 모달에서 기존 공제값 복원 및 자동계산 비교로 수동 표시
- 상여금 → 식대 라벨 변경 (등록/상세/CSV)
This commit is contained in:
김보곤
2026-02-27 10:58:13 +09:00
parent bbdad75468
commit e6eb1d7691
3 changed files with 209 additions and 22 deletions

View File

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

View File

@@ -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인)
*/

View File

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