feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine - ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직 - ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함) - ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리) - 뷰 8개: drafts, pending, completed, references, create, edit, show - partials: _status-badge, _step-progress, _approval-line-editor - api.php/web.php 라우트 등록
This commit is contained in:
207
resources/views/approvals/edit.blade.php
Normal file
207
resources/views/approvals/edit.blade.php
Normal file
@@ -0,0 +1,207 @@
|
||||
@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">
|
||||
<a href="{{ route('approvals.show', $approval->id) }}" class="text-gray-600 hover:text-gray-800 text-sm px-3 py-2 border rounded-lg">
|
||||
상세보기
|
||||
</a>
|
||||
<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>
|
||||
|
||||
@if($approval->status === 'rejected')
|
||||
<div class="bg-red-50 border-l-4 border-red-400 p-4 mb-6 rounded">
|
||||
<div class="flex items-center">
|
||||
<span class="text-red-700 font-medium">반려됨</span>
|
||||
</div>
|
||||
@php
|
||||
$rejectedStep = $approval->steps->firstWhere('status', 'rejected');
|
||||
@endphp
|
||||
@if($rejectedStep)
|
||||
<p class="text-sm text-red-600 mt-1">
|
||||
{{ $rejectedStep->approver_name ?? '' }} ({{ $rejectedStep->acted_at?->format('Y-m-d H:i') }}):
|
||||
{{ $rejectedStep->comment }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
{{-- 좌측: 양식 --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">문서 내용</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">양식 <span class="text-red-500">*</span></label>
|
||||
<select id="form_id" 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">
|
||||
@foreach($forms as $form)
|
||||
<option value="{{ $form->id }}" {{ $approval->form_id == $form->id ? 'selected' : '' }}>{{ $form->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">제목 <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="title" maxlength="200" value="{{ $approval->title }}"
|
||||
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">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" id="is_urgent" {{ $approval->is_urgent ? 'checked' : '' }}
|
||||
class="rounded border-gray-300 text-red-600 focus:ring-red-500">
|
||||
<span class="text-sm text-gray-700">긴급</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">본문</label>
|
||||
<textarea id="body" rows="12"
|
||||
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"
|
||||
style="min-height: 300px;">{{ $approval->body }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 우측: 결재선 --}}
|
||||
<div class="shrink-0" style="width: 100%; max-width: 380px;">
|
||||
@php
|
||||
$initialSteps = $approval->steps->map(fn($s) => [
|
||||
'user_id' => $s->approver_id,
|
||||
'user_name' => $s->approver_name ?? ($s->approver?->name ?? ''),
|
||||
'department' => $s->approver_department ?? '',
|
||||
'position' => $s->approver_position ?? '',
|
||||
'step_type' => $s->step_type,
|
||||
])->toArray();
|
||||
@endphp
|
||||
@include('approvals.partials._approval-line-editor', [
|
||||
'lines' => $lines,
|
||||
'initialSteps' => $initialSteps,
|
||||
'selectedLineId' => $approval->line_id ?? '',
|
||||
])
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<button onclick="updateApproval('save')"
|
||||
class="w-full bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition text-sm font-medium">
|
||||
저장
|
||||
</button>
|
||||
<button onclick="updateApproval('submit')"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-sm font-medium">
|
||||
{{ $approval->status === 'rejected' ? '재상신' : '상신' }}
|
||||
</button>
|
||||
@if($approval->isDeletable())
|
||||
<button onclick="deleteApproval()"
|
||||
class="w-full bg-red-100 hover:bg-red-200 text-red-700 px-4 py-2 rounded-lg transition text-sm font-medium">
|
||||
삭제
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
async function updateApproval(action) {
|
||||
const title = document.getElementById('title').value.trim();
|
||||
if (!title) {
|
||||
showToast('제목을 입력해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = document.querySelector('[x-data]').__x.$data;
|
||||
const steps = editor.getStepsData();
|
||||
|
||||
if (action === 'submit' && steps.filter(s => s.step_type !== 'reference').length === 0) {
|
||||
showToast('결재자를 1명 이상 추가해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
form_id: document.getElementById('form_id').value,
|
||||
title: title,
|
||||
body: document.getElementById('body').value,
|
||||
is_urgent: document.getElementById('is_urgent').checked,
|
||||
steps: steps,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/approvals/{{ $approval->id }}', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
showToast(data.message || '저장에 실패했습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'submit') {
|
||||
const submitResponse = await fetch('/api/admin/approvals/{{ $approval->id }}/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const submitData = await submitResponse.json();
|
||||
|
||||
if (submitData.success) {
|
||||
showToast('결재가 상신되었습니다.', 'success');
|
||||
setTimeout(() => location.href = '/approval-mgmt/drafts', 500);
|
||||
} else {
|
||||
showToast(submitData.message || '상신에 실패했습니다.', 'error');
|
||||
}
|
||||
} else {
|
||||
showToast('저장되었습니다.', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApproval() {
|
||||
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('삭제되었습니다.', 'success');
|
||||
setTimeout(() => location.href = '/approval-mgmt/drafts', 500);
|
||||
} else {
|
||||
showToast(data.message || '삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
Reference in New Issue
Block a user