feat: [hr] 사원등록 기능 확장

- 기본정보에 주민등록번호 필드 추가
- 급여이체정보 섹션 추가 (이체은행, 예금주, 계좌번호)
- 부양가족 정보 섹션 추가 (동적 행 추가/삭제)
- 첨부파일 업로드/다운로드/삭제 기능 추가
- 은행 목록 config/banks.php 설정 파일 생성
- show 페이지 주민등록번호 뒷자리 마스킹 처리
This commit is contained in:
김보곤
2026-02-26 19:59:15 +09:00
parent 5e06f53d2d
commit 2edce0d282
9 changed files with 739 additions and 12 deletions

View File

@@ -3,10 +3,13 @@
namespace App\Http\Controllers\Api\Admin\HR;
use App\Http\Controllers\Controller;
use App\Models\Boards\File;
use App\Services\HR\EmployeeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class EmployeeController extends Controller
{
@@ -88,6 +91,18 @@ public function store(Request $request): JsonResponse
'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',
];
// 신규 사용자일 때만 이메일 unique 검증
@@ -157,6 +172,18 @@ public function update(Request $request, int $id): JsonResponse
'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',
]);
try {
@@ -228,6 +255,101 @@ public function destroy(Request $request, int $id): JsonResponse|Response
}
}
/**
* 사원 첨부파일 업로드
*/
public function uploadFile(Request $request, int $id): JsonResponse
{
$employee = $this->employeeService->getEmployeeById($id);
if (! $employee) {
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();
$path = "tenants/{$tenantId}/employees/{$employee->id}";
$file->storeAs($path, $storedName, 'tenant');
$fileRecord = File::create([
'tenant_id' => $tenantId,
'document_id' => $employee->id,
'document_type' => 'employee_profile',
'original_name' => $file->getClientOriginalName(),
'stored_name' => $storedName,
'file_path' => $path.'/'.$storedName,
'mime_type' => $file->getMimeType(),
'file_size' => $file->getSize(),
'file_type' => strtolower($file->getClientOriginalExtension()),
'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): JsonResponse
{
$tenantId = session('selected_tenant_id');
$file = File::where('id', $fileId)
->where('document_id', $id)
->where('document_type', 'employee_profile')
->where('tenant_id', $tenantId)
->first();
if (! $file) {
return response()->json(['success' => false, 'message' => '파일을 찾을 수 없습니다.'], 404);
}
Storage::disk('tenant')->delete($file->file_path);
$file->delete();
return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']);
}
/**
* 사원 첨부파일 다운로드
*/
public function downloadFile(int $id, int $fileId)
{
$tenantId = session('selected_tenant_id');
$file = File::where('id', $fileId)
->where('document_id', $id)
->where('document_type', 'employee_profile')
->where('tenant_id', $tenantId)
->first();
if (! $file) {
abort(404, '파일을 찾을 수 없습니다.');
}
$disk = Storage::disk('tenant');
if (! $disk->exists($file->file_path)) {
abort(404, '파일이 서버에 존재하지 않습니다.');
}
return $disk->download($file->file_path, $file->original_name);
}
/**
* 직급/직책 추가
*/

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\HR;
use App\Http\Controllers\Controller;
use App\Models\Boards\File;
use App\Services\HR\EmployeeService;
use Illuminate\Contracts\View\View;
@@ -39,6 +40,7 @@ public function create(): View
'departments' => $departments,
'ranks' => $ranks,
'titles' => $titles,
'banks' => config('banks', []),
]);
}
@@ -53,8 +55,15 @@ public function show(int $id): View
abort(404, '사원 정보를 찾을 수 없습니다.');
}
$files = File::where('document_type', 'employee_profile')
->where('document_id', $employee->id)
->where('tenant_id', session('selected_tenant_id'))
->orderBy('created_at', 'desc')
->get();
return view('hr.employees.show', [
'employee' => $employee,
'files' => $files,
]);
}
@@ -73,11 +82,19 @@ public function edit(int $id): View
$ranks = $this->employeeService->getPositions('rank');
$titles = $this->employeeService->getPositions('title');
$files = File::where('document_type', 'employee_profile')
->where('document_id', $employee->id)
->where('tenant_id', session('selected_tenant_id'))
->orderBy('created_at', 'desc')
->get();
return view('hr.employees.edit', [
'employee' => $employee,
'departments' => $departments,
'ranks' => $ranks,
'titles' => $titles,
'banks' => config('banks', []),
'files' => $files,
]);
}
}

View File

