feat: [hr] 사업소득자 임금대장 입력 기능 구현
- BusinessIncomePayment 모델 (소득세3%/지방소득세0.3% 자동계산) - BusinessIncomePaymentService (일괄저장/통계/CSV내보내기) - 웹/API 컨트롤러 (ALLOWED_PAYROLL_USERS 접근 제한) - 스프레드시트 UI (인라인 편집, 실시간 세금 계산) - HTMX 연월 변경 갱신, CSV 내보내기
This commit is contained in:
@@ -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']);
|
||||
}
|
||||
}
|
||||
48
app/Http/Controllers/HR/BusinessIncomePaymentController.php
Normal file
48
app/Http/Controllers/HR/BusinessIncomePaymentController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
192
app/Models/HR/BusinessIncomePayment.php
Normal file
192
app/Models/HR/BusinessIncomePayment.php
Normal 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);
|
||||
}
|
||||
}
|
||||
179
app/Services/HR/BusinessIncomePaymentService.php
Normal file
179
app/Services/HR/BusinessIncomePaymentService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
235
resources/views/hr/business-income-payments/index.blade.php
Normal file
235
resources/views/hr/business-income-payments/index.blade.php
Normal 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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user