feat: [hr] 근태등록 + 휴가관리 통합 시스템 구현

- Leave 모델 확장: 6개 유형 추가 (출장/재택/외근/조퇴/지각사유서/결근사유서)
- LeaveService: 유형별 결재양식 자동 선택, 유형별 Attendance 반영 분기
- ApprovalService: 콜백 3개 결재양식코드로 확장
- AttendanceIntegratedController: 통합 화면 컨트롤러
- 통합 UI: 근태현황/신청결재/연차잔여 3탭 + 신규 신청 드롭다운
- AttendanceRequest 모델/서비스/컨트롤러/뷰 삭제 (Leave로 일원화)
- AttendanceService: deductLeaveBalance 제거 (Leave 시스템으로 일원화)
This commit is contained in:
김보곤
2026-03-03 23:50:27 +09:00
parent 896446f388
commit 6cdcc293cf
16 changed files with 826 additions and 592 deletions

View File

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

View File

@@ -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)
*/

View 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,
]);
}
}

View File

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

View File

@@ -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 = [

View File

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

View File

@@ -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(),
]
);
}
}
}

View File

@@ -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,
]);
}
}
/**
* 부서 목록 (드롭다운용)
*/

View File

@@ -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(),
]

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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