@@ -87,6 +87,21 @@ 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'] ?? [];
}
public function getPositionLabelAttribute(): ?string
{
if (! $this->position_key || ! $this->tenant_id) {

View File

@@ -205,17 +205,28 @@ public function createEmployee(array $data): Employee
// json_extra 구성
$jsonExtra = [];
if (! empty($data['hire_date'])) {
$jsonExtra['hire_date'] = $data['hire_date'];
foreach (['hire_date', 'resign_date', 'address', 'emergency_contact', 'resident_number'] as $key) {
if (! empty($data[$key])) {
$jsonExtra[$key] = $data[$key];
}
}
if (! empty($data['resign_date'])) {
$jsonExtra['resign_date'] = $data['resign_date'];
// 급여이체정보 (bank_account 객체)
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['address'])) {
$jsonExtra['address'] = $data['address'];
}
if (! empty($data['emergency_contact'])) {
$jsonExtra['emergency_contact'] = $data['emergency_contact'];
// 부양가족 정보 (dependents 배열)
if (! empty($data['dependents']) && is_array($data['dependents'])) {
$dependents = array_values(array_filter($data['dependents'], function ($dep) {
return ! empty($dep['name']);
}));
if (! empty($dependents)) {
$jsonExtra['dependents'] = $dependents;
}
}
// Employee(TenantUserProfile) 생성
@@ -259,8 +270,8 @@ public function updateEmployee(int $id, array $data): ?Employee
'display_name' => $data['display_name'] ?? null,
], fn ($v) => $v !== null);
// json_extra 업데이트
$jsonExtraKeys = ['hire_date', 'resign_date', 'address', 'emergency_contact', 'salary', 'bank_account'];
// json_extra 업데이트 (스칼라 값)
$jsonExtraKeys = ['hire_date', 'resign_date', 'address', 'emergency_contact', 'salary', 'resident_number'];
$extra = $employee->json_extra ?? [];
foreach ($jsonExtraKeys as $key) {
if (array_key_exists($key, $data)) {
@@ -271,6 +282,37 @@ public function updateEmployee(int $id, array $data): ?Employee
}
}
}
// 급여이체정보 (bank_account 객체)
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']);
}
}
// 부양가족 정보 (dependents 배열)
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']);
}));
if (! empty($dependents)) {
$extra['dependents'] = $dependents;
} else {
unset($extra['dependents']);
}
} else {
unset($extra['dependents']);
}
}
$updateData['json_extra'] = ! empty($extra) ? $extra : null;
$employee->update($updateData);

28
config/banks.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
return [
'KB' => 'KB국민은행',
'SHINHAN' => '신한은행',
'WOORI' => '우리은행',
'HANA' => '하나은행',
'NH' => 'NH농협은행',
'IBK' => 'IBK기업은행',
'SC' => 'SC제일은행',
'CITI' => '한국씨티은행',
'KAKAO' => '카카오뱅크',
'TOSS' => '토스뱅크',
'KBANK' => '케이뱅크',
'SUHYUP' => '수협은행',
'BNK_BUSAN' => 'BNK부산은행',
'BNK_GYEONGNAM' => 'BNK경남은행',
'DGB' => 'DGB대구은행',
'KWANGJU' => '광주은행',
'JEONBUK' => '전북은행',
'JEJU' => '제주은행',
'SAEMAUL' => '새마을금고',
'SHINHYUP' => '신협',
'POST' => '우체국',
'SANLIM' => '산림조합',
'KDB' => 'KDB산업은행',
'EXIMBANK' => '한국수출입은행',
];

View File

@@ -125,6 +125,15 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
</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>
@@ -223,6 +232,114 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
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.employees.index') }}"
@@ -307,6 +424,21 @@ function userSearch() {
};
}
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 !== 'employeeForm') return;

View File

@@ -66,6 +66,16 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
</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="{{ $employee->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>
@@ -172,8 +182,112 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
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 = $employee->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()">
<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">
<div class="flex justify-end gap-3 pt-4 border-t mt-6">
<a href="{{ route('hr.employees.show', $employee->id) }}"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
취소
@@ -185,6 +299,57 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-
</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.employees.download-file', [$employee->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>
{{-- 직급/직책 추가 모달 --}}
@@ -193,6 +358,98 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-
@push('scripts')
<script>
function dependentsManager() {
return {
dependents: @json($employee->dependents ?? []),
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.employees.upload-file", $employee->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/employees") }}/{{ $employee->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 !== 'employeeForm') return;

View File

@@ -85,6 +85,16 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 te
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">연락처</div>
<div class="text-sm text-gray-900">{{ $employee->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($employee->resident_number)
{{ Str::mask($employee->resident_number, '*', 8) }}
@else
-
@endif
</div>
</div>
{{-- 근무 정보 --}}
<div class="px-6 py-4 bg-gray-50">
@@ -142,6 +152,105 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 te
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">수정일</div>
<div class="text-sm text-gray-900">{{ $employee->updated_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 = $employee->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 = $employee->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">{{ !empty($dep['is_disabled']) ? 'Y' : 'N' }}</td>
<td class="py-2">{{ !empty($dep['is_dependent']) ? '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.employees.download-file', [$employee->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>

View File

@@ -1048,6 +1048,11 @@
Route::get('/{id}', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'show'])->name('show');
Route::put('/{id}', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'update'])->name('update');
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'destroy'])->name('destroy');
// 첨부파일
Route::post('/{id}/files', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'uploadFile'])->name('upload-file');
Route::delete('/{id}/files/{fileId}', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'deleteFile'])->name('delete-file');
Route::get('/{id}/files/{fileId}/download', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'downloadFile'])->name('download-file');
});
// 직급/직책 관리 API