feat: [hr] 근태등록 + 휴가관리 통합 시스템 구현
- Leave 모델 확장: 6개 유형 추가 (출장/재택/외근/조퇴/지각사유서/결근사유서) - LeaveService: 유형별 결재양식 자동 선택, 유형별 Attendance 반영 분기 - ApprovalService: 콜백 3개 결재양식코드로 확장 - AttendanceIntegratedController: 통합 화면 컨트롤러 - 통합 UI: 근태현황/신청결재/연차잔여 3탭 + 신규 신청 드롭다운 - AttendanceRequest 모델/서비스/컨트롤러/뷰 삭제 (Leave로 일원화) - AttendanceService: deductLeaveBalance 제거 (Leave 시스템으로 일원화)
This commit is contained in:
@@ -1,141 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\HR\AttendanceRequestService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class AttendanceRequestController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private AttendanceRequestService $requestService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 신청 목록 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|Response
|
||||
{
|
||||
$requests = $this->requestService->getRequests(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.attendances.partials.requests', compact('requests')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $requests->items(),
|
||||
'meta' => [
|
||||
'current_page' => $requests->currentPage(),
|
||||
'last_page' => $requests->lastPage(),
|
||||
'per_page' => $requests->perPage(),
|
||||
'total' => $requests->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신청 등록
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
'request_type' => 'required|string|in:vacation,businessTrip,remote,fieldWork',
|
||||
'start_date' => 'required|date',
|
||||
'end_date' => 'required|date|after_or_equal:start_date',
|
||||
'reason' => 'nullable|string|max:1000',
|
||||
'json_details' => 'nullable|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
$attendanceRequest = $this->requestService->storeRequest($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '근태 신청이 등록되었습니다.',
|
||||
'data' => $attendanceRequest,
|
||||
], 201);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '신청 등록 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인
|
||||
*/
|
||||
public function approve(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$attendanceRequest = $this->requestService->approve($id);
|
||||
|
||||
if (! $attendanceRequest) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '대기 중인 신청을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '승인 처리되었습니다. 근태 레코드가 자동 생성되었습니다.',
|
||||
'data' => $attendanceRequest,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '승인 처리 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려
|
||||
*/
|
||||
public function reject(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'reject_reason' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$attendanceRequest = $this->requestService->reject($id, $validated['reject_reason'] ?? null);
|
||||
|
||||
if (! $attendanceRequest) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '대기 중인 신청을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '반려 처리되었습니다.',
|
||||
'data' => $attendanceRequest,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '반려 처리 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
'leave_type' => 'required|string|in:annual,half_am,half_pm,sick,family,maternity,parental',
|
||||
'leave_type' => 'required|string|in:annual,half_am,half_pm,sick,family,maternity,parental,business_trip,remote,field_work,early_leave,late_reason,absent_reason',
|
||||
'start_date' => 'required|date',
|
||||
'end_date' => 'required|date|after_or_equal:start_date',
|
||||
'reason' => 'nullable|string|max:1000',
|
||||
@@ -176,6 +176,36 @@ public function cancel(Request $request, int $id): JsonResponse
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* pending 상태 신청 삭제
|
||||
*/
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$leave = $this->leaveService->deletePendingLeave($id);
|
||||
|
||||
if (! $leave) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '삭제 가능한 신청을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 잔여연차 목록 (HTMX → HTML)
|
||||
*/
|
||||
|
||||
54
app/Http/Controllers/HR/AttendanceIntegratedController.php
Normal file
54
app/Http/Controllers/HR/AttendanceIntegratedController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HR\Attendance;
|
||||
use App\Models\HR\Leave;
|
||||
use App\Services\HR\AttendanceService;
|
||||
use App\Services\HR\LeaveService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class AttendanceIntegratedController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private AttendanceService $attendanceService,
|
||||
private LeaveService $leaveService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 근태관리 통합 화면
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
// JS 필요 페이지 → HTMX 시 전체 리로드
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('hr.attendance.index'));
|
||||
}
|
||||
|
||||
$stats = $this->attendanceService->getMonthlyStats();
|
||||
$departments = $this->leaveService->getDepartments();
|
||||
$employees = $this->leaveService->getActiveEmployees();
|
||||
$statusMap = Attendance::STATUS_MAP;
|
||||
$leaveTypeMap = Leave::TYPE_MAP;
|
||||
$leaveStatusMap = Leave::STATUS_MAP;
|
||||
|
||||
// 결재선 목록
|
||||
$approvalLines = \App\Models\Approvals\ApprovalLine::query()
|
||||
->where('tenant_id', session('selected_tenant_id'))
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'is_default']);
|
||||
|
||||
return view('hr.attendance-integrated.index', [
|
||||
'stats' => $stats,
|
||||
'departments' => $departments,
|
||||
'employees' => $employees,
|
||||
'statusMap' => $statusMap,
|
||||
'leaveTypeMap' => $leaveTypeMap,
|
||||
'leaveStatusMap' => $leaveStatusMap,
|
||||
'approvalLines' => $approvalLines,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\HR;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class AttendanceRequest extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'attendance_requests';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'request_type',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'reason',
|
||||
'status',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'reject_reason',
|
||||
'json_details',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tenant_id' => 'int',
|
||||
'user_id' => 'int',
|
||||
'approved_by' => 'int',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'approved_at' => 'datetime',
|
||||
'json_details' => 'array',
|
||||
];
|
||||
|
||||
public const TYPE_MAP = [
|
||||
'vacation' => '휴가',
|
||||
'businessTrip' => '출장',
|
||||
'remote' => '재택',
|
||||
'fieldWork' => '외근',
|
||||
];
|
||||
|
||||
public const STATUS_MAP = [
|
||||
'pending' => '대기',
|
||||
'approved' => '승인',
|
||||
'rejected' => '반려',
|
||||
];
|
||||
|
||||
public const STATUS_COLORS = [
|
||||
'pending' => 'amber',
|
||||
'approved' => 'emerald',
|
||||
'rejected' => 'red',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
public function getTypeLabelAttribute(): string
|
||||
{
|
||||
return self::TYPE_MAP[$this->request_type] ?? $this->request_type;
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return self::STATUS_MAP[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return self::STATUS_COLORS[$this->status] ?? 'gray';
|
||||
}
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId = null)
|
||||
{
|
||||
$tenantId = $tenantId ?? session('selected_tenant_id');
|
||||
if ($tenantId) {
|
||||
return $query->where($this->table.'.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,45 @@ class Leave extends Model
|
||||
'family' => '경조사',
|
||||
'maternity' => '출산',
|
||||
'parental' => '육아',
|
||||
'business_trip' => '출장',
|
||||
'remote' => '재택근무',
|
||||
'field_work' => '외근',
|
||||
'early_leave' => '조퇴',
|
||||
'late_reason' => '지각사유서',
|
||||
'absent_reason' => '결근사유서',
|
||||
];
|
||||
|
||||
// 그룹 상수
|
||||
public const VACATION_TYPES = ['annual', 'half_am', 'half_pm', 'sick', 'family', 'maternity', 'parental'];
|
||||
|
||||
public const ATTENDANCE_REQUEST_TYPES = ['business_trip', 'remote', 'field_work', 'early_leave'];
|
||||
|
||||
public const REASON_REPORT_TYPES = ['late_reason', 'absent_reason'];
|
||||
|
||||
// 유형 → 결재양식코드 매핑
|
||||
public const FORM_CODE_MAP = [
|
||||
'annual' => 'leave', 'half_am' => 'leave', 'half_pm' => 'leave',
|
||||
'sick' => 'leave', 'family' => 'leave', 'maternity' => 'leave', 'parental' => 'leave',
|
||||
'business_trip' => 'attendance_request', 'remote' => 'attendance_request',
|
||||
'field_work' => 'attendance_request', 'early_leave' => 'attendance_request',
|
||||
'late_reason' => 'reason_report', 'absent_reason' => 'reason_report',
|
||||
];
|
||||
|
||||
// 유형 → 근태상태 매핑 (승인 시 Attendance에 반영할 상태)
|
||||
public const ATTENDANCE_STATUS_MAP = [
|
||||
'annual' => 'vacation', 'half_am' => 'vacation', 'half_pm' => 'vacation',
|
||||
'sick' => 'vacation', 'family' => 'vacation', 'maternity' => 'vacation', 'parental' => 'vacation',
|
||||
'business_trip' => 'businessTrip', 'remote' => 'remote', 'field_work' => 'fieldWork',
|
||||
'early_leave' => null,
|
||||
'late_reason' => null,
|
||||
'absent_reason' => null,
|
||||
];
|
||||
|
||||
// 그룹별 라벨
|
||||
public const GROUP_LABELS = [
|
||||
'vacation' => '휴가',
|
||||
'attendance_request' => '근태신청',
|
||||
'reason_report' => '사유서',
|
||||
];
|
||||
|
||||
public const STATUS_MAP = [
|
||||
|
||||
@@ -711,11 +711,19 @@ public function getBadgeCounts(int $userId): array
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 결재 최종 승인 시 연동 처리 (휴가 등)
|
||||
* 휴가/근태신청/사유서 관련 결재 양식인지 확인
|
||||
*/
|
||||
private function isLeaveRelatedForm(?string $code): bool
|
||||
{
|
||||
return in_array($code, ['leave', 'attendance_request', 'reason_report']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 최종 승인 시 연동 처리 (휴가/근태신청/사유서)
|
||||
*/
|
||||
private function handleApprovalCompleted(Approval $approval): void
|
||||
{
|
||||
if (! $approval->form || $approval->form->code !== 'leave') {
|
||||
if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -726,11 +734,11 @@ private function handleApprovalCompleted(Approval $approval): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 반려 시 연동 처리 (휴가 등)
|
||||
* 결재 반려 시 연동 처리 (휴가/근태신청/사유서)
|
||||
*/
|
||||
private function handleApprovalRejected(Approval $approval, string $comment): void
|
||||
{
|
||||
if (! $approval->form || $approval->form->code !== 'leave') {
|
||||
if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -745,11 +753,11 @@ private function handleApprovalRejected(Approval $approval, string $comment): vo
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 삭제 시 연동 처리 (휴가 등)
|
||||
* 결재 삭제 시 연동 처리 (휴가/근태신청/사유서)
|
||||
*/
|
||||
private function handleApprovalDeleted(Approval $approval): void
|
||||
{
|
||||
if (! $approval->form || $approval->form->code !== 'leave') {
|
||||
if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -763,11 +771,11 @@ private function handleApprovalDeleted(Approval $approval): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 회수 시 연동 처리 (휴가 등)
|
||||
* 결재 회수 시 연동 처리 (휴가/근태신청/사유서)
|
||||
*/
|
||||
private function handleApprovalCancelled(Approval $approval): void
|
||||
{
|
||||
if (! $approval->form || $approval->form->code !== 'leave') {
|
||||
if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\HR;
|
||||
|
||||
use App\Models\HR\Attendance;
|
||||
use App\Models\HR\AttendanceRequest;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AttendanceRequestService
|
||||
{
|
||||
/**
|
||||
* 신청 목록 조회
|
||||
*/
|
||||
public function getRequests(array $filters = [], int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$query = AttendanceRequest::query()
|
||||
->with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department', 'approver'])
|
||||
->forTenant($tenantId)
|
||||
->orderByRaw("FIELD(status, 'pending', 'approved', 'rejected')")
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (! empty($filters['user_id'])) {
|
||||
$query->where('user_id', $filters['user_id']);
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신청 등록
|
||||
*/
|
||||
public function storeRequest(array $data): AttendanceRequest
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return AttendanceRequest::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $data['user_id'],
|
||||
'request_type' => $data['request_type'],
|
||||
'start_date' => $data['start_date'],
|
||||
'end_date' => $data['end_date'],
|
||||
'reason' => $data['reason'] ?? null,
|
||||
'status' => 'pending',
|
||||
'json_details' => $data['json_details'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 처리
|
||||
*/
|
||||
public function approve(int $id): ?AttendanceRequest
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$request = AttendanceRequest::query()
|
||||
->forTenant($tenantId)
|
||||
->where('status', 'pending')
|
||||
->find($id);
|
||||
|
||||
if (! $request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($request, $tenantId) {
|
||||
$request->update([
|
||||
'status' => 'approved',
|
||||
'approved_by' => auth()->id(),
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
|
||||
// 승인 시 해당 기간의 근태 레코드 자동 생성
|
||||
$this->createAttendanceRecords($request, $tenantId);
|
||||
|
||||
return $request->fresh(['user', 'approver']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려 처리
|
||||
*/
|
||||
public function reject(int $id, ?string $reason = null): ?AttendanceRequest
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$request = AttendanceRequest::query()
|
||||
->forTenant($tenantId)
|
||||
->where('status', 'pending')
|
||||
->find($id);
|
||||
|
||||
if (! $request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$request->update([
|
||||
'status' => 'rejected',
|
||||
'approved_by' => auth()->id(),
|
||||
'approved_at' => now(),
|
||||
'reject_reason' => $reason,
|
||||
]);
|
||||
|
||||
return $request->fresh(['user', 'approver']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 후 근태 레코드 자동 생성
|
||||
*/
|
||||
private function createAttendanceRecords(AttendanceRequest $request, int $tenantId): void
|
||||
{
|
||||
$statusMap = [
|
||||
'vacation' => 'vacation',
|
||||
'businessTrip' => 'businessTrip',
|
||||
'remote' => 'remote',
|
||||
'fieldWork' => 'fieldWork',
|
||||
];
|
||||
|
||||
$status = $statusMap[$request->request_type] ?? $request->request_type;
|
||||
|
||||
$period = CarbonPeriod::create($request->start_date, $request->end_date);
|
||||
|
||||
foreach ($period as $date) {
|
||||
// 주말 제외
|
||||
if ($date->isWeekend()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Attendance::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $request->user_id,
|
||||
'base_date' => $date->toDateString(),
|
||||
],
|
||||
[
|
||||
'status' => $status,
|
||||
'remarks' => $request->reason ? mb_substr($request->reason, 0, 100) : null,
|
||||
'updated_by' => auth()->id(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\HR\Attendance;
|
||||
use App\Models\HR\Employee;
|
||||
use App\Models\HR\LeaveBalance;
|
||||
use App\Models\Tenants\Department;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@@ -145,10 +144,7 @@ public function storeAttendance(array $data): Attendance
|
||||
$attendance->update(['created_by' => auth()->id()]);
|
||||
}
|
||||
|
||||
// 휴가 상태이면 연차 차감
|
||||
if (($data['status'] ?? '') === 'vacation') {
|
||||
$this->deductLeaveBalance($tenantId, $data['user_id']);
|
||||
}
|
||||
// 휴가 상태: Leave 시스템에서 연차 차감 처리 (수동 등록 시에는 차감하지 않음)
|
||||
|
||||
return $attendance->load('user');
|
||||
});
|
||||
@@ -202,9 +198,7 @@ public function bulkStore(array $data): array
|
||||
$updated++;
|
||||
}
|
||||
|
||||
if (($data['status'] ?? '') === 'vacation') {
|
||||
$this->deductLeaveBalance($tenantId, $userId);
|
||||
}
|
||||
// 휴가 상태: Leave 시스템에서 연차 차감 처리 (일괄 등록 시에는 차감하지 않음)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -508,26 +502,6 @@ public function getLeaveBalance(int $userId): ?LeaveBalance
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 연차 차감 (remaining_days는 stored generated이므로 used_days만 업데이트)
|
||||
*/
|
||||
private function deductLeaveBalance(int $tenantId, int $userId): void
|
||||
{
|
||||
$year = now()->year;
|
||||
|
||||
$balance = LeaveBalance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $userId)
|
||||
->where('year', $year)
|
||||
->first();
|
||||
|
||||
if ($balance && $balance->remaining_days > 0) {
|
||||
$balance->update([
|
||||
'used_days' => $balance->used_days + 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 목록 (드롭다운용)
|
||||
*/
|
||||
|
||||
@@ -76,15 +76,22 @@ public function getLeaves(array $filters = [], int $perPage = 20): LengthAwarePa
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 신청 등록 → 결재 자동 생성 + 상신
|
||||
* 휴가/근태신청/사유서 등록 → 결재 자동 생성 + 상신
|
||||
*/
|
||||
public function storeLeave(array $data): Leave
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$days = $this->calculateDays($data['leave_type'], $data['start_date'], $data['end_date']);
|
||||
$leaveType = $data['leave_type'];
|
||||
|
||||
// 사유서는 days=0, 그 외는 자동 계산
|
||||
if (in_array($leaveType, Leave::REASON_REPORT_TYPES)) {
|
||||
$days = 0;
|
||||
} else {
|
||||
$days = $this->calculateDays($leaveType, $data['start_date'], $data['end_date']);
|
||||
}
|
||||
|
||||
// 연차 차감 대상이면 잔여일수 검증
|
||||
if (in_array($data['leave_type'], Leave::DEDUCTIBLE_TYPES)) {
|
||||
if (in_array($leaveType, Leave::DEDUCTIBLE_TYPES)) {
|
||||
$balance = LeaveBalance::query()
|
||||
->forTenant($tenantId)
|
||||
->forUser($data['user_id'])
|
||||
@@ -96,11 +103,11 @@ public function storeLeave(array $data): Leave
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId, $days) {
|
||||
return DB::transaction(function () use ($data, $tenantId, $days, $leaveType) {
|
||||
$leave = Leave::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $data['user_id'],
|
||||
'leave_type' => $data['leave_type'],
|
||||
'leave_type' => $leaveType,
|
||||
'start_date' => $data['start_date'],
|
||||
'end_date' => $data['end_date'],
|
||||
'days' => $days,
|
||||
@@ -110,7 +117,7 @@ public function storeLeave(array $data): Leave
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 결재 자동 생성 + 상신 (선택된 결재선 전달)
|
||||
// 결재 자동 생성 + 상신 (유형에 맞는 결재양식 자동 선택)
|
||||
$approval = $this->createLeaveApproval($leave, $tenantId, $data['approval_line_id'] ?? null);
|
||||
$leave->update(['approval_id' => $approval->id]);
|
||||
|
||||
@@ -119,7 +126,7 @@ public function storeLeave(array $data): Leave
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 → LeaveBalance 차감 → Attendance 자동 생성
|
||||
* 승인 → LeaveBalance 차감 → 유형별 Attendance 반영
|
||||
*/
|
||||
public function approve(int $id): ?Leave
|
||||
{
|
||||
@@ -155,8 +162,8 @@ public function approve(int $id): ?Leave
|
||||
}
|
||||
}
|
||||
|
||||
// 기간 내 영업일마다 Attendance(vacation) 자동 생성
|
||||
$this->createAttendanceRecords($leave, $tenantId);
|
||||
// 유형별 Attendance 반영
|
||||
$this->applyAttendanceByType($leave, $tenantId);
|
||||
|
||||
return $leave->fresh(['user', 'approver']);
|
||||
});
|
||||
@@ -269,13 +276,13 @@ public function deletePendingLeave(int $id): ?Leave
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 승인에 의한 휴가 자동 승인
|
||||
* 결재 승인에 의한 휴가/근태신청/사유서 자동 승인
|
||||
*/
|
||||
public function approveByApproval(Leave $leave, Approval $approval): Leave
|
||||
{
|
||||
$tenantId = $leave->tenant_id;
|
||||
|
||||
// 최종 결재자 ID 찾기 (DB에서 fresh 조회, 기본 정렬 제거 후 역순)
|
||||
// 최종 결재자 ID 찾기
|
||||
$lastApprover = $approval->steps()
|
||||
->where('status', 'approved')
|
||||
->reorder('step_order', 'desc')
|
||||
@@ -288,7 +295,7 @@ public function approveByApproval(Leave $leave, Approval $approval): Leave
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 연차 차감
|
||||
// 연차 차감 (DEDUCTIBLE_TYPES만)
|
||||
if ($leave->is_deductible) {
|
||||
$balance = LeaveBalance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
@@ -301,12 +308,74 @@ public function approveByApproval(Leave $leave, Approval $approval): Leave
|
||||
}
|
||||
}
|
||||
|
||||
// Attendance 생성
|
||||
$this->createAttendanceRecords($leave, $tenantId);
|
||||
// 유형별 Attendance 반영
|
||||
$this->applyAttendanceByType($leave, $tenantId);
|
||||
|
||||
return $leave->fresh(['user', 'approver']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유형별 Attendance 반영 분기
|
||||
*/
|
||||
private function applyAttendanceByType(Leave $leave, int $tenantId): void
|
||||
{
|
||||
$attendanceStatus = Leave::ATTENDANCE_STATUS_MAP[$leave->leave_type] ?? null;
|
||||
|
||||
if ($attendanceStatus) {
|
||||
// 휴가/출장/재택/외근 → Attendance 자동 생성
|
||||
$this->createAttendanceRecords($leave, $tenantId, $attendanceStatus);
|
||||
} elseif ($leave->leave_type === 'early_leave') {
|
||||
// 조퇴 → 기존 Attendance에 비고 기록
|
||||
$this->markEarlyLeave($leave, $tenantId);
|
||||
} elseif (in_array($leave->leave_type, Leave::REASON_REPORT_TYPES)) {
|
||||
// 지각/결근 사유서 → 기존 Attendance에 사유 기록
|
||||
$this->addReasonToAttendance($leave, $tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조퇴 승인 시 기존 Attendance에 비고 기록
|
||||
*/
|
||||
private function markEarlyLeave(Leave $leave, int $tenantId): void
|
||||
{
|
||||
$attendance = Attendance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $leave->user_id)
|
||||
->where('base_date', $leave->start_date->toDateString())
|
||||
->first();
|
||||
|
||||
if ($attendance) {
|
||||
$remarks = $attendance->remarks;
|
||||
$reason = $leave->reason ? " ({$leave->reason})" : '';
|
||||
$attendance->update([
|
||||
'remarks' => trim(($remarks ? $remarks.' / ' : '').'조퇴'.$reason),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지각/결근 사유서 승인 시 기존 Attendance에 사유 기록
|
||||
*/
|
||||
private function addReasonToAttendance(Leave $leave, int $tenantId): void
|
||||
{
|
||||
$attendance = Attendance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $leave->user_id)
|
||||
->where('base_date', $leave->start_date->toDateString())
|
||||
->first();
|
||||
|
||||
if ($attendance) {
|
||||
$typeName = $leave->leave_type === 'late_reason' ? '지각사유' : '결근사유';
|
||||
$reason = $leave->reason ?? '';
|
||||
$remarks = $attendance->remarks;
|
||||
$attendance->update([
|
||||
'remarks' => trim(($remarks ? $remarks.' / ' : '')."[{$typeName}] {$reason}"),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 반려에 의한 휴가 자동 반려
|
||||
*/
|
||||
@@ -631,20 +700,23 @@ public function getActiveEmployees(): \Illuminate\Database\Eloquent\Collection
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 휴가신청 결재 자동 생성 + 상신
|
||||
* 휴가/근태신청/사유서 결재 자동 생성 + 상신
|
||||
*/
|
||||
private function createLeaveApproval(Leave $leave, int $tenantId, ?int $approvalLineId = null): Approval
|
||||
{
|
||||
$approvalService = app(ApprovalService::class);
|
||||
|
||||
// 1. 휴가신청 양식 조회
|
||||
$form = ApprovalForm::where('code', 'leave')
|
||||
// 1. 유형에 맞는 결재양식 조회 (FORM_CODE_MAP으로 동적 결정)
|
||||
$formCode = Leave::FORM_CODE_MAP[$leave->leave_type] ?? 'leave';
|
||||
$form = ApprovalForm::where('code', $formCode)
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $form) {
|
||||
throw new \RuntimeException('휴가신청 결재 양식이 등록되지 않았습니다.');
|
||||
$formNames = ['leave' => '휴가신청', 'attendance_request' => '근태신청', 'reason_report' => '사유서'];
|
||||
$formName = $formNames[$formCode] ?? $formCode;
|
||||
throw new \RuntimeException("{$formName} 결재 양식이 등록되지 않았습니다.");
|
||||
}
|
||||
|
||||
// 2. 결재선 조회: 지정된 ID 우선, 없으면 기본결재선
|
||||
@@ -671,15 +743,21 @@ private function createLeaveApproval(Leave $leave, int $tenantId, ?int $approval
|
||||
'step_type' => $s['step_type'] ?? $s['type'] ?? 'approval',
|
||||
])->toArray();
|
||||
|
||||
// 5. 결재 생성
|
||||
// 5. 결재 제목 생성 (유형별 차별화)
|
||||
$typeName = Leave::TYPE_MAP[$leave->leave_type] ?? $leave->leave_type;
|
||||
$userName = $leave->user->name ?? '';
|
||||
$period = $leave->start_date->format('n/j').'~'.$leave->end_date->format('n/j');
|
||||
|
||||
$titlePrefix = match ($formCode) {
|
||||
'attendance_request' => '근태신청',
|
||||
'reason_report' => '사유서',
|
||||
default => '휴가신청',
|
||||
};
|
||||
|
||||
$approval = $approvalService->createApproval([
|
||||
'form_id' => $form->id,
|
||||
'line_id' => $line->id,
|
||||
'title' => "휴가신청 - {$userName} ({$typeName} {$period})",
|
||||
'title' => "{$titlePrefix} - {$userName} ({$typeName} {$period})",
|
||||
'body' => $body,
|
||||
'content' => [
|
||||
'leave_id' => $leave->id,
|
||||
@@ -701,51 +779,64 @@ private function createLeaveApproval(Leave $leave, int $tenantId, ?int $approval
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 본문 HTML 생성
|
||||
* 결재 본문 HTML 생성 (유형별 차별화)
|
||||
*/
|
||||
private function buildLeaveApprovalBody(Leave $leave, int $tenantId): string
|
||||
{
|
||||
$user = $leave->user;
|
||||
$typeName = Leave::TYPE_MAP[$leave->leave_type] ?? $leave->leave_type;
|
||||
$period = $leave->start_date->format('Y-m-d').' ~ '.$leave->end_date->format('Y-m-d');
|
||||
$daysStr = ($leave->days == (int) $leave->days) ? (int) $leave->days.'일' : $leave->days.'일';
|
||||
|
||||
// 잔여연차 정보
|
||||
$balanceInfo = '';
|
||||
if (in_array($leave->leave_type, Leave::DEDUCTIBLE_TYPES)) {
|
||||
$balance = LeaveBalance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $leave->user_id)
|
||||
->where('year', now()->year)
|
||||
->first();
|
||||
|
||||
if ($balance) {
|
||||
$balanceInfo = $balance->remaining.'일'
|
||||
.' (부여: '.$balance->total_days.' / 사용: '.$balance->used_days.')';
|
||||
}
|
||||
}
|
||||
$formCode = Leave::FORM_CODE_MAP[$leave->leave_type] ?? 'leave';
|
||||
|
||||
// 부서 정보
|
||||
$profile = $user?->tenantProfiles?->where('tenant_id', $tenantId)->first();
|
||||
$deptName = $profile?->department?->name ?? '';
|
||||
|
||||
// HTML 테이블 본문
|
||||
$rows = [
|
||||
['신청자', e($user->name ?? '')],
|
||||
];
|
||||
// 공통 행
|
||||
$rows = [['신청자', e($user->name ?? '')]];
|
||||
if ($deptName) {
|
||||
$rows[] = ['부서', e($deptName)];
|
||||
}
|
||||
$rows[] = ['휴가유형', $typeName];
|
||||
$rows[] = ['기간', $period.' ('.$daysStr.')'];
|
||||
if ($leave->reason) {
|
||||
$rows[] = ['사유', e($leave->reason)];
|
||||
}
|
||||
if ($balanceInfo) {
|
||||
$rows[] = ['잔여연차', $balanceInfo];
|
||||
$rows[] = ['유형', $typeName];
|
||||
|
||||
// 사유서: 대상일 + 사유
|
||||
if ($formCode === 'reason_report') {
|
||||
$rows[] = ['대상일', $leave->start_date->format('Y-m-d')];
|
||||
if ($leave->reason) {
|
||||
$rows[] = ['사유', e($leave->reason)];
|
||||
}
|
||||
|
||||
$intro = '아래와 같이 사유서를 제출합니다.';
|
||||
} else {
|
||||
// 휴가/근태신청: 기간 + 일수
|
||||
$period = $leave->start_date->format('Y-m-d').' ~ '.$leave->end_date->format('Y-m-d');
|
||||
$daysStr = ($leave->days == (int) $leave->days) ? (int) $leave->days.'일' : $leave->days.'일';
|
||||
$rows[] = ['기간', $period.' ('.$daysStr.')'];
|
||||
|
||||
if ($leave->reason) {
|
||||
$rows[] = ['사유', e($leave->reason)];
|
||||
}
|
||||
|
||||
// 잔여연차 (연차 차감 대상만)
|
||||
if (in_array($leave->leave_type, Leave::DEDUCTIBLE_TYPES)) {
|
||||
$balance = LeaveBalance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $leave->user_id)
|
||||
->where('year', now()->year)
|
||||
->first();
|
||||
|
||||
if ($balance) {
|
||||
$rows[] = ['잔여연차', $balance->remaining.'일 (부여: '.$balance->total_days.' / 사용: '.$balance->used_days.')'];
|
||||
}
|
||||
}
|
||||
|
||||
$intro = match ($formCode) {
|
||||
'attendance_request' => '아래와 같이 근태를 신청합니다.',
|
||||
default => '아래와 같이 휴가를 신청합니다.',
|
||||
};
|
||||
}
|
||||
|
||||
$html = '<p>아래와 같이 휴가를 신청합니다.</p>';
|
||||
// HTML 테이블 생성
|
||||
$html = "<p>{$intro}</p>";
|
||||
$html .= '<table style="border-collapse:collapse; width:100%; margin-top:12px; font-size:14px;">';
|
||||
$thStyle = 'style="padding:8px 12px; background:#f8f9fa; border:1px solid #dee2e6; text-align:left; width:120px; font-weight:600;"';
|
||||
$tdStyle = 'style="padding:8px 12px; border:1px solid #dee2e6;"';
|
||||
@@ -758,9 +849,9 @@ private function buildLeaveApprovalBody(Leave $leave, int $tenantId): string
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 시 기간 내 영업일마다 Attendance(vacation) 자동 생성
|
||||
* 승인 시 기간 내 영업일마다 Attendance 자동 생성
|
||||
*/
|
||||
private function createAttendanceRecords(Leave $leave, int $tenantId): void
|
||||
private function createAttendanceRecords(Leave $leave, int $tenantId, string $status = 'vacation'): void
|
||||
{
|
||||
$period = CarbonPeriod::create($leave->start_date, $leave->end_date);
|
||||
|
||||
@@ -776,7 +867,7 @@ private function createAttendanceRecords(Leave $leave, int $tenantId): void
|
||||
'base_date' => $date->toDateString(),
|
||||
],
|
||||
[
|
||||
'status' => 'vacation',
|
||||
'status' => $status,
|
||||
'remarks' => $leave->reason ? mb_substr($leave->reason, 0, 100) : null,
|
||||
'updated_by' => auth()->id(),
|
||||
]
|
||||
|
||||
356
resources/views/hr/attendance-integrated/index.blade.php
Normal file
356
resources/views/hr/attendance-integrated/index.blade.php
Normal file
@@ -0,0 +1,356 @@
|
||||
@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">근태 조회, 휴가/근태 신청, 사유서 제출을 한 화면에서 관리합니다.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{-- 신규 신청 드롭다운 --}}
|
||||
<div class="relative" id="new-request-dropdown">
|
||||
<button type="button" onclick="toggleDropdown()"
|
||||
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>
|
||||
신규 신청
|
||||
<svg class="w-3 h-3 ml-1" 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>
|
||||
</button>
|
||||
<div id="dropdown-menu" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
|
||||
<div class="py-1">
|
||||
<div class="px-3 py-1.5 text-xs font-semibold text-gray-400 uppercase">휴가</div>
|
||||
<button onclick="openLeaveModal('annual')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">연차</button>
|
||||
<button onclick="openLeaveModal('half_am')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">오전반차</button>
|
||||
<button onclick="openLeaveModal('half_pm')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">오후반차</button>
|
||||
<button onclick="openLeaveModal('sick')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">병가</button>
|
||||
<button onclick="openLeaveModal('family')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">경조사</button>
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
<div class="px-3 py-1.5 text-xs font-semibold text-gray-400 uppercase">근태신청</div>
|
||||
<button onclick="openLeaveModal('business_trip')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">출장</button>
|
||||
<button onclick="openLeaveModal('remote')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">재택근무</button>
|
||||
<button onclick="openLeaveModal('field_work')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">외근</button>
|
||||
<button onclick="openLeaveModal('early_leave')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">조퇴</button>
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
<div class="px-3 py-1.5 text-xs font-semibold text-gray-400 uppercase">사유서</div>
|
||||
<button onclick="openLeaveModal('late_reason')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">지각 사유서</button>
|
||||
<button onclick="openLeaveModal('absent_reason')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">결근 사유서</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 탭 네비게이션 --}}
|
||||
<div class="flex items-center gap-1 mb-4 border-b border-gray-200">
|
||||
<button type="button" onclick="switchTab('attendance')" id="tab-attendance"
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-blue-600 text-blue-600">
|
||||
근태현황
|
||||
</button>
|
||||
<button type="button" onclick="switchTab('requests')" id="tab-requests"
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-gray-500 hover:text-gray-700">
|
||||
신청/결재
|
||||
</button>
|
||||
<button type="button" onclick="switchTab('balance')" id="tab-balance"
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-gray-500 hover:text-gray-700">
|
||||
연차잔여
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 탭 콘텐츠 영역 --}}
|
||||
<div id="integrated-content">
|
||||
{{-- 탭 1: 근태현황 --}}
|
||||
<div id="content-attendance">
|
||||
@include('hr.attendance-integrated.partials.tab-attendance')
|
||||
</div>
|
||||
|
||||
{{-- 탭 2: 신청/결재 (lazy load) --}}
|
||||
<div id="content-requests" class="hidden"></div>
|
||||
|
||||
{{-- 탭 3: 연차잔여 (lazy load) --}}
|
||||
<div id="content-balance" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 신청 모달 --}}
|
||||
@include('hr.attendance-integrated.partials.modal-leave')
|
||||
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// =========================================================================
|
||||
// 탭 전환
|
||||
// =========================================================================
|
||||
let currentTab = 'attendance';
|
||||
const tabLoaded = { attendance: true, requests: false, balance: false };
|
||||
|
||||
function switchTab(tab) {
|
||||
// 이전 탭 숨기기
|
||||
document.getElementById('content-' + currentTab).classList.add('hidden');
|
||||
document.getElementById('tab-' + currentTab).classList.remove('border-blue-600', 'text-blue-600');
|
||||
document.getElementById('tab-' + currentTab).classList.add('border-transparent', 'text-gray-500');
|
||||
|
||||
// 새 탭 보이기
|
||||
currentTab = tab;
|
||||
document.getElementById('content-' + tab).classList.remove('hidden');
|
||||
document.getElementById('tab-' + tab).classList.add('border-blue-600', 'text-blue-600');
|
||||
document.getElementById('tab-' + tab).classList.remove('border-transparent', 'text-gray-500');
|
||||
|
||||
// lazy load
|
||||
if (!tabLoaded[tab]) {
|
||||
tabLoaded[tab] = true;
|
||||
if (tab === 'requests') loadRequests();
|
||||
if (tab === 'balance') loadBalance();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 근태현황 탭
|
||||
// =========================================================================
|
||||
function loadAttendances(page) {
|
||||
const params = new URLSearchParams();
|
||||
const q = document.getElementById('att-search')?.value;
|
||||
const dept = document.getElementById('att-department')?.value;
|
||||
const status = document.getElementById('att-status')?.value;
|
||||
const from = document.getElementById('att-date-from')?.value;
|
||||
const to = document.getElementById('att-date-to')?.value;
|
||||
|
||||
if (q) params.set('q', q);
|
||||
if (dept) params.set('department_id', dept);
|
||||
if (status) params.set('status', status);
|
||||
if (from) params.set('date_from', from);
|
||||
if (to) params.set('date_to', to);
|
||||
if (page) params.set('page', page);
|
||||
|
||||
htmx.ajax('GET', '/admin/hr/attendances?' + params.toString(), {
|
||||
target: '#attendance-table-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
|
||||
function loadAttendanceStats(year, month) {
|
||||
htmx.ajax('GET', '/admin/hr/attendances/stats?year=' + year + '&month=' + month, {
|
||||
target: '#attendance-stats-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 신청/결재 탭
|
||||
// =========================================================================
|
||||
function loadRequests(page) {
|
||||
const params = new URLSearchParams();
|
||||
const q = document.getElementById('req-search')?.value;
|
||||
const type = document.getElementById('req-type')?.value;
|
||||
const status = document.getElementById('req-status')?.value;
|
||||
const from = document.getElementById('req-date-from')?.value;
|
||||
const to = document.getElementById('req-date-to')?.value;
|
||||
|
||||
if (q) params.set('q', q);
|
||||
if (type) params.set('leave_type', type);
|
||||
if (status) params.set('status', status);
|
||||
if (from) params.set('date_from', from);
|
||||
if (to) params.set('date_to', to);
|
||||
if (page) params.set('page', page);
|
||||
|
||||
htmx.ajax('GET', '/admin/hr/leaves?' + params.toString(), {
|
||||
target: '#requests-table-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 연차잔여 탭
|
||||
// =========================================================================
|
||||
function loadBalance() {
|
||||
const year = document.getElementById('balance-year')?.value || new Date().getFullYear();
|
||||
htmx.ajax('GET', '/admin/hr/leaves/balance?year=' + year, {
|
||||
target: '#balance-table-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 드롭다운
|
||||
// =========================================================================
|
||||
function toggleDropdown() {
|
||||
document.getElementById('dropdown-menu').classList.toggle('hidden');
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
const dd = document.getElementById('new-request-dropdown');
|
||||
if (dd && !dd.contains(e.target)) {
|
||||
document.getElementById('dropdown-menu')?.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 신청 모달
|
||||
// =========================================================================
|
||||
const leaveTypeMap = @json($leaveTypeMap);
|
||||
|
||||
function openLeaveModal(type) {
|
||||
document.getElementById('dropdown-menu')?.classList.add('hidden');
|
||||
|
||||
const modal = document.getElementById('leave-modal');
|
||||
const form = document.getElementById('leave-form');
|
||||
form.reset();
|
||||
|
||||
// 유형 설정
|
||||
document.getElementById('modal-leave-type').value = type;
|
||||
document.getElementById('modal-type-label').textContent = leaveTypeMap[type] || type;
|
||||
|
||||
// 사유서: end_date를 start_date와 동일하게, 기간 표시 숨기기
|
||||
const isReasonReport = ['late_reason', 'absent_reason'].includes(type);
|
||||
const isHalfDay = ['half_am', 'half_pm'].includes(type);
|
||||
|
||||
document.getElementById('end-date-row').style.display = isReasonReport || isHalfDay ? 'none' : '';
|
||||
document.getElementById('days-info').style.display = isReasonReport ? 'none' : '';
|
||||
|
||||
// 날짜 기본값 설정
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('modal-start-date').value = today;
|
||||
if (!isReasonReport && !isHalfDay) {
|
||||
document.getElementById('modal-end-date').value = today;
|
||||
}
|
||||
|
||||
// 잔여연차 표시 (연차 차감 대상만)
|
||||
const deductibleTypes = ['annual', 'half_am', 'half_pm'];
|
||||
document.getElementById('balance-info-row').style.display = deductibleTypes.includes(type) ? '' : 'none';
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeLeaveModal() {
|
||||
document.getElementById('leave-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function onUserChange() {
|
||||
const userId = document.getElementById('modal-user-id').value;
|
||||
if (!userId) return;
|
||||
|
||||
// 잔여연차 조회
|
||||
fetch('/admin/hr/leaves/balance/' + userId)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success && data.data) {
|
||||
document.getElementById('balance-remaining').textContent =
|
||||
data.data.remaining_days + '일 (부여: ' + data.data.total_days + ' / 사용: ' + data.data.used_days + ')';
|
||||
} else {
|
||||
document.getElementById('balance-remaining').textContent = '정보 없음';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function submitLeave() {
|
||||
const type = document.getElementById('modal-leave-type').value;
|
||||
const isReasonReport = ['late_reason', 'absent_reason'].includes(type);
|
||||
const isHalfDay = ['half_am', 'half_pm'].includes(type);
|
||||
|
||||
const startDate = document.getElementById('modal-start-date').value;
|
||||
let endDate = startDate;
|
||||
if (!isReasonReport && !isHalfDay) {
|
||||
endDate = document.getElementById('modal-end-date').value || startDate;
|
||||
}
|
||||
|
||||
const body = {
|
||||
user_id: document.getElementById('modal-user-id').value,
|
||||
leave_type: type,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
reason: document.getElementById('modal-reason').value || null,
|
||||
approval_line_id: document.getElementById('modal-approval-line')?.value || null,
|
||||
};
|
||||
|
||||
fetch('/admin/hr/leaves', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
closeLeaveModal();
|
||||
showToast(data.message, 'success');
|
||||
// 신청/결재 탭 새로고침
|
||||
if (tabLoaded.requests) loadRequests();
|
||||
if (tabLoaded.balance) loadBalance();
|
||||
} else {
|
||||
showToast(data.message || '등록 실패', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('등록 중 오류 발생', 'error'));
|
||||
}
|
||||
|
||||
function cancelLeave(id) {
|
||||
if (!confirm('신청을 취소하시겠습니까? 승인된 건은 연차가 복원됩니다.')) return;
|
||||
|
||||
fetch('/admin/hr/leaves/' + id + '/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
loadRequests();
|
||||
if (tabLoaded.balance) loadBalance();
|
||||
} else {
|
||||
showToast(data.message || '취소 실패', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteLeave(id) {
|
||||
if (!confirm('신청을 삭제하시겠습니까?')) return;
|
||||
|
||||
fetch('/admin/hr/leaves/' + id, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
loadRequests();
|
||||
} else {
|
||||
showToast(data.message || '삭제 실패', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 토스트
|
||||
// =========================================================================
|
||||
function showToast(message, type) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed top-4 right-4 z-[9999] px-4 py-3 rounded-lg shadow-lg text-sm font-medium transition-all ' +
|
||||
(type === 'success' ? 'bg-emerald-500 text-white' : 'bg-red-500 text-white');
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 초기 로드
|
||||
// =========================================================================
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadAttendances();
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,94 @@
|
||||
{{-- 휴가/근태신청/사유서 통합 모달 --}}
|
||||
<div id="leave-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
|
||||
{{-- 배경 오버레이 --}}
|
||||
<div class="fixed inset-0 bg-black/50" onclick="closeLeaveModal()"></div>
|
||||
|
||||
{{-- 모달 본문 --}}
|
||||
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800">
|
||||
<span id="modal-type-label">신청</span>
|
||||
</h3>
|
||||
<button onclick="closeLeaveModal()" class="p-1 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="leave-form" onsubmit="event.preventDefault(); submitLeave();">
|
||||
<input type="hidden" id="modal-leave-type" name="leave_type">
|
||||
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
{{-- 신청자 --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">신청자 <span class="text-red-500">*</span></label>
|
||||
<select id="modal-user-id" name="user_id" required onchange="onUserChange()"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($employees as $emp)
|
||||
<option value="{{ $emp->user_id }}">{{ $emp->display_name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- 잔여연차 (연차 차감 대상만) --}}
|
||||
<div id="balance-info-row" style="display:none;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">잔여연차</label>
|
||||
<p id="balance-remaining" class="text-sm text-blue-600 font-medium">-</p>
|
||||
</div>
|
||||
|
||||
{{-- 시작일 --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
<span class="reason-label">날짜</span> <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="date" id="modal-start-date" name="start_date" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- 종료일 --}}
|
||||
<div id="end-date-row">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">종료일 <span class="text-red-500">*</span></label>
|
||||
<input type="date" id="modal-end-date" name="end_date"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- 일수 --}}
|
||||
<div id="days-info">
|
||||
<p class="text-xs text-gray-500">일수는 영업일 기준 자동 계산됩니다.</p>
|
||||
</div>
|
||||
|
||||
{{-- 사유 --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">사유</label>
|
||||
<textarea id="modal-reason" name="reason" rows="3" placeholder="사유를 입력하세요..."
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
{{-- 결재선 선택 --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">결재선</label>
|
||||
<select id="modal-approval-line" name="approval_line_id"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">기본결재선 사용</option>
|
||||
@foreach($approvalLines as $line)
|
||||
<option value="{{ $line->id }}" @if($line->is_default) selected @endif>{{ $line->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-xl">
|
||||
<button type="button" onclick="closeLeaveModal()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
신청 (결재 자동 생성)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
@php
|
||||
$statItems = [
|
||||
['label' => '정상출근', 'value' => $stats['onTime'] ?? 0, 'color' => 'emerald', 'icon' => 'M5 13l4 4L19 7'],
|
||||
['label' => '지각', 'value' => $stats['late'] ?? 0, 'color' => 'amber', 'icon' => 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z'],
|
||||
['label' => '결근', 'value' => $stats['absent'] ?? 0, 'color' => 'red', 'icon' => 'M6 18L18 6M6 6l12 12'],
|
||||
['label' => '휴가', 'value' => $stats['vacation'] ?? 0, 'color' => 'blue', 'icon' => 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'],
|
||||
['label' => '출장/외근/재택', 'value' => $stats['etc'] ?? 0, 'color' => 'purple', 'icon' => 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z'],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
@foreach($statItems as $item)
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-{{ $item['color'] }}-50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-{{ $item['color'] }}-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ $item['icon'] }}"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">{{ $item['label'] }}</p>
|
||||
<p class="text-lg font-bold text-gray-800">{{ number_format($item['value']) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
{{-- 통계 카드 --}}
|
||||
<div id="attendance-stats-container" class="mb-4">
|
||||
@include('hr.attendance-integrated.partials.stats')
|
||||
</div>
|
||||
|
||||
{{-- 필터 --}}
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4 mb-4">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div style="flex: 1 1 180px; max-width: 220px;">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">이름검색</label>
|
||||
<input type="text" id="att-search" placeholder="이름 검색..."
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
onkeydown="if(event.key==='Enter') loadAttendances()">
|
||||
</div>
|
||||
<div style="flex: 0 0 150px;">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
|
||||
<select id="att-department" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" onchange="loadAttendances()">
|
||||
<option value="">전체</option>
|
||||
@foreach($departments as $dept)
|
||||
<option value="{{ $dept->id }}">{{ $dept->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 0 130px;">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">상태</label>
|
||||
<select id="att-status" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" onchange="loadAttendances()">
|
||||
<option value="">전체</option>
|
||||
@foreach($statusMap as $code => $label)
|
||||
<option value="{{ $code }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 0 150px;">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">시작일</label>
|
||||
<input type="date" id="att-date-from" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" onchange="loadAttendances()">
|
||||
</div>
|
||||
<div style="flex: 0 0 150px;">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">종료일</label>
|
||||
<input type="date" id="att-date-to" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" onchange="loadAttendances()">
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<button onclick="loadAttendances()" class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-medium rounded-lg transition-colors">
|
||||
검색
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 근태 목록 --}}
|
||||
<div id="attendance-table-container">
|
||||
<div class="flex items-center justify-center py-12 text-gray-400">
|
||||
<svg class="w-5 h-5 animate-spin mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
|
||||
불러오는 중...
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,110 +0,0 @@
|
||||
{{-- 근태 신청/승인 목록 (HTMX로 로드) --}}
|
||||
@php
|
||||
use App\Models\HR\AttendanceRequest;
|
||||
@endphp
|
||||
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-800">근태 신청/승인</h3>
|
||||
<button type="button" onclick="openRequestModal()"
|
||||
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>
|
||||
신청
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if($requests->isEmpty())
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="w-12 h-12 text-gray-300 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">근태 신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
@else
|
||||
<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-4 py-3 text-center text-sm font-semibold text-gray-600">유형</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">기간</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">사유</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">상태</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">처리자</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
@foreach($requests as $req)
|
||||
@php
|
||||
$profile = $req->user?->tenantProfiles?->first();
|
||||
$displayName = $profile?->display_name ?? $req->user?->name ?? '-';
|
||||
$statusColor = AttendanceRequest::STATUS_COLORS[$req->status] ?? 'gray';
|
||||
$typeLabel = AttendanceRequest::TYPE_MAP[$req->request_type] ?? $req->request_type;
|
||||
$statusLabel = AttendanceRequest::STATUS_MAP[$req->status] ?? $req->status;
|
||||
$dateRange = $req->start_date->format('m/d') . ($req->start_date->ne($req->end_date) ? ' ~ ' . $req->end_date->format('m/d') : '');
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="shrink-0 w-7 h-7 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-medium">
|
||||
{{ mb_substr($displayName, 0, 1) }}
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $displayName }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
|
||||
{{ $typeLabel }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-gray-700 whitespace-nowrap">{{ $dateRange }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-500" style="max-width: 200px;">
|
||||
<span class="truncate block">{{ $req->reason ?? '-' }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ $statusColor }}-100 text-{{ $statusColor }}-700">
|
||||
{{ $statusLabel }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">
|
||||
@if($req->approved_by)
|
||||
{{ $req->approver?->name ?? '-' }}
|
||||
<div class="text-xs text-gray-400">{{ $req->approved_at?->format('m/d H:i') }}</div>
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center whitespace-nowrap">
|
||||
@if($req->status === 'pending')
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button type="button" onclick="approveRequest({{ $req->id }})"
|
||||
class="px-3 py-1 text-xs bg-emerald-600 hover:bg-emerald-700 text-white rounded transition-colors">
|
||||
승인
|
||||
</button>
|
||||
<button type="button" onclick="rejectRequest({{ $req->id }})"
|
||||
class="px-3 py-1 text-xs bg-red-600 hover:bg-red-700 text-white rounded transition-colors">
|
||||
반려
|
||||
</button>
|
||||
</div>
|
||||
@elseif($req->status === 'rejected' && $req->reject_reason)
|
||||
<span class="text-xs text-red-500" title="{{ $req->reject_reason }}">
|
||||
사유: {{ mb_substr($req->reject_reason, 0, 20) }}{{ mb_strlen($req->reject_reason) > 20 ? '...' : '' }}
|
||||
</span>
|
||||
@else
|
||||
<span class="text-xs text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</x-table-swipe>
|
||||
|
||||
@if($requests->hasPages())
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
{{ $requests->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
@@ -1186,15 +1186,9 @@
|
||||
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// 근태 신청/승인 API
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/attendance-requests')->name('api.admin.hr.attendances.requests.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'store'])->name('store');
|
||||
Route::post('/{id}/approve', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'approve'])->name('approve');
|
||||
Route::post('/{id}/reject', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'reject'])->name('reject');
|
||||
});
|
||||
// 근태 신청/승인 API — Leave 시스템으로 통합됨 (attendance-requests 라우트 폐기)
|
||||
|
||||
// 휴가관리 API
|
||||
// 휴가/근태신청/사유서 통합 API
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/leaves')->name('api.admin.hr.leaves.')->group(function () {
|
||||
Route::get('/balance', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'balance'])->name('balance');
|
||||
Route::get('/balance/{userId}', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'userBalance'])->name('user-balance');
|
||||
@@ -1205,6 +1199,7 @@
|
||||
Route::post('/{id}/approve', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'approve'])->name('approve');
|
||||
Route::post('/{id}/reject', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'reject'])->name('reject');
|
||||
Route::post('/{id}/cancel', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'cancel'])->name('cancel');
|
||||
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// 급여관리 API
|
||||
|
||||
@@ -1004,18 +1004,21 @@
|
||||
// 입퇴사자 현황
|
||||
Route::get('/employee-tenure', [\App\Http\Controllers\HR\EmployeeTenureController::class, 'index'])->name('employee-tenure');
|
||||
|
||||
// 근태현황
|
||||
// 근태현황 (기존)
|
||||
Route::prefix('attendances')->name('attendances.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\HR\AttendanceController::class, 'index'])->name('index');
|
||||
Route::get('/manage', [\App\Http\Controllers\HR\AttendanceController::class, 'manage'])->name('manage');
|
||||
});
|
||||
|
||||
// 휴가관리
|
||||
// 휴가관리 (기존)
|
||||
Route::prefix('leaves')->name('leaves.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\HR\LeaveController::class, 'index'])->name('index');
|
||||
Route::get('/help', [\App\Http\Controllers\HR\LeaveController::class, 'helpGuide'])->name('help');
|
||||
});
|
||||
|
||||
// 근태관리 통합 (신규)
|
||||
Route::get('/attendance', [\App\Http\Controllers\HR\AttendanceIntegratedController::class, 'index'])->name('attendance.index');
|
||||
|
||||
// 급여관리
|
||||
Route::prefix('payrolls')->name('payrolls.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\HR\PayrollController::class, 'index'])->name('index');
|
||||
|
||||
Reference in New Issue
Block a user