feat:결재 워크플로우 구현 (Phase 2.3)
- API: submit(DRAFT→PENDING), approve(단계별 승인), reject(반려 사유 필수) - 전체 승인 완료 시 자동 APPROVED, 재제출 시 결재라인 초기화 - edit: 결재 제출 버튼 + submitForApproval() JS - show: 승인/반려 버튼, 반려 사유 모달, 결재 현황 속성 수정, 상태 배지 CSS - 라우트: submit/approve/reject 3개 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -215,6 +215,151 @@ public function destroy(int $id): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 제출 (DRAFT → PENDING)
|
||||
*/
|
||||
public function submit(int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$userId = auth()->id();
|
||||
|
||||
$document = Document::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
if ($document->status !== Document::STATUS_DRAFT && $document->status !== Document::STATUS_REJECTED) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '작성중 또는 반려 상태의 문서만 제출할 수 있습니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$document->update([
|
||||
'status' => Document::STATUS_PENDING,
|
||||
'submitted_at' => now(),
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
// 결재라인 상태 초기화 (반려 후 재제출 시)
|
||||
$document->approvals()->update([
|
||||
'status' => DocumentApproval::STATUS_PENDING,
|
||||
'comment' => null,
|
||||
'acted_at' => null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재가 제출되었습니다.',
|
||||
'data' => $document->fresh(['approvals']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 승인 (단계별)
|
||||
*/
|
||||
public function approve(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$userId = auth()->id();
|
||||
|
||||
$document = Document::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
if ($document->status !== Document::STATUS_PENDING) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '결재중 상태의 문서만 승인할 수 있습니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 현재 단계의 미처리 결재 찾기
|
||||
$pendingApproval = $document->approvals()
|
||||
->where('status', DocumentApproval::STATUS_PENDING)
|
||||
->orderBy('step')
|
||||
->first();
|
||||
|
||||
if (! $pendingApproval) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '승인 대기 중인 결재 단계가 없습니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$pendingApproval->update([
|
||||
'user_id' => $userId,
|
||||
'status' => DocumentApproval::STATUS_APPROVED,
|
||||
'comment' => $request->input('comment'),
|
||||
'acted_at' => now(),
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
// 모든 결재가 완료되었는지 확인
|
||||
$remainingPending = $document->approvals()
|
||||
->where('status', DocumentApproval::STATUS_PENDING)
|
||||
->count();
|
||||
|
||||
if ($remainingPending === 0) {
|
||||
$document->update([
|
||||
'status' => Document::STATUS_APPROVED,
|
||||
'completed_at' => now(),
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $remainingPending === 0 ? '최종 승인되었습니다.' : '승인되었습니다. (다음 단계 대기)',
|
||||
'data' => $document->fresh(['approvals']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 반려
|
||||
*/
|
||||
public function reject(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$userId = auth()->id();
|
||||
|
||||
$request->validate([
|
||||
'comment' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
$document = Document::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
if ($document->status !== Document::STATUS_PENDING) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '결재중 상태의 문서만 반려할 수 있습니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 현재 단계 결재에 반려 기록
|
||||
$pendingApproval = $document->approvals()
|
||||
->where('status', DocumentApproval::STATUS_PENDING)
|
||||
->orderBy('step')
|
||||
->first();
|
||||
|
||||
if ($pendingApproval) {
|
||||
$pendingApproval->update([
|
||||
'user_id' => $userId,
|
||||
'status' => DocumentApproval::STATUS_REJECTED,
|
||||
'comment' => $request->input('comment'),
|
||||
'acted_at' => now(),
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
$document->update([
|
||||
'status' => Document::STATUS_REJECTED,
|
||||
'completed_at' => now(),
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '문서가 반려되었습니다.',
|
||||
'data' => $document->fresh(['approvals']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 데이터 저장 (기본필드 + 섹션 테이블 데이터)
|
||||
*/
|
||||
|
||||
@@ -320,8 +320,14 @@ class="px-6 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors">
|
||||
{{ $isCreate ? '저장' : '수정' }}
|
||||
{{ $isCreate ? '저장' : '저장' }}
|
||||
</button>
|
||||
@if(!$isCreate && $document && $document->canEdit())
|
||||
<button type="button" onclick="submitForApproval()"
|
||||
class="px-6 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors">
|
||||
결재 제출
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
@@ -418,5 +424,32 @@ class="px-6 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@if(!$isCreate && $document)
|
||||
window.submitForApproval = function() {
|
||||
if (!confirm('결재를 제출하시겠습니까? 제출 후에는 수정이 불가합니다.')) return;
|
||||
|
||||
fetch('/api/admin/documents/{{ $document->id }}/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showToast(result.message, 'success');
|
||||
window.location.href = '/documents/{{ $document->id }}';
|
||||
} else {
|
||||
showToast(result.message || '오류가 발생했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Submit error:', error);
|
||||
showToast('결재 제출 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
};
|
||||
@endif
|
||||
</script>
|
||||
@endpush
|
||||
@@ -10,7 +10,7 @@
|
||||
<p class="text-sm text-gray-500 mt-1 hidden sm:block">{{ $document->document_no }} - {{ $document->title }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
@if($document->status === 'DRAFT' || $document->status === 'REJECTED')
|
||||
@if($document->canEdit())
|
||||
<a href="{{ route('documents.edit', $document->id) }}"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -19,6 +19,22 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition
|
||||
수정
|
||||
</a>
|
||||
@endif
|
||||
@if($document->isPending())
|
||||
<button onclick="approveDocument()"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
승인
|
||||
</button>
|
||||
<button onclick="showRejectModal()"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
반려
|
||||
</button>
|
||||
@endif
|
||||
<a href="{{ route('documents.index') }}"
|
||||
class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg transition">
|
||||
목록
|
||||
@@ -50,7 +66,15 @@ class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg transiti
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">상태</dt>
|
||||
<dd class="mt-1">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $document->status_color }}">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
@switch($document->status)
|
||||
@case('DRAFT') bg-gray-100 text-gray-800 @break
|
||||
@case('PENDING') bg-yellow-100 text-yellow-800 @break
|
||||
@case('APPROVED') bg-green-100 text-green-800 @break
|
||||
@case('REJECTED') bg-red-100 text-red-800 @break
|
||||
@default bg-gray-100 text-gray-800
|
||||
@endswitch
|
||||
">
|
||||
{{ $document->status_label }}
|
||||
</span>
|
||||
</dd>
|
||||
@@ -147,7 +171,7 @@ class="text-sm text-blue-600 hover:text-blue-800">
|
||||
|
||||
@if($document->approvals && $document->approvals->count() > 0)
|
||||
<ol class="relative border-l border-gray-200 ml-3">
|
||||
@foreach($document->approvals->sortBy('step_order') as $approval)
|
||||
@foreach($document->approvals as $approval)
|
||||
<li class="mb-6 ml-6">
|
||||
<span class="absolute flex items-center justify-center w-6 h-6 rounded-full -left-3 ring-4 ring-white
|
||||
@if($approval->status === 'APPROVED') bg-green-500
|
||||
@@ -164,14 +188,19 @@ class="text-sm text-blue-600 hover:text-blue-800">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
@else
|
||||
<span class="text-white text-xs font-bold">{{ $approval->step_order }}</span>
|
||||
<span class="text-white text-xs font-bold">{{ $approval->step }}</span>
|
||||
@endif
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900">{{ $approval->user->name ?? '미지정' }}</h3>
|
||||
<p class="text-xs text-gray-500">{{ $approval->step_name }}</p>
|
||||
@if($approval->approved_at)
|
||||
<p class="text-xs text-gray-400 mt-1">{{ $approval->approved_at->format('Y-m-d H:i') }}</p>
|
||||
<h3 class="text-sm font-medium text-gray-900">
|
||||
{{ $approval->role }}
|
||||
<span class="text-xs text-gray-400 ml-1">
|
||||
({{ $approval->status_label }})
|
||||
</span>
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500">{{ $approval->user->name ?? '미지정' }}</p>
|
||||
@if($approval->acted_at)
|
||||
<p class="text-xs text-gray-400 mt-1">{{ $approval->acted_at->format('Y-m-d H:i') }}</p>
|
||||
@endif
|
||||
@if($approval->comment)
|
||||
<p class="text-xs text-gray-600 mt-1 bg-gray-50 p-2 rounded">{{ $approval->comment }}</p>
|
||||
@@ -192,4 +221,103 @@ class="text-sm text-blue-600 hover:text-blue-800">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
{{-- 반려 사유 모달 --}}
|
||||
@if($document->isPending())
|
||||
<div id="rejectModal" class="hidden fixed inset-0 bg-gray-600/50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">문서 반려</h3>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">반려 사유 <span class="text-red-500">*</span></label>
|
||||
<textarea id="rejectComment" rows="4" required
|
||||
class="w-full rounded-lg border-gray-300 text-sm focus:border-red-500 focus:ring-red-500"
|
||||
placeholder="반려 사유를 입력하세요"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button onclick="closeRejectModal()"
|
||||
class="px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200">
|
||||
취소
|
||||
</button>
|
||||
<button onclick="rejectDocument()"
|
||||
class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700">
|
||||
반려
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
@if($document->isPending())
|
||||
window.approveDocument = function() {
|
||||
if (!confirm('이 문서를 승인하시겠습니까?')) return;
|
||||
|
||||
fetch('/api/admin/documents/{{ $document->id }}/approve', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
body: JSON.stringify({ comment: '' })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showToast(result.message, 'success');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showToast(result.message || '오류가 발생했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Approve error:', error);
|
||||
showToast('승인 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
window.showRejectModal = function() {
|
||||
document.getElementById('rejectModal').classList.remove('hidden');
|
||||
};
|
||||
|
||||
window.closeRejectModal = function() {
|
||||
document.getElementById('rejectModal').classList.add('hidden');
|
||||
document.getElementById('rejectComment').value = '';
|
||||
};
|
||||
|
||||
window.rejectDocument = function() {
|
||||
const comment = document.getElementById('rejectComment').value.trim();
|
||||
if (!comment) {
|
||||
showToast('반려 사유를 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/admin/documents/{{ $document->id }}/reject', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
body: JSON.stringify({ comment: comment })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showToast(result.message, 'success');
|
||||
closeRejectModal();
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showToast(result.message || '오류가 발생했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Reject error:', error);
|
||||
showToast('반려 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
};
|
||||
@endif
|
||||
</script>
|
||||
@endpush
|
||||
@@ -803,6 +803,10 @@
|
||||
Route::get('/{id}', [DocumentApiController::class, 'show'])->name('show');
|
||||
Route::patch('/{id}', [DocumentApiController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [DocumentApiController::class, 'destroy'])->name('destroy');
|
||||
// 결재 워크플로우
|
||||
Route::post('/{id}/submit', [DocumentApiController::class, 'submit'])->name('submit');
|
||||
Route::post('/{id}/approve', [DocumentApiController::class, 'approve'])->name('approve');
|
||||
Route::post('/{id}/reject', [DocumentApiController::class, 'reject'])->name('reject');
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user