- 영업팀 포함 부서 사원 기본 제외 (외부직원) - json_extra.is_excluded 플래그로 강제 제외/복원 토글 - 필터에 '제외 사원 표시' 체크박스 추가 - 제외 사원 시각적 구분 (주황 배경, 제외 뱃지)
598 lines
21 KiB
PHP
598 lines
21 KiB
PHP
<?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\EmployeeService;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class EmployeeController extends Controller
|
|
{
|
|
public function __construct(
|
|
private EmployeeService $employeeService
|
|
) {}
|
|
|
|
/**
|
|
* 사원 목록 조회 (HTMX → HTML / 일반 → JSON)
|
|
*/
|
|
public function index(Request $request): JsonResponse|Response
|
|
{
|
|
$employees = $this->employeeService->getEmployees(
|
|
$request->all(),
|
|
$request->integer('per_page', 20)
|
|
);
|
|
|
|
if ($request->header('HX-Request')) {
|
|
return response(view('hr.employees.partials.table', compact('employees')));
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => $employees->items(),
|
|
'meta' => [
|
|
'current_page' => $employees->currentPage(),
|
|
'last_page' => $employees->lastPage(),
|
|
'per_page' => $employees->perPage(),
|
|
'total' => $employees->total(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 기존 사용자 검색 (사원 미등록, 테넌트 소속)
|
|
*/
|
|
public function searchUsers(Request $request): JsonResponse
|
|
{
|
|
$users = $this->employeeService->searchTenantUsers($request->get('q') ?? '');
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => $users,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 사원 통계
|
|
*/
|
|
public function stats(): JsonResponse
|
|
{
|
|
$stats = $this->employeeService->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',
|
|
];
|
|
|
|
// 신규 사용자일 때만 이메일 unique 검증
|
|
if (! $request->filled('existing_user_id')) {
|
|
$rules['email'] = 'nullable|email|max:100|unique:users,email';
|
|
}
|
|
|
|
$validated = $request->validate($rules);
|
|
|
|
try {
|
|
$employee = $this->employeeService->createEmployee($validated);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '사원이 등록되었습니다.',
|
|
'data' => $employee,
|
|
], 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
|
|
{
|
|
$employee = $this->employeeService->getEmployeeById($id);
|
|
|
|
if (! $employee) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '사원 정보를 찾을 수 없습니다.',
|
|
], 404);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => $employee,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 사원 수정
|
|
*/
|
|
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',
|
|
]);
|
|
|
|
// 부양가족 섹션이 포함된 폼인데 dependents 데이터가 없으면 → 전체 삭제
|
|
if ($request->has('dependents_submitted') && ! array_key_exists('dependents', $validated)) {
|
|
$validated['dependents'] = [];
|
|
}
|
|
|
|
try {
|
|
$employee = $this->employeeService->updateEmployee($id, $validated);
|
|
|
|
if (! $employee) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '사원 정보를 찾을 수 없습니다.',
|
|
], 404);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '사원 정보가 수정되었습니다.',
|
|
'data' => $employee,
|
|
]);
|
|
} 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->employeeService->deleteEmployee($id);
|
|
|
|
if (! $result) {
|
|
if ($request->header('HX-Request')) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '사원 정보를 찾을 수 없습니다.',
|
|
], 404);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '사원 정보를 찾을 수 없습니다.',
|
|
], 404);
|
|
}
|
|
|
|
if ($request->header('HX-Request')) {
|
|
$employees = $this->employeeService->getEmployees($request->all(), $request->integer('per_page', 20));
|
|
|
|
return response(view('hr.employees.partials.table', compact('employees')));
|
|
}
|
|
|
|
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 forceDestroy(Request $request, int $id): JsonResponse|Response
|
|
{
|
|
if (! auth()->user()?->is_super_admin) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '슈퍼관리자만 영구삭제할 수 있습니다.',
|
|
], 403);
|
|
}
|
|
|
|
try {
|
|
$result = $this->employeeService->forceDeleteEmployee($id);
|
|
|
|
if (! $result['success']) {
|
|
return response()->json($result, 422);
|
|
}
|
|
|
|
if ($request->header('HX-Request')) {
|
|
$employees = $this->employeeService->getEmployees($request->all(), $request->integer('per_page', 20));
|
|
|
|
return response(view('hr.employees.partials.table', compact('employees')));
|
|
}
|
|
|
|
return response()->json($result);
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '영구삭제 중 오류가 발생했습니다.',
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 사원 제외/복원 토글
|
|
*/
|
|
public function toggleExclude(Request $request, int $id): JsonResponse|Response
|
|
{
|
|
$employee = $this->employeeService->toggleExclude($id);
|
|
|
|
if (! $employee) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '사원 정보를 찾을 수 없습니다.',
|
|
], 404);
|
|
}
|
|
|
|
$isExcluded = $employee->getJsonExtraValue('is_excluded', false);
|
|
|
|
if ($request->header('HX-Request')) {
|
|
$employees = $this->employeeService->getEmployees(
|
|
$request->all(),
|
|
$request->integer('per_page', 20)
|
|
);
|
|
|
|
return response(view('hr.employees.partials.table', compact('employees')));
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => $isExcluded ? '사원이 목록에서 제외되었습니다.' : '사원이 목록에 복원되었습니다.',
|
|
'is_excluded' => $isExcluded,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 사원 첨부파일 업로드 (로컬 + GCS 듀얼 저장)
|
|
*/
|
|
public function uploadFile(Request $request, int $id, GoogleCloudStorageService $gcs): 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();
|
|
$storagePath = "employees/{$tenantId}/{$employee->id}/{$storedName}";
|
|
|
|
// 로컬 저장
|
|
Storage::disk('tenant')->put($storagePath, file_get_contents($file));
|
|
|
|
// GCS 업로드
|
|
$gcsUri = null;
|
|
$gcsObjectName = null;
|
|
if ($gcs->isAvailable()) {
|
|
$gcsObjectName = $storagePath;
|
|
$gcsUri = $gcs->upload($file->getRealPath(), $gcsObjectName);
|
|
}
|
|
|
|
$fileRecord = File::create([
|
|
'tenant_id' => $tenantId,
|
|
'document_id' => $employee->id,
|
|
'document_type' => 'employee_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);
|
|
}
|
|
|
|
/**
|
|
* 사원 첨부파일 삭제 (GCS + 로컬 모두 삭제)
|
|
*/
|
|
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', 'employee_profile')
|
|
->where('tenant_id', $tenantId)
|
|
->first();
|
|
|
|
if (! $file) {
|
|
return response()->json(['success' => false, 'message' => '파일을 찾을 수 없습니다.'], 404);
|
|
}
|
|
|
|
// GCS 삭제
|
|
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' => '파일이 삭제되었습니다.']);
|
|
}
|
|
|
|
/**
|
|
* 사원 첨부파일 다운로드 (GCS Signed URL 우선, 로컬 폴백)
|
|
*/
|
|
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', 'employee_profile')
|
|
->where('tenant_id', $tenantId)
|
|
->first();
|
|
|
|
if (! $file) {
|
|
abort(404, '파일을 찾을 수 없습니다.');
|
|
}
|
|
|
|
// GCS Signed URL로 리다이렉트
|
|
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, '파일이 서버에 존재하지 않습니다.');
|
|
}
|
|
|
|
/**
|
|
* 입퇴사자 현황 목록 (HTMX → HTML / 일반 → JSON)
|
|
*/
|
|
public function tenure(Request $request): JsonResponse|Response
|
|
{
|
|
$employees = $this->employeeService->getEmployeeTenure(
|
|
$request->all(),
|
|
$request->integer('per_page', 50)
|
|
);
|
|
|
|
// 근속기간 계산 추가
|
|
$employees->getCollection()->each(function ($employee) {
|
|
$hireDate = $employee->hire_date;
|
|
if ($hireDate) {
|
|
$hire = Carbon::parse($hireDate);
|
|
$end = $employee->resign_date ? Carbon::parse($employee->resign_date) : today();
|
|
$tenureDays = $hire->diffInDays($end);
|
|
$diff = $hire->diff($end);
|
|
|
|
$employee->tenure_days = $tenureDays;
|
|
$employee->tenure_label = $this->formatTenureLabel($diff);
|
|
} else {
|
|
$employee->tenure_days = 0;
|
|
$employee->tenure_label = '-';
|
|
}
|
|
});
|
|
|
|
if ($request->header('HX-Request')) {
|
|
$stats = $this->employeeService->getTenureStats();
|
|
|
|
return response(view('hr.employee-tenure.partials.table', compact('employees', 'stats')));
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => $employees->items(),
|
|
'meta' => [
|
|
'current_page' => $employees->currentPage(),
|
|
'last_page' => $employees->lastPage(),
|
|
'per_page' => $employees->perPage(),
|
|
'total' => $employees->total(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 입퇴사자 현황 CSV 내보내기
|
|
*/
|
|
public function tenureExport(Request $request): StreamedResponse
|
|
{
|
|
$employees = $this->employeeService->getTenureExportData($request->all());
|
|
|
|
$filename = '입퇴사자현황_'.now()->format('Ymd').'.csv';
|
|
|
|
return response()->streamDownload(function () use ($employees) {
|
|
$handle = fopen('php://output', 'w');
|
|
|
|
// BOM for Excel UTF-8
|
|
fwrite($handle, "\xEF\xBB\xBF");
|
|
|
|
// 헤더
|
|
fputcsv($handle, ['No.', '사원명', '부서', '직책', '상태', '입사일', '퇴사일', '근속기간', '근속일수']);
|
|
|
|
$index = 1;
|
|
foreach ($employees as $employee) {
|
|
$hireDate = $employee->hire_date;
|
|
$tenureDays = 0;
|
|
$tenureLabel = '-';
|
|
|
|
if ($hireDate) {
|
|
$hire = Carbon::parse($hireDate);
|
|
$end = $employee->resign_date ? Carbon::parse($employee->resign_date) : today();
|
|
$tenureDays = $hire->diffInDays($end);
|
|
$tenureLabel = $this->formatTenureLabel($hire->diff($end));
|
|
}
|
|
|
|
$statusMap = ['active' => '재직', 'leave' => '휴직', 'resigned' => '퇴직'];
|
|
|
|
fputcsv($handle, [
|
|
$index++,
|
|
$employee->display_name ?? $employee->user?->name ?? '-',
|
|
$employee->department?->name ?? '-',
|
|
$employee->position_label ?? '-',
|
|
$statusMap[$employee->employee_status] ?? $employee->employee_status,
|
|
$employee->hire_date ?? '-',
|
|
$employee->resign_date ?? '-',
|
|
$tenureLabel,
|
|
$tenureDays,
|
|
]);
|
|
}
|
|
|
|
fclose($handle);
|
|
}, $filename, [
|
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
|
]);
|
|
}
|
|
|
|
private function formatTenureLabel(\DateInterval $diff): string
|
|
{
|
|
$parts = [];
|
|
if ($diff->y > 0) {
|
|
$parts[] = "{$diff->y}년";
|
|
}
|
|
if ($diff->m > 0) {
|
|
$parts[] = "{$diff->m}개월";
|
|
}
|
|
if ($diff->d > 0 || empty($parts)) {
|
|
$parts[] = "{$diff->d}일";
|
|
}
|
|
|
|
return implode(' ', $parts);
|
|
}
|
|
|
|
/**
|
|
* 직급/직책 추가
|
|
*/
|
|
public function storePosition(Request $request): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'type' => 'required|string|in:rank,title',
|
|
'name' => 'required|string|max:50',
|
|
]);
|
|
|
|
$position = $this->employeeService->createPosition($validated['type'], $validated['name']);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => ($validated['type'] === 'rank' ? '직급' : '직책').'이 추가되었습니다.',
|
|
'data' => [
|
|
'id' => $position->id,
|
|
'key' => $position->key,
|
|
'name' => $position->name,
|
|
],
|
|
], 201);
|
|
}
|
|
}
|