- 문서 상세 우측 상단에 결재서명란 테이블 배치 - 작성자 + 결재자 컬럼, 직급/이름/서명/날짜 표시 - 승인/반려/보류/전결 상태별 도장 아이콘 - 기존 원형 타임라인 결재 진행 제거, 결재 의견만 유지
454 lines
20 KiB
PHP
454 lines
20 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '결재 상세')
|
|
|
|
@section('content')
|
|
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-800">결재 상세</h1>
|
|
<p class="text-sm text-gray-500 mt-1">{{ $approval->document_number }}</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
@if($approval->isEditable() && $approval->drafter_id === auth()->id())
|
|
<a href="{{ route('approvals.edit', $approval->id) }}"
|
|
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition text-sm">
|
|
수정
|
|
</a>
|
|
@endif
|
|
<a href="{{ route('approvals.drafts') }}" class="text-gray-600 hover:text-gray-800 text-sm px-3 py-2 border rounded-lg">
|
|
목록
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 문서 정보 --}}
|
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
<div class="flex justify-between items-start gap-4 mb-4">
|
|
<div class="flex flex-wrap gap-y-3" style="gap-column: 0;">
|
|
<div class="pr-6 border-r border-gray-200 mr-6">
|
|
<span class="text-xs text-gray-500">상태</span>
|
|
<div class="mt-1">
|
|
@include('approvals.partials._status-badge', ['status' => $approval->status])
|
|
@if($approval->is_urgent)
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700 ml-1">긴급</span>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
<div class="pr-6 border-r border-gray-200 mr-6">
|
|
<span class="text-xs text-gray-500">양식</span>
|
|
<div class="mt-1 text-sm font-medium">{{ $approval->form?->name ?? '-' }}</div>
|
|
</div>
|
|
<div class="pr-6 border-r border-gray-200 mr-6">
|
|
<span class="text-xs text-gray-500">기안자</span>
|
|
<div class="mt-1 text-sm font-medium">{{ $approval->drafter?->name ?? '-' }}</div>
|
|
</div>
|
|
<div class="pr-6{{ $approval->completed_at || $approval->parent_doc_id ? ' border-r border-gray-200 mr-6' : '' }}">
|
|
<span class="text-xs text-gray-500">기안일</span>
|
|
<div class="mt-1 text-sm">{{ $approval->drafted_at?->format('Y-m-d H:i') ?? '-' }}</div>
|
|
</div>
|
|
@if($approval->completed_at)
|
|
<div class="pr-6{{ $approval->parent_doc_id ? ' border-r border-gray-200 mr-6' : '' }}">
|
|
<span class="text-xs text-gray-500">완료일</span>
|
|
<div class="mt-1 text-sm">{{ $approval->completed_at->format('Y-m-d H:i') }}</div>
|
|
</div>
|
|
@endif
|
|
@if($approval->parent_doc_id)
|
|
<div class="pr-6">
|
|
<span class="text-xs text-gray-500">원본 문서</span>
|
|
<div class="mt-1 text-sm">
|
|
<a href="{{ route('approvals.show', $approval->parent_doc_id) }}" class="text-blue-600 hover:underline">
|
|
{{ $approval->parentDocument?->document_number ?? '원본 보기' }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- 결재서명란 --}}
|
|
<div class="shrink-0">
|
|
@include('approvals.partials._approval-stamp-table', ['approval' => $approval])
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 회수 사유 표시 --}}
|
|
@if($approval->status === 'cancelled' && $approval->recall_reason)
|
|
<div class="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
<span class="text-xs font-medium text-yellow-700">회수 사유</span>
|
|
<p class="text-sm text-yellow-800 mt-1">{{ $approval->recall_reason }}</p>
|
|
</div>
|
|
@endif
|
|
|
|
<div class="border-t pt-4">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-2">{{ $approval->title }}</h2>
|
|
@if(!empty($approval->content) && $approval->form?->code === 'expense')
|
|
@include('approvals.partials._expense-show', ['content' => $approval->content])
|
|
@elseif($approval->body && preg_match('/<[a-z][\s\S]*>/i', $approval->body))
|
|
<div class="prose prose-sm max-w-none text-gray-700">
|
|
{!! strip_tags($approval->body, '<p><br><strong><b><em><i><u><s><del><h1><h2><h3><h4><h5><h6><ul><ol><li><blockquote><pre><code><a><span><div><table><thead><tbody><tr><th><td>') !!}
|
|
</div>
|
|
@else
|
|
<div class="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap">{{ $approval->body ?? '(내용 없음)' }}</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 결재 의견 --}}
|
|
@php
|
|
$stepsWithComments = $approval->steps->filter(fn($s) => $s->comment);
|
|
@endphp
|
|
@if($stepsWithComments->isNotEmpty())
|
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
<h3 class="text-lg font-semibold text-gray-800 mb-2">결재 의견</h3>
|
|
<div class="space-y-2">
|
|
@foreach($stepsWithComments as $step)
|
|
<div class="flex gap-3 p-3 bg-gray-50 rounded-lg">
|
|
<div class="shrink-0">
|
|
@if($step->status === 'approved')
|
|
@if(($step->approval_type ?? 'normal') === 'pre_decided')
|
|
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-indigo-100 text-indigo-600 text-xs">⚡</span>
|
|
@else
|
|
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-green-100 text-green-600 text-xs">✓</span>
|
|
@endif
|
|
@elseif($step->status === 'on_hold')
|
|
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-amber-100 text-amber-600 text-xs">⏸</span>
|
|
@else
|
|
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-red-100 text-red-600 text-xs">✗</span>
|
|
@endif
|
|
</div>
|
|
<div>
|
|
<div class="text-sm font-medium">
|
|
{{ $step->approver_name ?? ($step->approver?->name ?? '') }}
|
|
@if(($step->approval_type ?? 'normal') === 'pre_decided')
|
|
<span class="text-xs text-indigo-500 font-normal">(전결)</span>
|
|
@endif
|
|
@if($step->status === 'on_hold')
|
|
<span class="text-xs text-amber-500 font-normal">(보류)</span>
|
|
@endif
|
|
<span class="text-gray-400 font-normal text-xs">{{ $step->acted_at?->format('Y-m-d H:i') }}</span>
|
|
</div>
|
|
<p class="text-sm text-gray-600 mt-1">{{ $step->comment }}</p>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- 결재 처리 (승인/반려/보류/전결) --}}
|
|
@if($approval->isActionable() && $approval->isCurrentApprover(auth()->id()))
|
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">결재 처리</h3>
|
|
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">결재 의견</label>
|
|
<textarea id="approval-comment" rows="3" placeholder="의견을 입력하세요 (반려/보류 시 필수)"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2">
|
|
<button onclick="processApproval('approve')"
|
|
class="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
|
|
승인
|
|
</button>
|
|
<button onclick="processApproval('reject')"
|
|
class="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
|
|
반려
|
|
</button>
|
|
<button onclick="processHold()"
|
|
class="bg-amber-500 hover:bg-amber-600 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
|
|
보류
|
|
</button>
|
|
<button onclick="processPreDecide()"
|
|
class="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
|
|
전결
|
|
</button>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- 보류 해제 (보류 상태에서 보류한 결재자만) --}}
|
|
@if($approval->isHoldReleasable())
|
|
@php
|
|
$holdStep = $approval->steps->firstWhere('status', 'on_hold');
|
|
$canRelease = $holdStep && $holdStep->approver_id === auth()->id();
|
|
@endphp
|
|
@if($canRelease)
|
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
<div class="flex items-center gap-3">
|
|
<button onclick="releaseHold()"
|
|
class="bg-amber-500 hover:bg-amber-600 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
|
|
보류 해제
|
|
</button>
|
|
<span class="text-xs text-gray-500">보류를 해제하고 결재를 다시 진행합니다.</span>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
@endif
|
|
|
|
{{-- 회수 (기안자 + pending/on_hold) --}}
|
|
@if($approval->isCancellable() && $approval->drafter_id === auth()->id())
|
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
@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)
|
|
<div class="mb-3">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">회수 사유</label>
|
|
<textarea id="recall-reason" rows="2" placeholder="회수 사유를 입력하세요"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-yellow-500"></textarea>
|
|
</div>
|
|
<button onclick="cancelApproval()"
|
|
class="bg-yellow-500 hover:bg-yellow-600 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
|
|
결재 회수
|
|
</button>
|
|
<span class="text-xs text-gray-500 ml-2">진행 중인 결재를 취소합니다.</span>
|
|
@else
|
|
<p class="text-sm text-gray-500">첫 번째 결재자가 이미 처리하여 회수할 수 없습니다.</p>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
{{-- 복사 재기안 (완료/반려/회수 상태에서 기안자만) --}}
|
|
@if($approval->isCopyable() && $approval->drafter_id === auth()->id())
|
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
<button onclick="copyForRedraft()"
|
|
class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
|
|
복사하여 재기안
|
|
</button>
|
|
<span class="text-xs text-gray-500 ml-2">이 문서를 복사하여 새 결재를 작성합니다.</span>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- 삭제 (기안자: draft만 / 관리자: 모든 상태) --}}
|
|
@if($approval->isDeletableBy(auth()->user()))
|
|
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
<div class="flex items-center gap-3">
|
|
<button onclick="deleteApproval()"
|
|
class="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
|
|
문서 삭제
|
|
</button>
|
|
<span class="text-xs text-gray-500">이 결재 문서를 삭제합니다. 삭제 후 복구할 수 없습니다.</span>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
async function processApproval(action) {
|
|
const comment = document.getElementById('approval-comment')?.value || '';
|
|
|
|
if (action === 'reject' && !comment.trim()) {
|
|
showToast('반려 시 사유를 입력해주세요.', 'warning');
|
|
return;
|
|
}
|
|
|
|
if (action === 'approve' && !confirm('승인하시겠습니까?')) return;
|
|
if (action === 'reject' && !confirm('반려하시겠습니까?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/admin/approvals/{{ $approval->id }}/${action}`, {
|
|
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 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();
|
|
|
|
if (data.success) {
|
|
showToast(data.message, 'success');
|
|
setTimeout(() => location.reload(), 500);
|
|
} else {
|
|
showToast(data.message || '회수에 실패했습니다.', 'error');
|
|
}
|
|
} catch (e) {
|
|
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');
|
|
}
|
|
}
|
|
|
|
async function deleteApproval() {
|
|
const status = '{{ $approval->status }}';
|
|
const isActive = ['pending', 'on_hold'].includes(status);
|
|
|
|
if (isActive) {
|
|
if (!confirm('이 문서는 현재 진행 중입니다.\n삭제하면 연관된 휴가 등의 처리도 취소됩니다.\n정말 삭제하시겠습니까?')) return;
|
|
}
|
|
|
|
if (!confirm('결재 문서를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/admin/approvals/{{ $approval->id }}', {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
'Accept': 'application/json',
|
|
},
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
showToast(data.message, 'success');
|
|
setTimeout(() => {
|
|
location.href = '{{ route("approvals.drafts") }}';
|
|
}, 500);
|
|
} else {
|
|
showToast(data.message || '삭제에 실패했습니다.', 'error');
|
|
}
|
|
} catch (e) {
|
|
showToast('서버 오류가 발생했습니다.', 'error');
|
|
}
|
|
}
|
|
</script>
|
|
@endpush
|