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:
2026-01-31 05:00:48 +09:00
parent d43f8d0ba1
commit 5111db24c2
4 changed files with 320 additions and 10 deletions

View File

@@ -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']),
]);
}
/**
* 문서 데이터 저장 (기본필드 + 섹션 테이블 데이터)
*/

View File

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

View File

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

View File

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