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