diff --git a/app/Http/Controllers/Api/Admin/HR/BusinessIncomePaymentController.php b/app/Http/Controllers/Api/Admin/HR/BusinessIncomePaymentController.php index 71404372..314a62a9 100644 --- a/app/Http/Controllers/Api/Admin/HR/BusinessIncomePaymentController.php +++ b/app/Http/Controllers/Api/Admin/HR/BusinessIncomePaymentController.php @@ -49,14 +49,20 @@ public function index(Request $request): JsonResponse|Response $earners = $this->service->getActiveEarners(); $payments = $this->service->getPayments($year, $month); - $paymentsByUser = $payments->keyBy('user_id'); $stats = $this->service->getMonthlyStats($year, $month); + $earnersForJs = $earners->map(fn ($e) => [ + 'user_id' => $e->user_id, + 'business_name' => $e->business_name ?? ($e->user?->name ?? ''), + 'user_name' => $e->user?->name ?? '', + 'business_reg_number' => $e->business_registration_number ?? '', + ])->values(); + if ($request->header('HX-Request')) { return response( view('hr.business-income-payments.partials.stats', compact('stats')). ''. - view('hr.business-income-payments.partials.spreadsheet', compact('earners', 'paymentsByUser', 'year', 'month')) + view('hr.business-income-payments.partials.spreadsheet', compact('payments', 'earnersForJs', 'year', 'month')) ); } @@ -80,7 +86,10 @@ public function bulkSave(Request $request): JsonResponse 'year' => 'required|integer|min:2020|max:2100', 'month' => 'required|integer|min:1|max:12', 'items' => 'required|array', - 'items.*.user_id' => 'required|integer', + 'items.*.payment_id' => 'nullable|integer', + 'items.*.user_id' => 'nullable|integer', + 'items.*.display_name' => 'required|string|max:100', + 'items.*.business_reg_number' => 'nullable|string|max:20', 'items.*.gross_amount' => 'required|numeric|min:0', 'items.*.service_content' => 'nullable|string|max:200', 'items.*.payment_date' => 'nullable|date', @@ -150,9 +159,8 @@ public function export(Request $request): StreamedResponse|JsonResponse $moneyColumns = ['E', 'F', 'G', 'H', 'I']; foreach ($payments as $idx => $payment) { - $earner = $payment->earner ?? null; - $name = $earner?->business_name ?? $payment->user?->name ?? '-'; - $regNumber = $earner?->business_registration_number ?? $earner?->resident_number ?? ''; + $name = $payment->display_name ?: ($payment->user?->name ?? '-'); + $regNumber = $payment->business_reg_number ?? ''; $sheet->setCellValue("A{$row}", $idx + 1); $sheet->setCellValue("B{$row}", $name); diff --git a/app/Http/Controllers/HR/BusinessIncomePaymentController.php b/app/Http/Controllers/HR/BusinessIncomePaymentController.php index 8c51f5bf..0ece1097 100644 --- a/app/Http/Controllers/HR/BusinessIncomePaymentController.php +++ b/app/Http/Controllers/HR/BusinessIncomePaymentController.php @@ -34,12 +34,18 @@ public function index(Request $request): View|Response $earners = $this->service->getActiveEarners(); $payments = $this->service->getPayments($year, $month); - $paymentsByUser = $payments->keyBy('user_id'); $stats = $this->service->getMonthlyStats($year, $month); + $earnersForJs = $earners->map(fn ($e) => [ + 'user_id' => $e->user_id, + 'business_name' => $e->business_name ?? ($e->user?->name ?? ''), + 'user_name' => $e->user?->name ?? '', + 'business_reg_number' => $e->business_registration_number ?? '', + ])->values(); + return view('hr.business-income-payments.index', [ - 'earners' => $earners, - 'paymentsByUser' => $paymentsByUser, + 'payments' => $payments, + 'earnersForJs' => $earnersForJs, 'stats' => $stats, 'year' => $year, 'month' => $month, diff --git a/app/Models/HR/BusinessIncomePayment.php b/app/Models/HR/BusinessIncomePayment.php index 37e6aa9a..f634077c 100644 --- a/app/Models/HR/BusinessIncomePayment.php +++ b/app/Models/HR/BusinessIncomePayment.php @@ -17,6 +17,8 @@ class BusinessIncomePayment extends Model protected $fillable = [ 'tenant_id', 'user_id', + 'display_name', + 'business_reg_number', 'pay_year', 'pay_month', 'service_content', diff --git a/app/Services/HR/BusinessIncomePaymentService.php b/app/Services/HR/BusinessIncomePaymentService.php index acc6148c..3d909bd7 100644 --- a/app/Services/HR/BusinessIncomePaymentService.php +++ b/app/Services/HR/BusinessIncomePaymentService.php @@ -42,9 +42,11 @@ public function getActiveEarners(): Collection /** * 일괄 저장 * - * - 지급총액 > 0: upsert (신규 생성 또는 draft 수정) + * - payment_id 기반 기존 레코드 조회 (수정 시) + * - payment_id 없고 user_id 있으면 기존 방식 조회 + * - 둘 다 없으면 신규 생성 * - 지급총액 == 0: draft면 삭제, confirmed/paid는 무시 - * - confirmed/paid 상태 레코드는 수정하지 않음 + * - 제출되지 않은 기존 draft 행 자동 삭제 (사용자가 행을 삭제한 경우) */ public function bulkSave(int $year, int $month, array $items): array { @@ -54,22 +56,39 @@ public function bulkSave(int $year, int $month, array $items): array $skipped = 0; DB::transaction(function () use ($items, $tenantId, $year, $month, &$saved, &$deleted, &$skipped) { + $submittedPaymentIds = []; + foreach ($items as $item) { - $userId = (int) ($item['user_id'] ?? 0); + $paymentId = ! empty($item['payment_id']) ? (int) $item['payment_id'] : null; + $userId = ! empty($item['user_id']) ? (int) $item['user_id'] : null; + $displayName = trim($item['display_name'] ?? ''); + $businessRegNumber = $item['business_reg_number'] ?? null; $grossAmount = (float) ($item['gross_amount'] ?? 0); - if ($userId === 0) { + if (empty($displayName)) { continue; } - // 기존 레코드 조회 (SoftDeletes 포함, 행 잠금) - $existing = BusinessIncomePayment::withTrashed() - ->where('tenant_id', $tenantId) - ->where('user_id', $userId) - ->where('pay_year', $year) - ->where('pay_month', $month) - ->lockForUpdate() - ->first(); + $existing = null; + + // payment_id로 기존 레코드 조회 + if ($paymentId) { + $existing = BusinessIncomePayment::where('id', $paymentId) + ->where('tenant_id', $tenantId) + ->lockForUpdate() + ->first(); + } + + // user_id로 기존 레코드 조회 (payment_id 없고 user_id 있는 경우) + if (! $existing && $userId) { + $existing = BusinessIncomePayment::withTrashed() + ->where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->where('pay_year', $year) + ->where('pay_month', $month) + ->lockForUpdate() + ->first(); + } if ($grossAmount <= 0) { // 지급총액 0: draft면 삭제 @@ -84,6 +103,7 @@ public function bulkSave(int $year, int $month, array $items): array // confirmed/paid 상태는 수정하지 않음 if ($existing && ! $existing->trashed() && ! $existing->isEditable()) { + $submittedPaymentIds[] = $existing->id; $skipped++; continue; @@ -94,6 +114,8 @@ public function bulkSave(int $year, int $month, array $items): array $data = [ 'tenant_id' => $tenantId, 'user_id' => $userId, + 'display_name' => $displayName, + 'business_reg_number' => $businessRegNumber, 'pay_year' => $year, 'pay_month' => $month, 'service_content' => $item['service_content'] ?? null, @@ -109,18 +131,35 @@ public function bulkSave(int $year, int $month, array $items): array if ($existing && $existing->trashed()) { $existing->forceDelete(); + $existing = null; } - if ($existing && ! $existing->trashed()) { + if ($existing) { $existing->update($data); + $submittedPaymentIds[] = $existing->id; } else { $data['status'] = 'draft'; $data['created_by'] = auth()->id(); - BusinessIncomePayment::create($data); + $record = BusinessIncomePayment::create($data); + $submittedPaymentIds[] = $record->id; } $saved++; } + + // 제출되지 않은 기존 draft 행 자동 삭제 (사용자가 행을 삭제한 경우) + $orphanDrafts = BusinessIncomePayment::where('tenant_id', $tenantId) + ->where('pay_year', $year) + ->where('pay_month', $month) + ->where('status', 'draft') + ->when(count($submittedPaymentIds) > 0, fn ($q) => $q->whereNotIn('id', $submittedPaymentIds)) + ->get(); + + foreach ($orphanDrafts as $orphan) { + $orphan->update(['deleted_by' => auth()->id()]); + $orphan->delete(); + $deleted++; + } }); return [ @@ -163,28 +202,17 @@ public function getMonthlyStats(int $year, int $month): array } /** - * XLSX 내보내기 데이터 (earner 프로필 포함) + * XLSX 내보내기 데이터 */ public function getExportData(int $year, int $month): Collection { $tenantId = session('selected_tenant_id', 1); - $payments = BusinessIncomePayment::query() + return BusinessIncomePayment::query() ->with('user:id,name') ->forTenant($tenantId) ->forPeriod($year, $month) ->orderBy('id') ->get(); - - // earner 프로필 일괄 로드 (N+1 방지) - $userIds = $payments->pluck('user_id')->unique(); - $earners = BusinessIncomeEarner::whereIn('user_id', $userIds) - ->where('tenant_id', $tenantId) - ->get() - ->keyBy('user_id'); - - $payments->each(fn ($p) => $p->earner = $earners->get($p->user_id)); - - return $payments; } } diff --git a/resources/views/hr/business-income-payments/index.blade.php b/resources/views/hr/business-income-payments/index.blade.php index 3a076256..ffe410cb 100644 --- a/resources/views/hr/business-income-payments/index.blade.php +++ b/resources/views/hr/business-income-payments/index.blade.php @@ -52,8 +52,8 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 te
@include('hr.business-income-payments.partials.spreadsheet', [ - 'earners' => $earners, - 'paymentsByUser' => $paymentsByUser, + 'payments' => $payments, + 'earnersForJs' => $earnersForJs, 'year' => $year, 'month' => $month, ]) @@ -114,12 +114,9 @@ function calcRow(el) { function updateFooterSums() { let sumGross = 0, sumIT = 0, sumLT = 0, sumDed = 0, sumNet = 0; - document.querySelectorAll('#bipTable tbody tr.bip-row').forEach(row => { - sumGross += parseMoneyValue(row.querySelector('[name="gross_amount"]')); - }); - // 합계 행의 세금도 직접 계산 (표시값 합산이 아닌 각 행 기준) document.querySelectorAll('#bipTable tbody tr.bip-row').forEach(row => { const g = parseMoneyValue(row.querySelector('[name="gross_amount"]')); + sumGross += g; sumIT += Math.floor(g * 0.03 / 10) * 10; sumLT += Math.floor(g * 0.003 / 10) * 10; }); @@ -134,6 +131,121 @@ function updateFooterSums() { if (el('bipSumNet')) el('bipSumNet').textContent = fmtNum(sumNet); } +// ========================================================================= +// 행 추가/삭제/번호 재정렬 +// ========================================================================= +function addBipRow() { + const tbody = document.getElementById('bipTbody'); + const rowCount = tbody.querySelectorAll('tr.bip-row').length; + const num = rowCount + 1; + + const tr = document.createElement('tr'); + tr.className = 'hover:bg-gray-50 transition-colors bip-row'; + tr.dataset.paymentId = ''; + tr.dataset.userId = ''; + + tr.innerHTML = ` + ${num} + + + + + + + + + + + + + 0 + 0 + 0 + 0 + + + + + + + + + + `; + + tbody.appendChild(tr); + tr.querySelector('[name="display_name"]').focus(); +} + +function removeBipRow(el) { + const row = el.closest('tr.bip-row'); + if (!row) return; + row.remove(); + renumberBipRows(); + updateFooterSums(); +} + +function renumberBipRows() { + document.querySelectorAll('#bipTbody tr.bip-row').forEach((row, idx) => { + const numCell = row.querySelector('.bip-row-num'); + if (numCell) { + const badge = numCell.querySelector('span'); + const num = document.createTextNode(idx + 1); + numCell.textContent = ''; + numCell.appendChild(num); + if (badge) numCell.appendChild(badge); + } + }); +} + +// ========================================================================= +// datalist 선택 → user_id + 사업자등록번호 자동 채움 +// ========================================================================= +function onEarnerInput(input) { + const row = input.closest('tr.bip-row'); + if (!row) return; + row.dataset.userId = ''; +} + +function onEarnerSelect(input) { + const row = input.closest('tr.bip-row'); + if (!row) return; + + const val = input.value.trim(); + const earners = window.__bipEarners || []; + + const match = earners.find(e => + e.business_name === val || e.user_name === val + ); + + if (match) { + row.dataset.userId = match.user_id; + const regInput = row.querySelector('[name="business_reg_number"]'); + if (regInput) regInput.value = match.business_reg_number || ''; + } else { + row.dataset.userId = ''; + } +} + // ========================================================================= // 연월 변경 → HTMX 갱신 // ========================================================================= @@ -153,6 +265,13 @@ function refreshBIP() { if (parts.length === 2) { document.getElementById('bipStatsContainer').innerHTML = parts[0]; document.getElementById('bipSpreadsheet').innerHTML = parts[1]; + + // script 태그 수동 실행 (innerHTML로 삽입된 script는 실행되지 않음) + document.getElementById('bipSpreadsheet').querySelectorAll('script').forEach(oldScript => { + const newScript = document.createElement('script'); + newScript.textContent = oldScript.textContent; + oldScript.parentNode.replaceChild(newScript, oldScript); + }); } }); } @@ -169,14 +288,34 @@ function saveBIP() { const year = document.getElementById('bipYear').value; const month = document.getElementById('bipMonth').value; const items = []; + const seenUserIds = new Set(); + let hasDuplicate = false; - document.querySelectorAll('#bipTable tbody tr.bip-row').forEach(row => { - const userId = row.dataset.userId; + document.querySelectorAll('#bipTbody tr.bip-row').forEach(row => { const grossEl = row.querySelector('[name="gross_amount"]'); - if (!userId || !grossEl || grossEl.disabled) return; + + // locked (confirmed/paid) 행은 건너뜀 + if (grossEl && grossEl.disabled) return; + + const displayNameEl = row.querySelector('[name="display_name"]'); + const displayName = displayNameEl?.value?.trim() || ''; + + // display_name이 비어있으면 건너뜀 (빈 행) + if (!displayName) return; + + const userId = row.dataset.userId ? parseInt(row.dataset.userId) : null; + + // 동일 earner 중복 체크 + if (userId) { + if (seenUserIds.has(userId)) hasDuplicate = true; + seenUserIds.add(userId); + } items.push({ - user_id: parseInt(userId), + payment_id: row.dataset.paymentId ? parseInt(row.dataset.paymentId) : null, + user_id: userId, + display_name: displayName, + business_reg_number: row.querySelector('[name="business_reg_number"]')?.value || '', gross_amount: parseMoneyValue(grossEl), service_content: row.querySelector('[name="service_content"]')?.value || '', payment_date: row.querySelector('[name="payment_date"]')?.value || '', @@ -184,6 +323,19 @@ function saveBIP() { }); }); + if (hasDuplicate && !confirm('동일한 사업소득자가 중복 입력되어 있습니다. 그래도 저장하시겠습니까?')) { + btn.disabled = false; + btn.innerHTML = origText; + return; + } + + if (items.length === 0) { + btn.disabled = false; + btn.innerHTML = origText; + showToast('저장할 데이터가 없습니다.', 'error'); + return; + } + fetch('{{ route("api.admin.hr.business-income-payments.bulk-save") }}', { method: 'POST', headers: { diff --git a/resources/views/hr/business-income-payments/partials/spreadsheet.blade.php b/resources/views/hr/business-income-payments/partials/spreadsheet.blade.php index 34add7fb..80cf0a6b 100644 --- a/resources/views/hr/business-income-payments/partials/spreadsheet.blade.php +++ b/resources/views/hr/business-income-payments/partials/spreadsheet.blade.php @@ -1,17 +1,24 @@ {{-- 사업소득자 임금대장 스프레드시트 (HTMX로 갱신) --}} @php use App\Models\HR\BusinessIncomePayment; - - // 최소 10행 보장 - $rowCount = max(10, $earners->count()); @endphp +{{-- earners 데이터를 JS에 전달 --}} + + +{{-- 공유 datalist --}} + + @foreach($earnersForJs as $earner) + + @endforeach + + - + @@ -21,20 +28,21 @@ + - - @foreach($earners as $idx => $earner) + + @foreach($payments as $idx => $payment) @php - $payment = $paymentsByUser->get($earner->user_id); - $isLocked = $payment && !$payment->isEditable(); - $statusColor = $payment ? (BusinessIncomePayment::STATUS_COLORS[$payment->status] ?? 'gray') : ''; - $statusLabel = $payment ? (BusinessIncomePayment::STATUS_MAP[$payment->status] ?? '') : ''; + $isLocked = !$payment->isEditable(); + $statusColor = BusinessIncomePayment::STATUS_COLORS[$payment->status] ?? 'gray'; + $statusLabel = BusinessIncomePayment::STATUS_MAP[$payment->status] ?? ''; @endphp - {{-- 구분 (번호) --}} - {{-- 상호/성명 --}} - {{-- 사업자등록번호 --}} - {{-- 용역내용 --}} @@ -70,7 +96,7 @@ class="w-full px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focu - {{ $payment ? number_format($payment->income_tax) : '0' }} + {{ number_format($payment->income_tax) }} {{-- 지방소득세 (자동계산) --}} {{-- 공제합계 (자동계산) --}} {{-- 실지급액 (자동계산) --}} {{-- 지급일자 --}} + + {{-- 삭제 --}} + @endforeach - - {{-- 빈 행 채우기 (최소 10행) --}} - @for($i = $earners->count(); $i < 10; $i++) - - - - - - - @endfor + {{-- 행 추가 버튼 --}} + + + + {{-- 합계 --}} - - + - +
구분상호/성명상호/성명 사업자등록번호 용역내용 지급총액실지급액 지급일자 비고삭제
+ data-payment-id="{{ $payment->id }}" + data-user-id="{{ $payment->user_id ?? '' }}"> + {{-- 구분 --}} + {{ $idx + 1 }} @if($isLocked) {{ $statusLabel }} @@ -42,16 +50,34 @@ - {{ $earner->business_name ?: ($earner->user?->name ?? '-') }} - @if($earner->business_name && $earner->user?->name) - {{ $earner->user->name }} + + @if($isLocked) + {{ $payment->display_name ?: ($payment->user?->name ?? '-') }} + @else + @endif - {{ $earner->business_registration_number ?? '-' }} + + @if($isLocked) + {{ $payment->business_reg_number ?? '-' }} + @else + + @endif - {{ $payment ? number_format($payment->local_income_tax) : '0' }} + {{ number_format($payment->local_income_tax) }} - {{ $payment ? number_format($payment->total_deductions) : '0' }} + {{ number_format($payment->total_deductions) }} - {{ $payment ? number_format($payment->net_amount) : '0' }} + {{ number_format($payment->net_amount) }} @@ -119,39 +145,55 @@ class="w-full px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focu class="w-full px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-blue-500 focus:border-blue-500 {{ $isLocked ? 'bg-gray-100 text-gray-500' : '' }}" style="min-width: 80px;"> + @if(!$isLocked) + + @else + + @endif +
{{ $i + 1 }}--
+ +
합계합계 - {{ number_format($paymentsByUser->sum('gross_amount')) }} + {{ number_format($payments->sum('gross_amount')) }} - {{ number_format($paymentsByUser->sum('income_tax')) }} + {{ number_format($payments->sum('income_tax')) }} - {{ number_format($paymentsByUser->sum('local_income_tax')) }} + {{ number_format($payments->sum('local_income_tax')) }} - {{ number_format($paymentsByUser->sum('total_deductions')) }} + {{ number_format($payments->sum('total_deductions')) }} - {{ number_format($paymentsByUser->sum('net_amount')) }} + {{ number_format($payments->sum('net_amount')) }}