Files
sam-manage/resources/views/approvals/create.blade.php
김보곤 1a3ec05d6d feat: [approvals] 기안 작성/수정 결재선을 모달로 전환
- 2열 레이아웃(양식 50% + 결재선 50%)을 1열 풀와이드로 변경
- 결재선 편집기를 모달로 이동, 메인 화면에 요약 바만 표시
- ESC 키로 모달 닫기 지원
- edit 페이지 로드 시 기존 결재선 요약 즉시 표시
2026-02-28 14:41:37 +09:00

300 lines
12 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">
<h1 class="text-2xl font-bold text-gray-800">기안 작성</h1>
<a href="{{ route('approvals.drafts') }}" class="text-gray-600 hover:text-gray-800 text-sm">
&larr; 기안함으로 돌아가기
</a>
</div>
<div class="mx-auto" style="max-width: 960px;">
<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 }}">{{ $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" 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">
</div>
{{-- 긴급 여부 --}}
<div class="mb-4">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="is_urgent" 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 class="inline-flex items-center gap-1 ml-3 cursor-pointer">
<input type="checkbox" id="useEditor" onchange="toggleEditor()"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-xs text-gray-500 font-normal">편집기</span>
</label>
</label>
<textarea id="body" rows="12" 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"
style="min-height: 300px;"></textarea>
<div id="quill-container" style="display: none; min-height: 300px;"></div>
</div>
{{-- 결재선 요약 --}}
<div class="border-t pt-4 mt-4">
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-gray-700">결재선</label>
<button type="button" onclick="openApprovalLineModal()"
class="px-3 py-1.5 bg-blue-50 text-blue-600 hover:bg-blue-100 rounded-lg text-xs font-medium transition">
결재선 설정
</button>
</div>
<div id="approval-line-summary" class="text-sm text-gray-400">
결재선이 설정되지 않았습니다.
</div>
</div>
{{-- 액션 버튼 --}}
<div class="border-t pt-4 mt-4 flex gap-2 justify-end">
<button onclick="saveApproval('draft')"
class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
임시저장
</button>
<button onclick="saveApproval('submit')"
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
상신
</button>
</div>
</div>
</div>
{{-- 결재선 모달 --}}
<div id="approval-line-modal" style="display: none;" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/50" onclick="closeApprovalLineModal()"></div>
<div class="relative flex items-center justify-center min-h-full p-4">
<div class="bg-white rounded-xl shadow-2xl w-full overflow-hidden relative" style="max-width: 720px;">
<button type="button" onclick="closeApprovalLineModal()"
class="absolute top-3 right-3 z-10 p-1 text-gray-400 hover:text-gray-600 transition bg-white rounded-full shadow-sm">
<svg class="w-5 h-5" 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>
<div class="overflow-y-auto" style="max-height: 70vh;">
@php
$defaultLine = collect($lines)->firstWhere('is_default', true);
@endphp
@include('approvals.partials._approval-line-editor', [
'lines' => $lines,
'initialSteps' => $defaultLine?->steps ?? [],
'selectedLineId' => $defaultLine?->id ?? '',
])
</div>
<div class="px-5 py-3 border-t border-gray-200 flex justify-end">
<button type="button" onclick="closeApprovalLineModal()"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition">
확인
</button>
</div>
</div>
</div>
</div>
@endsection
@push('styles')
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" rel="stylesheet">
<style>
#quill-container .ql-editor { min-height: 260px; font-size: 0.875rem; }
#quill-container .ql-toolbar { border-radius: 0.5rem 0.5rem 0 0; border-color: #d1d5db; }
#quill-container .ql-container { border-radius: 0 0 0.5rem 0.5rem; border-color: #d1d5db; }
</style>
@endpush
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
<script>
let quillInstance = null;
function toggleEditor() {
const useEditor = document.getElementById('useEditor').checked;
const textarea = document.getElementById('body');
const container = document.getElementById('quill-container');
if (useEditor) {
container.style.display = '';
textarea.style.display = 'none';
if (!quillInstance) {
quillInstance = new Quill('#quill-container', {
theme: 'snow',
modules: {
toolbar: [
[{ 'header': [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
[{ 'align': [] }],
['blockquote', 'code-block'],
['link'],
['clean'],
],
},
placeholder: '기안 내용을 입력하세요...',
});
}
const text = textarea.value.trim();
if (text) {
if (/<[a-z][\s\S]*>/i.test(text)) {
quillInstance.root.innerHTML = text;
} else {
quillInstance.setText(text);
}
}
} else {
container.style.display = 'none';
textarea.style.display = '';
if (quillInstance) {
const html = quillInstance.root.innerHTML;
textarea.value = (html === '<p><br></p>') ? '' : html;
}
}
}
function getBodyContent() {
const useEditor = document.getElementById('useEditor')?.checked;
if (useEditor && quillInstance) {
const html = quillInstance.root.innerHTML;
return (html === '<p><br></p>') ? '' : html;
}
return document.getElementById('body').value;
}
function openApprovalLineModal() {
document.getElementById('approval-line-modal').style.display = '';
document.body.style.overflow = 'hidden';
}
function closeApprovalLineModal() {
document.getElementById('approval-line-modal').style.display = 'none';
document.body.style.overflow = '';
updateApprovalLineSummary();
}
function updateApprovalLineSummary() {
const editorEl = document.getElementById('approval-line-editor');
if (!editorEl || !editorEl._x_dataStack) return;
const steps = editorEl._x_dataStack[0].steps;
const summaryEl = document.getElementById('approval-line-summary');
if (!steps || steps.length === 0) {
summaryEl.innerHTML = '<span class="text-gray-400">결재선이 설정되지 않았습니다.</span>';
return;
}
const typeLabels = { approval: '결재', agreement: '합의', reference: '참조' };
const parts = steps.map(s => {
const label = typeLabels[s.step_type] || s.step_type;
return '<span class="inline-flex items-center gap-1"><strong class="text-gray-800">' + s.user_name + '</strong> <span class="text-gray-400 text-xs">(' + label + ')</span></span>';
});
summaryEl.innerHTML = '<div class="flex flex-wrap items-center gap-1 text-sm">' +
parts.join('<span class="text-gray-300 mx-0.5">&rarr;</span>') + '</div>';
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const modal = document.getElementById('approval-line-modal');
if (modal && modal.style.display !== 'none') {
closeApprovalLineModal();
}
}
});
document.addEventListener('DOMContentLoaded', function() {
setTimeout(updateApprovalLineSummary, 200);
});
async function saveApproval(action) {
const title = document.getElementById('title').value.trim();
if (!title) {
showToast('제목을 입력해주세요.', 'warning');
return;
}
const editorEl = document.getElementById('approval-line-editor');
const steps = editorEl._x_dataStack[0].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: getBodyContent(),
is_urgent: document.getElementById('is_urgent').checked,
steps: steps,
};
try {
const response = await fetch('/api/admin/approvals', {
method: 'POST',
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 && !data.data) {
showToast(data.message || '저장에 실패했습니다.', 'error');
return;
}
if (action === 'submit') {
const submitResponse = await fetch(`/api/admin/approvals/${data.data.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');
setTimeout(() => location.href = `/approval-mgmt/${data.data.id}/edit`, 500);
}
} catch (e) {
showToast('서버 오류가 발생했습니다.', 'error');
}
}
</script>
@endpush