feat: [hr] 사업소득자 임금대장 입력 기능 구현

- BusinessIncomePayment 모델 (소득세3%/지방소득세0.3% 자동계산)
- BusinessIncomePaymentService (일괄저장/통계/CSV내보내기)
- 웹/API 컨트롤러 (ALLOWED_PAYROLL_USERS 접근 제한)
- 스프레드시트 UI (인라인 편집, 실시간 세금 계산)
- HTMX 연월 변경 갱신, CSV 내보내기
This commit is contained in:
김보곤
2026-02-27 20:22:07 +09:00
parent cd61dc8366
commit 30973d1772
9 changed files with 992 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Http\Controllers\Api\Admin\HR;
use App\Http\Controllers\Controller;
use App\Models\HR\BusinessIncomePayment;
use App\Services\HR\BusinessIncomePaymentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BusinessIncomePaymentController extends Controller
{
private const ALLOWED_PAYROLL_USERS = ['이경호', '전진선', '김보곤'];
public function __construct(
private BusinessIncomePaymentService $service
) {}
private function checkPayrollAccess(): ?JsonResponse
{
if (! in_array(auth()->user()->name, self::ALLOWED_PAYROLL_USERS)) {
return response()->json([
'success' => false,
'message' => '급여관리는 관계자만 볼 수 있습니다.',
], 403);
}
return null;
}
/**
* 사업소득 지급 목록 (HTMX → 스프레드시트 파셜)
*/
public function index(Request $request): JsonResponse|Response
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$year = $request->integer('year') ?: now()->year;
$month = $request->integer('month') ?: now()->month;
$earners = $this->service->getActiveEarners();
$payments = $this->service->getPayments($year, $month);
$paymentsByUser = $payments->keyBy('user_id');
$stats = $this->service->getMonthlyStats($year, $month);
if ($request->header('HX-Request')) {
return response(
view('hr.business-income-payments.partials.stats', compact('stats')).
'<!-- SPLIT -->'.
view('hr.business-income-payments.partials.spreadsheet', compact('earners', 'paymentsByUser', 'year', 'month'))
);
}
return response()->json([
'success' => true,
'data' => $payments,
'stats' => $stats,
]);
}
/**
* 일괄 저장
*/
public function bulkSave(Request $request): JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$validated = $request->validate([
'year' => 'required|integer|min:2020|max:2100',
'month' => 'required|integer|min:1|max:12',
'items' => 'required|array',
'items.*.user_id' => 'required|integer',
'items.*.gross_amount' => 'required|numeric|min:0',
'items.*.service_content' => 'nullable|string|max:200',
'items.*.payment_date' => 'nullable|date',
'items.*.note' => 'nullable|string|max:500',
]);
$result = $this->service->bulkSave(
$validated['year'],
$validated['month'],
$validated['items']
);
return response()->json([
'success' => true,
'message' => "저장 {$result['saved']}건, 삭제 {$result['deleted']}건, 건너뜀 {$result['skipped']}",
'data' => $result,
]);
}
/**
* CSV 내보내기
*/
public function export(Request $request): StreamedResponse|JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$year = $request->integer('year') ?: now()->year;
$month = $request->integer('month') ?: now()->month;
$payments = $this->service->getExportData($year, $month);
$filename = "사업소득자임금대장_{$year}{$month}월_".now()->format('Ymd').'.csv';
return response()->streamDownload(function () use ($payments) {
$file = fopen('php://output', 'w');
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM
fputcsv($file, [
'번호', '상호/성명', '사업자등록번호', '용역내용',
'지급총액', '소득세(3%)', '지방소득세(0.3%)', '공제합계',
'실지급액', '지급일자', '비고', '상태',
]);
foreach ($payments as $idx => $payment) {
$earnerName = $payment->user?->name ?? '-';
fputcsv($file, [
$idx + 1,
$earnerName,
'', // 사업자등록번호는 별도 조회 필요
$payment->service_content ?? '',
$payment->gross_amount,
$payment->income_tax,
$payment->local_income_tax,
$payment->total_deductions,
$payment->net_amount,
$payment->payment_date?->format('Y-m-d') ?? '',
$payment->note ?? '',
BusinessIncomePayment::STATUS_MAP[$payment->status] ?? $payment->status,
]);
}
fclose($file);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\HR;
use App\Http\Controllers\Controller;
use App\Services\HR\BusinessIncomePaymentService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class BusinessIncomePaymentController extends Controller
{
private const ALLOWED_PAYROLL_USERS = ['이경호', '전진선', '김보곤'];
public function __construct(
private BusinessIncomePaymentService $service
) {}
/**
* 사업소득자 임금대장 페이지
*/
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('hr.business-income-payments.index'));
}
if (! in_array(auth()->user()->name, self::ALLOWED_PAYROLL_USERS)) {
return view('hr.payrolls.restricted');
}
$year = $request->integer('year') ?: now()->year;
$month = $request->integer('month') ?: now()->month;
$earners = $this->service->getActiveEarners();
$payments = $this->service->getPayments($year, $month);
$paymentsByUser = $payments->keyBy('user_id');
$stats = $this->service->getMonthlyStats($year, $month);
return view('hr.business-income-payments.index', [
'earners' => $earners,
'paymentsByUser' => $paymentsByUser,
'stats' => $stats,
'year' => $year,
'month' => $month,
]);
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Models\HR;
use App\Models\User;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class BusinessIncomePayment extends Model
{
use ModelTrait, SoftDeletes;
protected $table = 'business_income_payments';
protected $fillable = [
'tenant_id',
'user_id',
'pay_year',
'pay_month',
'service_content',
'gross_amount',
'income_tax',
'local_income_tax',
'total_deductions',
'net_amount',
'payment_date',
'note',
'status',
'confirmed_at',
'confirmed_by',
'paid_at',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'tenant_id' => 'int',
'user_id' => 'int',
'pay_year' => 'int',
'pay_month' => 'int',
'gross_amount' => 'decimal:0',
'income_tax' => 'decimal:0',
'local_income_tax' => 'decimal:0',
'total_deductions' => 'decimal:0',
'net_amount' => 'decimal:0',
'payment_date' => 'date',
'confirmed_at' => 'datetime',
'paid_at' => 'datetime',
];
protected $attributes = [
'status' => 'draft',
];
// =========================================================================
// 상수
// =========================================================================
public const STATUS_DRAFT = 'draft';
public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_PAID = 'paid';
public const STATUS_MAP = [
'draft' => '작성중',
'confirmed' => '확정',
'paid' => '지급완료',
];
public const STATUS_COLORS = [
'draft' => 'amber',
'confirmed' => 'blue',
'paid' => 'emerald',
];
/** 소득세율 3% */
public const INCOME_TAX_RATE = 0.03;
/** 지방소득세율 0.3% */
public const LOCAL_TAX_RATE = 0.003;
// =========================================================================
// 관계 정의
// =========================================================================
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function confirmer(): BelongsTo
{
return $this->belongsTo(User::class, 'confirmed_by');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
// =========================================================================
// Accessor
// =========================================================================
public function getStatusLabelAttribute(): string
{
return self::STATUS_MAP[$this->status] ?? $this->status;
}
public function getStatusColorAttribute(): string
{
return self::STATUS_COLORS[$this->status] ?? 'gray';
}
public function getPeriodLabelAttribute(): string
{
return sprintf('%d년 %d월', $this->pay_year, $this->pay_month);
}
// =========================================================================
// 상태 헬퍼
// =========================================================================
public function isEditable(): bool
{
return $this->status === self::STATUS_DRAFT;
}
public function isConfirmable(): bool
{
return $this->status === self::STATUS_DRAFT;
}
public function isPayable(): bool
{
return $this->status === self::STATUS_CONFIRMED;
}
public function isDeletable(): bool
{
return $this->status === self::STATUS_DRAFT;
}
// =========================================================================
// 세금 계산 (정적)
// =========================================================================
/**
* 지급총액으로 세금/공제/실지급액 계산
*
* @return array{income_tax: int, local_income_tax: int, total_deductions: int, net_amount: int}
*/
public static function calculateTax(float $grossAmount): array
{
$incomeTax = (int) (floor($grossAmount * self::INCOME_TAX_RATE / 10) * 10);
$localIncomeTax = (int) (floor($grossAmount * self::LOCAL_TAX_RATE / 10) * 10);
$totalDeductions = $incomeTax + $localIncomeTax;
$netAmount = (int) max(0, $grossAmount - $totalDeductions);
return [
'income_tax' => $incomeTax,
'local_income_tax' => $localIncomeTax,
'total_deductions' => $totalDeductions,
'net_amount' => $netAmount,
];
}
// =========================================================================
// 스코프
// =========================================================================
public function scopeForTenant($query, ?int $tenantId = null)
{
$tenantId = $tenantId ?? session('selected_tenant_id', 1);
return $query->where($this->table.'.tenant_id', $tenantId);
}
public function scopeForPeriod($query, int $year, int $month)
{
return $query->where('pay_year', $year)->where('pay_month', $month);
}
public function scopeWithStatus($query, string $status)
{
return $query->where('status', $status);
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace App\Services\HR;
use App\Models\HR\BusinessIncomeEarner;
use App\Models\HR\BusinessIncomePayment;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class BusinessIncomePaymentService
{
/**
* 월별 사업소득 지급 내역 조회
*/
public function getPayments(int $year, int $month): Collection
{
$tenantId = session('selected_tenant_id', 1);
return BusinessIncomePayment::query()
->with('user:id,name')
->forTenant($tenantId)
->forPeriod($year, $month)
->orderBy('id')
->get();
}
/**
* 활성 사업소득자 목록
*/
public function getActiveEarners(): Collection
{
$tenantId = session('selected_tenant_id', 1);
return BusinessIncomeEarner::query()
->with('user:id,name')
->forTenant($tenantId)
->activeEmployees()
->orderBy('display_name')
->get();
}
/**
* 일괄 저장
*
* - 지급총액 > 0: upsert (신규 생성 또는 draft 수정)
* - 지급총액 == 0: draft면 삭제, confirmed/paid는 무시
* - confirmed/paid 상태 레코드는 수정하지 않음
*/
public function bulkSave(int $year, int $month, array $items): array
{
$tenantId = session('selected_tenant_id', 1);
$saved = 0;
$deleted = 0;
$skipped = 0;
DB::transaction(function () use ($items, $tenantId, $year, $month, &$saved, &$deleted, &$skipped) {
foreach ($items as $item) {
$userId = (int) ($item['user_id'] ?? 0);
$grossAmount = (float) ($item['gross_amount'] ?? 0);
if ($userId === 0) {
continue;
}
// 기존 레코드 조회 (SoftDeletes 포함, 행 잠금)
$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면 삭제
if ($existing && ! $existing->trashed() && $existing->isEditable()) {
$existing->update(['deleted_by' => auth()->id()]);
$existing->delete();
$deleted++;
}
continue;
}
// confirmed/paid 상태는 수정하지 않음
if ($existing && ! $existing->trashed() && ! $existing->isEditable()) {
$skipped++;
continue;
}
$tax = BusinessIncomePayment::calculateTax($grossAmount);
$data = [
'tenant_id' => $tenantId,
'user_id' => $userId,
'pay_year' => $year,
'pay_month' => $month,
'service_content' => $item['service_content'] ?? null,
'gross_amount' => (int) $grossAmount,
'income_tax' => $tax['income_tax'],
'local_income_tax' => $tax['local_income_tax'],
'total_deductions' => $tax['total_deductions'],
'net_amount' => $tax['net_amount'],
'payment_date' => ! empty($item['payment_date']) ? $item['payment_date'] : null,
'note' => $item['note'] ?? null,
'updated_by' => auth()->id(),
];
if ($existing && $existing->trashed()) {
$existing->forceDelete();
}
if ($existing && ! $existing->trashed()) {
$existing->update($data);
} else {
$data['status'] = 'draft';
$data['created_by'] = auth()->id();
BusinessIncomePayment::create($data);
}
$saved++;
}
});
return [
'saved' => $saved,
'deleted' => $deleted,
'skipped' => $skipped,
];
}
/**
* 월간 통계 (통계 카드용)
*/
public function getMonthlyStats(int $year, int $month): array
{
$tenantId = session('selected_tenant_id', 1);
$result = BusinessIncomePayment::query()
->forTenant($tenantId)
->forPeriod($year, $month)
->select(
DB::raw('COUNT(*) as total_count'),
DB::raw('SUM(gross_amount) as total_gross'),
DB::raw('SUM(total_deductions) as total_deductions'),
DB::raw('SUM(net_amount) as total_net'),
DB::raw("SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END) as draft_count"),
DB::raw("SUM(CASE WHEN status = 'confirmed' THEN 1 ELSE 0 END) as confirmed_count"),
DB::raw("SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid_count"),
)
->first();
return [
'total_gross' => (int) ($result->total_gross ?? 0),
'total_deductions' => (int) ($result->total_deductions ?? 0),
'total_net' => (int) ($result->total_net ?? 0),
'total_count' => (int) ($result->total_count ?? 0),
'draft_count' => (int) ($result->draft_count ?? 0),
'confirmed_count' => (int) ($result->confirmed_count ?? 0),
'paid_count' => (int) ($result->paid_count ?? 0),
];
}
/**
* CSV 내보내기 데이터
*/
public function getExportData(int $year, int $month): Collection
{
$tenantId = session('selected_tenant_id', 1);
return BusinessIncomePayment::query()
->with('user:id,name')
->forTenant($tenantId)
->forPeriod($year, $month)
->orderBy('id')
->get();
}
}

View File

@@ -0,0 +1,235 @@
@extends('layouts.app')
@section('title', '사업소득자 임금대장')
@section('content')
<div class="px-4 py-6">
{{-- 페이지 헤더 --}}
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">사업소득자 임금대장</h1>
<div class="flex items-center gap-2 mt-1">
<select id="bipYear" class="px-2 py-1 border border-gray-300 rounded text-sm text-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
onchange="refreshBIP()">
@for($y = now()->year; $y >= now()->year - 2; $y--)
<option value="{{ $y }}" {{ $year == $y ? 'selected' : '' }}>{{ $y }}</option>
@endfor
</select>
<select id="bipMonth" class="px-2 py-1 border border-gray-300 rounded text-sm text-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
onchange="refreshBIP()">
@for($m = 1; $m <= 12; $m++)
<option value="{{ $m }}" {{ $month == $m ? 'selected' : '' }}>{{ $m }}</option>
@endfor
</select>
<span class="text-sm text-gray-400 ml-1">소득세 3% + 지방소득세 0.3%</span>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
<button type="button" onclick="exportBIP()"
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-medium rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
CSV 내보내기
</button>
<button type="button" onclick="saveBIP()"
id="bipSaveBtn"
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"/>
</svg>
일괄 저장
</button>
</div>
</div>
{{-- 통계 카드 --}}
<div id="bipStatsContainer" class="mb-6">
@include('hr.business-income-payments.partials.stats', ['stats' => $stats])
</div>
{{-- 스프레드시트 --}}
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<div id="bipSpreadsheet">
@include('hr.business-income-payments.partials.spreadsheet', [
'earners' => $earners,
'paymentsByUser' => $paymentsByUser,
'year' => $year,
'month' => $month,
])
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
// =========================================================================
// Money Input 유틸리티
// =========================================================================
function formatMoneyInput(el) {
const pos = el.selectionStart;
const oldLen = el.value.length;
const isNeg = el.value.indexOf('-') === 0;
const raw = el.value.replace(/[^0-9]/g, '');
const num = parseInt(raw, 10);
if (isNaN(num)) { el.value = isNeg ? '-' : ''; return; }
el.value = (isNeg ? '-' : '') + num.toLocaleString('ko-KR');
const newLen = el.value.length;
el.setSelectionRange(Math.max(0, pos + (newLen - oldLen)), Math.max(0, pos + (newLen - oldLen)));
}
function moneyFocus(el) { if (parseMoneyValue(el) === 0) el.value = ''; }
function moneyBlur(el) { if (el.value.trim() === '') el.value = '0'; calcRow(el); }
function parseMoneyValue(el) {
if (typeof el === 'string') return parseFloat(el.replace(/,/g, '')) || 0;
return parseFloat((el.value || '').replace(/,/g, '')) || 0;
}
function fmtNum(n) {
return Number(Math.round(n)).toLocaleString('ko-KR');
}
// =========================================================================
// 행 자동 계산 (지급총액 → 소득세/지방소득세/공제합계/실지급액)
// =========================================================================
function calcRow(el) {
const row = el.closest('tr.bip-row');
if (!row) return;
const gross = parseMoneyValue(row.querySelector('[name="gross_amount"]'));
const incomeTax = Math.floor(gross * 0.03 / 10) * 10;
const localTax = Math.floor(gross * 0.003 / 10) * 10;
const totalDed = incomeTax + localTax;
const net = Math.max(0, gross - totalDed);
row.querySelector('.bip-income-tax').textContent = fmtNum(incomeTax);
row.querySelector('.bip-local-tax').textContent = fmtNum(localTax);
row.querySelector('.bip-total-ded').textContent = fmtNum(totalDed);
row.querySelector('.bip-net').textContent = fmtNum(net);
updateFooterSums();
}
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"]'));
sumIT += Math.floor(g * 0.03 / 10) * 10;
sumLT += Math.floor(g * 0.003 / 10) * 10;
});
sumDed = sumIT + sumLT;
sumNet = Math.max(0, sumGross - sumDed);
const el = id => document.getElementById(id);
if (el('bipSumGross')) el('bipSumGross').textContent = fmtNum(sumGross);
if (el('bipSumIncomeTax'))el('bipSumIncomeTax').textContent = fmtNum(sumIT);
if (el('bipSumLocalTax')) el('bipSumLocalTax').textContent = fmtNum(sumLT);
if (el('bipSumDed')) el('bipSumDed').textContent = fmtNum(sumDed);
if (el('bipSumNet')) el('bipSumNet').textContent = fmtNum(sumNet);
}
// =========================================================================
// 연월 변경 → HTMX 갱신
// =========================================================================
function refreshBIP() {
const year = document.getElementById('bipYear').value;
const month = document.getElementById('bipMonth').value;
fetch(`{{ route('api.admin.hr.business-income-payments.index') }}?year=${year}&month=${month}`, {
headers: {
'HX-Request': 'true',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
}
})
.then(r => r.text())
.then(html => {
const parts = html.split('<!-- SPLIT -->');
if (parts.length === 2) {
document.getElementById('bipStatsContainer').innerHTML = parts[0];
document.getElementById('bipSpreadsheet').innerHTML = parts[1];
}
});
}
// =========================================================================
// 일괄 저장
// =========================================================================
function saveBIP() {
const btn = document.getElementById('bipSaveBtn');
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> 저장중...';
const year = document.getElementById('bipYear').value;
const month = document.getElementById('bipMonth').value;
const items = [];
document.querySelectorAll('#bipTable tbody tr.bip-row').forEach(row => {
const userId = row.dataset.userId;
const grossEl = row.querySelector('[name="gross_amount"]');
if (!userId || !grossEl || grossEl.disabled) return;
items.push({
user_id: parseInt(userId),
gross_amount: parseMoneyValue(grossEl),
service_content: row.querySelector('[name="service_content"]')?.value || '',
payment_date: row.querySelector('[name="payment_date"]')?.value || '',
note: row.querySelector('[name="note"]')?.value || '',
});
});
fetch('{{ route("api.admin.hr.business-income-payments.bulk-save") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ year: parseInt(year), month: parseInt(month), items }),
})
.then(r => r.json())
.then(result => {
btn.disabled = false;
btn.innerHTML = origText;
if (result.success) {
showToast(result.message, 'success');
refreshBIP();
} else {
showToast(result.message || '저장 실패', 'error');
}
})
.catch(err => {
btn.disabled = false;
btn.innerHTML = origText;
showToast('네트워크 오류', 'error');
});
}
// =========================================================================
// CSV 내보내기
// =========================================================================
function exportBIP() {
const year = document.getElementById('bipYear').value;
const month = document.getElementById('bipMonth').value;
window.location.href = `{{ route('api.admin.hr.business-income-payments.export') }}?year=${year}&month=${month}`;
}
// =========================================================================
// 토스트 (전역 showToast가 없을 때 대비)
// =========================================================================
if (typeof showToast === 'undefined') {
window.showToast = function(msg, type) {
const div = document.createElement('div');
div.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-white text-sm ${type === 'error' ? 'bg-red-600' : 'bg-emerald-600'}`;
div.textContent = msg;
document.body.appendChild(div);
setTimeout(() => div.remove(), 3000);
};
}
</script>
@endpush

View File

@@ -0,0 +1,158 @@
{{-- 사업소득자 임금대장 스프레드시트 (HTMX로 갱신) --}}
@php
use App\Models\HR\BusinessIncomePayment;
// 최소 10행 보장
$rowCount = max(10, $earners->count());
@endphp
<x-table-swipe>
<table class="min-w-full" id="bipTable">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-3 py-3 text-center text-xs font-semibold text-gray-600" style="width: 50px;">구분</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600" style="min-width: 120px;">상호/성명</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600" style="min-width: 130px;">사업자등록번호</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600" style="min-width: 150px;">용역내용</th>
<th class="px-3 py-3 text-right text-xs font-semibold text-gray-600" style="min-width: 130px;">지급총액</th>
<th class="px-3 py-3 text-right text-xs font-semibold text-gray-600 bg-red-50" style="min-width: 110px;">소득세(3%)</th>
<th class="px-3 py-3 text-right text-xs font-semibold text-gray-600 bg-red-50" style="min-width: 110px;">지방소득세(0.3%)</th>
<th class="px-3 py-3 text-right text-xs font-semibold text-gray-600 bg-red-50" style="min-width: 110px;">공제합계</th>
<th class="px-3 py-3 text-right text-xs font-semibold text-gray-600 bg-emerald-50" style="min-width: 130px;">실지급액</th>
<th class="px-3 py-3 text-center text-xs font-semibold text-gray-600" style="min-width: 130px;">지급일자</th>
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600" style="min-width: 120px;">비고</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
@foreach($earners as $idx => $earner)
@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] ?? '') : '';
@endphp
<tr class="hover:bg-gray-50 transition-colors bip-row {{ $isLocked ? 'bg-gray-50' : '' }}"
data-user-id="{{ $earner->user_id }}">
{{-- 구분 (번호) --}}
<td class="px-3 py-2 text-center text-sm text-gray-500">
{{ $idx + 1 }}
@if($isLocked)
<span class="block text-xs mt-0.5 px-1 rounded bg-{{ $statusColor }}-100 text-{{ $statusColor }}-700">{{ $statusLabel }}</span>
@endif
</td>
{{-- 상호/성명 --}}
<td class="px-3 py-2 whitespace-nowrap">
<span class="text-sm font-medium text-gray-900">{{ $earner->business_name ?: ($earner->user?->name ?? '-') }}</span>
@if($earner->business_name && $earner->user?->name)
<span class="block text-xs text-gray-400">{{ $earner->user->name }}</span>
@endif
</td>
{{-- 사업자등록번호 --}}
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-600">
{{ $earner->business_registration_number ?? '-' }}
</td>
{{-- 용역내용 --}}
<td class="px-3 py-2">
<input type="text"
name="service_content"
value="{{ $payment->service_content ?? '' }}"
placeholder="용역내용"
{{ $isLocked ? 'disabled' : '' }}
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: 120px;">
</td>
{{-- 지급총액 --}}
<td class="px-3 py-2">
<input type="text"
name="gross_amount"
inputmode="numeric"
value="{{ $payment ? number_format($payment->gross_amount) : '0' }}"
{{ $isLocked ? 'disabled' : '' }}
oninput="formatMoneyInput(this); calcRow(this)"
onfocus="moneyFocus(this)"
onblur="moneyBlur(this)"
class="w-full px-2 py-1 border border-gray-200 rounded text-sm text-right money-input focus:ring-1 focus:ring-blue-500 focus:border-blue-500 {{ $isLocked ? 'bg-gray-100 text-gray-500' : '' }}"
style="min-width: 100px;">
</td>
{{-- 소득세 (자동계산) --}}
<td class="px-3 py-2 text-right text-sm text-red-600 bg-red-50/30 bip-income-tax">
{{ $payment ? number_format($payment->income_tax) : '0' }}
</td>
{{-- 지방소득세 (자동계산) --}}
<td class="px-3 py-2 text-right text-sm text-red-600 bg-red-50/30 bip-local-tax">
{{ $payment ? number_format($payment->local_income_tax) : '0' }}
</td>
{{-- 공제합계 (자동계산) --}}
<td class="px-3 py-2 text-right text-sm font-medium text-red-700 bg-red-50/30 bip-total-ded">
{{ $payment ? number_format($payment->total_deductions) : '0' }}
</td>
{{-- 실지급액 (자동계산) --}}
<td class="px-3 py-2 text-right text-sm font-medium text-emerald-700 bg-emerald-50/30 bip-net">
{{ $payment ? number_format($payment->net_amount) : '0' }}
</td>
{{-- 지급일자 --}}
<td class="px-3 py-2">
<input type="date"
name="payment_date"
value="{{ $payment && $payment->payment_date ? $payment->payment_date->format('Y-m-d') : '' }}"
{{ $isLocked ? 'disabled' : '' }}
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: 110px;">
</td>
{{-- 비고 --}}
<td class="px-3 py-2">
<input type="text"
name="note"
value="{{ $payment->note ?? '' }}"
placeholder="비고"
{{ $isLocked ? 'disabled' : '' }}
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;">
</td>
</tr>
@endforeach
{{-- 채우기 (최소 10) --}}
@for($i = $earners->count(); $i < 10; $i++)
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-3 py-2 text-center text-sm text-gray-300">{{ $i + 1 }}</td>
<td class="px-3 py-2 text-sm text-gray-300">-</td>
<td class="px-3 py-2 text-sm text-gray-300">-</td>
<td class="px-3 py-2" colspan="8"></td>
</tr>
@endfor
</tbody>
<tfoot class="bg-gray-100 border-t-2 border-gray-300">
<tr class="font-semibold">
<td class="px-3 py-3 text-center text-sm text-gray-700" colspan="3">합계</td>
<td class="px-3 py-3"></td>
<td class="px-3 py-3 text-right text-sm text-gray-800" id="bipSumGross">
{{ number_format($paymentsByUser->sum('gross_amount')) }}
</td>
<td class="px-3 py-3 text-right text-sm text-red-600 bg-red-50/30" id="bipSumIncomeTax">
{{ number_format($paymentsByUser->sum('income_tax')) }}
</td>
<td class="px-3 py-3 text-right text-sm text-red-600 bg-red-50/30" id="bipSumLocalTax">
{{ number_format($paymentsByUser->sum('local_income_tax')) }}
</td>
<td class="px-3 py-3 text-right text-sm text-red-700 bg-red-50/30" id="bipSumDed">
{{ number_format($paymentsByUser->sum('total_deductions')) }}
</td>
<td class="px-3 py-3 text-right text-sm text-emerald-700 bg-emerald-50/30" id="bipSumNet">
{{ number_format($paymentsByUser->sum('net_amount')) }}
</td>
<td class="px-3 py-3" colspan="2"></td>
</tr>
</tfoot>
</table>
</x-table-swipe>

View File

@@ -0,0 +1,23 @@
{{-- 사업소득자 월간 통계 카드 (HTMX로 갱신) --}}
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));">
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500"> 지급액</div>
<div class="text-2xl font-bold text-blue-600">{{ number_format($stats['total_gross']) }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500"> 공제액</div>
<div class="text-2xl font-bold text-red-600">{{ number_format($stats['total_deductions']) }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">실지급 총액</div>
<div class="text-2xl font-bold text-emerald-600">{{ number_format($stats['total_net']) }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">대상 인원</div>
<div class="text-2xl font-bold text-gray-600">{{ $stats['total_count'] }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">미확정</div>
<div class="text-2xl font-bold text-amber-600">{{ $stats['draft_count'] }}</div>
</div>
</div>

View File

@@ -1135,3 +1135,10 @@
Route::get('/', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'settingsIndex'])->name('index');
Route::put('/', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'settingsUpdate'])->name('update');
});
// 사업소득자 임금대장 API
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/business-income-payments')->name('api.admin.hr.business-income-payments.')->group(function () {
Route::get('/export', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomePaymentController::class, 'export'])->name('export');
Route::post('/bulk-save', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomePaymentController::class, 'bulkSave'])->name('bulk-save');
Route::get('/', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomePaymentController::class, 'index'])->name('index');
});

View File

@@ -924,6 +924,11 @@
Route::prefix('payrolls')->name('payrolls.')->group(function () {
Route::get('/', [\App\Http\Controllers\HR\PayrollController::class, 'index'])->name('index');
});
// 사업소득자 임금대장
Route::prefix('business-income-payments')->name('business-income-payments.')->group(function () {
Route::get('/', [\App\Http\Controllers\HR\BusinessIncomePaymentController::class, 'index'])->name('index');
});
});
/*