From 12c9ad620a20a7ddbfb6654dbbc52f93109ea793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 27 Feb 2026 23:17:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[approval]=20=EA=B2=B0=EC=9E=AC?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20Phase=201=20MVP=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine - ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직 - ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함) - ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리) - 뷰 8개: drafts, pending, completed, references, create, edit, show - partials: _status-badge, _step-progress, _approval-line-editor - api.php/web.php 라우트 등록 --- .../Api/Admin/ApprovalApiController.php | 288 ++++++++++ app/Http/Controllers/ApprovalController.php | 113 ++++ app/Models/Approvals/Approval.php | 242 +++++++++ app/Models/Approvals/ApprovalForm.php | 92 ++++ app/Models/Approvals/ApprovalLine.php | 83 +++ app/Models/Approvals/ApprovalStep.php | 137 +++++ app/Services/ApprovalService.php | 500 ++++++++++++++++++ resources/views/approvals/completed.blade.php | 122 +++++ resources/views/approvals/create.blade.php | 147 +++++ resources/views/approvals/drafts.blade.php | 169 ++++++ resources/views/approvals/edit.blade.php | 207 ++++++++ .../partials/_approval-line-editor.blade.php | 172 ++++++ .../partials/_status-badge.blade.php | 16 + .../partials/_step-progress.blade.php | 55 ++ resources/views/approvals/pending.blade.php | 109 ++++ .../views/approvals/references.blade.php | 116 ++++ resources/views/approvals/show.blade.php | 199 +++++++ routes/api.php | 28 + routes/web.php | 11 + 19 files changed, 2806 insertions(+) create mode 100644 app/Http/Controllers/Api/Admin/ApprovalApiController.php create mode 100644 app/Http/Controllers/ApprovalController.php create mode 100644 app/Models/Approvals/Approval.php create mode 100644 app/Models/Approvals/ApprovalForm.php create mode 100644 app/Models/Approvals/ApprovalLine.php create mode 100644 app/Models/Approvals/ApprovalStep.php create mode 100644 app/Services/ApprovalService.php create mode 100644 resources/views/approvals/completed.blade.php create mode 100644 resources/views/approvals/create.blade.php create mode 100644 resources/views/approvals/drafts.blade.php create mode 100644 resources/views/approvals/edit.blade.php create mode 100644 resources/views/approvals/partials/_approval-line-editor.blade.php create mode 100644 resources/views/approvals/partials/_status-badge.blade.php create mode 100644 resources/views/approvals/partials/_step-progress.blade.php create mode 100644 resources/views/approvals/pending.blade.php create mode 100644 resources/views/approvals/references.blade.php create mode 100644 resources/views/approvals/show.blade.php diff --git a/app/Http/Controllers/Api/Admin/ApprovalApiController.php b/app/Http/Controllers/Api/Admin/ApprovalApiController.php new file mode 100644 index 00000000..91c31e6c --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ApprovalApiController.php @@ -0,0 +1,288 @@ +service->getMyDrafts( + $request->only(['search', 'status', 'is_urgent', 'date_from', 'date_to']), + (int) $request->get('per_page', 15) + ); + + return response()->json($result); + } + + /** + * 결재 대기함 + */ + public function pending(Request $request): JsonResponse + { + $result = $this->service->getPendingForMe( + auth()->id(), + $request->only(['search', 'is_urgent', 'date_from', 'date_to']), + (int) $request->get('per_page', 15) + ); + + return response()->json($result); + } + + /** + * 처리 완료함 + */ + public function completed(Request $request): JsonResponse + { + $result = $this->service->getCompletedByMe( + auth()->id(), + $request->only(['search', 'status', 'date_from', 'date_to']), + (int) $request->get('per_page', 15) + ); + + return response()->json($result); + } + + /** + * 참조함 + */ + public function references(Request $request): JsonResponse + { + $result = $this->service->getReferencesForMe( + auth()->id(), + $request->only(['search', 'date_from', 'date_to']), + (int) $request->get('per_page', 15) + ); + + return response()->json($result); + } + + // ========================================================================= + // CRUD + // ========================================================================= + + /** + * 상세 조회 + */ + public function show(int $id): JsonResponse + { + $approval = $this->service->getApproval($id); + + return response()->json(['success' => true, 'data' => $approval]); + } + + /** + * 생성 (임시저장) + */ + public function store(Request $request): JsonResponse + { + $request->validate([ + 'form_id' => 'required|exists:approval_forms,id', + 'title' => 'required|string|max:200', + 'body' => 'nullable|string', + 'is_urgent' => 'boolean', + 'steps' => 'nullable|array', + 'steps.*.user_id' => 'required_with:steps|exists:users,id', + 'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference', + ]); + + $approval = $this->service->createApproval($request->all()); + + return response()->json([ + 'success' => true, + 'message' => '결재 문서가 저장되었습니다.', + 'data' => $approval, + ], 201); + } + + /** + * 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + $request->validate([ + 'title' => 'sometimes|string|max:200', + 'body' => 'nullable|string', + 'is_urgent' => 'boolean', + 'steps' => 'nullable|array', + 'steps.*.user_id' => 'required_with:steps|exists:users,id', + 'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference', + ]); + + try { + $approval = $this->service->updateApproval($id, $request->all()); + + return response()->json([ + 'success' => true, + 'message' => '결재 문서가 수정되었습니다.', + 'data' => $approval, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 삭제 + */ + public function destroy(int $id): JsonResponse + { + try { + $this->service->deleteApproval($id); + + return response()->json([ + 'success' => true, + 'message' => '결재 문서가 삭제되었습니다.', + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + // ========================================================================= + // 워크플로우 + // ========================================================================= + + /** + * 상신 + */ + public function submit(int $id): JsonResponse + { + try { + $approval = $this->service->submit($id); + + return response()->json([ + 'success' => true, + 'message' => '결재가 상신되었습니다.', + 'data' => $approval, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 승인 + */ + public function approve(Request $request, int $id): JsonResponse + { + try { + $approval = $this->service->approve($id, $request->get('comment')); + + return response()->json([ + 'success' => true, + 'message' => '승인되었습니다.', + 'data' => $approval, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 반려 + */ + public function reject(Request $request, int $id): JsonResponse + { + $request->validate([ + 'comment' => 'required|string|max:1000', + ]); + + try { + $approval = $this->service->reject($id, $request->get('comment')); + + return response()->json([ + 'success' => true, + 'message' => '반려되었습니다.', + 'data' => $approval, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 회수 + */ + public function cancel(int $id): JsonResponse + { + try { + $approval = $this->service->cancel($id); + + return response()->json([ + 'success' => true, + 'message' => '결재가 회수되었습니다.', + 'data' => $approval, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + // ========================================================================= + // 유틸 + // ========================================================================= + + /** + * 결재선 템플릿 목록 + */ + public function lines(): JsonResponse + { + $lines = $this->service->getApprovalLines(); + + return response()->json(['success' => true, 'data' => $lines]); + } + + /** + * 양식 목록 + */ + public function forms(): JsonResponse + { + $forms = $this->service->getApprovalForms(); + + return response()->json(['success' => true, 'data' => $forms]); + } + + /** + * 미처리 건수 + */ + public function badgeCounts(): JsonResponse + { + $counts = $this->service->getBadgeCounts(auth()->id()); + + return response()->json(['success' => true, 'data' => $counts]); + } +} diff --git a/app/Http/Controllers/ApprovalController.php b/app/Http/Controllers/ApprovalController.php new file mode 100644 index 00000000..21ac1a42 --- /dev/null +++ b/app/Http/Controllers/ApprovalController.php @@ -0,0 +1,113 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.drafts')); + } + + return view('approvals.drafts'); + } + + /** + * 기안 작성 + */ + public function create(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.create')); + } + + $forms = $this->service->getApprovalForms(); + $lines = $this->service->getApprovalLines(); + + return view('approvals.create', compact('forms', 'lines')); + } + + /** + * 기안 수정 + */ + public function edit(Request $request, int $id): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.edit', $id)); + } + + $approval = $this->service->getApproval($id); + + if (! $approval->isEditable() || $approval->drafter_id !== auth()->id()) { + abort(403, '수정할 수 없습니다.'); + } + + $forms = $this->service->getApprovalForms(); + $lines = $this->service->getApprovalLines(); + + return view('approvals.edit', compact('approval', 'forms', 'lines')); + } + + /** + * 결재 상세 + */ + public function show(Request $request, int $id): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.show', $id)); + } + + $approval = $this->service->getApproval($id); + + return view('approvals.show', compact('approval')); + } + + /** + * 결재 대기함 + */ + public function pending(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.pending')); + } + + return view('approvals.pending'); + } + + /** + * 참조함 + */ + public function references(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.references')); + } + + return view('approvals.references'); + } + + /** + * 완료함 + */ + public function completed(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.completed')); + } + + return view('approvals.completed'); + } +} diff --git a/app/Models/Approvals/Approval.php b/app/Models/Approvals/Approval.php new file mode 100644 index 00000000..761b4368 --- /dev/null +++ b/app/Models/Approvals/Approval.php @@ -0,0 +1,242 @@ + 'array', + 'attachments' => 'array', + 'drafted_at' => 'datetime', + 'completed_at' => 'datetime', + 'current_step' => 'integer', + 'is_urgent' => 'boolean', + ]; + + protected $fillable = [ + 'tenant_id', + 'document_number', + 'form_id', + 'line_id', + 'title', + 'content', + 'body', + 'status', + 'is_urgent', + 'drafter_id', + 'department_id', + 'drafted_at', + 'completed_at', + 'current_step', + 'attachments', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + protected $attributes = [ + 'status' => 'draft', + 'current_step' => 0, + 'is_urgent' => false, + ]; + + // ========================================================================= + // 상태 상수 + // ========================================================================= + + public const STATUS_DRAFT = 'draft'; + + public const STATUS_PENDING = 'pending'; + + public const STATUS_APPROVED = 'approved'; + + public const STATUS_REJECTED = 'rejected'; + + public const STATUS_CANCELLED = 'cancelled'; + + public const STATUSES = [ + self::STATUS_DRAFT, + self::STATUS_PENDING, + self::STATUS_APPROVED, + self::STATUS_REJECTED, + self::STATUS_CANCELLED, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function form(): BelongsTo + { + return $this->belongsTo(ApprovalForm::class, 'form_id'); + } + + public function line(): BelongsTo + { + return $this->belongsTo(ApprovalLine::class, 'line_id'); + } + + public function drafter(): BelongsTo + { + return $this->belongsTo(User::class, 'drafter_id'); + } + + public function steps(): HasMany + { + return $this->hasMany(ApprovalStep::class, 'approval_id')->orderBy('step_order'); + } + + public function approverSteps(): HasMany + { + return $this->hasMany(ApprovalStep::class, 'approval_id') + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->orderBy('step_order'); + } + + public function referenceSteps(): HasMany + { + return $this->hasMany(ApprovalStep::class, 'approval_id') + ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE) + ->orderBy('step_order'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeWithStatus($query, string $status) + { + return $query->where('status', $status); + } + + public function scopeDraft($query) + { + return $query->where('status', self::STATUS_DRAFT); + } + + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + public function scopeApproved($query) + { + return $query->where('status', self::STATUS_APPROVED); + } + + public function scopeRejected($query) + { + return $query->where('status', self::STATUS_REJECTED); + } + + public function scopeCompleted($query) + { + return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]); + } + + public function scopeByDrafter($query, int $userId) + { + return $query->where('drafter_id', $userId); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public function isEditable(): bool + { + return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]); + } + + public function isSubmittable(): bool + { + return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]); + } + + public function isActionable(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isCancellable(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isDeletable(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_DRAFT => '임시저장', + self::STATUS_PENDING => '진행', + self::STATUS_APPROVED => '완료', + self::STATUS_REJECTED => '반려', + self::STATUS_CANCELLED => '회수', + default => $this->status, + }; + } + + public function getStatusColorAttribute(): string + { + return match ($this->status) { + self::STATUS_DRAFT => 'gray', + self::STATUS_PENDING => 'blue', + self::STATUS_APPROVED => 'green', + self::STATUS_REJECTED => 'red', + self::STATUS_CANCELLED => 'yellow', + default => 'gray', + }; + } + + public function getCurrentApproverStep(): ?ApprovalStep + { + return $this->steps() + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->where('status', ApprovalStep::STATUS_PENDING) + ->orderBy('step_order') + ->first(); + } + + public function isCurrentApprover(int $userId): bool + { + $currentStep = $this->getCurrentApproverStep(); + + return $currentStep && $currentStep->approver_id === $userId; + } + + public function isReferee(int $userId): bool + { + return $this->referenceSteps() + ->where('approver_id', $userId) + ->exists(); + } + + public function getProgressAttribute(): array + { + $totalSteps = $this->approverSteps()->count(); + $completedSteps = $this->approverSteps() + ->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED]) + ->count(); + + return [ + 'total' => $totalSteps, + 'completed' => $completedSteps, + 'percentage' => $totalSteps > 0 ? round(($completedSteps / $totalSteps) * 100) : 0, + ]; + } +} diff --git a/app/Models/Approvals/ApprovalForm.php b/app/Models/Approvals/ApprovalForm.php new file mode 100644 index 00000000..cbf07c7c --- /dev/null +++ b/app/Models/Approvals/ApprovalForm.php @@ -0,0 +1,92 @@ + 'array', + 'is_active' => 'boolean', + ]; + + protected $fillable = [ + 'tenant_id', + 'name', + 'code', + 'category', + 'template', + 'is_active', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + // ========================================================================= + // 카테고리 상수 + // ========================================================================= + + public const CATEGORY_REQUEST = 'request'; + + public const CATEGORY_EXPENSE = 'expense'; + + public const CATEGORY_EXPENSE_ESTIMATE = 'expense_estimate'; + + public const CATEGORIES = [ + self::CATEGORY_REQUEST, + self::CATEGORY_EXPENSE, + self::CATEGORY_EXPENSE_ESTIMATE, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function approvals(): HasMany + { + return $this->hasMany(Approval::class, 'form_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeCategory($query, string $category) + { + return $query->where('category', $category); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public function getCategoryLabelAttribute(): string + { + return match ($this->category) { + self::CATEGORY_REQUEST => '품의서', + self::CATEGORY_EXPENSE => '지출결의서', + self::CATEGORY_EXPENSE_ESTIMATE => '지출 예상 내역서', + default => $this->category ?? '', + }; + } +} diff --git a/app/Models/Approvals/ApprovalLine.php b/app/Models/Approvals/ApprovalLine.php new file mode 100644 index 00000000..45b3f395 --- /dev/null +++ b/app/Models/Approvals/ApprovalLine.php @@ -0,0 +1,83 @@ + 'array', + 'is_default' => 'boolean', + ]; + + protected $fillable = [ + 'tenant_id', + 'name', + 'steps', + 'is_default', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + // ========================================================================= + // 단계 유형 상수 + // ========================================================================= + + public const STEP_TYPE_APPROVAL = 'approval'; + + public const STEP_TYPE_AGREEMENT = 'agreement'; + + public const STEP_TYPE_REFERENCE = 'reference'; + + public const STEP_TYPES = [ + self::STEP_TYPE_APPROVAL, + self::STEP_TYPE_AGREEMENT, + self::STEP_TYPE_REFERENCE, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeDefault($query) + { + return $query->where('is_default', true); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public function getStepCountAttribute(): int + { + return count($this->steps ?? []); + } + + public function getApproverIdsAttribute(): array + { + return collect($this->steps ?? []) + ->pluck('user_id') + ->filter() + ->values() + ->toArray(); + } +} diff --git a/app/Models/Approvals/ApprovalStep.php b/app/Models/Approvals/ApprovalStep.php new file mode 100644 index 00000000..488d5e58 --- /dev/null +++ b/app/Models/Approvals/ApprovalStep.php @@ -0,0 +1,137 @@ + 'integer', + 'acted_at' => 'datetime', + 'is_read' => 'boolean', + 'read_at' => 'datetime', + ]; + + protected $fillable = [ + 'approval_id', + 'step_order', + 'step_type', + 'approver_id', + 'approver_name', + 'approver_department', + 'approver_position', + 'status', + 'comment', + 'acted_at', + 'is_read', + 'read_at', + ]; + + protected $attributes = [ + 'status' => 'pending', + 'is_read' => false, + ]; + + // ========================================================================= + // 상태 상수 + // ========================================================================= + + public const STATUS_PENDING = 'pending'; + + public const STATUS_APPROVED = 'approved'; + + public const STATUS_REJECTED = 'rejected'; + + public const STATUS_SKIPPED = 'skipped'; + + public const STATUSES = [ + self::STATUS_PENDING, + self::STATUS_APPROVED, + self::STATUS_REJECTED, + self::STATUS_SKIPPED, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function approval(): BelongsTo + { + return $this->belongsTo(Approval::class, 'approval_id'); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approver_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + public function scopeApproved($query) + { + return $query->where('status', self::STATUS_APPROVED); + } + + public function scopeByApprover($query, int $userId) + { + return $query->where('approver_id', $userId); + } + + public function scopeApprovalOnly($query) + { + return $query->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]); + } + + public function scopeReferenceOnly($query) + { + return $query->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public function isActionable(): bool + { + return $this->status === self::STATUS_PENDING + && in_array($this->step_type, [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]); + } + + public function isReference(): bool + { + return $this->step_type === ApprovalLine::STEP_TYPE_REFERENCE; + } + + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => '대기', + self::STATUS_APPROVED => '승인', + self::STATUS_REJECTED => '반려', + self::STATUS_SKIPPED => '건너뜀', + default => $this->status, + }; + } + + public function getStepTypeLabelAttribute(): string + { + return match ($this->step_type) { + ApprovalLine::STEP_TYPE_APPROVAL => '결재', + ApprovalLine::STEP_TYPE_AGREEMENT => '합의', + ApprovalLine::STEP_TYPE_REFERENCE => '참조', + default => $this->step_type, + }; + } +} diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php new file mode 100644 index 00000000..4f603389 --- /dev/null +++ b/app/Services/ApprovalService.php @@ -0,0 +1,500 @@ +id(); + + $query = Approval::with(['form', 'steps.approver']) + ->byDrafter($userId); + + $this->applyFilters($query, $filters); + + return $query->orderByDesc('created_at')->paginate($perPage); + } + + /** + * 결재 대기함 (내가 현재 결재자인 문서) + */ + public function getPendingForMe(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator + { + $query = Approval::with(['form', 'drafter', 'steps.approver']) + ->pending() + ->whereHas('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->where('status', ApprovalStep::STATUS_PENDING) + ->approvalOnly() + ->whereColumn('step_order', function ($sub) { + $sub->selectRaw('MIN(step_order)') + ->from('approval_steps as inner_steps') + ->whereColumn('inner_steps.approval_id', 'approval_steps.approval_id') + ->where('inner_steps.status', ApprovalStep::STATUS_PENDING) + ->whereIn('inner_steps.step_type', [ + ApprovalLine::STEP_TYPE_APPROVAL, + ApprovalLine::STEP_TYPE_AGREEMENT, + ]); + }); + }); + + $this->applyFilters($query, $filters); + + return $query->orderByDesc('drafted_at')->paginate($perPage); + } + + /** + * 처리 완료함 (내가 결재한 문서) + */ + public function getCompletedByMe(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator + { + $query = Approval::with(['form', 'drafter', 'steps.approver']) + ->whereHas('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED]); + }); + + $this->applyFilters($query, $filters); + + return $query->orderByDesc('updated_at')->paginate($perPage); + } + + /** + * 참조함 (내가 참조자인 문서) + */ + public function getReferencesForMe(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator + { + $query = Approval::with(['form', 'drafter', 'steps.approver']) + ->whereHas('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE); + }); + + $this->applyFilters($query, $filters); + + return $query->orderByDesc('created_at')->paginate($perPage); + } + + // ========================================================================= + // CRUD + // ========================================================================= + + /** + * 상세 조회 + */ + public function getApproval(int $id): Approval + { + return Approval::with(['form', 'drafter', 'line', 'steps.approver']) + ->findOrFail($id); + } + + /** + * 생성 (임시저장) + */ + public function createApproval(array $data): Approval + { + return DB::transaction(function () use ($data) { + $tenantId = session('selected_tenant_id'); + $userId = auth()->id(); + + $approval = Approval::create([ + 'tenant_id' => $tenantId, + 'document_number' => $this->generateDocumentNumber($tenantId), + 'form_id' => $data['form_id'], + 'line_id' => $data['line_id'] ?? null, + 'title' => $data['title'], + 'content' => $data['content'] ?? [], + 'body' => $data['body'] ?? null, + 'status' => Approval::STATUS_DRAFT, + 'is_urgent' => $data['is_urgent'] ?? false, + 'drafter_id' => $userId, + 'department_id' => $data['department_id'] ?? null, + 'current_step' => 0, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + if (! empty($data['steps'])) { + $this->saveApprovalSteps($approval, $data['steps']); + } + + return $approval->load(['form', 'drafter', 'steps.approver']); + }); + } + + /** + * 수정 (draft/rejected만) + */ + public function updateApproval(int $id, array $data): Approval + { + return DB::transaction(function () use ($id, $data) { + $approval = Approval::findOrFail($id); + + if (! $approval->isEditable()) { + throw new \InvalidArgumentException('수정할 수 없는 상태입니다.'); + } + + $approval->update([ + 'form_id' => $data['form_id'] ?? $approval->form_id, + 'line_id' => $data['line_id'] ?? $approval->line_id, + 'title' => $data['title'] ?? $approval->title, + 'content' => $data['content'] ?? $approval->content, + 'body' => $data['body'] ?? $approval->body, + 'is_urgent' => $data['is_urgent'] ?? $approval->is_urgent, + 'department_id' => $data['department_id'] ?? $approval->department_id, + 'updated_by' => auth()->id(), + ]); + + if (isset($data['steps'])) { + $approval->steps()->delete(); + $this->saveApprovalSteps($approval, $data['steps']); + } + + return $approval->load(['form', 'drafter', 'steps.approver']); + }); + } + + /** + * 삭제 (draft만) + */ + public function deleteApproval(int $id): bool + { + $approval = Approval::findOrFail($id); + + if (! $approval->isDeletable()) { + throw new \InvalidArgumentException('삭제할 수 없는 상태입니다.'); + } + + $approval->steps()->delete(); + $approval->update(['deleted_by' => auth()->id()]); + + return $approval->delete(); + } + + // ========================================================================= + // 워크플로우 + // ========================================================================= + + /** + * 상신 (draft/rejected → pending) + */ + public function submit(int $id): Approval + { + return DB::transaction(function () use ($id) { + $approval = Approval::with('steps')->findOrFail($id); + + if (! $approval->isSubmittable()) { + throw new \InvalidArgumentException('상신할 수 없는 상태입니다.'); + } + + $approverSteps = $approval->steps + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]); + + if ($approverSteps->isEmpty()) { + throw new \InvalidArgumentException('결재선을 설정해주세요.'); + } + + // 반려 후 재상신이면 모든 step 초기화 + if ($approval->status === Approval::STATUS_REJECTED) { + $approval->steps()->update([ + 'status' => ApprovalStep::STATUS_PENDING, + 'comment' => null, + 'acted_at' => null, + ]); + } + + $approval->update([ + 'status' => Approval::STATUS_PENDING, + 'drafted_at' => now(), + 'current_step' => 1, + 'updated_by' => auth()->id(), + ]); + + return $approval->fresh(['form', 'drafter', 'steps.approver']); + }); + } + + /** + * 승인 + */ + public function approve(int $id, ?string $comment = null): Approval + { + return DB::transaction(function () use ($id, $comment) { + $approval = Approval::with('steps')->findOrFail($id); + + if (! $approval->isActionable()) { + throw new \InvalidArgumentException('승인할 수 없는 상태입니다.'); + } + + $currentStep = $approval->getCurrentApproverStep(); + if (! $currentStep || $currentStep->approver_id !== auth()->id()) { + throw new \InvalidArgumentException('현재 결재자가 아닙니다.'); + } + + $currentStep->update([ + 'status' => ApprovalStep::STATUS_APPROVED, + 'comment' => $comment, + 'acted_at' => now(), + ]); + + // 다음 결재자 확인 + $nextStep = $approval->steps() + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->where('status', ApprovalStep::STATUS_PENDING) + ->orderBy('step_order') + ->first(); + + if ($nextStep) { + $approval->update([ + 'current_step' => $nextStep->step_order, + 'updated_by' => auth()->id(), + ]); + } else { + // 마지막 결재자 → 문서 승인 완료 + $approval->update([ + 'status' => Approval::STATUS_APPROVED, + 'completed_at' => now(), + 'updated_by' => auth()->id(), + ]); + } + + return $approval->fresh(['form', 'drafter', 'steps.approver']); + }); + } + + /** + * 반려 + */ + public function reject(int $id, string $comment): Approval + { + return DB::transaction(function () use ($id, $comment) { + $approval = Approval::with('steps')->findOrFail($id); + + if (! $approval->isActionable()) { + throw new \InvalidArgumentException('반려할 수 없는 상태입니다.'); + } + + $currentStep = $approval->getCurrentApproverStep(); + if (! $currentStep || $currentStep->approver_id !== auth()->id()) { + throw new \InvalidArgumentException('현재 결재자가 아닙니다.'); + } + + if (empty($comment)) { + throw new \InvalidArgumentException('반려 사유를 입력해주세요.'); + } + + $currentStep->update([ + 'status' => ApprovalStep::STATUS_REJECTED, + 'comment' => $comment, + 'acted_at' => now(), + ]); + + $approval->update([ + 'status' => Approval::STATUS_REJECTED, + 'completed_at' => now(), + 'updated_by' => auth()->id(), + ]); + + return $approval->fresh(['form', 'drafter', 'steps.approver']); + }); + } + + /** + * 회수 (기안자만, pending → cancelled) + */ + public function cancel(int $id): Approval + { + return DB::transaction(function () use ($id) { + $approval = Approval::with('steps')->findOrFail($id); + + if (! $approval->isCancellable()) { + throw new \InvalidArgumentException('회수할 수 없는 상태입니다.'); + } + + if ($approval->drafter_id !== auth()->id()) { + throw new \InvalidArgumentException('기안자만 회수할 수 있습니다.'); + } + + // 모든 pending steps → skipped + $approval->steps() + ->where('status', ApprovalStep::STATUS_PENDING) + ->update(['status' => ApprovalStep::STATUS_SKIPPED]); + + $approval->update([ + 'status' => Approval::STATUS_CANCELLED, + 'completed_at' => now(), + 'updated_by' => auth()->id(), + ]); + + return $approval->fresh(['form', 'drafter', 'steps.approver']); + }); + } + + // ========================================================================= + // 결재선 + // ========================================================================= + + /** + * 결재선 템플릿 목록 + */ + public function getApprovalLines(): Collection + { + return ApprovalLine::orderBy('name')->get(); + } + + /** + * 양식 목록 + */ + public function getApprovalForms(): Collection + { + return ApprovalForm::active()->orderBy('name')->get(); + } + + /** + * 결재 단계 저장 + 스냅샷 + */ + public function saveApprovalSteps(Approval $approval, array $steps): void + { + foreach ($steps as $index => $step) { + $user = User::find($step['user_id']); + $tenantId = session('selected_tenant_id'); + + $departmentName = null; + $positionName = null; + + if ($user) { + $employee = $user->tenantProfiles() + ->where('tenant_id', $tenantId) + ->first(); + + if ($employee) { + $departmentName = $employee->department?->name; + $positionName = $employee->position_label; + } + } + + ApprovalStep::create([ + 'approval_id' => $approval->id, + 'step_order' => $index + 1, + 'step_type' => $step['step_type'] ?? ApprovalLine::STEP_TYPE_APPROVAL, + 'approver_id' => $step['user_id'], + 'approver_name' => $user?->name ?? '', + 'approver_department' => $departmentName, + 'approver_position' => $positionName, + 'status' => ApprovalStep::STATUS_PENDING, + ]); + } + } + + /** + * 미처리 건수 (뱃지용) + */ + public function getBadgeCounts(int $userId): array + { + $pendingCount = Approval::pending() + ->whereHas('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->where('status', ApprovalStep::STATUS_PENDING) + ->approvalOnly() + ->whereColumn('step_order', function ($sub) { + $sub->selectRaw('MIN(step_order)') + ->from('approval_steps as inner_steps') + ->whereColumn('inner_steps.approval_id', 'approval_steps.approval_id') + ->where('inner_steps.status', ApprovalStep::STATUS_PENDING) + ->whereIn('inner_steps.step_type', [ + ApprovalLine::STEP_TYPE_APPROVAL, + ApprovalLine::STEP_TYPE_AGREEMENT, + ]); + }); + }) + ->count(); + + $draftCount = Approval::draft()->byDrafter($userId)->count(); + + return [ + 'pending' => $pendingCount, + 'draft' => $draftCount, + ]; + } + + // ========================================================================= + // Private 헬퍼 + // ========================================================================= + + /** + * 문서번호 채번 (APR-YYMMDD-001) + */ + private function generateDocumentNumber(int $tenantId): string + { + $prefix = 'APR'; + $dateKey = now()->format('ymd'); + $documentType = 'approval'; + $periodKey = $dateKey; + + DB::statement( + 'INSERT INTO numbering_sequences + (tenant_id, document_type, scope_key, period_key, last_sequence, created_at, updated_at) + VALUES (?, ?, ?, ?, 1, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + last_sequence = last_sequence + 1, + updated_at = NOW()', + [$tenantId, $documentType, '', $periodKey] + ); + + $sequence = (int) DB::table('numbering_sequences') + ->where('tenant_id', $tenantId) + ->where('document_type', $documentType) + ->where('scope_key', '') + ->where('period_key', $periodKey) + ->value('last_sequence'); + + return $prefix.'-'.$dateKey.'-'.str_pad((string) $sequence, 3, '0', STR_PAD_LEFT); + } + + /** + * 공통 필터 적용 + */ + private function applyFilters($query, array $filters): void + { + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('document_number', 'like', "%{$search}%"); + }); + } + + if (! empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (! empty($filters['is_urgent'])) { + $query->where('is_urgent', true); + } + + if (! empty($filters['date_from'])) { + $query->where('created_at', '>=', $filters['date_from']); + } + + if (! empty($filters['date_to'])) { + $query->where('created_at', '<=', $filters['date_to'].' 23:59:59'); + } + } +} diff --git a/resources/views/approvals/completed.blade.php b/resources/views/approvals/completed.blade.php new file mode 100644 index 00000000..374bab2d --- /dev/null +++ b/resources/views/approvals/completed.blade.php @@ -0,0 +1,122 @@ +@extends('layouts.app') + +@section('title', '완료함') + +@section('content') +
+

