feat: [hr] 사업소득자관리 메뉴 신설
- BusinessIncomeEarner 모델 생성 (worker_type 글로벌 스코프) - Employee 모델에 worker_type 글로벌 스코프 추가 (기존 사원 격리) - BusinessIncomeEarnerService 생성 (등록/수정/삭제/조회) - Web/API 컨트롤러 생성 (CRUD + 파일 업로드) - 라우트 추가 (web.php, api.php) - View 5개 생성 (index, create, show, edit, partials/table) - 사업장등록정보 6개 필드 (사업자등록번호, 상호, 대표자명, 업태, 종목, 소재지)
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Boards\File;
|
||||
use App\Services\GoogleCloudStorageService;
|
||||
use App\Services\HR\BusinessIncomeEarnerService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BusinessIncomeEarnerController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private BusinessIncomeEarnerService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 사업소득자 목록 조회 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|Response
|
||||
{
|
||||
$earners = $this->service->getBusinessIncomeEarners(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.business-income-earners.partials.table', compact('earners')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $earners->items(),
|
||||
'meta' => [
|
||||
'current_page' => $earners->currentPage(),
|
||||
'last_page' => $earners->lastPage(),
|
||||
'per_page' => $earners->perPage(),
|
||||
'total' => $earners->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 사용자 검색 (사업소득자 미등록, 테넌트 소속)
|
||||
*/
|
||||
public function searchUsers(Request $request): JsonResponse
|
||||
{
|
||||
$users = $this->service->searchTenantUsers($request->get('q') ?? '');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $users,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 통계
|
||||
*/
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
$stats = $this->service->getStats();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 등록
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$rules = [
|
||||
'existing_user_id' => 'nullable|integer|exists:users,id',
|
||||
'name' => 'required|string|max:50',
|
||||
'email' => 'nullable|email|max:100',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'position_key' => 'nullable|string|max:50',
|
||||
'job_title_key' => 'nullable|string|max:50',
|
||||
'work_location_key' => 'nullable|string|max:50',
|
||||
'employment_type_key' => 'nullable|string|max:50',
|
||||
'employee_status' => 'nullable|string|in:active,leave,resigned',
|
||||
'manager_user_id' => 'nullable|integer|exists:users,id',
|
||||
'display_name' => 'nullable|string|max:50',
|
||||
'hire_date' => 'nullable|date',
|
||||
'resign_date' => 'nullable|date',
|
||||
'address' => 'nullable|string|max:200',
|
||||
'emergency_contact' => 'nullable|string|max:100',
|
||||
'resident_number' => 'nullable|string|max:14',
|
||||
'bank_account.bank_code' => 'nullable|string|max:20',
|
||||
'bank_account.bank_name' => 'nullable|string|max:50',
|
||||
'bank_account.account_holder' => 'nullable|string|max:50',
|
||||
'bank_account.account_number' => 'nullable|string|max:30',
|
||||
'dependents' => 'nullable|array',
|
||||
'dependents.*.name' => 'required_with:dependents|string|max:50',
|
||||
'dependents.*.nationality' => 'nullable|string|in:korean,foreigner',
|
||||
'dependents.*.resident_number' => 'nullable|string|max:14',
|
||||
'dependents.*.relationship' => 'nullable|string|max:20',
|
||||
'dependents.*.is_disabled' => 'nullable|boolean',
|
||||
'dependents.*.is_dependent' => 'nullable|boolean',
|
||||
// 사업장등록정보
|
||||
'business_registration_number' => 'nullable|string|max:12',
|
||||
'business_name' => 'nullable|string|max:100',
|
||||
'business_representative' => 'nullable|string|max:50',
|
||||
'business_type' => 'nullable|string|max:50',
|
||||
'business_category' => 'nullable|string|max:50',
|
||||
'business_address' => 'nullable|string|max:200',
|
||||
];
|
||||
|
||||
if (! $request->filled('existing_user_id')) {
|
||||
$rules['email'] = 'nullable|email|max:100|unique:users,email';
|
||||
}
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
|
||||
try {
|
||||
$earner = $this->service->create($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '사업소득자가 등록되었습니다.',
|
||||
'data' => $earner,
|
||||
], 201);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사업소득자 등록 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$earner = $this->service->getById($id);
|
||||
|
||||
if (! $earner) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사업소득자 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $earner,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|required|string|max:50',
|
||||
'email' => 'nullable|email|max:100',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'position_key' => 'nullable|string|max:50',
|
||||
'job_title_key' => 'nullable|string|max:50',
|
||||
'work_location_key' => 'nullable|string|max:50',
|
||||
'employment_type_key' => 'nullable|string|max:50',
|
||||
'employee_status' => 'nullable|string|in:active,leave,resigned',
|
||||
'manager_user_id' => 'nullable|integer|exists:users,id',
|
||||
'display_name' => 'nullable|string|max:50',
|
||||
'hire_date' => 'nullable|date',
|
||||
'resign_date' => 'nullable|date',
|
||||
'address' => 'nullable|string|max:200',
|
||||
'emergency_contact' => 'nullable|string|max:100',
|
||||
'resident_number' => 'nullable|string|max:14',
|
||||
'bank_account.bank_code' => 'nullable|string|max:20',
|
||||
'bank_account.bank_name' => 'nullable|string|max:50',
|
||||
'bank_account.account_holder' => 'nullable|string|max:50',
|
||||
'bank_account.account_number' => 'nullable|string|max:30',
|
||||
'dependents' => 'nullable|array',
|
||||
'dependents.*.name' => 'required_with:dependents|string|max:50',
|
||||
'dependents.*.nationality' => 'nullable|string|in:korean,foreigner',
|
||||
'dependents.*.resident_number' => 'nullable|string|max:14',
|
||||
'dependents.*.relationship' => 'nullable|string|max:20',
|
||||
'dependents.*.is_disabled' => 'nullable|boolean',
|
||||
'dependents.*.is_dependent' => 'nullable|boolean',
|
||||
// 사업장등록정보
|
||||
'business_registration_number' => 'nullable|string|max:12',
|
||||
'business_name' => 'nullable|string|max:100',
|
||||
'business_representative' => 'nullable|string|max:50',
|
||||
'business_type' => 'nullable|string|max:50',
|
||||
'business_category' => 'nullable|string|max:50',
|
||||
'business_address' => 'nullable|string|max:200',
|
||||
]);
|
||||
|
||||
if ($request->has('dependents_submitted') && ! array_key_exists('dependents', $validated)) {
|
||||
$validated['dependents'] = [];
|
||||
}
|
||||
|
||||
try {
|
||||
$earner = $this->service->update($id, $validated);
|
||||
|
||||
if (! $earner) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사업소득자 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '사업소득자 정보가 수정되었습니다.',
|
||||
'data' => $earner,
|
||||
]);
|
||||
} 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->service->delete($id);
|
||||
|
||||
if (! $result) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사업소득자 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$earners = $this->service->getBusinessIncomeEarners($request->all(), $request->integer('per_page', 20));
|
||||
|
||||
return response(view('hr.business-income-earners.partials.table', compact('earners')));
|
||||
}
|
||||
|
||||
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 uploadFile(Request $request, int $id, GoogleCloudStorageService $gcs): JsonResponse
|
||||
{
|
||||
$earner = $this->service->getById($id);
|
||||
if (! $earner) {
|
||||
return response()->json(['success' => false, 'message' => '사업소득자 정보를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'files' => 'required|array|max:10',
|
||||
'files.*' => 'file|max:20480',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$uploaded = [];
|
||||
|
||||
foreach ($request->file('files') as $file) {
|
||||
$storedName = Str::random(40).'.'.$file->getClientOriginalExtension();
|
||||
$storagePath = "business-income-earners/{$tenantId}/{$earner->id}/{$storedName}";
|
||||
|
||||
Storage::disk('tenant')->put($storagePath, file_get_contents($file));
|
||||
|
||||
$gcsUri = null;
|
||||
$gcsObjectName = null;
|
||||
if ($gcs->isAvailable()) {
|
||||
$gcsObjectName = $storagePath;
|
||||
$gcsUri = $gcs->upload($file->getRealPath(), $gcsObjectName);
|
||||
}
|
||||
|
||||
$fileRecord = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'document_id' => $earner->id,
|
||||
'document_type' => 'business_income_earner_profile',
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $storagePath,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'file_size' => $file->getSize(),
|
||||
'file_type' => strtolower($file->getClientOriginalExtension()),
|
||||
'gcs_object_name' => $gcsObjectName,
|
||||
'gcs_uri' => $gcsUri,
|
||||
'uploaded_by' => auth()->id(),
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$uploaded[] = $fileRecord;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => count($uploaded).'개 파일이 업로드되었습니다.',
|
||||
'data' => $uploaded,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 삭제
|
||||
*/
|
||||
public function deleteFile(int $id, int $fileId, GoogleCloudStorageService $gcs): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$file = File::where('id', $fileId)
|
||||
->where('document_id', $id)
|
||||
->where('document_type', 'business_income_earner_profile')
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $file) {
|
||||
return response()->json(['success' => false, 'message' => '파일을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
if ($gcs->isAvailable() && $file->gcs_object_name) {
|
||||
$gcs->delete($file->gcs_object_name);
|
||||
}
|
||||
|
||||
if ($file->file_path && Storage::disk('tenant')->exists($file->file_path)) {
|
||||
Storage::disk('tenant')->delete($file->file_path);
|
||||
}
|
||||
|
||||
$file->deleted_by = auth()->id();
|
||||
$file->save();
|
||||
$file->delete();
|
||||
|
||||
return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 다운로드
|
||||
*/
|
||||
public function downloadFile(int $id, int $fileId, GoogleCloudStorageService $gcs)
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$file = File::where('id', $fileId)
|
||||
->where('document_id', $id)
|
||||
->where('document_type', 'business_income_earner_profile')
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $file) {
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
if ($gcs->isAvailable() && $file->gcs_object_name) {
|
||||
$signedUrl = $gcs->getSignedUrl($file->gcs_object_name, 60);
|
||||
if ($signedUrl) {
|
||||
return redirect($signedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
$disk = Storage::disk('tenant');
|
||||
if ($file->file_path && $disk->exists($file->file_path)) {
|
||||
return $disk->download($file->file_path, $file->original_name);
|
||||
}
|
||||
|
||||
abort(404, '파일이 서버에 존재하지 않습니다.');
|
||||
}
|
||||
}
|
||||
88
app/Http/Controllers/HR/BusinessIncomeEarnerController.php
Normal file
88
app/Http/Controllers/HR/BusinessIncomeEarnerController.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Boards\File;
|
||||
use App\Services\HR\BusinessIncomeEarnerService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
class BusinessIncomeEarnerController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private BusinessIncomeEarnerService $service
|
||||
) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$stats = $this->service->getStats();
|
||||
$departments = $this->service->getDepartments();
|
||||
|
||||
return view('hr.business-income-earners.index', [
|
||||
'stats' => $stats,
|
||||
'departments' => $departments,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$departments = $this->service->getDepartments();
|
||||
$ranks = $this->service->getPositions('rank');
|
||||
$titles = $this->service->getPositions('title');
|
||||
|
||||
return view('hr.business-income-earners.create', [
|
||||
'departments' => $departments,
|
||||
'ranks' => $ranks,
|
||||
'titles' => $titles,
|
||||
'banks' => config('banks', []),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id): View
|
||||
{
|
||||
$earner = $this->service->getById($id);
|
||||
|
||||
if (! $earner) {
|
||||
abort(404, '사업소득자 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$files = File::where('document_type', 'business_income_earner_profile')
|
||||
->where('document_id', $earner->id)
|
||||
->where('tenant_id', session('selected_tenant_id'))
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return view('hr.business-income-earners.show', [
|
||||
'earner' => $earner,
|
||||
'files' => $files,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(int $id): View
|
||||
{
|
||||
$earner = $this->service->getById($id);
|
||||
|
||||
if (! $earner) {
|
||||
abort(404, '사업소득자 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$departments = $this->service->getDepartments();
|
||||
$ranks = $this->service->getPositions('rank');
|
||||
$titles = $this->service->getPositions('title');
|
||||
|
||||
$files = File::where('document_type', 'business_income_earner_profile')
|
||||
->where('document_id', $earner->id)
|
||||
->where('tenant_id', session('selected_tenant_id'))
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return view('hr.business-income-earners.edit', [
|
||||
'earner' => $earner,
|
||||
'departments' => $departments,
|
||||
'ranks' => $ranks,
|
||||
'titles' => $titles,
|
||||
'banks' => config('banks', []),
|
||||
'files' => $files,
|
||||
]);
|
||||
}
|
||||
}
|
||||
236
app/Models/HR/BusinessIncomeEarner.php
Normal file
236
app/Models/HR/BusinessIncomeEarner.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\HR;
|
||||
|
||||
use App\Models\Tenants\Department;
|
||||
use App\Models\User;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BusinessIncomeEarner extends Model
|
||||
{
|
||||
use ModelTrait;
|
||||
|
||||
protected $table = 'tenant_user_profiles';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'department_id',
|
||||
'position_key',
|
||||
'job_title_key',
|
||||
'work_location_key',
|
||||
'employment_type_key',
|
||||
'employee_status',
|
||||
'worker_type',
|
||||
'manager_user_id',
|
||||
'json_extra',
|
||||
'profile_photo_path',
|
||||
'display_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'json_extra' => 'array',
|
||||
'tenant_id' => 'int',
|
||||
'user_id' => 'int',
|
||||
'department_id' => 'int',
|
||||
'manager_user_id' => 'int',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'hire_date',
|
||||
'resign_date',
|
||||
'position_label',
|
||||
'job_title_label',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 글로벌 스코프 + 이벤트
|
||||
// =========================================================================
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::addGlobalScope('business_income', function (Builder $builder) {
|
||||
$builder->where('worker_type', 'business_income');
|
||||
});
|
||||
|
||||
static::creating(function (self $model) {
|
||||
$model->worker_type = 'business_income';
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class, 'department_id');
|
||||
}
|
||||
|
||||
public function manager(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'manager_user_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// json_extra Accessor (Employee 동일)
|
||||
// =========================================================================
|
||||
|
||||
public function getHireDateAttribute(): ?string
|
||||
{
|
||||
return $this->json_extra['hire_date'] ?? null;
|
||||
}
|
||||
|
||||
public function getResignDateAttribute(): ?string
|
||||
{
|
||||
return $this->json_extra['resign_date'] ?? null;
|
||||
}
|
||||
|
||||
public function getAddressAttribute(): ?string
|
||||
{
|
||||
return $this->json_extra['address'] ?? null;
|
||||
}
|
||||
|
||||
public function getEmergencyContactAttribute(): ?string
|
||||
{
|
||||
return $this->json_extra['emergency_contact'] ?? null;
|
||||
}
|
||||
|
||||
public function getResidentNumberAttribute(): ?string
|
||||
{
|
||||
return $this->json_extra['resident_number'] ?? null;
|
||||
}
|
||||
|
||||
public function getBankAccountAttribute(): ?array
|
||||
{
|
||||
return $this->json_extra['bank_account'] ?? null;
|
||||
}
|
||||
|
||||
public function getDependentsAttribute(): array
|
||||
{
|
||||
return $this->json_extra['dependents'] ?? [];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 사업장등록정보 Accessor (json_extra에서 읽기)
|
||||
// =========================================================================
|
||||
|
||||
public function getBusinessRegistrationNumberAttribute(): ?string
|
||||
{
|
||||
return $this->json_extra['business_registration_number'] ?? null;
|
||||
}
|
||||
|
||||
public function getBusinessNameAttribute(): ?string
|
||||
{
|
||||
return $this->json_extra['business_name'] ?? null;
|
||||
}
|
||||
|
||||
public function getBusinessRepresentativeAttribute(): ?string
|
||||
{
|
||||
return $this->json_extra['business_representative'] ?? null;
|
||||
}
|
||||
|
||||
public function getBusinessTypeAttribute(): ?string
|
||||
{
|
||||
return $this->json_extra['business_type'] ?? null;
|
||||
}
|
||||
|
||||
public function getBusinessCategoryAttribute(): ?string
|
||||
{
|
||||
return $this->json_extra['business_category'] ?? null;
|
||||
}
|
||||
|
||||
public function getBusinessAddressAttribute(): ?string
|
||||
{
|
||||
return $this->json_extra['business_address'] ?? null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 직급/직책 레이블
|
||||
// =========================================================================
|
||||
|
||||
public function getPositionLabelAttribute(): ?string
|
||||
{
|
||||
if (! $this->position_key || ! $this->tenant_id) {
|
||||
return $this->position_key;
|
||||
}
|
||||
|
||||
$position = Position::where('tenant_id', $this->tenant_id)
|
||||
->where('type', Position::TYPE_RANK)
|
||||
->where('key', $this->position_key)
|
||||
->first();
|
||||
|
||||
return $position?->name ?? $this->position_key;
|
||||
}
|
||||
|
||||
public function getJobTitleLabelAttribute(): ?string
|
||||
{
|
||||
if (! $this->job_title_key || ! $this->tenant_id) {
|
||||
return $this->job_title_key;
|
||||
}
|
||||
|
||||
$position = Position::where('tenant_id', $this->tenant_id)
|
||||
->where('type', Position::TYPE_TITLE)
|
||||
->where('key', $this->job_title_key)
|
||||
->first();
|
||||
|
||||
return $position?->name ?? $this->job_title_key;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// json_extra 헬퍼
|
||||
// =========================================================================
|
||||
|
||||
public function getJsonExtraValue(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->json_extra[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function setJsonExtraValue(string $key, mixed $value): void
|
||||
{
|
||||
$extra = $this->json_extra ?? [];
|
||||
if ($value === null) {
|
||||
unset($extra[$key]);
|
||||
} else {
|
||||
$extra[$key] = $value;
|
||||
}
|
||||
$this->json_extra = $extra;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId = null)
|
||||
{
|
||||
$tenantId = $tenantId ?? session('selected_tenant_id');
|
||||
if ($tenantId) {
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function scopeActiveEmployees($query)
|
||||
{
|
||||
return $query->where('employee_status', 'active');
|
||||
}
|
||||
|
||||
public function scopeOnLeave($query)
|
||||
{
|
||||
return $query->where('employee_status', 'leave');
|
||||
}
|
||||
|
||||
public function scopeResigned($query)
|
||||
{
|
||||
return $query->where('employee_status', 'resigned');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
use App\Models\Tenants\Department;
|
||||
use App\Models\User;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
@@ -23,12 +24,23 @@ class Employee extends Model
|
||||
'work_location_key',
|
||||
'employment_type_key',
|
||||
'employee_status',
|
||||
'worker_type',
|
||||
'manager_user_id',
|
||||
'json_extra',
|
||||
'profile_photo_path',
|
||||
'display_name',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::addGlobalScope('employee', function (Builder $builder) {
|
||||
$builder->where(function ($q) {
|
||||
$q->where('worker_type', 'employee')
|
||||
->orWhereNull('worker_type');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
'json_extra' => 'array',
|
||||
'tenant_id' => 'int',
|
||||
|
||||
394
app/Services/HR/BusinessIncomeEarnerService.php
Normal file
394
app/Services/HR/BusinessIncomeEarnerService.php
Normal file
@@ -0,0 +1,394 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\HR;
|
||||
|
||||
use App\Models\HR\BusinessIncomeEarner;
|
||||
use App\Models\HR\Position;
|
||||
use App\Models\Tenants\Department;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BusinessIncomeEarnerService
|
||||
{
|
||||
/**
|
||||
* 사업소득자 목록 조회 (페이지네이션)
|
||||
*/
|
||||
public function getBusinessIncomeEarners(array $filters = [], int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$query = BusinessIncomeEarner::query()
|
||||
->with(['user', 'department'])
|
||||
->forTenant($tenantId);
|
||||
|
||||
if (! empty($filters['q'])) {
|
||||
$search = $filters['q'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('display_name', 'like', "%{$search}%")
|
||||
->orWhereHas('user', function ($uq) use ($search) {
|
||||
$uq->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%")
|
||||
->orWhere('phone', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('employee_status', $filters['status']);
|
||||
}
|
||||
|
||||
if (! empty($filters['department_id'])) {
|
||||
$query->where('department_id', $filters['department_id']);
|
||||
}
|
||||
|
||||
$sortBy = $filters['sort_by'] ?? 'hire_date_asc';
|
||||
switch ($sortBy) {
|
||||
case 'hire_date_asc':
|
||||
$query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) ASC");
|
||||
break;
|
||||
case 'hire_date_desc':
|
||||
$query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) DESC");
|
||||
break;
|
||||
case 'resign_date_asc':
|
||||
$query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.resign_date')) ASC");
|
||||
break;
|
||||
case 'resign_date_desc':
|
||||
$query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.resign_date')) DESC");
|
||||
break;
|
||||
default:
|
||||
$query->orderByRaw("FIELD(employee_status, 'active', 'leave', 'resigned')")
|
||||
->orderBy('created_at', 'desc');
|
||||
break;
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 상세 조회
|
||||
*/
|
||||
public function getById(int $id): ?BusinessIncomeEarner
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return BusinessIncomeEarner::query()
|
||||
->with(['user', 'department', 'manager'])
|
||||
->forTenant($tenantId)
|
||||
->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 통계
|
||||
*/
|
||||
public function getStats(): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$baseQuery = BusinessIncomeEarner::query()->forTenant($tenantId);
|
||||
|
||||
return [
|
||||
'total' => (clone $baseQuery)->count(),
|
||||
'active' => (clone $baseQuery)->where('employee_status', 'active')->count(),
|
||||
'leave' => (clone $baseQuery)->where('employee_status', 'leave')->count(),
|
||||
'resigned' => (clone $baseQuery)->where('employee_status', 'resigned')->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 소속이지만 사업소득자 미등록인 사용자 검색
|
||||
*/
|
||||
public function searchTenantUsers(string $query): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$builder = User::query()
|
||||
->select('users.id', 'users.name', 'users.email', 'users.phone')
|
||||
->join('user_tenants as ut', function ($join) use ($tenantId) {
|
||||
$join->on('users.id', '=', 'ut.user_id')
|
||||
->where('ut.tenant_id', $tenantId)
|
||||
->whereNull('ut.deleted_at');
|
||||
})
|
||||
->leftJoin('tenant_user_profiles as tup', function ($join) use ($tenantId) {
|
||||
$join->on('users.id', '=', 'tup.user_id')
|
||||
->where('tup.tenant_id', $tenantId);
|
||||
})
|
||||
->whereNull('tup.id')
|
||||
->whereNull('users.deleted_at');
|
||||
|
||||
if ($query !== '') {
|
||||
$like = "%{$query}%";
|
||||
$builder->where(function ($q) use ($like) {
|
||||
$q->where('users.name', 'like', $like)
|
||||
->orWhere('users.email', 'like', $like)
|
||||
->orWhere('users.phone', 'like', $like);
|
||||
});
|
||||
}
|
||||
|
||||
return $builder->orderBy('users.name')->limit(20)->get()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 등록 (User is_active=false + TenantUserProfile 생성)
|
||||
*/
|
||||
public function create(array $data): BusinessIncomeEarner
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId) {
|
||||
if (! empty($data['existing_user_id'])) {
|
||||
$user = User::findOrFail($data['existing_user_id']);
|
||||
|
||||
$isMember = $user->tenants()
|
||||
->wherePivot('tenant_id', $tenantId)
|
||||
->wherePivotNull('deleted_at')
|
||||
->exists();
|
||||
|
||||
if (! $isMember) {
|
||||
throw new \RuntimeException('해당 사용자는 현재 테넌트에 소속되어 있지 않습니다.');
|
||||
}
|
||||
|
||||
$alreadyRegistered = BusinessIncomeEarner::where('tenant_id', $tenantId)
|
||||
->where('user_id', $user->id)
|
||||
->exists();
|
||||
|
||||
if ($alreadyRegistered) {
|
||||
throw new \RuntimeException('이미 사업소득자로 등록된 사용자입니다.');
|
||||
}
|
||||
} else {
|
||||
// 신규 사용자 생성 (is_active=false, 로그인 불가)
|
||||
$loginId = ! empty($data['email'])
|
||||
? Str::before($data['email'], '@')
|
||||
: 'BIZ_'.strtolower(Str::random(6));
|
||||
|
||||
while (User::where('user_id', $loginId)->exists()) {
|
||||
$loginId = $loginId.'_'.Str::random(3);
|
||||
}
|
||||
|
||||
$email = ! empty($data['email'])
|
||||
? $data['email']
|
||||
: $loginId.'@placeholder.local';
|
||||
|
||||
while (User::where('email', $email)->exists()) {
|
||||
$email = $loginId.'_'.Str::random(3).'@placeholder.local';
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'user_id' => $loginId,
|
||||
'name' => $data['name'],
|
||||
'email' => $email,
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'password' => Hash::make(Str::random(32)),
|
||||
'role' => 'ops',
|
||||
'is_active' => false,
|
||||
'must_change_password' => false,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
if ($tenantId) {
|
||||
$user->tenants()->attach($tenantId, [
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// json_extra 구성
|
||||
$jsonExtra = [];
|
||||
$scalarKeys = [
|
||||
'hire_date', 'resign_date', 'address', 'emergency_contact', 'resident_number',
|
||||
'business_registration_number', 'business_name', 'business_representative',
|
||||
'business_type', 'business_category', 'business_address',
|
||||
];
|
||||
foreach ($scalarKeys as $key) {
|
||||
if (! empty($data[$key])) {
|
||||
$jsonExtra[$key] = $data[$key];
|
||||
}
|
||||
}
|
||||
|
||||
// 급여이체정보
|
||||
if (! empty($data['bank_account']) && is_array($data['bank_account'])) {
|
||||
$bankAccount = array_filter($data['bank_account'], fn ($v) => $v !== null && $v !== '');
|
||||
if (! empty($bankAccount)) {
|
||||
$jsonExtra['bank_account'] = $bankAccount;
|
||||
}
|
||||
}
|
||||
|
||||
// 부양가족 정보
|
||||
if (! empty($data['dependents']) && is_array($data['dependents'])) {
|
||||
$dependents = array_values(array_filter($data['dependents'], function ($dep) {
|
||||
return ! empty($dep['name']);
|
||||
}));
|
||||
$dependents = array_map(function ($dep) {
|
||||
$dep['is_disabled'] = filter_var($dep['is_disabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$dep['is_dependent'] = filter_var($dep['is_dependent'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
return $dep;
|
||||
}, $dependents);
|
||||
if (! empty($dependents)) {
|
||||
$jsonExtra['dependents'] = $dependents;
|
||||
}
|
||||
}
|
||||
|
||||
$earner = BusinessIncomeEarner::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $user->id,
|
||||
'department_id' => $data['department_id'] ?? null,
|
||||
'position_key' => $data['position_key'] ?? null,
|
||||
'job_title_key' => $data['job_title_key'] ?? null,
|
||||
'work_location_key' => $data['work_location_key'] ?? null,
|
||||
'employment_type_key' => $data['employment_type_key'] ?? null,
|
||||
'employee_status' => $data['employee_status'] ?? 'active',
|
||||
'manager_user_id' => $data['manager_user_id'] ?? null,
|
||||
'display_name' => $data['display_name'] ?? $data['name'],
|
||||
'json_extra' => ! empty($jsonExtra) ? $jsonExtra : null,
|
||||
]);
|
||||
|
||||
return $earner->load(['user', 'department']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 정보 수정
|
||||
*/
|
||||
public function update(int $id, array $data): ?BusinessIncomeEarner
|
||||
{
|
||||
$earner = $this->getById($id);
|
||||
if (! $earner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$updateData = array_filter([
|
||||
'department_id' => $data['department_id'] ?? null,
|
||||
'position_key' => $data['position_key'] ?? null,
|
||||
'job_title_key' => $data['job_title_key'] ?? null,
|
||||
'work_location_key' => $data['work_location_key'] ?? null,
|
||||
'employment_type_key' => $data['employment_type_key'] ?? null,
|
||||
'employee_status' => $data['employee_status'] ?? null,
|
||||
'manager_user_id' => $data['manager_user_id'] ?? null,
|
||||
'display_name' => $data['display_name'] ?? null,
|
||||
], fn ($v) => $v !== null);
|
||||
|
||||
// json_extra 업데이트
|
||||
$jsonExtraKeys = [
|
||||
'hire_date', 'resign_date', 'address', 'emergency_contact', 'salary', 'resident_number',
|
||||
'business_registration_number', 'business_name', 'business_representative',
|
||||
'business_type', 'business_category', 'business_address',
|
||||
];
|
||||
$extra = $earner->json_extra ?? [];
|
||||
foreach ($jsonExtraKeys as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
if ($data[$key] === null || $data[$key] === '') {
|
||||
unset($extra[$key]);
|
||||
} else {
|
||||
$extra[$key] = $data[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 급여이체정보
|
||||
if (array_key_exists('bank_account', $data)) {
|
||||
if (! empty($data['bank_account']) && is_array($data['bank_account'])) {
|
||||
$bankAccount = array_filter($data['bank_account'], fn ($v) => $v !== null && $v !== '');
|
||||
if (! empty($bankAccount)) {
|
||||
$extra['bank_account'] = $bankAccount;
|
||||
} else {
|
||||
unset($extra['bank_account']);
|
||||
}
|
||||
} else {
|
||||
unset($extra['bank_account']);
|
||||
}
|
||||
}
|
||||
|
||||
// 부양가족 정보
|
||||
if (array_key_exists('dependents', $data)) {
|
||||
if (! empty($data['dependents']) && is_array($data['dependents'])) {
|
||||
$dependents = array_values(array_filter($data['dependents'], function ($dep) {
|
||||
return ! empty($dep['name']);
|
||||
}));
|
||||
$dependents = array_map(function ($dep) {
|
||||
$dep['is_disabled'] = filter_var($dep['is_disabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$dep['is_dependent'] = filter_var($dep['is_dependent'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
return $dep;
|
||||
}, $dependents);
|
||||
if (! empty($dependents)) {
|
||||
$extra['dependents'] = $dependents;
|
||||
} else {
|
||||
unset($extra['dependents']);
|
||||
}
|
||||
} else {
|
||||
unset($extra['dependents']);
|
||||
}
|
||||
}
|
||||
|
||||
$updateData['json_extra'] = ! empty($extra) ? $extra : null;
|
||||
|
||||
$earner->update($updateData);
|
||||
|
||||
// User 기본정보 동기화
|
||||
if ($earner->user) {
|
||||
$userUpdate = [];
|
||||
if (! empty($data['name'])) {
|
||||
$userUpdate['name'] = $data['name'];
|
||||
}
|
||||
if (! empty($data['email'])) {
|
||||
$userUpdate['email'] = $data['email'];
|
||||
}
|
||||
if (! empty($data['phone'])) {
|
||||
$userUpdate['phone'] = $data['phone'];
|
||||
}
|
||||
if (! empty($userUpdate)) {
|
||||
$earner->user->update($userUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
return $earner->fresh(['user', 'department']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 삭제 (퇴직 처리)
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$earner = $this->getById($id);
|
||||
if (! $earner) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$earner->update(['employee_status' => 'resigned']);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 목록 (드롭다운용)
|
||||
*/
|
||||
public function getDepartments(): \Illuminate\Database\Eloquent\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 getPositions(string $type = 'rank'): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return Position::query()
|
||||
->forTenant()
|
||||
->where('type', $type)
|
||||
->where('is_active', true)
|
||||
->ordered()
|
||||
->get(['id', 'key', 'name']);
|
||||
}
|
||||
}
|
||||
533
resources/views/hr/business-income-earners/create.blade.php
Normal file
533
resources/views/hr/business-income-earners/create.blade.php
Normal file
@@ -0,0 +1,533 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '사업소득자 등록')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-6 max-w-2xl">
|
||||
{{-- 페이지 헤더 --}}
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('hr.business-income-earners.index') }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1 mb-2">
|
||||
<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 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
사업소득자 목록으로
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-800">사업소득자 등록</h1>
|
||||
</div>
|
||||
|
||||
{{-- 등록 폼 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<form id="bizEarnerForm"
|
||||
hx-post="{{ route('api.admin.hr.business-income-earners.store') }}"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}", "Accept": "application/json"}'
|
||||
hx-target="#form-message"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-6">
|
||||
|
||||
<div id="form-message"></div>
|
||||
|
||||
{{-- 기존 직원 불러오기 --}}
|
||||
<div x-data="userSearch()" class="border border-blue-200 bg-blue-50 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-blue-800 mb-3">기존 사용자 불러오기</h3>
|
||||
|
||||
<template x-if="!selectedUser">
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input type="text"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="search()"
|
||||
@focus="onFocus()"
|
||||
@click.outside="showDropdown = false"
|
||||
placeholder="이름, 이메일, 연락처로 검색..."
|
||||
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white text-sm">
|
||||
</div>
|
||||
|
||||
<div x-show="showDropdown"
|
||||
x-transition
|
||||
class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
<template x-if="loading">
|
||||
<div class="px-4 py-3 text-sm text-gray-500 text-center">검색 중...</div>
|
||||
</template>
|
||||
<template x-if="!loading && users.length === 0">
|
||||
<div class="px-4 py-3 text-sm text-gray-500 text-center">검색 결과가 없습니다</div>
|
||||
</template>
|
||||
<template x-for="user in users" :key="user.id">
|
||||
<button type="button"
|
||||
@click="selectUser(user)"
|
||||
class="w-full text-left px-4 py-2.5 hover:bg-blue-50 border-b border-gray-100 last:border-0 transition-colors">
|
||||
<div class="text-sm font-medium text-gray-800" x-text="user.name"></div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<span x-show="user.email" x-text="user.email"></span>
|
||||
<span x-show="user.email && user.phone"> · </span>
|
||||
<span x-show="user.phone" x-text="user.phone"></span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="selectedUser">
|
||||
<div class="flex items-center justify-between bg-white border border-green-300 rounded-lg px-4 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-green-600 shrink-0" 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>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-800" x-text="selectedUser.name"></span>
|
||||
<span class="text-xs text-gray-500 ml-1" x-text="selectedUser.email"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" @click="clearSelection()"
|
||||
class="text-xs text-red-500 hover:text-red-700 font-medium shrink-0">
|
||||
선택 해제
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<input type="hidden" name="existing_user_id" :value="selectedUser ? selectedUser.id : ''">
|
||||
<p class="text-xs text-blue-600 mt-2">이미 시스템에 등록된 사용자를 사업소득자로 추가할 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
{{-- 기본 정보 --}}
|
||||
<div class="border-b border-gray-200 pb-4 mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-700">기본 정보</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
이름 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" id="name" required
|
||||
placeholder="홍길동"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||||
<input type="email" name="email" id="email"
|
||||
placeholder="user@example.com"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">연락처</label>
|
||||
<input type="text" name="phone" id="phone"
|
||||
placeholder="010-1234-5678"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="resident_number" class="block text-sm font-medium text-gray-700 mb-1">주민등록번호</label>
|
||||
<input type="text" name="resident_number" id="resident_number"
|
||||
placeholder="000000-0000000"
|
||||
maxlength="14"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- 근무 정보 --}}
|
||||
<div class="border-b border-gray-200 pb-4 mb-4 mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-700">근무 정보</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="department_id" class="block text-sm font-medium text-gray-700 mb-1">부서</label>
|
||||
<select name="department_id" id="department_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg 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 class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">직급</label>
|
||||
<div class="flex gap-2">
|
||||
<select name="position_key" id="position_key"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($ranks as $rank)
|
||||
<option value="{{ $rank->key }}">{{ $rank->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="button" onclick="openPositionModal('rank')"
|
||||
class="shrink-0 w-9 h-9 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300 transition-colors"
|
||||
title="직급 추가">
|
||||
<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="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">직책</label>
|
||||
<div class="flex gap-2">
|
||||
<select name="job_title_key" id="job_title_key"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($titles as $title)
|
||||
<option value="{{ $title->key }}">{{ $title->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="button" onclick="openPositionModal('title')"
|
||||
class="shrink-0 w-9 h-9 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300 transition-colors"
|
||||
title="직책 추가">
|
||||
<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="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 150px;">
|
||||
<label for="hire_date" class="block text-sm font-medium text-gray-700 mb-1">계약일</label>
|
||||
<input type="date" name="hire_date" id="hire_date"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1 1 150px;">
|
||||
<label for="resign_date" class="block text-sm font-medium text-gray-700 mb-1">계약종료일</label>
|
||||
<input type="date" name="resign_date" id="resign_date"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1 1 150px;">
|
||||
<label for="employee_status" class="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||
<select name="employee_status" id="employee_status"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="active" selected>계약중</option>
|
||||
<option value="leave">휴직</option>
|
||||
<option value="resigned">계약종료</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="address" class="block text-sm font-medium text-gray-700 mb-1">주소</label>
|
||||
<input type="text" name="address" id="address"
|
||||
placeholder="주소를 입력하세요"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="emergency_contact" class="block text-sm font-medium text-gray-700 mb-1">비상연락처</label>
|
||||
<input type="text" name="emergency_contact" id="emergency_contact"
|
||||
placeholder="긴급 시 연락할 전화번호"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- 사업장등록정보 --}}
|
||||
<div class="border-b border-gray-200 pb-4 mb-4 mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-700">사업장등록정보</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="business_registration_number" class="block text-sm font-medium text-gray-700 mb-1">사업자등록번호</label>
|
||||
<input type="text" name="business_registration_number" id="business_registration_number"
|
||||
placeholder="000-00-00000"
|
||||
maxlength="12"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="business_name" class="block text-sm font-medium text-gray-700 mb-1">상호</label>
|
||||
<input type="text" name="business_name" id="business_name"
|
||||
placeholder="상호명"
|
||||
maxlength="100"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="business_representative" class="block text-sm font-medium text-gray-700 mb-1">대표자명</label>
|
||||
<input type="text" name="business_representative" id="business_representative"
|
||||
placeholder="대표자 이름"
|
||||
maxlength="50"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="business_type" class="block text-sm font-medium text-gray-700 mb-1">업태</label>
|
||||
<input type="text" name="business_type" id="business_type"
|
||||
placeholder="서비스업, 제조업 등"
|
||||
maxlength="50"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="business_category" class="block text-sm font-medium text-gray-700 mb-1">종목</label>
|
||||
<input type="text" name="business_category" id="business_category"
|
||||
placeholder="디자인, 컨설팅 등"
|
||||
maxlength="50"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="business_address" class="block text-sm font-medium text-gray-700 mb-1">사업장소재지</label>
|
||||
<input type="text" name="business_address" id="business_address"
|
||||
placeholder="사업장 주소"
|
||||
maxlength="200"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- 급여이체정보 --}}
|
||||
<div class="border-b border-gray-200 pb-4 mb-4 mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-700">급여이체정보</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="bank_account_bank_code" class="block text-sm font-medium text-gray-700 mb-1">이체은행</label>
|
||||
<select name="bank_account[bank_code]" id="bank_account_bank_code"
|
||||
onchange="this.form['bank_account[bank_name]'].value = this.options[this.selectedIndex].text !== '선택하세요' ? this.options[this.selectedIndex].text : ''"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach(config('banks', []) as $code => $name)
|
||||
<option value="{{ $code }}">{{ $name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<input type="hidden" name="bank_account[bank_name]" value="">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="bank_account_account_holder" class="block text-sm font-medium text-gray-700 mb-1">예금주</label>
|
||||
<input type="text" name="bank_account[account_holder]" id="bank_account_account_holder"
|
||||
placeholder="예금주명"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="bank_account_account_number" class="block text-sm font-medium text-gray-700 mb-1">계좌번호</label>
|
||||
<input type="text" name="bank_account[account_number]" id="bank_account_account_number"
|
||||
placeholder="숫자만 입력"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 부양가족 정보 --}}
|
||||
<div class="border-b border-gray-200 pb-4 mb-4 mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-700">부양가족 정보</h2>
|
||||
</div>
|
||||
|
||||
<div x-data="dependentsManager()">
|
||||
<template x-for="(dep, index) in dependents" :key="index">
|
||||
<div class="border border-gray-200 rounded-lg p-4 mb-3 relative">
|
||||
<button type="button" @click="removeDependent(index)"
|
||||
class="absolute top-2 right-2 text-red-400 hover:text-red-600 transition-colors" title="삭제">
|
||||
<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 class="text-xs font-medium text-gray-500 mb-2" x-text="'부양가족 ' + (index + 1)"></div>
|
||||
<div class="flex gap-3 mb-2" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 120px;">
|
||||
<input type="text" :name="'dependents['+index+'][name]'" x-model="dep.name"
|
||||
placeholder="이름" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500">
|
||||
</div>
|
||||
<div style="flex: 0 0 100px;">
|
||||
<select :name="'dependents['+index+'][nationality]'" x-model="dep.nationality"
|
||||
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500">
|
||||
<option value="korean">내국인</option>
|
||||
<option value="foreigner">외국인</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 1 1 150px;">
|
||||
<input type="text" :name="'dependents['+index+'][resident_number]'" x-model="dep.resident_number"
|
||||
placeholder="주민등록번호" maxlength="14" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 items-center" style="flex-wrap: wrap;">
|
||||
<div style="flex: 0 0 100px;">
|
||||
<select :name="'dependents['+index+'][relationship]'" x-model="dep.relationship"
|
||||
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500">
|
||||
<option value="">관계</option>
|
||||
<option value="spouse">배우자</option>
|
||||
<option value="child">자녀</option>
|
||||
<option value="parent">부모</option>
|
||||
<option value="sibling">형제자매</option>
|
||||
<option value="grandparent">조부모</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-1 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="hidden" :name="'dependents['+index+'][is_disabled]'" value="0">
|
||||
<input type="checkbox" :name="'dependents['+index+'][is_disabled]'" x-model="dep.is_disabled" value="1"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
장애인
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-1 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="hidden" :name="'dependents['+index+'][is_dependent]'" value="0">
|
||||
<input type="checkbox" :name="'dependents['+index+'][is_dependent]'" x-model="dep.is_dependent" value="1"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
피부양자적용
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button type="button" @click="addDependent()"
|
||||
class="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-blue-400 hover:text-blue-600 transition-colors">
|
||||
+ 부양가족 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 첨부파일 안내 --}}
|
||||
<div class="border-b border-gray-200 pb-4 mb-4 mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-700">첨부파일</h2>
|
||||
</div>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-500">등록 후 상세/수정 페이지에서 파일을 업로드할 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
{{-- 버튼 --}}
|
||||
<div class="flex justify-end gap-3 pt-4 border-t">
|
||||
<a href="{{ route('hr.business-income-earners.index') }}"
|
||||
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
취소
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{-- 직급/직책 추가 모달 --}}
|
||||
@include('hr.employees.partials.position-add-modal')
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function userSearch() {
|
||||
return {
|
||||
query: '',
|
||||
users: [],
|
||||
selectedUser: null,
|
||||
showDropdown: false,
|
||||
loading: false,
|
||||
|
||||
async search() {
|
||||
this.loading = true;
|
||||
this.showDropdown = true;
|
||||
try {
|
||||
const res = await fetch(`{{ route('api.admin.hr.business-income-earners.search-users') }}?q=${encodeURIComponent(this.query)}`, {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const json = await res.json();
|
||||
this.users = json.data || [];
|
||||
} catch (e) {
|
||||
this.users = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
onFocus() {
|
||||
if (this.users.length === 0) this.search();
|
||||
else this.showDropdown = true;
|
||||
},
|
||||
|
||||
selectUser(user) {
|
||||
this.selectedUser = user;
|
||||
this.showDropdown = false;
|
||||
this.query = '';
|
||||
|
||||
const nameEl = document.getElementById('name');
|
||||
const emailEl = document.getElementById('email');
|
||||
const phoneEl = document.getElementById('phone');
|
||||
|
||||
nameEl.value = user.name || '';
|
||||
emailEl.value = user.email || '';
|
||||
phoneEl.value = user.phone || '';
|
||||
|
||||
[nameEl, emailEl, phoneEl].forEach(el => {
|
||||
el.readOnly = true;
|
||||
el.classList.add('bg-gray-100', 'text-gray-500');
|
||||
});
|
||||
},
|
||||
|
||||
clearSelection() {
|
||||
this.selectedUser = null;
|
||||
|
||||
const nameEl = document.getElementById('name');
|
||||
const emailEl = document.getElementById('email');
|
||||
const phoneEl = document.getElementById('phone');
|
||||
|
||||
[nameEl, emailEl, phoneEl].forEach(el => {
|
||||
el.value = '';
|
||||
el.readOnly = false;
|
||||
el.classList.remove('bg-gray-100', 'text-gray-500');
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function dependentsManager() {
|
||||
return {
|
||||
dependents: [],
|
||||
addDependent() {
|
||||
this.dependents.push({
|
||||
name: '', nationality: 'korean', resident_number: '',
|
||||
relationship: '', is_disabled: false, is_dependent: false
|
||||
});
|
||||
},
|
||||
removeDependent(index) {
|
||||
this.dependents.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.elt.id !== 'bizEarnerForm') return;
|
||||
|
||||
const xhr = event.detail.xhr;
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
|
||||
if (response.success) {
|
||||
showToast(response.message || '사업소득자가 등록되었습니다.', 'success');
|
||||
window.location.href = '{{ route('hr.business-income-earners.index') }}';
|
||||
return;
|
||||
}
|
||||
|
||||
let msg = response.message || '저장에 실패했습니다.';
|
||||
if (response.error) msg += '\n' + response.error;
|
||||
showToast(msg, 'error', 5000);
|
||||
} catch (e) {
|
||||
if (!event.detail.successful) {
|
||||
showToast('서버 오류가 발생했습니다. (HTTP ' + xhr.status + ')', 'error', 5000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:responseError', function(event) {
|
||||
if (event.detail.elt.id !== 'bizEarnerForm') return;
|
||||
|
||||
const xhr = event.detail.xhr;
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
|
||||
if (xhr.status === 422 && response.errors) {
|
||||
const messages = [];
|
||||
for (const field in response.errors) {
|
||||
messages.push(response.errors[field].join(', '));
|
||||
}
|
||||
showToast(messages.join('\n'), 'error', 6000);
|
||||
} else {
|
||||
let msg = response.message || '오류가 발생했습니다.';
|
||||
if (response.error) msg += '\n' + response.error;
|
||||
showToast(msg, 'error', 5000);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('서버 오류가 발생했습니다. (HTTP ' + xhr.status + ')', 'error', 5000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
554
resources/views/hr/business-income-earners/edit.blade.php
Normal file
554
resources/views/hr/business-income-earners/edit.blade.php
Normal file
@@ -0,0 +1,554 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '사업소득자 수정')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-6 max-w-2xl">
|
||||
{{-- 페이지 헤더 --}}
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('hr.business-income-earners.index') }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1 mb-2">
|
||||
<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 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
사업소득자 목록으로
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-800">사업소득자 수정</h1>
|
||||
</div>
|
||||
|
||||
{{-- 수정 폼 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<form id="bizEarnerForm"
|
||||
hx-post="{{ route('api.admin.hr.business-income-earners.update', $earner->id) }}"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}", "Accept": "application/json"}'
|
||||
hx-target="#form-message"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-6">
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
<div id="form-message"></div>
|
||||
|
||||
{{-- 기본 정보 --}}
|
||||
<div class="border-b border-gray-200 pb-4 mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-700">기본 정보</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
이름 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" id="name" required
|
||||
value="{{ $earner->user?->name }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="display_name" class="block text-sm font-medium text-gray-700 mb-1">표시 이름</label>
|
||||
<input type="text" name="display_name" id="display_name"
|
||||
value="{{ $earner->display_name }}"
|
||||
placeholder="표시되는 이름"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||||
<input type="email" name="email" id="email"
|
||||
value="{{ $earner->user?->email }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">연락처</label>
|
||||
<input type="text" name="phone" id="phone"
|
||||
value="{{ $earner->user?->phone }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="resident_number" class="block text-sm font-medium text-gray-700 mb-1">주민등록번호</label>
|
||||
<input type="text" name="resident_number" id="resident_number"
|
||||
value="{{ $earner->resident_number }}"
|
||||
placeholder="000000-0000000"
|
||||
maxlength="14"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- 근무 정보 --}}
|
||||
<div class="border-b border-gray-200 pb-4 mb-4 mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-700">근무 정보</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="department_id" class="block text-sm font-medium text-gray-700 mb-1">부서</label>
|
||||
<select name="department_id" id="department_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($departments as $dept)
|
||||
<option value="{{ $dept->id }}" {{ $earner->department_id == $dept->id ? 'selected' : '' }}>
|
||||
{{ $dept->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">직급</label>
|
||||
<div class="flex gap-2">
|
||||
<select name="position_key" id="position_key"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($ranks as $rank)
|
||||
<option value="{{ $rank->key }}" {{ $earner->position_key === $rank->key ? 'selected' : '' }}>
|
||||
{{ $rank->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="button" onclick="openPositionModal('rank')"
|
||||
class="shrink-0 w-9 h-9 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300 transition-colors"
|
||||
title="직급 추가">
|
||||
<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="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">직책</label>
|
||||
<div class="flex gap-2">
|
||||
<select name="job_title_key" id="job_title_key"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($titles as $title)
|
||||
<option value="{{ $title->key }}" {{ $earner->job_title_key === $title->key ? 'selected' : '' }}>
|
||||
{{ $title->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="button" onclick="openPositionModal('title')"
|
||||
class="shrink-0 w-9 h-9 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300 transition-colors"
|
||||
title="직책 추가">
|
||||
<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="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 150px;">
|
||||
<label for="hire_date" class="block text-sm font-medium text-gray-700 mb-1">계약일</label>
|
||||
<input type="date" name="hire_date" id="hire_date"
|
||||
value="{{ $earner->hire_date }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1 1 150px;">
|
||||
<label for="resign_date" class="block text-sm font-medium text-gray-700 mb-1">계약종료일</label>
|
||||
<input type="date" name="resign_date" id="resign_date"
|
||||
value="{{ $earner->resign_date }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1 1 150px;">
|
||||
<label for="employee_status" class="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||
<select name="employee_status" id="employee_status"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="active" {{ $earner->employee_status === 'active' ? 'selected' : '' }}>계약중</option>
|
||||
<option value="leave" {{ $earner->employee_status === 'leave' ? 'selected' : '' }}>휴직</option>
|
||||
<option value="resigned" {{ $earner->employee_status === 'resigned' ? 'selected' : '' }}>계약종료</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="address" class="block text-sm font-medium text-gray-700 mb-1">주소</label>
|
||||
<input type="text" name="address" id="address"
|
||||
value="{{ $earner->address }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="emergency_contact" class="block text-sm font-medium text-gray-700 mb-1">비상연락처</label>
|
||||
<input type="text" name="emergency_contact" id="emergency_contact"
|
||||
value="{{ $earner->emergency_contact }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- 사업장등록정보 --}}
|
||||
<div class="border-b border-gray-200 pb-4 mb-4 mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-700">사업장등록정보</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="business_registration_number" class="block text-sm font-medium text-gray-700 mb-1">사업자등록번호</label>
|
||||
<input type="text" name="business_registration_number" id="business_registration_number"
|
||||
value="{{ $earner->business_registration_number }}"
|
||||
placeholder="000-00-00000"
|
||||
maxlength="12"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="business_name" class="block text-sm font-medium text-gray-700 mb-1">상호</label>
|
||||
<input type="text" name="business_name" id="business_name"
|
||||
value="{{ $earner->business_name }}"
|
||||
placeholder="상호명"
|
||||
maxlength="100"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="business_representative" class="block text-sm font-medium text-gray-700 mb-1">대표자명</label>
|
||||
<input type="text" name="business_representative" id="business_representative"
|
||||
value="{{ $earner->business_representative }}"
|
||||
placeholder="대표자 이름"
|
||||
maxlength="50"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="business_type" class="block text-sm font-medium text-gray-700 mb-1">업태</label>
|
||||
<input type="text" name="business_type" id="business_type"
|
||||
value="{{ $earner->business_type }}"
|
||||
placeholder="서비스업, 제조업 등"
|
||||
maxlength="50"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="business_category" class="block text-sm font-medium text-gray-700 mb-1">종목</label>
|
||||
<input type="text" name="business_category" id="business_category"
|
||||
value="{{ $earner->business_category }}"
|
||||
placeholder="디자인, 컨설팅 등"
|
||||
maxlength="50"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="business_address" class="block text-sm font-medium text-gray-700 mb-1">사업장소재지</label>
|
||||
<input type="text" name="business_address" id="business_address"
|
||||
value="{{ $earner->business_address }}"
|
||||
placeholder="사업장 주소"
|
||||
maxlength="200"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- 급여이체정보 --}}
|
||||
<div class="border-b border-gray-200 pb-4 mb-4 mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-700">급여이체정보</h2>
|
||||
</div>
|
||||
|
||||
@php $bankAccount = $earner->bank_account ?? []; @endphp
|
||||
|
||||
<div>
|
||||
<label for="bank_account_bank_code" class="block text-sm font-medium text-gray-700 mb-1">이체은행</label>
|
||||
<select name="bank_account[bank_code]" id="bank_account_bank_code"
|
||||
onchange="this.form['bank_account[bank_name]'].value = this.options[this.selectedIndex].text !== '선택하세요' ? this.options[this.selectedIndex].text : ''"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach(config('banks', []) as $code => $name)
|
||||
<option value="{{ $code }}" {{ ($bankAccount['bank_code'] ?? '') === $code ? 'selected' : '' }}>{{ $name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<input type="hidden" name="bank_account[bank_name]" value="{{ $bankAccount['bank_name'] ?? '' }}">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="bank_account_account_holder" class="block text-sm font-medium text-gray-700 mb-1">예금주</label>
|
||||
<input type="text" name="bank_account[account_holder]" id="bank_account_account_holder"
|
||||
value="{{ $bankAccount['account_holder'] ?? '' }}"
|
||||
placeholder="예금주명"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div style="flex: 1 1 200px;">
|
||||
<label for="bank_account_account_number" class="block text-sm font-medium text-gray-700 mb-1">계좌번호</label>
|
||||
<input type="text" name="bank_account[account_number]" id="bank_account_account_number"
|
||||
value="{{ $bankAccount['account_number'] ?? '' }}"
|
||||
placeholder="숫자만 입력"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 부양가족 정보 --}}
|
||||
<div class="border-b border-gray-200 pb-4 mb-4 mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-700">부양가족 정보</h2>
|
||||
</div>
|
||||
|
||||
<div x-data="dependentsManager()">
|
||||
<input type="hidden" name="dependents_submitted" value="1">
|
||||
<template x-for="(dep, index) in dependents" :key="index">
|
||||
<div class="border border-gray-200 rounded-lg p-4 mb-3 relative">
|
||||
<button type="button" @click="removeDependent(index)"
|
||||
class="absolute top-2 right-2 text-red-400 hover:text-red-600 transition-colors" title="삭제">
|
||||
<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 class="text-xs font-medium text-gray-500 mb-2" x-text="'부양가족 ' + (index + 1)"></div>
|
||||
<div class="flex gap-3 mb-2" style="flex-wrap: wrap;">
|
||||
<div style="flex: 1 1 120px;">
|
||||
<input type="text" :name="'dependents['+index+'][name]'" x-model="dep.name"
|
||||
placeholder="이름" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500">
|
||||
</div>
|
||||
<div style="flex: 0 0 100px;">
|
||||
<select :name="'dependents['+index+'][nationality]'" x-model="dep.nationality"
|
||||
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500">
|
||||
<option value="korean">내국인</option>
|
||||
<option value="foreigner">외국인</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 1 1 150px;">
|
||||
<input type="text" :name="'dependents['+index+'][resident_number]'" x-model="dep.resident_number"
|
||||
placeholder="주민등록번호" maxlength="14" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 items-center" style="flex-wrap: wrap;">
|
||||
<div style="flex: 0 0 100px;">
|
||||
<select :name="'dependents['+index+'][relationship]'" x-model="dep.relationship"
|
||||
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:ring-1 focus:ring-blue-500">
|
||||
<option value="">관계</option>
|
||||
<option value="spouse">배우자</option>
|
||||
<option value="child">자녀</option>
|
||||
<option value="parent">부모</option>
|
||||
<option value="sibling">형제자매</option>
|
||||
<option value="grandparent">조부모</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-1 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="hidden" :name="'dependents['+index+'][is_disabled]'" value="0">
|
||||
<input type="checkbox" :name="'dependents['+index+'][is_disabled]'" x-model="dep.is_disabled"
|
||||
value="1" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
장애인
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-1 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="hidden" :name="'dependents['+index+'][is_dependent]'" value="0">
|
||||
<input type="checkbox" :name="'dependents['+index+'][is_dependent]'" x-model="dep.is_dependent"
|
||||
value="1" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
피부양자적용
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button type="button" @click="addDependent()"
|
||||
class="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-blue-400 hover:text-blue-600 transition-colors">
|
||||
+ 부양가족 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 버튼 --}}
|
||||
<div class="flex justify-end gap-3 pt-4 border-t mt-6">
|
||||
<a href="{{ route('hr.business-income-earners.show', $earner->id) }}"
|
||||
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
취소
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- 첨부파일 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mt-6" x-data="fileUploader()">
|
||||
<div class="border-b border-gray-200 pb-4 mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-700">첨부파일</h2>
|
||||
</div>
|
||||
|
||||
<div id="file-list" class="space-y-2 mb-4">
|
||||
@forelse($files ?? [] as $file)
|
||||
<div class="flex items-center justify-between bg-gray-50 rounded-lg px-4 py-2" id="file-row-{{ $file->id }}">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/>
|
||||
</svg>
|
||||
<a href="{{ route('api.admin.hr.business-income-earners.download-file', [$earner->id, $file->id]) }}"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 truncate" title="{{ $file->original_name }}">
|
||||
{{ $file->original_name }}
|
||||
</a>
|
||||
<span class="text-xs text-gray-400 shrink-0">{{ number_format(($file->file_size ?? 0) / 1024, 0) }}KB</span>
|
||||
</div>
|
||||
<button type="button" @click="deleteFile({{ $file->id }})"
|
||||
class="text-red-400 hover:text-red-600 shrink-0 ml-2" 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>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-gray-400" id="no-files-msg">등록된 파일이 없습니다.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer hover:border-blue-400 transition-colors"
|
||||
@dragover.prevent="dragover = true"
|
||||
@dragleave.prevent="dragover = false"
|
||||
@drop.prevent="handleDrop($event)"
|
||||
:class="dragover ? 'border-blue-400 bg-blue-50' : ''"
|
||||
@click="$refs.fileInput.click()">
|
||||
<input type="file" multiple x-ref="fileInput" @change="handleFiles($event)" class="hidden">
|
||||
<svg class="mx-auto w-8 h-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||
</svg>
|
||||
<p class="text-sm text-gray-500">파일을 드래그하거나 클릭하여 업로드</p>
|
||||
<p class="text-xs text-gray-400 mt-1">파일당 최대 20MB</p>
|
||||
</div>
|
||||
|
||||
<div x-show="uploading" class="mt-3 text-sm text-blue-600">업로드 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 직급/직책 추가 모달 --}}
|
||||
@include('hr.employees.partials.position-add-modal')
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function dependentsManager() {
|
||||
return {
|
||||
dependents: (@json($earner->dependents ?? [])).map(dep => ({
|
||||
...dep,
|
||||
is_disabled: !!(dep.is_disabled && dep.is_disabled !== '0' && dep.is_disabled !== 0),
|
||||
is_dependent: !!(dep.is_dependent && dep.is_dependent !== '0' && dep.is_dependent !== 0),
|
||||
})),
|
||||
addDependent() {
|
||||
this.dependents.push({
|
||||
name: '', nationality: 'korean', resident_number: '',
|
||||
relationship: '', is_disabled: false, is_dependent: false
|
||||
});
|
||||
},
|
||||
removeDependent(index) {
|
||||
this.dependents.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function fileUploader() {
|
||||
return {
|
||||
dragover: false,
|
||||
uploading: false,
|
||||
|
||||
async handleFiles(event) {
|
||||
await this.upload(event.target.files);
|
||||
event.target.value = '';
|
||||
},
|
||||
|
||||
async handleDrop(event) {
|
||||
this.dragover = false;
|
||||
await this.upload(event.dataTransfer.files);
|
||||
},
|
||||
|
||||
async upload(files) {
|
||||
if (!files.length) return;
|
||||
this.uploading = true;
|
||||
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append('files[]', file);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ route("api.admin.hr.business-income-earners.upload-file", $earner->id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.success) {
|
||||
showToast(json.message, 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast(json.message || '업로드 실패', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('파일 업로드 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
this.uploading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteFile(fileId) {
|
||||
if (!confirm('이 파일을 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ url("/api/admin/hr/business-income-earners") }}/{{ $earner->id }}/files/' + fileId, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.success) {
|
||||
showToast(json.message, 'success');
|
||||
const row = document.getElementById('file-row-' + fileId);
|
||||
if (row) row.remove();
|
||||
} else {
|
||||
showToast(json.message || '삭제 실패', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('파일 삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.elt.id !== 'bizEarnerForm') return;
|
||||
|
||||
const xhr = event.detail.xhr;
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
|
||||
if (response.success) {
|
||||
showToast(response.message || '사업소득자 정보가 수정되었습니다.', 'success');
|
||||
window.location.href = '{{ route('hr.business-income-earners.show', $earner->id) }}';
|
||||
return;
|
||||
}
|
||||
|
||||
let msg = response.message || '저장에 실패했습니다.';
|
||||
if (response.error) msg += '\n' + response.error;
|
||||
showToast(msg, 'error', 5000);
|
||||
} catch (e) {
|
||||
if (!event.detail.successful) {
|
||||
showToast('서버 오류가 발생했습니다. (HTTP ' + xhr.status + ')', 'error', 5000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:responseError', function(event) {
|
||||
if (event.detail.elt.id !== 'bizEarnerForm') return;
|
||||
|
||||
const xhr = event.detail.xhr;
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
|
||||
if (xhr.status === 422 && response.errors) {
|
||||
const messages = [];
|
||||
for (const field in response.errors) {
|
||||
messages.push(response.errors[field].join(', '));
|
||||
}
|
||||
showToast(messages.join('\n'), 'error', 6000);
|
||||
} else {
|
||||
let msg = response.message || '오류가 발생했습니다.';
|
||||
if (response.error) msg += '\n' + response.error;
|
||||
showToast(msg, 'error', 5000);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('서버 오류가 발생했습니다. (HTTP ' + xhr.status + ')', 'error', 5000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
139
resources/views/hr/business-income-earners/index.blade.php
Normal file
139
resources/views/hr/business-income-earners/index.blade.php
Normal file
@@ -0,0 +1,139 @@
|
||||
@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>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ now()->format('Y년 n월 j일') }} 현재</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<a href="{{ route('hr.business-income-earners.create') }}"
|
||||
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 4v16m8-8H4"/>
|
||||
</svg>
|
||||
사업소득자 등록
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 통계 카드 --}}
|
||||
<div class="grid gap-4 mb-6" style="grid-template-columns: repeat(auto-fit, minmax(150px, 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-gray-800">{{ $stats['total'] }}명</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">{{ $stats['active'] }}명</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['leave'] }}명</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">{{ $stats['resigned'] }}명</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 테이블 컨테이너 --}}
|
||||
<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="bizEarnerFilter">
|
||||
<form id="bizEarnerFilterForm" class="flex flex-wrap gap-3 items-end">
|
||||
<div style="flex: 1 1 200px; max-width: 300px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">검색</label>
|
||||
<input type="text" name="q" placeholder="이름, 이메일, 연락처..."
|
||||
value="{{ request('q') }}"
|
||||
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 }}" {{ request('department_id') == $dept->id ? 'selected' : '' }}>
|
||||
{{ $dept->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 1 130px;">
|
||||
<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>
|
||||
<option value="active" {{ request('status') === 'active' ? 'selected' : '' }}>계약중</option>
|
||||
<option value="leave" {{ request('status') === 'leave' ? 'selected' : '' }}>휴직</option>
|
||||
<option value="resigned" {{ request('status') === 'resigned' ? 'selected' : '' }}>계약종료</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 1 160px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">정렬</label>
|
||||
<select name="sort_by"
|
||||
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="hire_date_asc" {{ request('sort_by', 'hire_date_asc') === 'hire_date_asc' ? 'selected' : '' }}>계약일 빠른순</option>
|
||||
<option value="hire_date_desc" {{ request('sort_by') === 'hire_date_desc' ? 'selected' : '' }}>계약일 최신순</option>
|
||||
<option value="default" {{ request('sort_by') === 'default' ? 'selected' : '' }}>상태순</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<button type="submit"
|
||||
hx-get="{{ route('api.admin.hr.business-income-earners.index') }}"
|
||||
hx-target="#biz-earners-table"
|
||||
hx-include="#bizEarnerFilterForm"
|
||||
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="biz-earners-table"
|
||||
hx-get="{{ route('api.admin.hr.business-income-earners.index') }}?sort_by=hire_date_asc"
|
||||
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>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.getElementById('bizEarnerFilterForm')?.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
htmx.trigger('#biz-earners-table', 'htmx:trigger');
|
||||
});
|
||||
|
||||
function toggleSort(field) {
|
||||
const sortSelect = document.querySelector('select[name="sort_by"]');
|
||||
const current = sortSelect.value;
|
||||
|
||||
if (current === field + '_asc') {
|
||||
sortSelect.value = field + '_desc';
|
||||
} else {
|
||||
sortSelect.value = field + '_asc';
|
||||
}
|
||||
|
||||
const table = document.getElementById('biz-earners-table');
|
||||
const form = document.getElementById('bizEarnerFilterForm');
|
||||
const params = new URLSearchParams(new FormData(form)).toString();
|
||||
const url = '{{ route('api.admin.hr.business-income-earners.index') }}?' + params;
|
||||
|
||||
htmx.ajax('GET', url, { target: '#biz-earners-table', swap: 'innerHTML' });
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,153 @@
|
||||
{{-- 사업소득자 목록 테이블 (HTMX로 로드) --}}
|
||||
<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-left 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-6 py-3 text-center text-sm font-semibold text-gray-600">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-blue-600 transition-colors"
|
||||
onclick="toggleSort('hire_date')">
|
||||
계약일
|
||||
@php $sortBy = request('sort_by', 'hire_date_asc'); @endphp
|
||||
@if($sortBy === 'hire_date_asc')
|
||||
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>
|
||||
@elseif($sortBy === 'hire_date_desc')
|
||||
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
@else
|
||||
<svg class="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/></svg>
|
||||
@endif
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">연락처</th>
|
||||
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
@forelse($earners as $earner)
|
||||
<tr class="hover:bg-gray-50 transition-colors {{ $earner->employee_status === 'resigned' ? 'opacity-50' : '' }}">
|
||||
{{-- 사업소득자 정보 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<a href="{{ route('hr.business-income-earners.show', $earner->id) }}"
|
||||
class="flex items-center gap-3 group">
|
||||
<div class="shrink-0 w-9 h-9 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center text-sm font-medium">
|
||||
{{ mb_substr($earner->display_name ?? $earner->user?->name ?? '?', 0, 1) }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 group-hover:text-blue-600">
|
||||
{{ $earner->display_name ?? $earner->user?->name ?? '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
{{-- 부서 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||
{{ $earner->department?->name ?? '-' }}
|
||||
</td>
|
||||
|
||||
{{-- 직급/직책 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||
<div>{{ $earner->position_label ?? '-' }}</div>
|
||||
@if($earner->job_title_label)
|
||||
<div class="text-xs text-gray-400">{{ $earner->job_title_label }}</div>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
{{-- 상태 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
@switch($earner->employee_status)
|
||||
@case('active')
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700">
|
||||
계약중
|
||||
</span>
|
||||
@break
|
||||
@case('leave')
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
|
||||
휴직
|
||||
</span>
|
||||
@break
|
||||
@case('resigned')
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
|
||||
계약종료
|
||||
</span>
|
||||
@break
|
||||
@default
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
|
||||
{{ $earner->employee_status ?? '-' }}
|
||||
</span>
|
||||
@endswitch
|
||||
</td>
|
||||
|
||||
{{-- 계약일 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500">
|
||||
{{ $earner->hire_date ?? '-' }}
|
||||
</td>
|
||||
|
||||
{{-- 연락처 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500">
|
||||
{{ $earner->user?->phone ?? $earner->user?->email ?? '-' }}
|
||||
</td>
|
||||
|
||||
{{-- 작업 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<a href="{{ route('hr.business-income-earners.show', $earner->id) }}"
|
||||
class="text-gray-600 hover:text-gray-800" title="상세">
|
||||
<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="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>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('hr.business-income-earners.edit', $earner->id) }}"
|
||||
class="text-blue-600 hover:text-blue-800" title="수정">
|
||||
<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="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>
|
||||
</a>
|
||||
|
||||
@if($earner->employee_status !== 'resigned')
|
||||
<button type="button"
|
||||
hx-delete="{{ route('api.admin.hr.business-income-earners.destroy', $earner->id) }}"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
hx-target="#biz-earners-table"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="{{ $earner->display_name ?? $earner->user?->name }}님의 계약을 종료 처리하시겠습니까?"
|
||||
class="text-red-600 hover:text-red-800" title="계약종료">
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" 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 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">등록된 사업소득자가 없습니다.</p>
|
||||
<a href="{{ route('hr.business-income-earners.create') }}"
|
||||
class="mt-2 text-sm text-blue-600 hover:text-blue-700 font-medium">
|
||||
첫 번째 사업소득자 등록하기 →
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</x-table-swipe>
|
||||
|
||||
{{-- 페이지네이션 --}}
|
||||
@if($earners->hasPages())
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
{{ $earners->links() }}
|
||||
</div>
|
||||
@endif
|
||||
282
resources/views/hr/business-income-earners/show.blade.php
Normal file
282
resources/views/hr/business-income-earners/show.blade.php
Normal file
@@ -0,0 +1,282 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '사업소득자 상세')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-6 max-w-3xl">
|
||||
{{-- 페이지 헤더 --}}
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('hr.business-income-earners.index') }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1 mb-2">
|
||||
<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 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
사업소득자 목록으로
|
||||
</a>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-800">사업소득자 상세</h1>
|
||||
<a href="{{ route('hr.business-income-earners.edit', $earner->id) }}"
|
||||
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="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>
|
||||
수정
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 프로필 카드 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="shrink-0 w-16 h-16 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center text-2xl font-bold">
|
||||
{{ mb_substr($earner->display_name ?? $earner->user?->name ?? '?', 0, 1) }}
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900">
|
||||
{{ $earner->display_name ?? $earner->user?->name ?? '-' }}
|
||||
</h2>
|
||||
<div class="flex flex-wrap items-center gap-2 mt-1">
|
||||
@if($earner->department)
|
||||
<span class="text-sm text-gray-500">{{ $earner->department->name }}</span>
|
||||
<span class="text-gray-300">|</span>
|
||||
@endif
|
||||
<span class="text-sm text-gray-500">{{ $earner->position_label ?? '-' }}</span>
|
||||
@if($earner->job_title_label)
|
||||
<span class="text-gray-300">|</span>
|
||||
<span class="text-sm text-gray-500">{{ $earner->job_title_label }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
@switch($earner->employee_status)
|
||||
@case('active')
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700">계약중</span>
|
||||
@break
|
||||
@case('leave')
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">휴직</span>
|
||||
@break
|
||||
@case('resigned')
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">계약종료</span>
|
||||
@break
|
||||
@endswitch
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 상세 정보 --}}
|
||||
<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>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{-- 기본 정보 --}}
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">이름</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->user?->name ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">표시 이름</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->display_name ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">이메일</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->user?->email ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">연락처</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->user?->phone ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">주민등록번호</div>
|
||||
<div class="text-sm text-gray-900">
|
||||
@if($earner->resident_number)
|
||||
{{ Str::mask($earner->resident_number, '*', 8) }}
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 근무 정보 --}}
|
||||
<div class="px-6 py-4 bg-gray-50">
|
||||
<span class="text-sm font-semibold text-gray-600">근무 정보</span>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">부서</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->department?->name ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">직급</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->position_label ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">직책</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->job_title_label ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">계약일</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->hire_date ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">계약종료일</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->resign_date ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">상태</div>
|
||||
<div class="text-sm text-gray-900">
|
||||
@switch($earner->employee_status)
|
||||
@case('active') 계약중 @break
|
||||
@case('leave') 휴직 @break
|
||||
@case('resigned') 계약종료 @break
|
||||
@default {{ $earner->employee_status }} @break
|
||||
@endswitch
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 사업장등록정보 --}}
|
||||
<div class="px-6 py-4 bg-gray-50">
|
||||
<span class="text-sm font-semibold text-gray-600">사업장등록정보</span>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">사업자등록번호</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->business_registration_number ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">상호</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->business_name ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">대표자명</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->business_representative ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">업태</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->business_type ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">종목</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->business_category ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">사업장소재지</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->business_address ?? '-' }}</div>
|
||||
</div>
|
||||
|
||||
{{-- 추가 정보 --}}
|
||||
<div class="px-6 py-4 bg-gray-50">
|
||||
<span class="text-sm font-semibold text-gray-600">추가 정보</span>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">주소</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->address ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">비상연락처</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->emergency_contact ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">등록일</div>
|
||||
<div class="text-sm text-gray-900">{{ $earner->created_at?->format('Y-m-d H:i') ?? '-' }}</div>
|
||||
</div>
|
||||
|
||||
{{-- 급여이체정보 --}}
|
||||
<div class="px-6 py-4 bg-gray-50">
|
||||
<span class="text-sm font-semibold text-gray-600">급여이체정보</span>
|
||||
</div>
|
||||
@php $bankAccount = $earner->bank_account ?? []; @endphp
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">이체은행</div>
|
||||
<div class="text-sm text-gray-900">{{ $bankAccount['bank_name'] ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">예금주</div>
|
||||
<div class="text-sm text-gray-900">{{ $bankAccount['account_holder'] ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
|
||||
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">계좌번호</div>
|
||||
<div class="text-sm text-gray-900">{{ $bankAccount['account_number'] ?? '-' }}</div>
|
||||
</div>
|
||||
|
||||
{{-- 부양가족 정보 --}}
|
||||
<div class="px-6 py-4 bg-gray-50">
|
||||
<span class="text-sm font-semibold text-gray-600">부양가족 정보</span>
|
||||
</div>
|
||||
@php $dependents = $earner->dependents ?? []; @endphp
|
||||
@if(count($dependents) > 0)
|
||||
<div class="px-6 py-3">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="text-left py-2 pr-3 font-medium">이름</th>
|
||||
<th class="text-left py-2 pr-3 font-medium">내/외국인</th>
|
||||
<th class="text-left py-2 pr-3 font-medium">주민등록번호</th>
|
||||
<th class="text-left py-2 pr-3 font-medium">관계</th>
|
||||
<th class="text-left py-2 pr-3 font-medium">장애인</th>
|
||||
<th class="text-left py-2 font-medium">피부양자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach($dependents as $dep)
|
||||
<tr class="text-gray-900">
|
||||
<td class="py-2 pr-3">{{ $dep['name'] ?? '-' }}</td>
|
||||
<td class="py-2 pr-3">{{ ($dep['nationality'] ?? 'korean') === 'korean' ? '내국인' : '외국인' }}</td>
|
||||
<td class="py-2 pr-3">
|
||||
@if(!empty($dep['resident_number']))
|
||||
{{ Str::mask($dep['resident_number'], '*', 8) }}
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2 pr-3">
|
||||
@switch($dep['relationship'] ?? '')
|
||||
@case('spouse') 배우자 @break
|
||||
@case('child') 자녀 @break
|
||||
@case('parent') 부모 @break
|
||||
@case('sibling') 형제자매 @break
|
||||
@case('grandparent') 조부모 @break
|
||||
@case('other') 기타 @break
|
||||
@default - @break
|
||||
@endswitch
|
||||
</td>
|
||||
<td class="py-2 pr-3">{{ filter_var($dep['is_disabled'] ?? false, FILTER_VALIDATE_BOOLEAN) ? 'Y' : 'N' }}</td>
|
||||
<td class="py-2">{{ filter_var($dep['is_dependent'] ?? false, FILTER_VALIDATE_BOOLEAN) ? 'Y' : 'N' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="px-6 py-3">
|
||||
<p class="text-sm text-gray-400">등록된 부양가족이 없습니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 첨부파일 --}}
|
||||
<div class="px-6 py-4 bg-gray-50">
|
||||
<span class="text-sm font-semibold text-gray-600">첨부파일</span>
|
||||
</div>
|
||||
@if(!empty($files) && count($files) > 0)
|
||||
<div class="px-6 py-3 space-y-2">
|
||||
@foreach($files as $file)
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/>
|
||||
</svg>
|
||||
<a href="{{ route('api.admin.hr.business-income-earners.download-file', [$earner->id, $file->id]) }}"
|
||||
class="text-sm text-blue-600 hover:text-blue-800">
|
||||
{{ $file->original_name }}
|
||||
</a>
|
||||
<span class="text-xs text-gray-400">{{ number_format(($file->file_size ?? 0) / 1024, 0) }}KB</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="px-6 py-3">
|
||||
<p class="text-sm text-gray-400">등록된 파일이 없습니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1057,6 +1057,22 @@
|
||||
Route::get('/{id}/files/{fileId}/download', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'downloadFile'])->name('download-file');
|
||||
});
|
||||
|
||||
// 사업소득자관리 API
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/business-income-earners')->name('api.admin.hr.business-income-earners.')->group(function () {
|
||||
Route::get('/search-users', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomeEarnerController::class, 'searchUsers'])->name('search-users');
|
||||
Route::get('/stats', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomeEarnerController::class, 'stats'])->name('stats');
|
||||
Route::get('/', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomeEarnerController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomeEarnerController::class, 'store'])->name('store');
|
||||
Route::get('/{id}', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomeEarnerController::class, 'show'])->name('show');
|
||||
Route::put('/{id}', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomeEarnerController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomeEarnerController::class, 'destroy'])->name('destroy');
|
||||
|
||||
// 첨부파일
|
||||
Route::post('/{id}/files', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomeEarnerController::class, 'uploadFile'])->name('upload-file');
|
||||
Route::delete('/{id}/files/{fileId}', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomeEarnerController::class, 'deleteFile'])->name('delete-file');
|
||||
Route::get('/{id}/files/{fileId}/download', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomeEarnerController::class, 'downloadFile'])->name('download-file');
|
||||
});
|
||||
|
||||
// 직급/직책 관리 API
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/positions')->name('api.admin.hr.positions.')->group(function () {
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'storePosition'])->name('store');
|
||||
|
||||
@@ -897,6 +897,14 @@
|
||||
Route::get('/{id}/edit', [\App\Http\Controllers\HR\EmployeeController::class, 'edit'])->name('edit');
|
||||
});
|
||||
|
||||
// 사업소득자관리
|
||||
Route::prefix('business-income-earners')->name('business-income-earners.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\HR\BusinessIncomeEarnerController::class, 'index'])->name('index');
|
||||
Route::get('/create', [\App\Http\Controllers\HR\BusinessIncomeEarnerController::class, 'create'])->name('create');
|
||||
Route::get('/{id}', [\App\Http\Controllers\HR\BusinessIncomeEarnerController::class, 'show'])->name('show');
|
||||
Route::get('/{id}/edit', [\App\Http\Controllers\HR\BusinessIncomeEarnerController::class, 'edit'])->name('edit');
|
||||
});
|
||||
|
||||
// 입퇴사자 현황
|
||||
Route::get('/employee-tenure', [\App\Http\Controllers\HR\EmployeeTenureController::class, 'index'])->name('employee-tenure');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user