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

@@ -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;