완료함

+
+ + +
+
+ +
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/approvals/create.blade.php b/resources/views/approvals/create.blade.php new file mode 100644 index 00000000..ff235395 --- /dev/null +++ b/resources/views/approvals/create.blade.php @@ -0,0 +1,147 @@ +@extends('layouts.app') + +@section('title', '기안 작성') + +@section('content') +
+

기안 작성

+ + ← 기안함으로 돌아가기 + +
+ +
+ {{-- 좌측: 양식 --}} +
+
+

문서 내용

+ + {{-- 양식 선택 --}} +
+ + +
+ + {{-- 제목 --}} +
+ + +
+ + {{-- 긴급 여부 --}} +
+ +
+ + {{-- 본문 --}} +
+ + +
+
+
+ + {{-- 우측: 결재선 --}} +
+ @include('approvals.partials._approval-line-editor', [ + 'lines' => $lines, + 'initialSteps' => [], + 'selectedLineId' => '', + ]) + + {{-- 액션 버튼 --}} +
+ + +
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/approvals/drafts.blade.php b/resources/views/approvals/drafts.blade.php new file mode 100644 index 00000000..23221ba0 --- /dev/null +++ b/resources/views/approvals/drafts.blade.php @@ -0,0 +1,169 @@ +@extends('layouts.app') + +@section('title', '기안함') + +@section('content') + +
+

