feat: [payroll] 급여관리 기능 구현
- Payroll, PayrollSetting 모델 생성 - PayrollService 구현 (CRUD, 자동계산, 간이세액표, 일괄생성) - Web/API 컨트롤러 생성 (HTMX/JSON 이중 응답) - 급여 목록, 통계 카드, 급여 설정 뷰 생성 - 라우트 추가 (web.php, api.php) - 상태 흐름: draft → confirmed → paid
This commit is contained in:
398
app/Http/Controllers/Api/Admin/HR/PayrollController.php
Normal file
398
app/Http/Controllers/Api/Admin/HR/PayrollController.php
Normal file
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HR\Payroll;
|
||||
use App\Services\HR\PayrollService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PayrollController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PayrollService $payrollService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 급여 목록 조회 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|Response
|
||||
{
|
||||
$payrolls = $this->payrollService->getPayrolls(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.payrolls.partials.table', compact('payrolls')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $payrolls->items(),
|
||||
'meta' => [
|
||||
'current_page' => $payrolls->currentPage(),
|
||||
'last_page' => $payrolls->lastPage(),
|
||||
'per_page' => $payrolls->perPage(),
|
||||
'total' => $payrolls->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 통계 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function stats(Request $request): JsonResponse|Response
|
||||
{
|
||||
$stats = $this->payrollService->getMonthlyStats(
|
||||
$request->integer('year') ?: null,
|
||||
$request->integer('month') ?: null
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.payrolls.partials.stats', compact('stats')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 등록
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
'pay_year' => 'required|integer|min:2020|max:2100',
|
||||
'pay_month' => 'required|integer|min:1|max:12',
|
||||
'base_salary' => 'required|numeric|min:0',
|
||||
'overtime_pay' => 'nullable|numeric|min:0',
|
||||
'bonus' => 'nullable|numeric|min:0',
|
||||
'allowances' => 'nullable|array',
|
||||
'allowances.*.name' => 'required_with:allowances|string',
|
||||
'allowances.*.amount' => 'required_with:allowances|numeric|min:0',
|
||||
'deductions' => 'nullable|array',
|
||||
'deductions.*.name' => 'required_with:deductions|string',
|
||||
'deductions.*.amount' => 'required_with:deductions|numeric|min:0',
|
||||
'note' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
try {
|
||||
$payroll = $this->payrollService->storePayroll($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '급여가 등록되었습니다.',
|
||||
'data' => $payroll,
|
||||
], 201);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '급여 등록 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'base_salary' => 'sometimes|required|numeric|min:0',
|
||||
'overtime_pay' => 'nullable|numeric|min:0',
|
||||
'bonus' => 'nullable|numeric|min:0',
|
||||
'allowances' => 'nullable|array',
|
||||
'allowances.*.name' => 'required_with:allowances|string',
|
||||
'allowances.*.amount' => 'required_with:allowances|numeric|min:0',
|
||||
'deductions' => 'nullable|array',
|
||||
'deductions.*.name' => 'required_with:deductions|string',
|
||||
'deductions.*.amount' => 'required_with:deductions|numeric|min:0',
|
||||
'note' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
try {
|
||||
$payroll = $this->payrollService->updatePayroll($id, $validated);
|
||||
|
||||
if (! $payroll) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '급여 정보를 찾을 수 없거나 수정할 수 없는 상태입니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '급여가 수정되었습니다.',
|
||||
'data' => $payroll,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '급여 수정 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 삭제
|
||||
*/
|
||||
public function destroy(Request $request, int $id): JsonResponse|Response
|
||||
{
|
||||
try {
|
||||
$result = $this->payrollService->deletePayroll($id);
|
||||
|
||||
if (! $result) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '급여 정보를 찾을 수 없거나 삭제할 수 없는 상태입니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$payrolls = $this->payrollService->getPayrolls(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
return response(view('hr.payrolls.partials.table', compact('payrolls')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '급여가 삭제되었습니다.',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '급여 삭제 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 확정
|
||||
*/
|
||||
public function confirm(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$payroll = $this->payrollService->confirmPayroll($id);
|
||||
|
||||
if (! $payroll) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '급여를 확정할 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '급여가 확정되었습니다.',
|
||||
'data' => $payroll,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '급여 확정 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 지급 처리
|
||||
*/
|
||||
public function pay(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$payroll = $this->payrollService->payPayroll($id);
|
||||
|
||||
if (! $payroll) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '급여를 지급 처리할 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '급여가 지급 처리되었습니다.',
|
||||
'data' => $payroll,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '급여 지급 처리 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 생성
|
||||
*/
|
||||
public function bulkGenerate(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'year' => 'required|integer|min:2020|max:2100',
|
||||
'month' => 'required|integer|min:1|max:12',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->payrollService->bulkGenerate($validated['year'], $validated['month']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "신규 {$result['created']}건 생성, {$result['skipped']}건 건너뜀 (이미 존재).",
|
||||
'data' => $result,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '일괄 생성 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀(CSV) 내보내기
|
||||
*/
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$payrolls = $this->payrollService->getExportData($request->all());
|
||||
$year = $request->input('year', now()->year);
|
||||
$month = $request->input('month', now()->month);
|
||||
|
||||
$filename = "급여관리_{$year}년{$month}월_".now()->format('Ymd').'.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($payrolls) {
|
||||
$file = fopen('php://output', 'w');
|
||||
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM
|
||||
|
||||
fputcsv($file, ['사원명', '부서', '기본급', '초과근무수당', '상여금', '총지급액', '소득세', '주민세', '건강보험', '국민연금', '고용보험', '총공제액', '실수령액', '상태']);
|
||||
|
||||
foreach ($payrolls as $payroll) {
|
||||
$profile = $payroll->user?->tenantProfiles?->first();
|
||||
$displayName = $profile?->display_name ?? $payroll->user?->name ?? '-';
|
||||
$department = $profile?->department?->name ?? '-';
|
||||
$statusLabel = Payroll::STATUS_MAP[$payroll->status] ?? $payroll->status;
|
||||
|
||||
fputcsv($file, [
|
||||
$displayName,
|
||||
$department,
|
||||
$payroll->base_salary,
|
||||
$payroll->overtime_pay,
|
||||
$payroll->bonus,
|
||||
$payroll->gross_salary,
|
||||
$payroll->income_tax,
|
||||
$payroll->resident_tax,
|
||||
$payroll->health_insurance,
|
||||
$payroll->pension,
|
||||
$payroll->employment_insurance,
|
||||
$payroll->total_deductions,
|
||||
$payroll->net_salary,
|
||||
$statusLabel,
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($file);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 설정 조회 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function settingsIndex(Request $request): JsonResponse|Response
|
||||
{
|
||||
$settings = $this->payrollService->getSettings();
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.payrolls.partials.settings', compact('settings')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 설정 수정
|
||||
*/
|
||||
public function settingsUpdate(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'health_insurance_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'long_term_care_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'pension_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'employment_insurance_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'pension_max_salary' => 'nullable|numeric|min:0',
|
||||
'pension_min_salary' => 'nullable|numeric|min:0',
|
||||
'pay_day' => 'nullable|integer|min:1|max:31',
|
||||
'auto_calculate' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
try {
|
||||
$settings = $this->payrollService->updateSettings($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '급여 설정이 저장되었습니다.',
|
||||
'data' => $settings,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '설정 저장 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 계산 미리보기 (AJAX)
|
||||
*/
|
||||
public function calculate(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'base_salary' => 'required|numeric|min:0',
|
||||
'overtime_pay' => 'nullable|numeric|min:0',
|
||||
'bonus' => 'nullable|numeric|min:0',
|
||||
'allowances' => 'nullable|array',
|
||||
'deductions' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$result = $this->payrollService->calculateAmounts($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/HR/PayrollController.php
Normal file
41
app/Http/Controllers/HR/PayrollController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HR\Payroll;
|
||||
use App\Services\HR\PayrollService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class PayrollController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PayrollService $payrollService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 급여관리 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('hr.payrolls.index'));
|
||||
}
|
||||
|
||||
$stats = $this->payrollService->getMonthlyStats();
|
||||
$departments = $this->payrollService->getDepartments();
|
||||
$employees = $this->payrollService->getActiveEmployees();
|
||||
$settings = $this->payrollService->getSettings();
|
||||
$statusMap = Payroll::STATUS_MAP;
|
||||
|
||||
return view('hr.payrolls.index', [
|
||||
'stats' => $stats,
|
||||
'departments' => $departments,
|
||||
'employees' => $employees,
|
||||
'settings' => $settings,
|
||||
'statusMap' => $statusMap,
|
||||
]);
|
||||
}
|
||||
}
|
||||
175
app/Models/HR/Payroll.php
Normal file
175
app/Models/HR/Payroll.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?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 Payroll extends Model
|
||||
{
|
||||
use ModelTrait, SoftDeletes;
|
||||
|
||||
protected $table = 'payrolls';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'pay_year',
|
||||
'pay_month',
|
||||
'base_salary',
|
||||
'overtime_pay',
|
||||
'bonus',
|
||||
'allowances',
|
||||
'gross_salary',
|
||||
'income_tax',
|
||||
'resident_tax',
|
||||
'health_insurance',
|
||||
'pension',
|
||||
'employment_insurance',
|
||||
'deductions',
|
||||
'total_deductions',
|
||||
'net_salary',
|
||||
'status',
|
||||
'confirmed_at',
|
||||
'confirmed_by',
|
||||
'paid_at',
|
||||
'withdrawal_id',
|
||||
'note',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tenant_id' => 'int',
|
||||
'user_id' => 'int',
|
||||
'pay_year' => 'int',
|
||||
'pay_month' => 'int',
|
||||
'base_salary' => 'decimal:0',
|
||||
'overtime_pay' => 'decimal:0',
|
||||
'bonus' => 'decimal:0',
|
||||
'gross_salary' => 'decimal:0',
|
||||
'income_tax' => 'decimal:0',
|
||||
'resident_tax' => 'decimal:0',
|
||||
'health_insurance' => 'decimal:0',
|
||||
'pension' => 'decimal:0',
|
||||
'employment_insurance' => 'decimal:0',
|
||||
'total_deductions' => 'decimal:0',
|
||||
'net_salary' => 'decimal:0',
|
||||
'allowances' => 'array',
|
||||
'deductions' => 'array',
|
||||
'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',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId = null)
|
||||
{
|
||||
$tenantId = $tenantId ?? session('selected_tenant_id');
|
||||
if ($tenantId) {
|
||||
return $query->where($this->table.'.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
107
app/Models/HR/PayrollSetting.php
Normal file
107
app/Models/HR/PayrollSetting.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\HR;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PayrollSetting extends Model
|
||||
{
|
||||
protected $table = 'payroll_settings';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'income_tax_rate',
|
||||
'resident_tax_rate',
|
||||
'health_insurance_rate',
|
||||
'long_term_care_rate',
|
||||
'pension_rate',
|
||||
'employment_insurance_rate',
|
||||
'pension_max_salary',
|
||||
'pension_min_salary',
|
||||
'pay_day',
|
||||
'auto_calculate',
|
||||
'allowance_types',
|
||||
'deduction_types',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tenant_id' => 'int',
|
||||
'income_tax_rate' => 'decimal:2',
|
||||
'resident_tax_rate' => 'decimal:2',
|
||||
'health_insurance_rate' => 'decimal:3',
|
||||
'long_term_care_rate' => 'decimal:4',
|
||||
'pension_rate' => 'decimal:3',
|
||||
'employment_insurance_rate' => 'decimal:3',
|
||||
'pension_max_salary' => 'decimal:0',
|
||||
'pension_min_salary' => 'decimal:0',
|
||||
'pay_day' => 'int',
|
||||
'auto_calculate' => 'boolean',
|
||||
'allowance_types' => 'array',
|
||||
'deduction_types' => 'array',
|
||||
];
|
||||
|
||||
public const DEFAULT_ALLOWANCE_TYPES = [
|
||||
['code' => 'meal', 'name' => '식대', 'is_taxable' => false],
|
||||
['code' => 'transport', 'name' => '교통비', 'is_taxable' => false],
|
||||
['code' => 'position', 'name' => '직책수당', 'is_taxable' => true],
|
||||
['code' => 'tech', 'name' => '기술수당', 'is_taxable' => true],
|
||||
['code' => 'family', 'name' => '가족수당', 'is_taxable' => true],
|
||||
['code' => 'housing', 'name' => '주거수당', 'is_taxable' => true],
|
||||
];
|
||||
|
||||
public const DEFAULT_DEDUCTION_TYPES = [
|
||||
['code' => 'loan', 'name' => '대출상환'],
|
||||
['code' => 'union', 'name' => '조합비'],
|
||||
['code' => 'savings', 'name' => '저축'],
|
||||
['code' => 'etc', 'name' => '기타공제'],
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId = null)
|
||||
{
|
||||
$tenantId = $tenantId ?? session('selected_tenant_id');
|
||||
if ($tenantId) {
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼
|
||||
// =========================================================================
|
||||
|
||||
public static function getOrCreate(?int $tenantId = null): self
|
||||
{
|
||||
$tenantId = $tenantId ?? session('selected_tenant_id');
|
||||
|
||||
return self::firstOrCreate(
|
||||
['tenant_id' => $tenantId],
|
||||
[
|
||||
'income_tax_rate' => 0,
|
||||
'resident_tax_rate' => 10,
|
||||
'health_insurance_rate' => 3.545,
|
||||
'long_term_care_rate' => 0.9082,
|
||||
'pension_rate' => 4.5,
|
||||
'employment_insurance_rate' => 0.9,
|
||||
'pension_max_salary' => 5900000,
|
||||
'pension_min_salary' => 370000,
|
||||
'pay_day' => 25,
|
||||
'auto_calculate' => false,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getAllowanceTypesWithDefault(): array
|
||||
{
|
||||
return $this->allowance_types ?? self::DEFAULT_ALLOWANCE_TYPES;
|
||||
}
|
||||
|
||||
public function getDeductionTypesWithDefault(): array
|
||||
{
|
||||
return $this->deduction_types ?? self::DEFAULT_DEDUCTION_TYPES;
|
||||
}
|
||||
}
|
||||
524
app/Services/HR/PayrollService.php
Normal file
524
app/Services/HR/PayrollService.php
Normal file
@@ -0,0 +1,524 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\HR;
|
||||
|
||||
use App\Models\HR\Employee;
|
||||
use App\Models\HR\Payroll;
|
||||
use App\Models\HR\PayrollSetting;
|
||||
use App\Models\Tenants\Department;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PayrollService
|
||||
{
|
||||
// =========================================================================
|
||||
// 간이세액표 (국세청 근로소득 간이세액표 기준, 부양가족 1인)
|
||||
// 월 급여액 구간별 세액 (원)
|
||||
// =========================================================================
|
||||
|
||||
private const INCOME_TAX_TABLE = [
|
||||
// [하한, 상한, 세액]
|
||||
[0, 1060000, 0],
|
||||
[1060000, 1500000, 19060],
|
||||
[1500000, 2000000, 34680],
|
||||
[2000000, 2500000, 60430],
|
||||
[2500000, 3000000, 95960],
|
||||
[3000000, 3500000, 137900],
|
||||
[3500000, 4000000, 179000],
|
||||
[4000000, 4500000, 215060],
|
||||
[4500000, 5000000, 258190],
|
||||
[5000000, 6000000, 311810],
|
||||
[6000000, 7000000, 414810],
|
||||
[7000000, 8000000, 519370],
|
||||
[8000000, 9000000, 633640],
|
||||
[9000000, 10000000, 758640],
|
||||
[10000000, 14000000, 974780],
|
||||
[14000000, 28000000, 1801980],
|
||||
[28000000, 30000000, 5765680],
|
||||
[30000000, 45000000, 6387680],
|
||||
[45000000, 87000000, 11462680],
|
||||
[87000000, PHP_INT_MAX, 29082680],
|
||||
];
|
||||
|
||||
/**
|
||||
* 필터 적용 쿼리 생성 (목록/엑셀 공통)
|
||||
*/
|
||||
private function buildFilteredQuery(array $filters = [])
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$query = Payroll::query()
|
||||
->with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department'])
|
||||
->forTenant($tenantId);
|
||||
|
||||
if (! empty($filters['q'])) {
|
||||
$search = $filters['q'];
|
||||
$query->whereHas('user', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($filters['department_id'])) {
|
||||
$deptId = $filters['department_id'];
|
||||
$query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) {
|
||||
$q->where('tenant_id', $tenantId)->where('department_id', $deptId);
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
$year = $filters['year'] ?? now()->year;
|
||||
$month = $filters['month'] ?? now()->month;
|
||||
$query->forPeriod((int) $year, (int) $month);
|
||||
|
||||
return $query->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 목록 조회 (페이지네이션)
|
||||
*/
|
||||
public function getPayrolls(array $filters = [], int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
return $this->buildFilteredQuery($filters)->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 내보내기용 데이터 (전체)
|
||||
*/
|
||||
public function getExportData(array $filters = []): Collection
|
||||
{
|
||||
return $this->buildFilteredQuery($filters)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 통계 (통계 카드용)
|
||||
*/
|
||||
public function getMonthlyStats(?int $year = null, ?int $month = null): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$year = $year ?? now()->year;
|
||||
$month = $month ?? now()->month;
|
||||
|
||||
$result = Payroll::query()
|
||||
->forTenant($tenantId)
|
||||
->forPeriod($year, $month)
|
||||
->select(
|
||||
DB::raw('COUNT(*) as total_count'),
|
||||
DB::raw('SUM(gross_salary) as total_gross'),
|
||||
DB::raw('SUM(total_deductions) as total_deductions'),
|
||||
DB::raw('SUM(net_salary) 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),
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 등록
|
||||
*/
|
||||
public function storePayroll(array $data): Payroll
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId) {
|
||||
$calculated = $this->calculateAmounts($data);
|
||||
|
||||
$payroll = Payroll::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $data['user_id'],
|
||||
'pay_year' => $data['pay_year'],
|
||||
'pay_month' => $data['pay_month'],
|
||||
'base_salary' => $data['base_salary'] ?? 0,
|
||||
'overtime_pay' => $data['overtime_pay'] ?? 0,
|
||||
'bonus' => $data['bonus'] ?? 0,
|
||||
'allowances' => $data['allowances'] ?? null,
|
||||
'gross_salary' => $calculated['gross_salary'],
|
||||
'income_tax' => $calculated['income_tax'],
|
||||
'resident_tax' => $calculated['resident_tax'],
|
||||
'health_insurance' => $calculated['health_insurance'],
|
||||
'pension' => $calculated['pension'],
|
||||
'employment_insurance' => $calculated['employment_insurance'],
|
||||
'deductions' => $data['deductions'] ?? null,
|
||||
'total_deductions' => $calculated['total_deductions'],
|
||||
'net_salary' => $calculated['net_salary'],
|
||||
'status' => 'draft',
|
||||
'note' => $data['note'] ?? null,
|
||||
'created_by' => auth()->id(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return $payroll->load('user');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 수정
|
||||
*/
|
||||
public function updatePayroll(int $id, array $data): ?Payroll
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$payroll = Payroll::query()
|
||||
->forTenant($tenantId)
|
||||
->find($id);
|
||||
|
||||
if (! $payroll || ! $payroll->isEditable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mergedData = array_merge($payroll->toArray(), $data);
|
||||
$calculated = $this->calculateAmounts($mergedData);
|
||||
|
||||
$payroll->update([
|
||||
'base_salary' => $data['base_salary'] ?? $payroll->base_salary,
|
||||
'overtime_pay' => $data['overtime_pay'] ?? $payroll->overtime_pay,
|
||||
'bonus' => $data['bonus'] ?? $payroll->bonus,
|
||||
'allowances' => array_key_exists('allowances', $data) ? $data['allowances'] : $payroll->allowances,
|
||||
'gross_salary' => $calculated['gross_salary'],
|
||||
'income_tax' => $calculated['income_tax'],
|
||||
'resident_tax' => $calculated['resident_tax'],
|
||||
'health_insurance' => $calculated['health_insurance'],
|
||||
'pension' => $calculated['pension'],
|
||||
'employment_insurance' => $calculated['employment_insurance'],
|
||||
'deductions' => array_key_exists('deductions', $data) ? $data['deductions'] : $payroll->deductions,
|
||||
'total_deductions' => $calculated['total_deductions'],
|
||||
'net_salary' => $calculated['net_salary'],
|
||||
'note' => $data['note'] ?? $payroll->note,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return $payroll->fresh(['user']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 삭제
|
||||
*/
|
||||
public function deletePayroll(int $id): bool
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$payroll = Payroll::query()
|
||||
->forTenant($tenantId)
|
||||
->find($id);
|
||||
|
||||
if (! $payroll || ! $payroll->isDeletable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payroll->update(['deleted_by' => auth()->id()]);
|
||||
$payroll->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 확정
|
||||
*/
|
||||
public function confirmPayroll(int $id): ?Payroll
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$payroll = Payroll::query()
|
||||
->forTenant($tenantId)
|
||||
->find($id);
|
||||
|
||||
if (! $payroll || ! $payroll->isConfirmable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payroll->update([
|
||||
'status' => Payroll::STATUS_CONFIRMED,
|
||||
'confirmed_at' => now(),
|
||||
'confirmed_by' => auth()->id(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return $payroll->fresh(['user']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 지급 처리
|
||||
*/
|
||||
public function payPayroll(int $id): ?Payroll
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$payroll = Payroll::query()
|
||||
->forTenant($tenantId)
|
||||
->find($id);
|
||||
|
||||
if (! $payroll || ! $payroll->isPayable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payroll->update([
|
||||
'status' => Payroll::STATUS_PAID,
|
||||
'paid_at' => now(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return $payroll->fresh(['user']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 생성 (재직 사원 전체)
|
||||
*/
|
||||
public function bulkGenerate(int $year, int $month): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$settings = PayrollSetting::getOrCreate($tenantId);
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
$employees = Employee::query()
|
||||
->with('user:id,name')
|
||||
->forTenant($tenantId)
|
||||
->activeEmployees()
|
||||
->get();
|
||||
|
||||
DB::transaction(function () use ($employees, $tenantId, $year, $month, $settings, &$created, &$skipped) {
|
||||
foreach ($employees as $employee) {
|
||||
$exists = Payroll::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $employee->user_id)
|
||||
->forPeriod($year, $month)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$annualSalary = $employee->getJsonExtraValue('salary', 0);
|
||||
$baseSalary = $annualSalary > 0 ? round($annualSalary / 12) : 0;
|
||||
|
||||
$data = [
|
||||
'base_salary' => $baseSalary,
|
||||
'overtime_pay' => 0,
|
||||
'bonus' => 0,
|
||||
'allowances' => null,
|
||||
'deductions' => null,
|
||||
];
|
||||
|
||||
$calculated = $this->calculateAmounts($data, $settings);
|
||||
|
||||
Payroll::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $employee->user_id,
|
||||
'pay_year' => $year,
|
||||
'pay_month' => $month,
|
||||
'base_salary' => $baseSalary,
|
||||
'overtime_pay' => 0,
|
||||
'bonus' => 0,
|
||||
'allowances' => null,
|
||||
'gross_salary' => $calculated['gross_salary'],
|
||||
'income_tax' => $calculated['income_tax'],
|
||||
'resident_tax' => $calculated['resident_tax'],
|
||||
'health_insurance' => $calculated['health_insurance'],
|
||||
'pension' => $calculated['pension'],
|
||||
'employment_insurance' => $calculated['employment_insurance'],
|
||||
'deductions' => null,
|
||||
'total_deductions' => $calculated['total_deductions'],
|
||||
'net_salary' => $calculated['net_salary'],
|
||||
'status' => 'draft',
|
||||
'created_by' => auth()->id(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$created++;
|
||||
}
|
||||
});
|
||||
|
||||
return ['created' => $created, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 금액 자동 계산
|
||||
*/
|
||||
public function calculateAmounts(array $data, ?PayrollSetting $settings = null): array
|
||||
{
|
||||
$settings = $settings ?? PayrollSetting::getOrCreate();
|
||||
|
||||
$baseSalary = (float) ($data['base_salary'] ?? 0);
|
||||
$overtimePay = (float) ($data['overtime_pay'] ?? 0);
|
||||
$bonus = (float) ($data['bonus'] ?? 0);
|
||||
|
||||
// 수당 합계
|
||||
$allowancesTotal = 0;
|
||||
if (! empty($data['allowances'])) {
|
||||
$allowances = is_string($data['allowances']) ? json_decode($data['allowances'], true) : $data['allowances'];
|
||||
foreach ($allowances ?? [] as $allowance) {
|
||||
$allowancesTotal += (float) ($allowance['amount'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 총 지급액
|
||||
$grossSalary = $baseSalary + $overtimePay + $bonus + $allowancesTotal;
|
||||
|
||||
// 4대보험 계산
|
||||
$healthInsurance = $this->calculateHealthInsurance($grossSalary, $settings);
|
||||
$pension = $this->calculatePension($grossSalary, $settings);
|
||||
$employmentInsurance = $this->calculateEmploymentInsurance($grossSalary, $settings);
|
||||
|
||||
// 소득세 (간이세액표)
|
||||
$incomeTax = $this->calculateIncomeTax($grossSalary);
|
||||
// 주민세 (소득세의 10%)
|
||||
$residentTax = (int) floor($incomeTax * ($settings->resident_tax_rate / 100));
|
||||
|
||||
// 추가 공제 합계
|
||||
$extraDeductions = 0;
|
||||
if (! empty($data['deductions'])) {
|
||||
$deductions = is_string($data['deductions']) ? json_decode($data['deductions'], true) : $data['deductions'];
|
||||
foreach ($deductions ?? [] as $deduction) {
|
||||
$extraDeductions += (float) ($deduction['amount'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 총 공제액
|
||||
$totalDeductions = $incomeTax + $residentTax + $healthInsurance + $pension + $employmentInsurance + $extraDeductions;
|
||||
|
||||
// 실수령액
|
||||
$netSalary = $grossSalary - $totalDeductions;
|
||||
|
||||
return [
|
||||
'gross_salary' => (int) $grossSalary,
|
||||
'income_tax' => $incomeTax,
|
||||
'resident_tax' => $residentTax,
|
||||
'health_insurance' => $healthInsurance,
|
||||
'pension' => $pension,
|
||||
'employment_insurance' => $employmentInsurance,
|
||||
'total_deductions' => (int) $totalDeductions,
|
||||
'net_salary' => (int) max(0, $netSalary),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 소득세 계산 (간이세액표 기준, 부양가족 1인)
|
||||
*/
|
||||
public function calculateIncomeTax(float $grossSalary, int $dependents = 1): int
|
||||
{
|
||||
if ($grossSalary <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$tax = 0;
|
||||
foreach (self::INCOME_TAX_TABLE as [$min, $max, $amount]) {
|
||||
if ($grossSalary > $min && $grossSalary <= $max) {
|
||||
$tax = $amount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 구간 초과
|
||||
if ($grossSalary > 87000000) {
|
||||
$tax = self::INCOME_TAX_TABLE[count(self::INCOME_TAX_TABLE) - 1][2];
|
||||
}
|
||||
|
||||
return (int) $tax;
|
||||
}
|
||||
|
||||
/**
|
||||
* 건강보험료 계산
|
||||
*/
|
||||
private function calculateHealthInsurance(float $grossSalary, PayrollSetting $settings): int
|
||||
{
|
||||
$healthInsurance = $grossSalary * ($settings->health_insurance_rate / 100);
|
||||
$longTermCare = $healthInsurance * ($settings->long_term_care_rate / 100);
|
||||
|
||||
return (int) round($healthInsurance + $longTermCare);
|
||||
}
|
||||
|
||||
/**
|
||||
* 국민연금 계산
|
||||
*/
|
||||
private function calculatePension(float $grossSalary, PayrollSetting $settings): int
|
||||
{
|
||||
$base = min(max($grossSalary, (float) $settings->pension_min_salary), (float) $settings->pension_max_salary);
|
||||
|
||||
return (int) round($base * ($settings->pension_rate / 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* 고용보험료 계산
|
||||
*/
|
||||
private function calculateEmploymentInsurance(float $grossSalary, PayrollSetting $settings): int
|
||||
{
|
||||
return (int) round($grossSalary * ($settings->employment_insurance_rate / 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 목록 (드롭다운용)
|
||||
*/
|
||||
public function getDepartments(): Collection
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return Department::query()
|
||||
->where('is_active', true)
|
||||
->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 사원 목록 (드롭다운용)
|
||||
*/
|
||||
public function getActiveEmployees(): Collection
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return Employee::query()
|
||||
->with('user:id,name')
|
||||
->forTenant($tenantId)
|
||||
->activeEmployees()
|
||||
->orderBy('display_name')
|
||||
->get(['id', 'user_id', 'display_name', 'department_id', 'json_extra']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 설정 조회/생성
|
||||
*/
|
||||
public function getSettings(): PayrollSetting
|
||||
{
|
||||
return PayrollSetting::getOrCreate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 설정 수정
|
||||
*/
|
||||
public function updateSettings(array $data): PayrollSetting
|
||||
{
|
||||
$settings = PayrollSetting::getOrCreate();
|
||||
|
||||
$settings->update([
|
||||
'health_insurance_rate' => $data['health_insurance_rate'] ?? $settings->health_insurance_rate,
|
||||
'long_term_care_rate' => $data['long_term_care_rate'] ?? $settings->long_term_care_rate,
|
||||
'pension_rate' => $data['pension_rate'] ?? $settings->pension_rate,
|
||||
'employment_insurance_rate' => $data['employment_insurance_rate'] ?? $settings->employment_insurance_rate,
|
||||
'pension_max_salary' => $data['pension_max_salary'] ?? $settings->pension_max_salary,
|
||||
'pension_min_salary' => $data['pension_min_salary'] ?? $settings->pension_min_salary,
|
||||
'pay_day' => $data['pay_day'] ?? $settings->pay_day,
|
||||
'auto_calculate' => $data['auto_calculate'] ?? $settings->auto_calculate,
|
||||
]);
|
||||
|
||||
return $settings->fresh();
|
||||
}
|
||||
}
|
||||
793
resources/views/hr/payrolls/index.blade.php
Normal file
793
resources/views/hr/payrolls/index.blade.php
Normal file
@@ -0,0 +1,793 @@
|
||||
@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="payrollYear" 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">
|
||||
@for($y = now()->year; $y >= now()->year - 2; $y--)
|
||||
<option value="{{ $y }}" {{ $stats['year'] == $y ? 'selected' : '' }}>{{ $y }}년</option>
|
||||
@endfor
|
||||
</select>
|
||||
<select id="payrollMonth" 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">
|
||||
@for($m = 1; $m <= 12; $m++)
|
||||
<option value="{{ $m }}" {{ $stats['month'] == $m ? 'selected' : '' }}>{{ $m }}월</option>
|
||||
@endfor
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<button type="button" onclick="openCreatePayrollModal()"
|
||||
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="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
급여 등록
|
||||
</button>
|
||||
<button type="button" onclick="bulkGeneratePayrolls()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
일괄 생성
|
||||
</button>
|
||||
<button type="button" onclick="exportPayrolls()"
|
||||
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>
|
||||
엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 탭 네비게이션 --}}
|
||||
<div class="flex items-center gap-1 mb-4 border-b border-gray-200">
|
||||
<button type="button" onclick="switchTab('list')" id="tab-list"
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-blue-600 text-blue-600">
|
||||
급여 목록
|
||||
</button>
|
||||
<button type="button" onclick="switchTab('settings')" id="tab-settings"
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-gray-500 hover:text-gray-700">
|
||||
급여 설정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 통계 카드 (HTMX 갱신 대상) --}}
|
||||
<div id="stats-container" class="mb-4">
|
||||
@include('hr.payrolls.partials.stats', ['stats' => $stats])
|
||||
</div>
|
||||
|
||||
{{-- 탭 콘텐츠 영역 --}}
|
||||
<div id="payrolls-content">
|
||||
{{-- 급여 목록 탭 --}}
|
||||
<div id="content-list">
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
{{-- 필터 --}}
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<x-filter-collapsible id="payrollFilter">
|
||||
<form id="payrollFilterForm" class="flex flex-wrap gap-3 items-end">
|
||||
<div style="flex: 1 1 180px; max-width: 260px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">검색</label>
|
||||
<input type="text" name="q" placeholder="사원 이름..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 0 1 160px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">부서</label>
|
||||
<select name="department_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">전체 부서</option>
|
||||
@foreach($departments as $dept)
|
||||
<option value="{{ $dept->id }}">{{ $dept->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 1 140px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">상태</label>
|
||||
<select name="status"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">전체 상태</option>
|
||||
@foreach($statusMap as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
|
||||
검색
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</x-filter-collapsible>
|
||||
</div>
|
||||
|
||||
{{-- HTMX 테이블 영역 --}}
|
||||
<div id="payrolls-table"
|
||||
hx-get="{{ route('api.admin.hr.payrolls.index') }}"
|
||||
hx-vals='{"year": "{{ $stats['year'] }}", "month": "{{ $stats['month'] }}"}'
|
||||
hx-trigger="load"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
class="min-h-[200px]">
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 급여 설정 탭 --}}
|
||||
<div id="content-settings" class="hidden">
|
||||
<div id="payroll-settings-container">
|
||||
@include('hr.payrolls.partials.settings', ['settings' => $settings])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 급여 등록/수정 모달 --}}
|
||||
<div id="payrollModal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/40" onclick="closePayrollModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4 overflow-y-auto">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl relative my-8">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h3 id="payrollModalTitle" class="text-lg font-semibold text-gray-800">급여 등록</h3>
|
||||
<button type="button" onclick="closePayrollModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="payrollForm" class="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<input type="hidden" id="payrollId" name="id" value="">
|
||||
|
||||
{{-- 기본 정보 --}}
|
||||
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">사원 <span class="text-red-500">*</span></label>
|
||||
<select id="payrollUserId" name="user_id" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">사원 선택</option>
|
||||
@foreach($employees as $emp)
|
||||
<option value="{{ $emp->user_id }}"
|
||||
data-salary="{{ $emp->getJsonExtraValue('salary', 0) }}">
|
||||
{{ $emp->display_name ?? $emp->user?->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">급여 연월</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="number" id="payrollPayYear" name="pay_year" min="2020" max="2100"
|
||||
value="{{ now()->year }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<input type="number" id="payrollPayMonth" name="pay_month" min="1" max="12"
|
||||
value="{{ now()->month }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 지급 항목 --}}
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">지급 항목</h4>
|
||||
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">기본급</label>
|
||||
<input type="number" id="payrollBaseSalary" name="base_salary" min="0" step="1000" value="0"
|
||||
onchange="recalculate()"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">초과근무수당</label>
|
||||
<input type="number" id="payrollOvertimePay" name="overtime_pay" min="0" step="1000" value="0"
|
||||
onchange="recalculate()"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">상여금</label>
|
||||
<input type="number" id="payrollBonus" name="bonus" min="0" step="1000" value="0"
|
||||
onchange="recalculate()"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 수당 (동적 추가) --}}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-sm font-medium text-gray-700">수당</h4>
|
||||
<button type="button" onclick="addAllowanceRow()" class="text-xs text-blue-600 hover:text-blue-800">+ 추가</button>
|
||||
</div>
|
||||
<div id="allowancesContainer" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
{{-- 공제 요약 (자동 계산) --}}
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">공제 항목 (자동 계산)</h4>
|
||||
<div class="bg-gray-50 rounded-lg p-3 space-y-1 text-sm">
|
||||
<div class="flex justify-between"><span class="text-gray-500">소득세</span><span id="calcIncomeTax" class="text-gray-700">0</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">주민세</span><span id="calcResidentTax" class="text-gray-700">0</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">건강보험</span><span id="calcHealth" class="text-gray-700">0</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">국민연금</span><span id="calcPension" class="text-gray-700">0</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">고용보험</span><span id="calcEmployment" class="text-gray-700">0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 추가 공제 (동적) --}}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-sm font-medium text-gray-700">추가 공제</h4>
|
||||
<button type="button" onclick="addDeductionRow()" class="text-xs text-blue-600 hover:text-blue-800">+ 추가</button>
|
||||
</div>
|
||||
<div id="deductionsContainer" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
{{-- 합계 --}}
|
||||
<div class="bg-blue-50 rounded-lg p-4 space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 font-medium">총 지급액</span>
|
||||
<span id="calcGross" class="text-blue-700 font-bold">0</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 font-medium">총 공제액</span>
|
||||
<span id="calcTotalDeductions" class="text-red-600 font-bold">0</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-base border-t border-blue-200 pt-2">
|
||||
<span class="text-gray-800 font-bold">실수령액</span>
|
||||
<span id="calcNet" class="text-emerald-600 font-bold">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 비고 --}}
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">비고</label>
|
||||
<textarea id="payrollNote" name="note" rows="2"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||
<button type="button" onclick="closePayrollModal()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="button" onclick="submitPayroll()"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 급여 상세 모달 --}}
|
||||
<div id="payrollDetailModal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/40" onclick="closePayrollDetailModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4 overflow-y-auto">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg relative my-8">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800">급여 상세</h3>
|
||||
<button type="button" onclick="closePayrollDetailModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="payrollDetailContent" class="px-6 py-4 max-h-[70vh] overflow-y-auto"></div>
|
||||
<div class="flex items-center justify-end px-6 py-4 border-t border-gray-200">
|
||||
<button type="button" onclick="closePayrollDetailModal()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// ===== 현재 탭 상태 =====
|
||||
let currentTab = 'list';
|
||||
const tabLoaded = { list: true, settings: true };
|
||||
|
||||
function switchTab(tab) {
|
||||
document.getElementById('tab-' + currentTab).className = 'px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-gray-500 hover:text-gray-700';
|
||||
document.getElementById('content-' + currentTab).classList.add('hidden');
|
||||
|
||||
currentTab = tab;
|
||||
document.getElementById('tab-' + tab).className = 'px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-blue-600 text-blue-600';
|
||||
document.getElementById('content-' + tab).classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ===== 연월 변경 =====
|
||||
document.getElementById('payrollYear').addEventListener('change', onPeriodChange);
|
||||
document.getElementById('payrollMonth').addEventListener('change', onPeriodChange);
|
||||
|
||||
function onPeriodChange() {
|
||||
refreshStats();
|
||||
refreshTable();
|
||||
}
|
||||
|
||||
function refreshStats() {
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.payrolls.stats") }}', {
|
||||
target: '#stats-container',
|
||||
swap: 'innerHTML',
|
||||
values: {
|
||||
year: document.getElementById('payrollYear').value,
|
||||
month: document.getElementById('payrollMonth').value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 필터 =====
|
||||
document.getElementById('payrollFilterForm')?.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
refreshTable();
|
||||
});
|
||||
|
||||
function refreshTable() {
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.payrolls.index") }}', {
|
||||
target: '#payrolls-table',
|
||||
swap: 'innerHTML',
|
||||
values: getFilterValues(),
|
||||
});
|
||||
}
|
||||
|
||||
function getFilterValues() {
|
||||
const form = document.getElementById('payrollFilterForm');
|
||||
const formData = new FormData(form);
|
||||
const values = {
|
||||
year: document.getElementById('payrollYear').value,
|
||||
month: document.getElementById('payrollMonth').value,
|
||||
};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) values[key] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
// ===== 엑셀 다운로드 =====
|
||||
function exportPayrolls() {
|
||||
const params = new URLSearchParams(getFilterValues());
|
||||
window.location.href = '{{ route("api.admin.hr.payrolls.export") }}?' + params.toString();
|
||||
}
|
||||
|
||||
// ===== 급여 등록 모달 =====
|
||||
let editingPayrollId = null;
|
||||
|
||||
function openCreatePayrollModal() {
|
||||
editingPayrollId = null;
|
||||
document.getElementById('payrollModalTitle').textContent = '급여 등록';
|
||||
document.getElementById('payrollForm').reset();
|
||||
document.getElementById('payrollId').value = '';
|
||||
document.getElementById('payrollPayYear').value = document.getElementById('payrollYear').value;
|
||||
document.getElementById('payrollPayMonth').value = document.getElementById('payrollMonth').value;
|
||||
document.getElementById('allowancesContainer').innerHTML = '';
|
||||
document.getElementById('deductionsContainer').innerHTML = '';
|
||||
resetCalculation();
|
||||
document.getElementById('payrollModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function openEditPayrollModal(id, data) {
|
||||
editingPayrollId = id;
|
||||
document.getElementById('payrollModalTitle').textContent = '급여 수정';
|
||||
document.getElementById('payrollId').value = id;
|
||||
document.getElementById('payrollUserId').value = data.user_id;
|
||||
document.getElementById('payrollUserId').disabled = true;
|
||||
document.getElementById('payrollPayYear').value = document.getElementById('payrollYear').value;
|
||||
document.getElementById('payrollPayMonth').value = document.getElementById('payrollMonth').value;
|
||||
document.getElementById('payrollBaseSalary').value = data.base_salary || 0;
|
||||
document.getElementById('payrollOvertimePay').value = data.overtime_pay || 0;
|
||||
document.getElementById('payrollBonus').value = data.bonus || 0;
|
||||
document.getElementById('payrollNote').value = data.note || '';
|
||||
|
||||
// 수당 복원
|
||||
document.getElementById('allowancesContainer').innerHTML = '';
|
||||
if (data.allowances && data.allowances.length > 0) {
|
||||
data.allowances.forEach(a => addAllowanceRow(a.name, a.amount));
|
||||
}
|
||||
|
||||
// 공제 복원
|
||||
document.getElementById('deductionsContainer').innerHTML = '';
|
||||
if (data.deductions && data.deductions.length > 0) {
|
||||
data.deductions.forEach(d => addDeductionRow(d.name, d.amount));
|
||||
}
|
||||
|
||||
recalculate();
|
||||
document.getElementById('payrollModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closePayrollModal() {
|
||||
document.getElementById('payrollModal').classList.add('hidden');
|
||||
document.getElementById('payrollUserId').disabled = false;
|
||||
}
|
||||
|
||||
// ===== 사원 선택 시 기본급 자동 입력 =====
|
||||
document.getElementById('payrollUserId').addEventListener('change', function() {
|
||||
if (editingPayrollId) return;
|
||||
const selected = this.options[this.selectedIndex];
|
||||
const salary = parseInt(selected.dataset.salary || 0);
|
||||
if (salary > 0) {
|
||||
document.getElementById('payrollBaseSalary').value = Math.round(salary / 12);
|
||||
recalculate();
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 수당/공제 동적 행 =====
|
||||
function addAllowanceRow(name, amount) {
|
||||
const container = document.getElementById('allowancesContainer');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'flex gap-2 items-center';
|
||||
div.innerHTML = `
|
||||
<input type="text" placeholder="수당명" value="${name || ''}" class="allowance-name px-3 py-1.5 border border-gray-300 rounded-lg text-sm" style="flex: 1 1 120px;">
|
||||
<input type="number" placeholder="금액" value="${amount || ''}" min="0" step="1000" onchange="recalculate()" class="allowance-amount px-3 py-1.5 border border-gray-300 rounded-lg text-sm" style="flex: 0 0 120px;">
|
||||
<button type="button" onclick="this.parentElement.remove(); recalculate();" class="shrink-0 text-red-400 hover:text-red-600">
|
||||
<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="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
function addDeductionRow(name, amount) {
|
||||
const container = document.getElementById('deductionsContainer');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'flex gap-2 items-center';
|
||||
div.innerHTML = `
|
||||
<input type="text" placeholder="공제명" value="${name || ''}" class="deduction-name px-3 py-1.5 border border-gray-300 rounded-lg text-sm" style="flex: 1 1 120px;">
|
||||
<input type="number" placeholder="금액" value="${amount || ''}" min="0" step="1000" onchange="recalculate()" class="deduction-amount px-3 py-1.5 border border-gray-300 rounded-lg text-sm" style="flex: 0 0 120px;">
|
||||
<button type="button" onclick="this.parentElement.remove(); recalculate();" class="shrink-0 text-red-400 hover:text-red-600">
|
||||
<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="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
// ===== 자동 계산 (서버 호출) =====
|
||||
let calcTimer = null;
|
||||
function recalculate() {
|
||||
clearTimeout(calcTimer);
|
||||
calcTimer = setTimeout(doRecalculate, 300);
|
||||
}
|
||||
|
||||
function doRecalculate() {
|
||||
const allowances = [];
|
||||
document.querySelectorAll('#allowancesContainer > div').forEach(row => {
|
||||
const name = row.querySelector('.allowance-name').value;
|
||||
const amount = parseFloat(row.querySelector('.allowance-amount').value) || 0;
|
||||
if (name && amount > 0) allowances.push({name, amount});
|
||||
});
|
||||
|
||||
const deductions = [];
|
||||
document.querySelectorAll('#deductionsContainer > div').forEach(row => {
|
||||
const name = row.querySelector('.deduction-name').value;
|
||||
const amount = parseFloat(row.querySelector('.deduction-amount').value) || 0;
|
||||
if (name && amount > 0) deductions.push({name, amount});
|
||||
});
|
||||
|
||||
const data = {
|
||||
base_salary: parseFloat(document.getElementById('payrollBaseSalary').value) || 0,
|
||||
overtime_pay: parseFloat(document.getElementById('payrollOvertimePay').value) || 0,
|
||||
bonus: parseFloat(document.getElementById('payrollBonus').value) || 0,
|
||||
allowances: allowances,
|
||||
deductions: deductions,
|
||||
};
|
||||
|
||||
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(data),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.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('calcPension').textContent = numberFormat(d.pension);
|
||||
document.getElementById('calcEmployment').textContent = numberFormat(d.employment_insurance);
|
||||
document.getElementById('calcGross').textContent = numberFormat(d.gross_salary);
|
||||
document.getElementById('calcTotalDeductions').textContent = numberFormat(d.total_deductions);
|
||||
document.getElementById('calcNet').textContent = numberFormat(d.net_salary);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function resetCalculation() {
|
||||
['calcIncomeTax','calcResidentTax','calcHealth','calcPension','calcEmployment','calcGross','calcTotalDeductions','calcNet'].forEach(id => {
|
||||
document.getElementById(id).textContent = '0';
|
||||
});
|
||||
}
|
||||
|
||||
function numberFormat(n) {
|
||||
return Number(n).toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
// ===== 급여 저장 =====
|
||||
function submitPayroll() {
|
||||
const allowances = [];
|
||||
document.querySelectorAll('#allowancesContainer > div').forEach(row => {
|
||||
const name = row.querySelector('.allowance-name').value;
|
||||
const amount = parseFloat(row.querySelector('.allowance-amount').value) || 0;
|
||||
if (name && amount > 0) allowances.push({name, amount});
|
||||
});
|
||||
|
||||
const deductions = [];
|
||||
document.querySelectorAll('#deductionsContainer > div').forEach(row => {
|
||||
const name = row.querySelector('.deduction-name').value;
|
||||
const amount = parseFloat(row.querySelector('.deduction-amount').value) || 0;
|
||||
if (name && amount > 0) deductions.push({name, amount});
|
||||
});
|
||||
|
||||
const data = {
|
||||
user_id: parseInt(document.getElementById('payrollUserId').value),
|
||||
pay_year: parseInt(document.getElementById('payrollPayYear').value),
|
||||
pay_month: parseInt(document.getElementById('payrollPayMonth').value),
|
||||
base_salary: parseFloat(document.getElementById('payrollBaseSalary').value) || 0,
|
||||
overtime_pay: parseFloat(document.getElementById('payrollOvertimePay').value) || 0,
|
||||
bonus: parseFloat(document.getElementById('payrollBonus').value) || 0,
|
||||
allowances: allowances.length > 0 ? allowances : null,
|
||||
deductions: deductions.length > 0 ? deductions : null,
|
||||
note: document.getElementById('payrollNote').value || null,
|
||||
};
|
||||
|
||||
const isEdit = !!editingPayrollId;
|
||||
const url = isEdit
|
||||
? '{{ url("/api/admin/hr/payrolls") }}/' + editingPayrollId
|
||||
: '{{ route("api.admin.hr.payrolls.store") }}';
|
||||
|
||||
fetch(url, {
|
||||
method: isEdit ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
closePayrollModal();
|
||||
refreshTable();
|
||||
refreshStats();
|
||||
showToast(result.message, 'success');
|
||||
} else {
|
||||
showToast(result.message || '오류가 발생했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
showToast('오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 급여 삭제 =====
|
||||
function deletePayroll(id) {
|
||||
if (!confirm('이 급여를 삭제하시겠습니까?')) return;
|
||||
|
||||
fetch('{{ url("/api/admin/hr/payrolls") }}/' + id, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
refreshTable();
|
||||
refreshStats();
|
||||
showToast(result.message, 'success');
|
||||
} else {
|
||||
showToast(result.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
showToast('삭제 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 급여 확정 =====
|
||||
function confirmPayroll(id) {
|
||||
if (!confirm('이 급여를 확정하시겠습니까? 확정 후에는 수정할 수 없습니다.')) return;
|
||||
|
||||
fetch('{{ url("/api/admin/hr/payrolls") }}/' + id + '/confirm', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
refreshTable();
|
||||
refreshStats();
|
||||
showToast(result.message, 'success');
|
||||
} else {
|
||||
showToast(result.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
showToast('확정 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 급여 지급 =====
|
||||
function payPayroll(id) {
|
||||
if (!confirm('이 급여를 지급 처리하시겠습니까?')) return;
|
||||
|
||||
fetch('{{ url("/api/admin/hr/payrolls") }}/' + id + '/pay', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
refreshTable();
|
||||
refreshStats();
|
||||
showToast(result.message, 'success');
|
||||
} else {
|
||||
showToast(result.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
showToast('지급 처리 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 일괄 생성 =====
|
||||
function bulkGeneratePayrolls() {
|
||||
const year = document.getElementById('payrollYear').value;
|
||||
const month = document.getElementById('payrollMonth').value;
|
||||
|
||||
if (!confirm(`${year}년 ${month}월 재직 사원 전체의 급여를 일괄 생성하시겠습니까?`)) return;
|
||||
|
||||
fetch('{{ route("api.admin.hr.payrolls.bulk-generate") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ year: parseInt(year), month: parseInt(month) }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
refreshTable();
|
||||
refreshStats();
|
||||
showToast(result.message, 'success');
|
||||
} else {
|
||||
showToast(result.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
showToast('일괄 생성 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 급여 상세 모달 =====
|
||||
function openPayrollDetail(id, data) {
|
||||
let html = '<div class="space-y-4">';
|
||||
html += `<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-10 h-10 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-medium">${data.user_name.charAt(0)}</div>
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">${data.user_name}</div>
|
||||
<div class="text-sm text-gray-500">${data.department} / ${data.period}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
html += '<div class="border rounded-lg overflow-hidden"><table class="w-full text-sm">';
|
||||
html += '<tr class="bg-blue-50"><th class="px-4 py-2 text-left text-blue-800 font-medium" colspan="2">지급 항목</th></tr>';
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">기본급</td><td class="px-4 py-2 text-right">${numberFormat(data.base_salary)}</td></tr>`;
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">초과근무수당</td><td class="px-4 py-2 text-right">${numberFormat(data.overtime_pay)}</td></tr>`;
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">상여금</td><td class="px-4 py-2 text-right">${numberFormat(data.bonus)}</td></tr>`;
|
||||
|
||||
if (data.allowances && data.allowances.length > 0) {
|
||||
data.allowances.forEach(a => {
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">${a.name}</td><td class="px-4 py-2 text-right">${numberFormat(a.amount)}</td></tr>`;
|
||||
});
|
||||
}
|
||||
|
||||
html += `<tr class="border-t bg-blue-50"><td class="px-4 py-2 font-medium text-blue-800">총 지급액</td><td class="px-4 py-2 text-right font-bold text-blue-700">${numberFormat(data.gross_salary)}</td></tr>`;
|
||||
|
||||
html += '<tr class="bg-red-50"><th class="px-4 py-2 text-left text-red-800 font-medium" colspan="2">공제 항목</th></tr>';
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">소득세</td><td class="px-4 py-2 text-right">${numberFormat(data.income_tax)}</td></tr>`;
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">주민세</td><td class="px-4 py-2 text-right">${numberFormat(data.resident_tax)}</td></tr>`;
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">건강보험</td><td class="px-4 py-2 text-right">${numberFormat(data.health_insurance)}</td></tr>`;
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">국민연금</td><td class="px-4 py-2 text-right">${numberFormat(data.pension)}</td></tr>`;
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">고용보험</td><td class="px-4 py-2 text-right">${numberFormat(data.employment_insurance)}</td></tr>`;
|
||||
|
||||
if (data.deductions && data.deductions.length > 0) {
|
||||
data.deductions.forEach(d => {
|
||||
html += `<tr class="border-t"><td class="px-4 py-2 text-gray-500">${d.name}</td><td class="px-4 py-2 text-right">${numberFormat(d.amount)}</td></tr>`;
|
||||
});
|
||||
}
|
||||
|
||||
html += `<tr class="border-t bg-red-50"><td class="px-4 py-2 font-medium text-red-800">총 공제액</td><td class="px-4 py-2 text-right font-bold text-red-700">${numberFormat(data.total_deductions)}</td></tr>`;
|
||||
html += `<tr class="bg-emerald-50"><td class="px-4 py-2 font-bold text-emerald-800">실수령액</td><td class="px-4 py-2 text-right font-bold text-lg text-emerald-700">${numberFormat(data.net_salary)}</td></tr>`;
|
||||
html += '</table></div>';
|
||||
|
||||
if (data.confirmed_at) html += `<div class="text-xs text-gray-400">확정일: ${data.confirmed_at}</div>`;
|
||||
if (data.paid_at) html += `<div class="text-xs text-gray-400">지급일: ${data.paid_at}</div>`;
|
||||
if (data.note) html += `<div class="text-sm text-gray-500 mt-2 p-2 bg-gray-50 rounded">${data.note}</div>`;
|
||||
|
||||
html += '</div>';
|
||||
|
||||
document.getElementById('payrollDetailContent').innerHTML = html;
|
||||
document.getElementById('payrollDetailModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closePayrollDetailModal() {
|
||||
document.getElementById('payrollDetailModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ===== 급여 설정 저장 =====
|
||||
function savePayrollSettings() {
|
||||
const form = document.getElementById('payrollSettingsForm');
|
||||
const formData = new FormData(form);
|
||||
const data = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
data[key] = value;
|
||||
}
|
||||
data.auto_calculate = form.querySelector('[name="auto_calculate"]').checked;
|
||||
|
||||
fetch('{{ route("api.admin.hr.payroll-settings.update") }}', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showToast(result.message, 'success');
|
||||
} else {
|
||||
showToast(result.message || '설정 저장 실패', 'error');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
showToast('설정 저장 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 토스트 메시지 =====
|
||||
function showToast(message, type) {
|
||||
if (typeof window.showToastMessage === 'function') {
|
||||
window.showToastMessage(message, type);
|
||||
return;
|
||||
}
|
||||
const color = type === 'success' ? 'bg-emerald-600' : 'bg-red-600';
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed top-4 right-4 z-[60] ${color} text-white px-4 py-3 rounded-lg shadow-lg text-sm transition-opacity duration-300`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 3000);
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
88
resources/views/hr/payrolls/partials/settings.blade.php
Normal file
88
resources/views/hr/payrolls/partials/settings.blade.php
Normal file
@@ -0,0 +1,88 @@
|
||||
{{-- 급여 설정 폼 (HTMX로 로드) --}}
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800">급여 설정</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">4대보험 요율 및 급여 지급일 설정</p>
|
||||
</div>
|
||||
|
||||
<form id="payrollSettingsForm" class="px-6 py-6 space-y-6">
|
||||
{{-- 4대보험 요율 --}}
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-3">4대보험 요율 (%)</h4>
|
||||
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">건강보험료율</label>
|
||||
<input type="number" name="health_insurance_rate" step="0.001" min="0" max="100"
|
||||
value="{{ $settings->health_insurance_rate }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">장기요양보험료율 (건강보험의 %)</label>
|
||||
<input type="number" name="long_term_care_rate" step="0.0001" min="0" max="100"
|
||||
value="{{ $settings->long_term_care_rate }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">국민연금 요율</label>
|
||||
<input type="number" name="pension_rate" step="0.001" min="0" max="100"
|
||||
value="{{ $settings->pension_rate }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">고용보험 요율</label>
|
||||
<input type="number" name="employment_insurance_rate" step="0.001" min="0" max="100"
|
||||
value="{{ $settings->employment_insurance_rate }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 국민연금 기준금액 --}}
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-3">국민연금 기준소득월액</h4>
|
||||
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">상한액 (원)</label>
|
||||
<input type="number" name="pension_max_salary" step="1000" min="0"
|
||||
value="{{ (int) $settings->pension_max_salary }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">하한액 (원)</label>
|
||||
<input type="number" name="pension_min_salary" step="1000" min="0"
|
||||
value="{{ (int) $settings->pension_min_salary }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 기타 설정 --}}
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-3">기타 설정</h4>
|
||||
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">급여 지급일</label>
|
||||
<input type="number" name="pay_day" min="1" max="31"
|
||||
value="{{ $settings->pay_day }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="auto_calculate" value="1"
|
||||
{{ $settings->auto_calculate ? 'checked' : '' }}
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<span class="text-sm text-gray-700">급여 등록 시 자동 계산</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 저장 버튼 --}}
|
||||
<div class="flex justify-end pt-4 border-t border-gray-200">
|
||||
<button type="button" onclick="savePayrollSettings()"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
설정 저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
23
resources/views/hr/payrolls/partials/stats.blade.php
Normal file
23
resources/views/hr/payrolls/partials/stats.blade.php
Normal 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>
|
||||
180
resources/views/hr/payrolls/partials/table.blade.php
Normal file
180
resources/views/hr/payrolls/partials/table.blade.php
Normal file
@@ -0,0 +1,180 @@
|
||||
{{-- 급여 목록 테이블 (HTMX로 로드) --}}
|
||||
@php
|
||||
use App\Models\HR\Payroll;
|
||||
@endphp
|
||||
|
||||
<x-table-swipe>
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">사원</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">부서</th>
|
||||
<th class="px-6 py-3 text-right text-sm font-semibold text-gray-600">기본급</th>
|
||||
<th class="px-6 py-3 text-right text-sm font-semibold text-gray-600">수당</th>
|
||||
<th class="px-6 py-3 text-right text-sm font-semibold text-gray-600">총지급액</th>
|
||||
<th class="px-6 py-3 text-right text-sm font-semibold text-gray-600">공제액</th>
|
||||
<th class="px-6 py-3 text-right text-sm font-semibold text-gray-600">실수령액</th>
|
||||
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">상태</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
@forelse($payrolls as $payroll)
|
||||
@php
|
||||
$profile = $payroll->user?->tenantProfiles?->first();
|
||||
$department = $profile?->department;
|
||||
$displayName = $profile?->display_name ?? $payroll->user?->name ?? '-';
|
||||
$color = Payroll::STATUS_COLORS[$payroll->status] ?? 'gray';
|
||||
$label = Payroll::STATUS_MAP[$payroll->status] ?? $payroll->status;
|
||||
$allowancesTotal = 0;
|
||||
if ($payroll->allowances) {
|
||||
foreach ($payroll->allowances as $a) { $allowancesTotal += ($a['amount'] ?? 0); }
|
||||
}
|
||||
$overtimeBonus = ($payroll->overtime_pay ?? 0) + ($payroll->bonus ?? 0) + $allowancesTotal;
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
{{-- 사원 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="shrink-0 w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-medium">
|
||||
{{ mb_substr($displayName, 0, 1) }}
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $displayName }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{{-- 부서 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||
{{ $department?->name ?? '-' }}
|
||||
</td>
|
||||
|
||||
{{-- 기본급 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm text-gray-700">
|
||||
{{ number_format($payroll->base_salary) }}
|
||||
</td>
|
||||
|
||||
{{-- 수당 (초과근무+상여+기타) --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm text-gray-500">
|
||||
{{ $overtimeBonus > 0 ? number_format($overtimeBonus) : '-' }}
|
||||
</td>
|
||||
|
||||
{{-- 총지급액 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-gray-800">
|
||||
{{ number_format($payroll->gross_salary) }}
|
||||
</td>
|
||||
|
||||
{{-- 공제액 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm text-red-600">
|
||||
{{ number_format($payroll->total_deductions) }}
|
||||
</td>
|
||||
|
||||
{{-- 실수령액 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-emerald-600">
|
||||
{{ number_format($payroll->net_salary) }}
|
||||
</td>
|
||||
|
||||
{{-- 상태 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ $color }}-100 text-{{ $color }}-700">
|
||||
{{ $label }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{{-- 작업 --}}
|
||||
<td class="px-4 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
{{-- 수정 (draft만) --}}
|
||||
@if($payroll->isEditable())
|
||||
<button type="button" onclick="openEditPayrollModal({{ $payroll->id }}, {{ json_encode([
|
||||
'user_id' => $payroll->user_id,
|
||||
'user_name' => $displayName,
|
||||
'base_salary' => $payroll->base_salary,
|
||||
'overtime_pay' => $payroll->overtime_pay,
|
||||
'bonus' => $payroll->bonus,
|
||||
'allowances' => $payroll->allowances,
|
||||
'deductions' => $payroll->deductions,
|
||||
'note' => $payroll->note,
|
||||
]) }})" class="p-1 text-blue-600 hover:text-blue-800" title="수정">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" onclick="deletePayroll({{ $payroll->id }})" class="p-1 text-red-600 hover:text-red-800" title="삭제">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
{{-- 확정 (draft만) --}}
|
||||
@if($payroll->isConfirmable())
|
||||
<button type="button" onclick="confirmPayroll({{ $payroll->id }})" class="p-1 text-blue-600 hover:text-blue-800" title="확정">
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
{{-- 지급 (confirmed만) --}}
|
||||
@if($payroll->isPayable())
|
||||
<button type="button" onclick="payPayroll({{ $payroll->id }})" class="p-1 text-emerald-600 hover:text-emerald-800" title="지급처리">
|
||||
<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="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
{{-- 상세보기 (paid) --}}
|
||||
@if($payroll->status === 'paid')
|
||||
<button type="button" onclick="openPayrollDetail({{ $payroll->id }}, {{ json_encode([
|
||||
'user_name' => $displayName,
|
||||
'department' => $department?->name ?? '-',
|
||||
'period' => $payroll->period_label,
|
||||
'base_salary' => $payroll->base_salary,
|
||||
'overtime_pay' => $payroll->overtime_pay,
|
||||
'bonus' => $payroll->bonus,
|
||||
'allowances' => $payroll->allowances,
|
||||
'gross_salary' => $payroll->gross_salary,
|
||||
'income_tax' => $payroll->income_tax,
|
||||
'resident_tax' => $payroll->resident_tax,
|
||||
'health_insurance' => $payroll->health_insurance,
|
||||
'pension' => $payroll->pension,
|
||||
'employment_insurance' => $payroll->employment_insurance,
|
||||
'deductions' => $payroll->deductions,
|
||||
'total_deductions' => $payroll->total_deductions,
|
||||
'net_salary' => $payroll->net_salary,
|
||||
'confirmed_at' => $payroll->confirmed_at?->format('Y-m-d H:i'),
|
||||
'paid_at' => $payroll->paid_at?->format('Y-m-d H:i'),
|
||||
'note' => $payroll->note,
|
||||
]) }})" class="p-1 text-gray-600 hover:text-gray-800" title="상세보기">
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="9" class="px-6 py-12 text-center">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<svg class="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">급여 기록이 없습니다.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</x-table-swipe>
|
||||
|
||||
{{-- 페이지네이션 --}}
|
||||
@if($payrolls->hasPages())
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
{{ $payrolls->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@@ -1096,3 +1096,23 @@
|
||||
Route::post('/{id}/reject', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'reject'])->name('reject');
|
||||
Route::post('/{id}/cancel', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'cancel'])->name('cancel');
|
||||
});
|
||||
|
||||
// 급여관리 API
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/payrolls')->name('api.admin.hr.payrolls.')->group(function () {
|
||||
Route::get('/stats', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'stats'])->name('stats');
|
||||
Route::get('/export', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'export'])->name('export');
|
||||
Route::post('/bulk-generate', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'bulkGenerate'])->name('bulk-generate');
|
||||
Route::post('/calculate', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'calculate'])->name('calculate');
|
||||
Route::get('/', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'store'])->name('store');
|
||||
Route::put('/{id}', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/{id}/confirm', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'confirm'])->name('confirm');
|
||||
Route::post('/{id}/pay', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'pay'])->name('pay');
|
||||
});
|
||||
|
||||
// 급여 설정 API
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/payroll-settings')->name('api.admin.hr.payroll-settings.')->group(function () {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -907,6 +907,11 @@
|
||||
Route::prefix('leaves')->name('leaves.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\HR\LeaveController::class, 'index'])->name('index');
|
||||
});
|
||||
|
||||
// 급여관리
|
||||
Route::prefix('payrolls')->name('payrolls.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\HR\PayrollController::class, 'index'])->name('index');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user