diff --git a/app/Http/Controllers/Api/Admin/ApprovalApiController.php b/app/Http/Controllers/Api/Admin/ApprovalApiController.php index 91c31e6c..c9b1deaf 100644 --- a/app/Http/Controllers/Api/Admin/ApprovalApiController.php +++ b/app/Http/Controllers/Api/Admin/ApprovalApiController.php @@ -65,7 +65,7 @@ public function references(Request $request): JsonResponse { $result = $this->service->getReferencesForMe( auth()->id(), - $request->only(['search', 'date_from', 'date_to']), + $request->only(['search', 'date_from', 'date_to', 'is_read']), (int) $request->get('per_page', 15) ); @@ -234,10 +234,10 @@ public function reject(Request $request, int $id): JsonResponse /** * 회수 */ - public function cancel(int $id): JsonResponse + public function cancel(Request $request, int $id): JsonResponse { try { - $approval = $this->service->cancel($id); + $approval = $this->service->cancel($id, $request->get('recall_reason')); return response()->json([ 'success' => true, @@ -252,6 +252,107 @@ public function cancel(int $id): JsonResponse } } + /** + * 보류 + */ + public function hold(Request $request, int $id): JsonResponse + { + $request->validate([ + 'comment' => 'required|string|max:1000', + ]); + + try { + $approval = $this->service->hold($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 releaseHold(int $id): JsonResponse + { + try { + $approval = $this->service->releaseHold($id); + + return response()->json([ + 'success' => true, + 'message' => '보류가 해제되었습니다.', + 'data' => $approval, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 전결 + */ + public function preDecide(Request $request, int $id): JsonResponse + { + try { + $approval = $this->service->preDecide($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 copyForRedraft(int $id): JsonResponse + { + try { + $approval = $this->service->copyForRedraft($id); + + return response()->json([ + 'success' => true, + 'message' => '문서가 복사되었습니다.', + 'data' => $approval, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 참조 열람 추적 + */ + public function markAsRead(int $id): JsonResponse + { + $this->service->markAsRead($id); + + return response()->json([ + 'success' => true, + 'message' => '열람 처리되었습니다.', + ]); + } + // ========================================================================= // 유틸 // ========================================================================= diff --git a/app/Models/Approvals/Approval.php b/app/Models/Approvals/Approval.php index 761b4368..361868a8 100644 --- a/app/Models/Approvals/Approval.php +++ b/app/Models/Approvals/Approval.php @@ -40,6 +40,8 @@ class Approval extends Model 'completed_at', 'current_step', 'attachments', + 'recall_reason', + 'parent_doc_id', 'created_by', 'updated_by', 'deleted_by', @@ -65,12 +67,15 @@ class Approval extends Model public const STATUS_CANCELLED = 'cancelled'; + public const STATUS_ON_HOLD = 'on_hold'; + public const STATUSES = [ self::STATUS_DRAFT, self::STATUS_PENDING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED, + self::STATUS_ON_HOLD, ]; // ========================================================================= @@ -111,6 +116,16 @@ public function referenceSteps(): HasMany ->orderBy('step_order'); } + public function parentDocument(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_doc_id'); + } + + public function childDocuments(): HasMany + { + return $this->hasMany(self::class, 'parent_doc_id'); + } + // ========================================================================= // 스코프 // ========================================================================= @@ -140,6 +155,11 @@ public function scopeRejected($query) return $query->where('status', self::STATUS_REJECTED); } + public function scopeOnHold($query) + { + return $query->where('status', self::STATUS_ON_HOLD); + } + public function scopeCompleted($query) { return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]); @@ -169,11 +189,26 @@ public function isActionable(): bool return $this->status === self::STATUS_PENDING; } - public function isCancellable(): bool + public function isHoldable(): bool { return $this->status === self::STATUS_PENDING; } + public function isHoldReleasable(): bool + { + return $this->status === self::STATUS_ON_HOLD; + } + + public function isCancellable(): bool + { + return in_array($this->status, [self::STATUS_PENDING, self::STATUS_ON_HOLD]); + } + + public function isCopyable(): bool + { + return in_array($this->status, [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]); + } + public function isDeletable(): bool { return $this->status === self::STATUS_DRAFT; @@ -187,6 +222,7 @@ public function getStatusLabelAttribute(): string self::STATUS_APPROVED => '완료', self::STATUS_REJECTED => '반려', self::STATUS_CANCELLED => '회수', + self::STATUS_ON_HOLD => '보류', default => $this->status, }; } @@ -199,6 +235,7 @@ public function getStatusColorAttribute(): string self::STATUS_APPROVED => 'green', self::STATUS_REJECTED => 'red', self::STATUS_CANCELLED => 'yellow', + self::STATUS_ON_HOLD => 'amber', default => 'gray', }; } diff --git a/app/Models/Approvals/ApprovalDelegation.php b/app/Models/Approvals/ApprovalDelegation.php new file mode 100644 index 00000000..dda6118a --- /dev/null +++ b/app/Models/Approvals/ApprovalDelegation.php @@ -0,0 +1,74 @@ + 'array', + 'start_date' => 'date', + 'end_date' => 'date', + 'notify_delegator' => 'boolean', + 'is_active' => 'boolean', + ]; + + protected $fillable = [ + 'tenant_id', + 'delegator_id', + 'delegate_id', + 'start_date', + 'end_date', + 'form_ids', + 'notify_delegator', + 'is_active', + 'reason', + 'created_by', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function delegator(): BelongsTo + { + return $this->belongsTo(User::class, 'delegator_id'); + } + + public function delegate(): BelongsTo + { + return $this->belongsTo(User::class, 'delegate_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeForDelegator($query, int $userId) + { + return $query->where('delegator_id', $userId); + } + + public function scopeCurrentlyActive($query) + { + $today = now()->toDateString(); + + return $query->active() + ->where('start_date', '<=', $today) + ->where('end_date', '>=', $today); + } +} diff --git a/app/Models/Approvals/ApprovalStep.php b/app/Models/Approvals/ApprovalStep.php index 488d5e58..f12c3752 100644 --- a/app/Models/Approvals/ApprovalStep.php +++ b/app/Models/Approvals/ApprovalStep.php @@ -12,6 +12,7 @@ class ApprovalStep extends Model protected $casts = [ 'step_order' => 'integer', + 'parallel_group' => 'integer', 'acted_at' => 'datetime', 'is_read' => 'boolean', 'read_at' => 'datetime', @@ -21,11 +22,14 @@ class ApprovalStep extends Model 'approval_id', 'step_order', 'step_type', + 'parallel_group', 'approver_id', + 'acted_by', 'approver_name', 'approver_department', 'approver_position', 'status', + 'approval_type', 'comment', 'acted_at', 'is_read', @@ -49,11 +53,14 @@ class ApprovalStep extends Model public const STATUS_SKIPPED = 'skipped'; + public const STATUS_ON_HOLD = 'on_hold'; + public const STATUSES = [ self::STATUS_PENDING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_SKIPPED, + self::STATUS_ON_HOLD, ]; // ========================================================================= @@ -70,6 +77,11 @@ public function approver(): BelongsTo return $this->belongsTo(User::class, 'approver_id'); } + public function actedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'acted_by'); + } + // ========================================================================= // 스코프 // ========================================================================= @@ -121,6 +133,7 @@ public function getStatusLabelAttribute(): string self::STATUS_APPROVED => '승인', self::STATUS_REJECTED => '반려', self::STATUS_SKIPPED => '건너뜀', + self::STATUS_ON_HOLD => '보류', default => $this->status, }; } diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php index 4f603389..d40cec5f 100644 --- a/app/Services/ApprovalService.php +++ b/app/Services/ApprovalService.php @@ -82,9 +82,12 @@ public function getCompletedByMe(int $userId, array $filters = [], int $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) { + ->whereHas('steps', function ($q) use ($userId, $filters) { $q->where('approver_id', $userId) ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE); + if (isset($filters['is_read'])) { + $q->where('is_read', $filters['is_read'] === 'true' || $filters['is_read'] === '1'); + } }); $this->applyFilters($query, $filters); @@ -317,11 +320,11 @@ public function reject(int $id, string $comment): Approval } /** - * 회수 (기안자만, pending → cancelled) + * 회수 (기안자만, pending/on_hold → cancelled) */ - public function cancel(int $id): Approval + public function cancel(int $id, ?string $recallReason = null): Approval { - return DB::transaction(function () use ($id) { + return DB::transaction(function () use ($id, $recallReason) { $approval = Approval::with('steps')->findOrFail($id); if (! $approval->isCancellable()) { @@ -332,14 +335,140 @@ public function cancel(int $id): Approval throw new \InvalidArgumentException('기안자만 회수할 수 있습니다.'); } - // 모든 pending steps → skipped + // 첫 번째 결재자가 이미 처리했으면 회수 불가 + $firstApproverStep = $approval->steps() + ->approvalOnly() + ->orderBy('step_order') + ->first(); + + if ($firstApproverStep && $firstApproverStep->status !== ApprovalStep::STATUS_PENDING + && $firstApproverStep->status !== ApprovalStep::STATUS_ON_HOLD) { + throw new \InvalidArgumentException('첫 번째 결재자가 이미 처리하여 회수할 수 없습니다.'); + } + + // 모든 pending/on_hold steps → skipped $approval->steps() - ->where('status', ApprovalStep::STATUS_PENDING) + ->whereIn('status', [ApprovalStep::STATUS_PENDING, ApprovalStep::STATUS_ON_HOLD]) ->update(['status' => ApprovalStep::STATUS_SKIPPED]); $approval->update([ 'status' => Approval::STATUS_CANCELLED, 'completed_at' => now(), + 'recall_reason' => $recallReason, + 'updated_by' => auth()->id(), + ]); + + return $approval->fresh(['form', 'drafter', 'steps.approver']); + }); + } + + /** + * 보류 (현재 결재자만, pending → on_hold) + */ + public function hold(int $id, string $comment): Approval + { + return DB::transaction(function () use ($id, $comment) { + $approval = Approval::with('steps')->findOrFail($id); + + if (! $approval->isHoldable()) { + 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_ON_HOLD, + 'comment' => $comment, + 'acted_at' => now(), + ]); + + $approval->update([ + 'status' => Approval::STATUS_ON_HOLD, + 'updated_by' => auth()->id(), + ]); + + return $approval->fresh(['form', 'drafter', 'steps.approver']); + }); + } + + /** + * 보류 해제 (보류한 결재자만, on_hold → pending) + */ + public function releaseHold(int $id): Approval + { + return DB::transaction(function () use ($id) { + $approval = Approval::with('steps')->findOrFail($id); + + if (! $approval->isHoldReleasable()) { + throw new \InvalidArgumentException('보류 해제할 수 없는 상태입니다.'); + } + + $holdStep = $approval->steps() + ->where('status', ApprovalStep::STATUS_ON_HOLD) + ->first(); + + if (! $holdStep || $holdStep->approver_id !== auth()->id()) { + throw new \InvalidArgumentException('보류한 결재자만 해제할 수 있습니다.'); + } + + $holdStep->update([ + 'status' => ApprovalStep::STATUS_PENDING, + 'comment' => null, + 'acted_at' => null, + ]); + + $approval->update([ + 'status' => Approval::STATUS_PENDING, + 'updated_by' => auth()->id(), + ]); + + return $approval->fresh(['form', 'drafter', 'steps.approver']); + }); + } + + /** + * 전결 (현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인) + */ + public function preDecide(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('현재 결재자가 아닙니다.'); + } + + // 현재 step → approved + pre_decided + $currentStep->update([ + 'status' => ApprovalStep::STATUS_APPROVED, + 'approval_type' => 'pre_decided', + 'comment' => $comment, + 'acted_at' => now(), + ]); + + // 이후 모든 pending approval/agreement steps → skipped + $approval->steps() + ->where('step_order', '>', $currentStep->step_order) + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->where('status', ApprovalStep::STATUS_PENDING) + ->update(['status' => ApprovalStep::STATUS_SKIPPED]); + + // 문서 최종 승인 + $approval->update([ + 'status' => Approval::STATUS_APPROVED, + 'completed_at' => now(), 'updated_by' => auth()->id(), ]); @@ -347,6 +476,78 @@ public function cancel(int $id): Approval }); } + /** + * 복사 재기안 (완료/반려/회수 문서를 복사하여 새 draft 생성) + */ + public function copyForRedraft(int $id): Approval + { + return DB::transaction(function () use ($id) { + $original = Approval::with('steps')->findOrFail($id); + + if (! $original->isCopyable()) { + throw new \InvalidArgumentException('복사할 수 없는 상태입니다.'); + } + + if ($original->drafter_id !== auth()->id()) { + throw new \InvalidArgumentException('기안자만 복사할 수 있습니다.'); + } + + $tenantId = session('selected_tenant_id'); + + // 새 문서 생성 + $newApproval = Approval::create([ + 'tenant_id' => $tenantId, + 'document_number' => $this->generateDocumentNumber($tenantId), + 'form_id' => $original->form_id, + 'line_id' => $original->line_id, + 'title' => $original->title, + 'content' => $original->content, + 'body' => $original->body, + 'status' => Approval::STATUS_DRAFT, + 'is_urgent' => $original->is_urgent, + 'drafter_id' => auth()->id(), + 'department_id' => $original->department_id, + 'current_step' => 0, + 'parent_doc_id' => $original->id, + 'created_by' => auth()->id(), + 'updated_by' => auth()->id(), + ]); + + // 결재선 복사 (모두 pending 상태로) + foreach ($original->steps as $step) { + ApprovalStep::create([ + 'approval_id' => $newApproval->id, + 'step_order' => $step->step_order, + 'step_type' => $step->step_type, + 'approver_id' => $step->approver_id, + 'approver_name' => $step->approver_name, + 'approver_department' => $step->approver_department, + 'approver_position' => $step->approver_position, + 'status' => ApprovalStep::STATUS_PENDING, + ]); + } + + return $newApproval->load(['form', 'drafter', 'steps.approver']); + }); + } + + /** + * 참조 열람 추적 + */ + public function markAsRead(int $approvalId): void + { + $userId = auth()->id(); + + ApprovalStep::where('approval_id', $approvalId) + ->where('approver_id', $userId) + ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE) + ->where('is_read', false) + ->update([ + 'is_read' => true, + 'read_at' => now(), + ]); + } + // ========================================================================= // 결재선 // ========================================================================= @@ -428,9 +629,15 @@ public function getBadgeCounts(int $userId): array $draftCount = Approval::draft()->byDrafter($userId)->count(); + $referenceUnreadCount = ApprovalStep::where('approver_id', $userId) + ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE) + ->where('is_read', false) + ->count(); + return [ 'pending' => $pendingCount, 'draft' => $draftCount, + 'reference_unread' => $referenceUnreadCount, ]; } diff --git a/resources/views/approvals/partials/_status-badge.blade.php b/resources/views/approvals/partials/_status-badge.blade.php index bff97d79..cad761b2 100644 --- a/resources/views/approvals/partials/_status-badge.blade.php +++ b/resources/views/approvals/partials/_status-badge.blade.php @@ -7,6 +7,7 @@ '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'], + 'on_hold' => ['label' => '보류', 'class' => 'bg-amber-100 text-amber-700'], default => ['label' => $status, 'class' => 'bg-gray-100 text-gray-700'], }; @endphp diff --git a/resources/views/approvals/partials/_step-progress.blade.php b/resources/views/approvals/partials/_step-progress.blade.php index e3807346..fb75f3a3 100644 --- a/resources/views/approvals/partials/_step-progress.blade.php +++ b/resources/views/approvals/partials/_step-progress.blade.php @@ -5,9 +5,13 @@ @foreach($steps as $index => $step) @php $isApprover = in_array($step['step_type'] ?? 'approval', ['approval', 'agreement']); + $isPreDecided = ($step['approval_type'] ?? 'normal') === 'pre_decided'; $statusConfig = match($step['status'] ?? 'pending') { - 'approved' => ['icon' => '✓', 'bg' => 'bg-green-500', 'border' => 'border-green-500', 'text' => 'text-green-700'], + 'approved' => $isPreDecided + ? ['icon' => '⚡', 'bg' => 'bg-indigo-500', 'border' => 'border-indigo-500', 'text' => 'text-indigo-700'] + : ['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'], + 'on_hold' => ['icon' => '⏸', 'bg' => 'bg-amber-500', 'border' => 'border-amber-500', 'text' => 'text-amber-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'], }; @@ -24,6 +28,7 @@ 'reference' => '참조', default => '', }; + $isFilledStatus = in_array($step['status'] ?? 'pending', ['approved', 'rejected', 'skipped', 'on_hold']) || $isCurrent; @endphp @if($index > 0) @@ -32,17 +37,23 @@
{{-- 원형 아이콘 --}} -
{!! $statusConfig['icon'] !!}
{{-- 결재자명 --}} - + {{ $step['approver_name'] ?? ($step['approver']['name'] ?? '미지정') }} {{-- 유형 + 직급 --}} {{ $typeLabel }}{{ !empty($step['approver_position']) ? ' · ' . $step['approver_position'] : '' }} + @if($isPreDecided && ($step['status'] ?? '') === 'approved') + 전결 + @endif + @if(($step['status'] ?? '') === 'on_hold') + 보류 + @endif {{-- 처리일시 --}} @if(!empty($step['acted_at'])) diff --git a/resources/views/approvals/references.blade.php b/resources/views/approvals/references.blade.php index e773cc14..111b416a 100644 --- a/resources/views/approvals/references.blade.php +++ b/resources/views/approvals/references.blade.php @@ -7,8 +7,26 @@

참조함

+ {{-- 미열람/열람 필터 탭 --}} +
+ + + +
+
+
@@ -30,14 +48,48 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc @push('scripts') @endpush diff --git a/resources/views/approvals/show.blade.php b/resources/views/approvals/show.blade.php index 95fcc10f..0a4a69c4 100644 --- a/resources/views/approvals/show.blade.php +++ b/resources/views/approvals/show.blade.php @@ -51,8 +51,26 @@ class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition
{{ $approval->completed_at->format('Y-m-d H:i') }}
@endif + @if($approval->parent_doc_id) +
+ 원본 문서 + +
+ @endif + {{-- 회수 사유 표시 --}} + @if($approval->status === 'cancelled' && $approval->recall_reason) +
+ 회수 사유 +

{{ $approval->recall_reason }}

+
+ @endif +

{{ $approval->title }}

{{ $approval->body ?? '(내용 없음)' }}
@@ -79,7 +97,13 @@ class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition
@if($step->status === 'approved') - + @if(($step->approval_type ?? 'normal') === 'pre_decided') + + @else + + @endif + @elseif($step->status === 'on_hold') + @else @endif @@ -87,6 +111,12 @@ class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition
{{ $step->approver_name ?? ($step->approver?->name ?? '') }} + @if(($step->approval_type ?? 'normal') === 'pre_decided') + (전결) + @endif + @if($step->status === 'on_hold') + (보류) + @endif {{ $step->acted_at?->format('Y-m-d H:i') }}

{{ $step->comment }}

@@ -98,18 +128,18 @@ class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition @endif
- {{-- 액션 버튼 --}} + {{-- 결재 처리 (승인/반려/보류/전결) --}} @if($approval->isActionable() && $approval->isCurrentApprover(auth()->id()))

결재 처리

-
-
+
+ +
@endif - {{-- 회수 버튼 (기안자 + pending) --}} + {{-- 보류 해제 (보류 상태에서 보류한 결재자만) --}} + @if($approval->isHoldReleasable()) + @php + $holdStep = $approval->steps->firstWhere('status', 'on_hold'); + $canRelease = $holdStep && $holdStep->approver_id === auth()->id(); + @endphp + @if($canRelease) +
+
+ + 보류를 해제하고 결재를 다시 진행합니다. +
+
+ @endif + @endif + + {{-- 회수 (기안자 + pending/on_hold) --}} @if($approval->isCancellable() && $approval->drafter_id === auth()->id()) +
+ @php + $firstStep = $approval->steps->whereIn('step_type', ['approval', 'agreement'])->sortBy('step_order')->first(); + $canCancel = $firstStep && in_array($firstStep->status, ['pending', 'on_hold']); + @endphp + + @if($canCancel) +
+ + +
+ + 진행 중인 결재를 취소합니다. + @else +

첫 번째 결재자가 이미 처리하여 회수할 수 없습니다.

+ @endif +
+ @endif + + {{-- 복사 재기안 (완료/반려/회수 상태에서 기안자만) --}} + @if($approval->isCopyable() && $approval->drafter_id === auth()->id())
- - 진행 중인 결재를 취소합니다. + 이 문서를 복사하여 새 결재를 작성합니다.
@endif @endsection @@ -171,16 +253,108 @@ class="bg-yellow-500 hover:bg-yellow-600 text-white px-6 py-2 rounded-lg transit } } +async function processHold() { + const comment = document.getElementById('approval-comment')?.value || ''; + + if (!comment.trim()) { + showToast('보류 사유를 입력해주세요.', 'warning'); + return; + } + + if (!confirm('이 결재를 보류하시겠습니까?')) return; + + try { + const response = await fetch('/api/admin/approvals/{{ $approval->id }}/hold', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json', + }, + body: JSON.stringify({ comment: comment }), + }); + + const data = await response.json(); + + if (data.success) { + showToast(data.message, 'success'); + setTimeout(() => location.reload(), 500); + } else { + showToast(data.message || '보류에 실패했습니다.', 'error'); + } + } catch (e) { + showToast('서버 오류가 발생했습니다.', 'error'); + } +} + +async function releaseHold() { + if (!confirm('보류를 해제하시겠습니까?')) return; + + try { + const response = await fetch('/api/admin/approvals/{{ $approval->id }}/release-hold', { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json', + }, + }); + + const data = await response.json(); + + if (data.success) { + showToast(data.message, 'success'); + setTimeout(() => location.reload(), 500); + } else { + showToast(data.message || '보류 해제에 실패했습니다.', 'error'); + } + } catch (e) { + showToast('서버 오류가 발생했습니다.', 'error'); + } +} + +async function processPreDecide() { + const comment = document.getElementById('approval-comment')?.value || ''; + + if (!confirm('전결 처리하시겠습니까?\n이후 모든 결재를 건너뛰고 문서를 최종 승인합니다.')) return; + + try { + const response = await fetch('/api/admin/approvals/{{ $approval->id }}/pre-decide', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json', + }, + body: JSON.stringify({ comment: comment }), + }); + + const data = await response.json(); + + if (data.success) { + showToast(data.message, 'success'); + setTimeout(() => location.reload(), 500); + } else { + showToast(data.message || '전결에 실패했습니다.', 'error'); + } + } catch (e) { + showToast('서버 오류가 발생했습니다.', 'error'); + } +} + async function cancelApproval() { + const recallReason = document.getElementById('recall-reason')?.value || ''; + if (!confirm('결재를 회수하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) return; try { const response = await fetch('/api/admin/approvals/{{ $approval->id }}/cancel', { method: 'POST', headers: { + 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json', }, + body: JSON.stringify({ recall_reason: recallReason }), }); const data = await response.json(); @@ -195,5 +369,32 @@ class="bg-yellow-500 hover:bg-yellow-600 text-white px-6 py-2 rounded-lg transit showToast('서버 오류가 발생했습니다.', 'error'); } } + +async function copyForRedraft() { + if (!confirm('이 문서를 복사하여 새 결재를 작성하시겠습니까?')) return; + + try { + const response = await fetch('/api/admin/approvals/{{ $approval->id }}/copy', { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json', + }, + }); + + const data = await response.json(); + + if (data.success) { + showToast(data.message, 'success'); + setTimeout(() => { + location.href = '/approval-mgmt/' + data.data.id + '/edit'; + }, 500); + } else { + showToast(data.message || '복사에 실패했습니다.', 'error'); + } + } catch (e) { + showToast('서버 오류가 발생했습니다.', 'error'); + } +} @endpush diff --git a/routes/api.php b/routes/api.php index f32141bf..1be43a69 100644 --- a/routes/api.php +++ b/routes/api.php @@ -919,6 +919,13 @@ 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'); + + // Phase 2 워크플로우 + Route::post('/{id}/hold', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'hold'])->name('hold'); + Route::post('/{id}/release-hold', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'releaseHold'])->name('release-hold'); + Route::post('/{id}/pre-decide', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'preDecide'])->name('pre-decide'); + Route::post('/{id}/copy', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'copyForRedraft'])->name('copy'); + Route::post('/{id}/mark-read', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'markAsRead'])->name('mark-read'); }); /*