기안함

+ + + 새 기안 + +
+ + + +
+
+ +
+
+ +
+ +
+
+ + +
+
+
+
+
+ + +
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/approvals/edit.blade.php b/resources/views/approvals/edit.blade.php new file mode 100644 index 00000000..60fa7ae5 --- /dev/null +++ b/resources/views/approvals/edit.blade.php @@ -0,0 +1,207 @@ +@extends('layouts.app') + +@section('title', '기안 수정') + +@section('content') +
+
+

기안 수정

+

{{ $approval->document_number }}

+
+ +
+ + @if($approval->status === 'rejected') +
+
+ 반려됨 +
+ @php + $rejectedStep = $approval->steps->firstWhere('status', 'rejected'); + @endphp + @if($rejectedStep) +

+ {{ $rejectedStep->approver_name ?? '' }} ({{ $rejectedStep->acted_at?->format('Y-m-d H:i') }}): + {{ $rejectedStep->comment }} +

+ @endif +
+ @endif + +
+ {{-- 좌측: 양식 --}} +
+
+

문서 내용

+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+
+
+ + {{-- 우측: 결재선 --}} +
+ @php + $initialSteps = $approval->steps->map(fn($s) => [ + 'user_id' => $s->approver_id, + 'user_name' => $s->approver_name ?? ($s->approver?->name ?? ''), + 'department' => $s->approver_department ?? '', + 'position' => $s->approver_position ?? '', + 'step_type' => $s->step_type, + ])->toArray(); + @endphp + @include('approvals.partials._approval-line-editor', [ + 'lines' => $lines, + 'initialSteps' => $initialSteps, + 'selectedLineId' => $approval->line_id ?? '', + ]) + +
+ + + @if($approval->isDeletable()) + + @endif +
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/approvals/partials/_approval-line-editor.blade.php b/resources/views/approvals/partials/_approval-line-editor.blade.php new file mode 100644 index 00000000..17ea9c9f --- /dev/null +++ b/resources/views/approvals/partials/_approval-line-editor.blade.php @@ -0,0 +1,172 @@ +{{-- 결재선 편집 컴포넌트 (Alpine.js) --}} +
+

