Files
sam-manage/resources/views/approvals/show.blade.php
김보곤 9beda571a4 feat: [approval] 지출결의서 전용 폼 UI 추가
- Alpine.js 기반 지출결의서 전용 폼 컴포넌트 (_expense-form.blade.php)
- 지출형식/세금계산서 라디오, 내역 테이블(동적 행 추가/삭제), 금액 자동합계
- 양식 code === 'expense' 시 Quill 대신 전용 폼 표시 (create/edit)
- content JSON 구조화 저장, show 페이지 읽기전용 테이블 렌더링
- 기존 Quill 방식 하위 호환 유지
2026-03-04 15:14:42 +09:00

456 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 flex-wrap gap-y-3 mb-4" 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>
{{-- 회수 사유 표시 --}}
@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>
{{-- 결재 진행 단계 --}}
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h3 class="text-lg font-semibold text-gray-800 mb-2">결재 진행</h3>
@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())
<div class="mt-4 border-t pt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">결재 의견</h4>
<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">&#9889;</span>
@else
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-green-100 text-green-600 text-xs">&#10003;</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">&#9208;</span>
@else
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-red-100 text-red-600 text-xs">&#10007;</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
</div>
{{-- 결재 처리 (승인/반려/보류/전결) --}}
@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