diff --git a/app/Http/Controllers/Api/V1/LeaveController.php b/app/Http/Controllers/Api/V1/LeaveController.php new file mode 100644 index 0000000..9ef9eaf --- /dev/null +++ b/app/Http/Controllers/Api/V1/LeaveController.php @@ -0,0 +1,146 @@ +service->index($request->validated()); + }, __('message.fetched')); + } + + /** + * 휴가 상세 조회 + * GET /v1/leaves/{id} + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + /** + * 휴가 신청 + * POST /v1/leaves + */ + public function store(StoreRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->store($request->validated()); + }, __('message.leave.created')); + } + + /** + * 휴가 수정 + * PATCH /v1/leaves/{id} + */ + public function update(int $id, UpdateRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->update($id, $request->validated()); + }, __('message.updated')); + } + + /** + * 휴가 삭제 + * DELETE /v1/leaves/{id} + */ + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->destroy($id); + }, __('message.deleted')); + } + + /** + * 휴가 승인 + * POST /v1/leaves/{id}/approve + */ + public function approve(int $id, Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->approve($id, $request->input('comment')); + }, __('message.leave.approved')); + } + + /** + * 휴가 반려 + * POST /v1/leaves/{id}/reject + */ + public function reject(int $id, RejectRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->reject($id, $request->input('reason')); + }, __('message.leave.rejected')); + } + + /** + * 휴가 취소 + * POST /v1/leaves/{id}/cancel + */ + public function cancel(int $id, Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->cancel($id, $request->input('reason')); + }, __('message.leave.cancelled')); + } + + /** + * 내 잔여 휴가 조회 + * GET /v1/leaves/balance + */ + public function balance(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->getMyBalance($request->input('year')); + }, __('message.fetched')); + } + + /** + * 특정 사용자 잔여 휴가 조회 + * GET /v1/leaves/balance/{userId} + */ + public function userBalance(int $userId, Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($userId, $request) { + return $this->service->getUserBalance($userId, $request->input('year')); + }, __('message.fetched')); + } + + /** + * 잔여 휴가 설정 + * PUT /v1/leaves/balance + */ + public function setBalance(BalanceRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + $data = $request->validated(); + + return $this->service->setBalance( + $data['user_id'], + $data['year'], + $data['total_days'] + ); + }, __('message.updated')); + } +} diff --git a/app/Http/Requests/Leave/BalanceRequest.php b/app/Http/Requests/Leave/BalanceRequest.php new file mode 100644 index 0000000..971c495 --- /dev/null +++ b/app/Http/Requests/Leave/BalanceRequest.php @@ -0,0 +1,22 @@ + 'required|integer|exists:users,id', + 'year' => 'required|integer|min:2020|max:2099', + 'total_days' => 'required|numeric|min:0|max:365', + ]; + } +} diff --git a/app/Http/Requests/Leave/IndexRequest.php b/app/Http/Requests/Leave/IndexRequest.php new file mode 100644 index 0000000..3a3e6bb --- /dev/null +++ b/app/Http/Requests/Leave/IndexRequest.php @@ -0,0 +1,30 @@ + 'nullable|integer', + 'status' => 'nullable|string|in:pending,approved,rejected,cancelled', + 'leave_type' => 'nullable|string|in:annual,half_am,half_pm,sick,family,maternity,parental', + 'date_from' => 'nullable|date', + 'date_to' => 'nullable|date|after_or_equal:date_from', + 'year' => 'nullable|integer|min:2020|max:2099', + 'department_id' => 'nullable|integer', + 'sort_by' => 'nullable|string|in:created_at,start_date,end_date,days,status', + 'sort_dir' => 'nullable|string|in:asc,desc', + 'per_page' => 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + ]; + } +} diff --git a/app/Http/Requests/Leave/RejectRequest.php b/app/Http/Requests/Leave/RejectRequest.php new file mode 100644 index 0000000..361732b --- /dev/null +++ b/app/Http/Requests/Leave/RejectRequest.php @@ -0,0 +1,27 @@ + 'required|string|max:1000', + ]; + } + + public function messages(): array + { + return [ + 'reason.required' => __('validation.required', ['attribute' => '반려사유']), + ]; + } +} diff --git a/app/Http/Requests/Leave/StoreRequest.php b/app/Http/Requests/Leave/StoreRequest.php new file mode 100644 index 0000000..944b73d --- /dev/null +++ b/app/Http/Requests/Leave/StoreRequest.php @@ -0,0 +1,39 @@ + 'nullable|integer|exists:users,id', + 'leave_type' => 'required|string|in:annual,half_am,half_pm,sick,family,maternity,parental', + 'start_date' => 'required|date|after_or_equal:today', + 'end_date' => 'required|date|after_or_equal:start_date', + 'days' => 'required|numeric|min:0.5|max:365', + 'reason' => 'nullable|string|max:1000', + ]; + } + + public function messages(): array + { + return [ + 'leave_type.required' => __('validation.required', ['attribute' => '휴가유형']), + 'leave_type.in' => __('validation.in', ['attribute' => '휴가유형']), + 'start_date.required' => __('validation.required', ['attribute' => '시작일']), + 'start_date.after_or_equal' => __('validation.after_or_equal', ['attribute' => '시작일', 'date' => '오늘']), + 'end_date.required' => __('validation.required', ['attribute' => '종료일']), + 'end_date.after_or_equal' => __('validation.after_or_equal', ['attribute' => '종료일', 'date' => '시작일']), + 'days.required' => __('validation.required', ['attribute' => '사용일수']), + 'days.min' => __('validation.min.numeric', ['attribute' => '사용일수', 'min' => 0.5]), + ]; + } +} diff --git a/app/Http/Requests/Leave/UpdateRequest.php b/app/Http/Requests/Leave/UpdateRequest.php new file mode 100644 index 0000000..349f952 --- /dev/null +++ b/app/Http/Requests/Leave/UpdateRequest.php @@ -0,0 +1,24 @@ + 'nullable|string|in:annual,half_am,half_pm,sick,family,maternity,parental', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'days' => 'nullable|numeric|min:0.5|max:365', + 'reason' => 'nullable|string|max:1000', + ]; + } +} diff --git a/app/Models/Tenants/Leave.php b/app/Models/Tenants/Leave.php new file mode 100644 index 0000000..21550fb --- /dev/null +++ b/app/Models/Tenants/Leave.php @@ -0,0 +1,260 @@ + 'date', + 'end_date' => 'date', + 'days' => 'decimal:1', + 'approved_at' => 'datetime', + ]; + + protected $fillable = [ + 'tenant_id', + 'user_id', + 'leave_type', + 'start_date', + 'end_date', + 'days', + 'reason', + 'status', + 'approved_by', + 'approved_at', + 'reject_reason', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + protected $attributes = [ + 'status' => 'pending', + ]; + + // ========================================================================= + // 상수 정의 + // ========================================================================= + + public const TYPE_ANNUAL = 'annual'; // 연차 + + public const TYPE_HALF_AM = 'half_am'; // 오전반차 + + public const TYPE_HALF_PM = 'half_pm'; // 오후반차 + + public const TYPE_SICK = 'sick'; // 병가 + + public const TYPE_FAMILY = 'family'; // 경조사 + + public const TYPE_MATERNITY = 'maternity'; // 출산 + + public const TYPE_PARENTAL = 'parental'; // 육아 + + public const STATUS_PENDING = 'pending'; + + public const STATUS_APPROVED = 'approved'; + + public const STATUS_REJECTED = 'rejected'; + + public const STATUS_CANCELLED = 'cancelled'; + + public const LEAVE_TYPES = [ + self::TYPE_ANNUAL, + self::TYPE_HALF_AM, + self::TYPE_HALF_PM, + self::TYPE_SICK, + self::TYPE_FAMILY, + self::TYPE_MATERNITY, + self::TYPE_PARENTAL, + ]; + + public const STATUSES = [ + self::STATUS_PENDING, + self::STATUS_APPROVED, + self::STATUS_REJECTED, + self::STATUS_CANCELLED, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 신청자 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * 승인자 + */ + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + /** + * 생성자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 특정 상태 필터 + */ + public function scopeWithStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * 승인 대기 중 + */ + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * 승인됨 + */ + public function scopeApproved($query) + { + return $query->where('status', self::STATUS_APPROVED); + } + + /** + * 특정 기간 내 + */ + public function scopeBetweenDates($query, string $startDate, string $endDate) + { + return $query->where(function ($q) use ($startDate, $endDate) { + $q->whereBetween('start_date', [$startDate, $endDate]) + ->orWhereBetween('end_date', [$startDate, $endDate]) + ->orWhere(function ($q2) use ($startDate, $endDate) { + $q2->where('start_date', '<=', $startDate) + ->where('end_date', '>=', $endDate); + }); + }); + } + + /** + * 특정 사용자 + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + /** + * 특정 연도 + */ + public function scopeForYear($query, int $year) + { + return $query->whereYear('start_date', $year); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 수정 가능 여부 + */ + public function isEditable(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * 승인 가능 여부 + */ + public function isApprovable(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * 취소 가능 여부 + */ + public function isCancellable(): bool + { + return in_array($this->status, [self::STATUS_PENDING, self::STATUS_APPROVED]); + } + + /** + * 휴가 유형 라벨 + */ + public function getLeaveTypeLabelAttribute(): string + { + return match ($this->leave_type) { + self::TYPE_ANNUAL => '연차', + self::TYPE_HALF_AM => '오전반차', + self::TYPE_HALF_PM => '오후반차', + self::TYPE_SICK => '병가', + self::TYPE_FAMILY => '경조사', + self::TYPE_MATERNITY => '출산휴가', + self::TYPE_PARENTAL => '육아휴직', + default => $this->leave_type, + }; + } + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => '승인대기', + self::STATUS_APPROVED => '승인', + self::STATUS_REJECTED => '반려', + self::STATUS_CANCELLED => '취소', + default => $this->status, + }; + } +} diff --git a/app/Models/Tenants/LeaveBalance.php b/app/Models/Tenants/LeaveBalance.php new file mode 100644 index 0000000..f26ac15 --- /dev/null +++ b/app/Models/Tenants/LeaveBalance.php @@ -0,0 +1,111 @@ + 'integer', + 'total_days' => 'decimal:1', + 'used_days' => 'decimal:1', + 'remaining_days' => 'decimal:1', + ]; + + protected $fillable = [ + 'tenant_id', + 'user_id', + 'year', + 'total_days', + 'used_days', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 사용자 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 특정 연도 + */ + public function scopeForYear($query, int $year) + { + return $query->where('year', $year); + } + + /** + * 특정 사용자 + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + /** + * 현재 연도 + */ + public function scopeCurrentYear($query) + { + return $query->where('year', now()->year); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 휴가 사용 + */ + public function useLeave(float $days): void + { + $this->used_days += $days; + $this->save(); + } + + /** + * 휴가 복원 (취소 시) + */ + public function restoreLeave(float $days): void + { + $this->used_days = max(0, $this->used_days - $days); + $this->save(); + } + + /** + * 사용 가능 여부 + */ + public function canUse(float $days): bool + { + return $this->remaining_days >= $days; + } +} diff --git a/app/Services/LeaveService.php b/app/Services/LeaveService.php new file mode 100644 index 0000000..070330c --- /dev/null +++ b/app/Services/LeaveService.php @@ -0,0 +1,385 @@ +tenantId(); + + $query = Leave::query() + ->where('tenant_id', $tenantId) + ->with(['user:id,name,email', 'approver:id,name']); + + // 사용자 필터 + if (! empty($params['user_id'])) { + $query->where('user_id', $params['user_id']); + } + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 휴가 유형 필터 + if (! empty($params['leave_type'])) { + $query->where('leave_type', $params['leave_type']); + } + + // 날짜 범위 필터 + if (! empty($params['date_from'])) { + $query->where('start_date', '>=', $params['date_from']); + } + if (! empty($params['date_to'])) { + $query->where('end_date', '<=', $params['date_to']); + } + + // 연도 필터 + if (! empty($params['year'])) { + $query->whereYear('start_date', $params['year']); + } + + // 부서 필터 + if (! empty($params['department_id'])) { + $query->whereHas('user.tenantProfile', function ($q) use ($params) { + $q->where('department_id', $params['department_id']); + }); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'created_at'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + // 페이지네이션 + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 휴가 상세 조회 + */ + public function show(int $id): Leave + { + $tenantId = $this->tenantId(); + + return Leave::query() + ->where('tenant_id', $tenantId) + ->with(['user:id,name,email', 'approver:id,name']) + ->findOrFail($id); + } + + /** + * 휴가 신청 + */ + public function store(array $data): Leave + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 신청자 ID (관리자가 대리 신청 가능) + $applicantId = $data['user_id'] ?? $userId; + + // 잔여 휴가 확인 (연차/반차만) + if (in_array($data['leave_type'], [Leave::TYPE_ANNUAL, Leave::TYPE_HALF_AM, Leave::TYPE_HALF_PM])) { + $year = \Carbon\Carbon::parse($data['start_date'])->year; + $balance = $this->getOrCreateBalance($tenantId, $applicantId, $year); + + if (! $balance->canUse($data['days'])) { + throw new BadRequestHttpException(__('error.leave.insufficient_balance')); + } + } + + // 중복 휴가 확인 + $overlapping = Leave::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $applicantId) + ->whereIn('status', [Leave::STATUS_PENDING, Leave::STATUS_APPROVED]) + ->where(function ($q) use ($data) { + $q->whereBetween('start_date', [$data['start_date'], $data['end_date']]) + ->orWhereBetween('end_date', [$data['start_date'], $data['end_date']]) + ->orWhere(function ($q2) use ($data) { + $q2->where('start_date', '<=', $data['start_date']) + ->where('end_date', '>=', $data['end_date']); + }); + }) + ->exists(); + + if ($overlapping) { + throw new BadRequestHttpException(__('error.leave.overlapping')); + } + + $leave = Leave::create([ + 'tenant_id' => $tenantId, + 'user_id' => $applicantId, + 'leave_type' => $data['leave_type'], + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'days' => $data['days'], + 'reason' => $data['reason'] ?? null, + 'status' => Leave::STATUS_PENDING, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + return $leave->fresh(['user:id,name,email']); + }); + } + + /** + * 휴가 수정 (pending 상태만) + */ + public function update(int $id, array $data): Leave + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $leave = Leave::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $leave->isEditable()) { + throw new BadRequestHttpException(__('error.leave.not_editable')); + } + + // 휴가 기간이 변경된 경우 잔여 휴가 확인 + if (isset($data['days']) && in_array($leave->leave_type, [Leave::TYPE_ANNUAL, Leave::TYPE_HALF_AM, Leave::TYPE_HALF_PM])) { + $year = \Carbon\Carbon::parse($data['start_date'] ?? $leave->start_date)->year; + $balance = $this->getOrCreateBalance($tenantId, $leave->user_id, $year); + + if (! $balance->canUse($data['days'])) { + throw new BadRequestHttpException(__('error.leave.insufficient_balance')); + } + } + + $leave->fill([ + 'leave_type' => $data['leave_type'] ?? $leave->leave_type, + 'start_date' => $data['start_date'] ?? $leave->start_date, + 'end_date' => $data['end_date'] ?? $leave->end_date, + 'days' => $data['days'] ?? $leave->days, + 'reason' => $data['reason'] ?? $leave->reason, + 'updated_by' => $userId, + ]); + + $leave->save(); + + return $leave->fresh(['user:id,name,email']); + }); + } + + /** + * 휴가 취소/삭제 (pending 상태만) + */ + public function destroy(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $leave = Leave::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $leave->isEditable()) { + throw new BadRequestHttpException(__('error.leave.not_editable')); + } + + $leave->deleted_by = $userId; + $leave->save(); + $leave->delete(); + + return true; + }); + } + + /** + * 휴가 승인 + */ + public function approve(int $id, ?string $comment = null): Leave + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $leave = Leave::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $leave->isApprovable()) { + throw new BadRequestHttpException(__('error.leave.not_approvable')); + } + + // 잔여 휴가 차감 (연차/반차만) + if (in_array($leave->leave_type, [Leave::TYPE_ANNUAL, Leave::TYPE_HALF_AM, Leave::TYPE_HALF_PM])) { + $year = \Carbon\Carbon::parse($leave->start_date)->year; + $balance = $this->getOrCreateBalance($tenantId, $leave->user_id, $year); + + if (! $balance->canUse($leave->days)) { + throw new BadRequestHttpException(__('error.leave.insufficient_balance')); + } + + $balance->useLeave($leave->days); + } + + $leave->status = Leave::STATUS_APPROVED; + $leave->approved_by = $userId; + $leave->approved_at = now(); + $leave->updated_by = $userId; + $leave->save(); + + return $leave->fresh(['user:id,name,email', 'approver:id,name']); + }); + } + + /** + * 휴가 반려 + */ + public function reject(int $id, string $reason): Leave + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $reason, $tenantId, $userId) { + $leave = Leave::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $leave->isApprovable()) { + throw new BadRequestHttpException(__('error.leave.not_approvable')); + } + + $leave->status = Leave::STATUS_REJECTED; + $leave->approved_by = $userId; + $leave->approved_at = now(); + $leave->reject_reason = $reason; + $leave->updated_by = $userId; + $leave->save(); + + return $leave->fresh(['user:id,name,email', 'approver:id,name']); + }); + } + + /** + * 승인된 휴가 취소 (휴가 복원) + */ + public function cancel(int $id, ?string $reason = null): Leave + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $reason, $tenantId, $userId) { + $leave = Leave::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (! $leave->isCancellable()) { + throw new BadRequestHttpException(__('error.leave.not_cancellable')); + } + + // 이미 승인된 휴가라면 잔여일수 복원 + if ($leave->status === Leave::STATUS_APPROVED) { + if (in_array($leave->leave_type, [Leave::TYPE_ANNUAL, Leave::TYPE_HALF_AM, Leave::TYPE_HALF_PM])) { + $year = \Carbon\Carbon::parse($leave->start_date)->year; + $balance = $this->getOrCreateBalance($tenantId, $leave->user_id, $year); + $balance->restoreLeave($leave->days); + } + } + + $leave->status = Leave::STATUS_CANCELLED; + $leave->reject_reason = $reason; + $leave->updated_by = $userId; + $leave->save(); + + return $leave->fresh(['user:id,name,email']); + }); + } + + /** + * 내 잔여 휴가 조회 + */ + public function getMyBalance(?int $year = null): LeaveBalance + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + $year = $year ?? now()->year; + + return $this->getOrCreateBalance($tenantId, $userId, $year); + } + + /** + * 특정 사용자 잔여 휴가 조회 + */ + public function getUserBalance(int $userId, ?int $year = null): LeaveBalance + { + $tenantId = $this->tenantId(); + $year = $year ?? now()->year; + + return $this->getOrCreateBalance($tenantId, $userId, $year); + } + + /** + * 잔여 휴가 설정 + */ + public function setBalance(int $userId, int $year, float $totalDays): LeaveBalance + { + $tenantId = $this->tenantId(); + + $balance = LeaveBalance::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->where('year', $year) + ->first(); + + if ($balance) { + $balance->total_days = $totalDays; + $balance->save(); + } else { + $balance = LeaveBalance::create([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'year' => $year, + 'total_days' => $totalDays, + 'used_days' => 0, + ]); + } + + return $balance->fresh(); + } + + /** + * 잔여 휴가 조회 또는 생성 + */ + private function getOrCreateBalance(int $tenantId, int $userId, int $year): LeaveBalance + { + $balance = LeaveBalance::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->where('year', $year) + ->first(); + + if (! $balance) { + $balance = LeaveBalance::create([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'year' => $year, + 'total_days' => 15, // 기본 연차 15일 + 'used_days' => 0, + ]); + } + + return $balance; + } +} diff --git a/app/Swagger/v1/LeaveApi.php b/app/Swagger/v1/LeaveApi.php new file mode 100644 index 0000000..1ee3d50 --- /dev/null +++ b/app/Swagger/v1/LeaveApi.php @@ -0,0 +1,493 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('user_id')->comment('신청자 ID'); + $table->string('leave_type', 20)->comment('휴가유형: annual/half_am/half_pm/sick/family/maternity/parental'); + $table->date('start_date')->comment('시작일'); + $table->date('end_date')->comment('종료일'); + $table->decimal('days', 3, 1)->comment('사용일수'); + $table->text('reason')->nullable()->comment('휴가 사유'); + $table->string('status', 20)->default('pending')->comment('상태: pending/approved/rejected/cancelled'); + $table->unsignedBigInteger('approved_by')->nullable()->comment('승인자 ID'); + $table->timestamp('approved_at')->nullable()->comment('승인/반려 일시'); + $table->text('reject_reason')->nullable()->comment('반려 사유'); + $table->unsignedBigInteger('created_by')->nullable()->comment('등록자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['tenant_id', 'user_id'], 'idx_tenant_user'); + $table->index('status', 'idx_status'); + $table->index(['start_date', 'end_date'], 'idx_dates'); + }); + } + + public function down(): void + { + Schema::dropIfExists('leaves'); + } +}; diff --git a/database/migrations/2025_12_17_100001_create_leave_balances_table.php b/database/migrations/2025_12_17_100001_create_leave_balances_table.php new file mode 100644 index 0000000..ff35f87 --- /dev/null +++ b/database/migrations/2025_12_17_100001_create_leave_balances_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('user_id')->comment('사용자 ID'); + $table->unsignedSmallInteger('year')->comment('연도'); + $table->decimal('total_days', 4, 1)->default(15)->comment('연간 부여일수'); + $table->decimal('used_days', 4, 1)->default(0)->comment('사용일수'); + $table->decimal('remaining_days', 4, 1)->storedAs('total_days - used_days')->comment('잔여일수'); + $table->timestamps(); + + $table->unique(['tenant_id', 'user_id', 'year'], 'uk_tenant_user_year'); + }); + } + + public function down(): void + { + Schema::dropIfExists('leave_balances'); + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index af52cdb..b8618e6 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -152,4 +152,15 @@ 'formula_parentheses_mismatch' => '괄호가 올바르게 닫히지 않았습니다.', 'formula_unsupported_function' => '지원하지 않는 함수입니다: :function', 'formula_calculation_error' => '계산 오류: :expression', + + // 휴가 관리 관련 + 'leave' => [ + 'not_found' => '휴가 정보를 찾을 수 없습니다.', + 'not_editable' => '대기 상태의 휴가만 수정할 수 있습니다.', + 'not_approvable' => '대기 상태의 휴가만 승인/반려할 수 있습니다.', + 'not_cancellable' => '승인된 휴가만 취소할 수 있습니다.', + 'insufficient_balance' => '잔여 휴가일수가 부족합니다.', + 'overlapping' => '해당 기간에 이미 신청된 휴가가 있습니다.', + 'balance_not_found' => '휴가 잔여일수 정보를 찾을 수 없습니다.', + ], ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index afd016d..be1f77a 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -196,4 +196,17 @@ ], 'quote_email_sent' => '견적서가 이메일로 발송되었습니다.', 'quote_kakao_sent' => '견적서가 카카오톡으로 발송되었습니다.', + + // 휴가 관리 + 'leave' => [ + 'fetched' => '휴가를 조회했습니다.', + 'created' => '휴가가 신청되었습니다.', + 'updated' => '휴가가 수정되었습니다.', + 'deleted' => '휴가가 삭제되었습니다.', + 'approved' => '휴가가 승인되었습니다.', + 'rejected' => '휴가가 반려되었습니다.', + 'cancelled' => '휴가가 취소되었습니다.', + 'balance_fetched' => '잔여 휴가를 조회했습니다.', + 'balance_updated' => '휴가 일수가 설정되었습니다.', + ], ]; diff --git a/routes/api.php b/routes/api.php index 7a40266..e8b79f3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -23,6 +23,7 @@ use App\Http\Controllers\Api\V1\EstimateController; use App\Http\Controllers\Api\V1\FileStorageController; use App\Http\Controllers\Api\V1\FolderController; +use App\Http\Controllers\Api\V1\LeaveController; use App\Http\Controllers\Api\V1\ItemMaster\CustomTabController; use App\Http\Controllers\Api\V1\ItemMaster\EntityRelationshipController; use App\Http\Controllers\Api\V1\ItemMaster\ItemBomItemController; @@ -241,6 +242,21 @@ Route::post('/bulk-delete', [AttendanceController::class, 'bulkDelete'])->name('v1.attendances.bulkDelete'); }); + // Leave API (휴가 관리) + Route::prefix('leaves')->group(function () { + Route::get('', [LeaveController::class, 'index'])->name('v1.leaves.index'); + Route::post('', [LeaveController::class, 'store'])->name('v1.leaves.store'); + Route::get('/balance', [LeaveController::class, 'balance'])->name('v1.leaves.balance'); + Route::get('/balance/{userId}', [LeaveController::class, 'userBalance'])->name('v1.leaves.userBalance'); + Route::put('/balance', [LeaveController::class, 'setBalance'])->name('v1.leaves.setBalance'); + Route::get('/{id}', [LeaveController::class, 'show'])->name('v1.leaves.show'); + Route::patch('/{id}', [LeaveController::class, 'update'])->name('v1.leaves.update'); + Route::delete('/{id}', [LeaveController::class, 'destroy'])->name('v1.leaves.destroy'); + Route::post('/{id}/approve', [LeaveController::class, 'approve'])->name('v1.leaves.approve'); + Route::post('/{id}/reject', [LeaveController::class, 'reject'])->name('v1.leaves.reject'); + Route::post('/{id}/cancel', [LeaveController::class, 'cancel'])->name('v1.leaves.cancel'); + }); + // Permission API Route::prefix('permissions')->group(function () { Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('v1.permissions.deptMenuMatrix'); // 부서별 권한 메트릭스