결재선

+ + {{-- 결재선 템플릿 선택 --}} +
+ + +
+ + {{-- 결재자 추가 --}} +
+ +
+ + + {{-- 검색 결과 드롭다운 --}} +
+ +
+
+
+ + {{-- 결재 단계 목록 --}} +
+ + +
+ 결재선이 비어 있습니다. 결재자를 추가해주세요. +
+
+ + {{-- hidden inputs --}} + +
+ + diff --git a/resources/views/approvals/partials/_status-badge.blade.php b/resources/views/approvals/partials/_status-badge.blade.php new file mode 100644 index 00000000..bff97d79 --- /dev/null +++ b/resources/views/approvals/partials/_status-badge.blade.php @@ -0,0 +1,16 @@ +@props(['status' => 'draft']) + +@php +$config = match($status) { + 'draft' => ['label' => '임시저장', 'class' => 'bg-gray-100 text-gray-700'], + 'pending' => ['label' => '진행', 'class' => 'bg-blue-100 text-blue-700'], + 'approved' => ['label' => '완료', 'class' => 'bg-green-100 text-green-700'], + 'rejected' => ['label' => '반려', 'class' => 'bg-red-100 text-red-700'], + 'cancelled' => ['label' => '회수', 'class' => 'bg-yellow-100 text-yellow-700'], + default => ['label' => $status, 'class' => 'bg-gray-100 text-gray-700'], +}; +@endphp + + + {{ $config['label'] }} + diff --git a/resources/views/approvals/partials/_step-progress.blade.php b/resources/views/approvals/partials/_step-progress.blade.php new file mode 100644 index 00000000..e3807346 --- /dev/null +++ b/resources/views/approvals/partials/_step-progress.blade.php @@ -0,0 +1,55 @@ +{{-- 결재 진행 단계 시각화 --}} +@props(['steps' => [], 'currentStep' => 0]) + +
+ @foreach($steps as $index => $step) + @php + $isApprover = in_array($step['step_type'] ?? 'approval', ['approval', 'agreement']); + $statusConfig = match($step['status'] ?? 'pending') { + 'approved' => ['icon' => '✓', 'bg' => 'bg-green-500', 'border' => 'border-green-500', 'text' => 'text-green-700'], + 'rejected' => ['icon' => '✗', 'bg' => 'bg-red-500', 'border' => 'border-red-500', 'text' => 'text-red-700'], + 'skipped' => ['icon' => '—', 'bg' => 'bg-gray-400', 'border' => 'border-gray-400', 'text' => 'text-gray-500'], + default => ['icon' => ($step['step_order'] ?? $index + 1), 'bg' => 'bg-white', 'border' => 'border-gray-300', 'text' => 'text-gray-500'], + }; + $isCurrent = $isApprover && ($step['status'] ?? 'pending') === 'pending' && ($step['step_order'] ?? 0) == $currentStep; + if ($isCurrent) { + $statusConfig['bg'] = 'bg-blue-500'; + $statusConfig['border'] = 'border-blue-500'; + $statusConfig['text'] = 'text-blue-700'; + $statusConfig['icon'] = ($step['step_order'] ?? $index + 1); + } + $typeLabel = match($step['step_type'] ?? 'approval') { + 'approval' => '결재', + 'agreement' => '합의', + 'reference' => '참조', + default => '', + }; + @endphp + + @if($index > 0) +
+ @endif + +
+ {{-- 원형 아이콘 --}} +
+ {!! $statusConfig['icon'] !!} +
+ {{-- 결재자명 --}} + + {{ $step['approver_name'] ?? ($step['approver']['name'] ?? '미지정') }} + + {{-- 유형 + 직급 --}} + + {{ $typeLabel }}{{ !empty($step['approver_position']) ? ' · ' . $step['approver_position'] : '' }} + + {{-- 처리일시 --}} + @if(!empty($step['acted_at'])) + + {{ \Carbon\Carbon::parse($step['acted_at'])->format('m/d H:i') }} + + @endif +
+ @endforeach +
diff --git a/resources/views/approvals/pending.blade.php b/resources/views/approvals/pending.blade.php new file mode 100644 index 00000000..8d10f5f5 --- /dev/null +++ b/resources/views/approvals/pending.blade.php @@ -0,0 +1,109 @@ +@extends('layouts.app') + +@section('title', '결재함') + +@section('content') +
+

