diff --git a/app/Http/Controllers/Api/Admin/HR/PayrollController.php b/app/Http/Controllers/Api/Admin/HR/PayrollController.php index b3724e2e..35af56b2 100644 --- a/app/Http/Controllers/Api/Admin/HR/PayrollController.php +++ b/app/Http/Controllers/Api/Admin/HR/PayrollController.php @@ -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(); diff --git a/app/Services/HR/PayrollService.php b/app/Services/HR/PayrollService.php index c27d8476..d49d92bc 100644 --- a/app/Services/HR/PayrollService.php +++ b/app/Services/HR/PayrollService.php @@ -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인) */ diff --git a/resources/views/hr/payrolls/index.blade.php b/resources/views/hr/payrolls/index.blade.php index c8eb28c4..392ee371 100644 --- a/resources/views/hr/payrolls/index.blade.php +++ b/resources/views/hr/payrolls/index.blade.php @@ -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">
- +
- {{-- 공제 요약 (자동 계산) --}} + {{-- 공제 항목 (자동 계산 + 수동 수정 가능) --}}
-

공제 항목 (자동 계산)

-
-
국민연금0
-
건강보험0
-
장기요양보험0
-
고용보험0
-
근로소득세0
-
지방소득세0
+
+

공제 항목

+ +
+
+
+ 국민연금 + +
+
+ 건강보험 + +
+
+ 장기요양보험 + +
+
+ 고용보험 + +
+
+ 근로소득세 + +
+
+ 지방소득세 + +
@@ -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 += '지급 항목'; html += `기본급${numberFormat(data.base_salary)}`; html += `고정연장근로수당${numberFormat(data.overtime_pay)}`; - html += `상여금${numberFormat(data.bonus)}`; + html += `식대${numberFormat(data.bonus)}`; if (data.allowances && data.allowances.length > 0) { data.allowances.forEach(a => {