결재함

+
+ + +
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/approvals/references.blade.php b/resources/views/approvals/references.blade.php new file mode 100644 index 00000000..e773cc14 --- /dev/null +++ b/resources/views/approvals/references.blade.php @@ -0,0 +1,116 @@ +@extends('layouts.app') + +@section('title', '참조함') + +@section('content') +
+

참조함

+
+ + +
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/approvals/show.blade.php b/resources/views/approvals/show.blade.php new file mode 100644 index 00000000..95fcc10f --- /dev/null +++ b/resources/views/approvals/show.blade.php @@ -0,0 +1,199 @@ +@extends('layouts.app') + +@section('title', '결재 상세') + +@section('content') +
+
+

결재 상세

+

{{ $approval->document_number }}

+
+
+ @if($approval->isEditable() && $approval->drafter_id === auth()->id()) + + 수정 + + @endif + + 목록 + +
+
+ + {{-- 문서 정보 --}} +
+
+
+ 상태 +
+ @include('approvals.partials._status-badge', ['status' => $approval->status]) + @if($approval->is_urgent) + 긴급 + @endif +
+
+
+ 양식 +
{{ $approval->form?->name ?? '-' }}
+
+
+ 기안자 +
{{ $approval->drafter?->name ?? '-' }}
+
+
+ 기안일 +
{{ $approval->drafted_at?->format('Y-m-d H:i') ?? '-' }}
+
+ @if($approval->completed_at) +
+ 완료일 +
{{ $approval->completed_at->format('Y-m-d H:i') }}
+
+ @endif +
+ +
+

{{ $approval->title }}

+
{{ $approval->body ?? '(내용 없음)' }}
+
+
+ + {{-- 결재 진행 단계 --}} +
+

결재 진행

+ @include('approvals.partials._step-progress', [ + 'steps' => $approval->steps->toArray(), + 'currentStep' => $approval->current_step, + ]) + + {{-- 결재 의견 목록 --}} + @php + $stepsWithComments = $approval->steps->filter(fn($s) => $s->comment); + @endphp + @if($stepsWithComments->isNotEmpty()) +
+

결재 의견

+
+ @foreach($stepsWithComments as $step) +
+
+ @if($step->status === 'approved') + + @else + + @endif +
+
+
+ {{ $step->approver_name ?? ($step->approver?->name ?? '') }} + {{ $step->acted_at?->format('Y-m-d H:i') }} +
+

{{ $step->comment }}

+
+
+ @endforeach +
+
+ @endif +
+ + {{-- 액션 버튼 --}} + @if($approval->isActionable() && $approval->isCurrentApprover(auth()->id())) +
+

결재 처리

+ +
+ + +
+ +
+ + +
+
+ @endif + + {{-- 회수 버튼 (기안자 + pending) --}} + @if($approval->isCancellable() && $approval->drafter_id === auth()->id()) +
+ + 진행 중인 결재를 취소합니다. +
+ @endif +@endsection + +@push('scripts') + +@endpush diff --git a/routes/api.php b/routes/api.php index 86d03ad3..f32141bf 100644 --- a/routes/api.php +++ b/routes/api.php @@ -893,6 +893,34 @@ Route::get('/search', [\App\Http\Controllers\Api\Admin\TenantUserApiController::class, 'search'])->name('search'); }); +/* +|-------------------------------------------------------------------------- +| 결재관리 API +|-------------------------------------------------------------------------- +*/ +Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/approvals')->name('api.admin.approvals.')->group(function () { + // 고정 경로 (먼저 선언) + Route::get('/drafts', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'drafts'])->name('drafts'); + Route::get('/pending', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'pending'])->name('pending'); + Route::get('/completed', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'completed'])->name('completed'); + Route::get('/references', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'references'])->name('references'); + Route::get('/lines', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'lines'])->name('lines'); + Route::get('/forms', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'forms'])->name('forms'); + Route::get('/badge-counts', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'badgeCounts'])->name('badge-counts'); + + // CRUD + Route::post('/', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'store'])->name('store'); + Route::get('/{id}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'show'])->name('show'); + Route::put('/{id}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'update'])->name('update'); + Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'destroy'])->name('destroy'); + + // 워크플로우 + Route::post('/{id}/submit', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'submit'])->name('submit'); + Route::post('/{id}/approve', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'approve'])->name('approve'); + Route::post('/{id}/reject', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'reject'])->name('reject'); + Route::post('/{id}/cancel', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'cancel'])->name('cancel'); +}); + /* |-------------------------------------------------------------------------- | 문서 관리 API diff --git a/routes/web.php b/routes/web.php index 941e6055..d4756eeb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -150,6 +150,17 @@ Route::get('/{id}/edit', [NumberingRuleController::class, 'edit'])->name('edit'); }); + // 결재관리 (Blade 화면) + Route::prefix('approval-mgmt')->name('approvals.')->group(function () { + Route::get('/drafts', [\App\Http\Controllers\ApprovalController::class, 'drafts'])->name('drafts'); + Route::get('/create', [\App\Http\Controllers\ApprovalController::class, 'create'])->name('create'); + Route::get('/pending', [\App\Http\Controllers\ApprovalController::class, 'pending'])->name('pending'); + Route::get('/references', [\App\Http\Controllers\ApprovalController::class, 'references'])->name('references'); + Route::get('/completed', [\App\Http\Controllers\ApprovalController::class, 'completed'])->name('completed'); + Route::get('/{id}', [\App\Http\Controllers\ApprovalController::class, 'show'])->name('show'); + Route::get('/{id}/edit', [\App\Http\Controllers\ApprovalController::class, 'edit'])->name('edit'); + }); + // 사용자 관리 (Blade 화면만) Route::prefix('users')->name('users.')->group(function () { Route::get('/', [UserController::class, 'index'])->name('index');