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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
@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')
|
2026-02-28 14:41:37 +09:00
|
|
|
<div class="bg-red-50 border-l-4 border-red-400 p-4 mb-6 rounded" style="max-width: 960px; margin-left: auto; margin-right: auto;">
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
<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
|
|
|
|
|
|
2026-03-05 13:50:45 +09:00
|
|
|
{{-- 반려 이력 (재상신 문서인 경우) --}}
|
|
|
|
|
@if(!empty($approval->rejection_history))
|
|
|
|
|
<div class="bg-orange-50 border border-orange-200 p-4 mb-6 rounded-lg" style="max-width: 960px; margin-left: auto; margin-right: auto;">
|
|
|
|
|
<h4 class="text-sm font-semibold text-orange-800 mb-2 flex items-center gap-2">
|
|
|
|
|
이전 반려 이력
|
|
|
|
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-200 text-orange-700">{{ count($approval->rejection_history) }}회</span>
|
|
|
|
|
</h4>
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
@foreach($approval->rejection_history as $history)
|
|
|
|
|
<div class="text-sm border-l-2 border-orange-300 pl-3 py-1">
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<span class="text-xs font-medium text-orange-600">{{ $history['round'] ?? '-' }}차</span>
|
|
|
|
|
<span class="font-medium text-gray-800">{{ $history['approver_name'] ?? '' }}</span>
|
|
|
|
|
<span class="text-gray-400 text-xs">{{ $history['rejected_at'] ?? '' }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="text-gray-600 mt-0.5">{{ $history['comment'] ?? '' }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
@endforeach
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@endif
|
|
|
|
|
|
2026-02-28 14:41:37 +09:00
|
|
|
<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>
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
|
2026-02-28 14:41:37 +09:00
|
|
|
<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>
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
|
2026-02-28 14:41:37 +09:00
|
|
|
<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>
|
|
|
|
|
|
2026-02-28 14:48:16 +09:00
|
|
|
{{-- 결재선 --}}
|
|
|
|
|
<div class="mb-4">
|
|
|
|
|
<div class="flex items-center justify-between mb-1">
|
|
|
|
|
<label class="block text-sm font-medium text-gray-700">결재선</label>
|
2026-03-04 14:18:54 +09:00
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<select id="quick-line-select" onchange="applyQuickLine(this.value)"
|
|
|
|
|
class="px-2 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
|
|
style="min-width: 160px;">
|
|
|
|
|
<option value="">결재선 선택</option>
|
|
|
|
|
@foreach($lines as $line)
|
|
|
|
|
<option value="{{ $line->id }}" {{ ($approval->line_id ?? '') == $line->id ? 'selected' : '' }}>
|
|
|
|
|
{{ $line->name }} ({{ count($line->steps ?? []) }}단계)
|
|
|
|
|
</option>
|
|
|
|
|
@endforeach
|
|
|
|
|
</select>
|
|
|
|
|
<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 whitespace-nowrap">
|
|
|
|
|
세부 설정
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-02-28 14:48:16 +09:00
|
|
|
</div>
|
|
|
|
|
<div id="approval-line-summary" class="p-3 bg-gray-50 rounded-lg border border-gray-200 flex items-center">
|
|
|
|
|
<span class="text-sm text-gray-400">결재선이 설정되지 않았습니다.</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-28 14:41:37 +09:00
|
|
|
<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>
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
|
2026-03-04 15:14:18 +09:00
|
|
|
{{-- 본문 (일반 양식) --}}
|
|
|
|
|
<div id="body-area" class="mb-4">
|
2026-02-28 14:41:37 +09:00
|
|
|
<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>
|
2026-02-28 14:18:16 +09:00
|
|
|
</label>
|
2026-02-28 14:41:37 +09:00
|
|
|
</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 id="quill-container" style="display: none; min-height: 300px;"></div>
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-03-05 18:53:42 +09:00
|
|
|
{{-- 재직증명서 전용 폼 --}}
|
|
|
|
|
@include('approvals.partials._certificate-form', [
|
|
|
|
|
'employees' => $employees ?? collect(),
|
|
|
|
|
])
|
|
|
|
|
|
2026-03-04 15:14:18 +09:00
|
|
|
{{-- 지출결의서 전용 폼 --}}
|
2026-03-04 20:07:49 +09:00
|
|
|
@php
|
|
|
|
|
$existingFiles = [];
|
|
|
|
|
if (!empty($approval->attachments)) {
|
|
|
|
|
$fileIds = collect($approval->attachments)->pluck('id')->filter();
|
|
|
|
|
if ($fileIds->isNotEmpty()) {
|
|
|
|
|
$existingFiles = \App\Models\Boards\File::whereIn('id', $fileIds)
|
|
|
|
|
->get()
|
|
|
|
|
->map(fn($f) => [
|
|
|
|
|
'id' => $f->id,
|
|
|
|
|
'name' => $f->original_name,
|
|
|
|
|
'size' => $f->file_size,
|
|
|
|
|
'mime_type' => $f->mime_type,
|
|
|
|
|
])->toArray();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@endphp
|
2026-03-04 15:14:18 +09:00
|
|
|
@include('approvals.partials._expense-form', [
|
|
|
|
|
'initialData' => $approval->content ?? [],
|
2026-03-04 20:07:49 +09:00
|
|
|
'initialFiles' => $existingFiles,
|
2026-03-04 20:29:25 +09:00
|
|
|
'cards' => $cards ?? collect(),
|
|
|
|
|
'accounts' => $accounts ?? collect(),
|
2026-03-04 15:14:18 +09:00
|
|
|
])
|
|
|
|
|
|
2026-02-28 14:41:37 +09:00
|
|
|
{{-- 액션 버튼 --}}
|
|
|
|
|
<div class="border-t pt-4 mt-4 flex gap-2 justify-end">
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
<button onclick="updateApproval('save')"
|
2026-02-28 14:41:37 +09:00
|
|
|
class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
저장
|
|
|
|
|
</button>
|
|
|
|
|
<button onclick="updateApproval('submit')"
|
2026-02-28 14:41:37 +09:00
|
|
|
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
{{ $approval->status === 'rejected' ? '재상신' : '상신' }}
|
|
|
|
|
</button>
|
|
|
|
|
@if($approval->isDeletable())
|
|
|
|
|
<button onclick="deleteApproval()"
|
2026-02-28 14:41:37 +09:00
|
|
|
class="bg-red-100 hover:bg-red-200 text-red-700 px-6 py-2 rounded-lg transition text-sm font-medium">
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
삭제
|
|
|
|
|
</button>
|
|
|
|
|
@endif
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-28 14:41:37 +09:00
|
|
|
|
|
|
|
|
{{-- 결재선 모달 --}}
|
|
|
|
|
<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
|
|
|
|
|
$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>
|
|
|
|
|
<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>
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
@endsection
|
|
|
|
|
|
2026-02-28 14:18:16 +09:00
|
|
|
@push('styles')
|
2026-02-28 14:21:01 +09:00
|
|
|
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" rel="stylesheet">
|
2026-02-28 14:18:16 +09:00
|
|
|
<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; }
|
2026-02-28 14:55:15 +09:00
|
|
|
#summary-sortable .step-card {
|
|
|
|
|
cursor: grab;
|
|
|
|
|
position: relative;
|
|
|
|
|
margin-right: 24px;
|
|
|
|
|
user-select: none;
|
|
|
|
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
|
|
|
}
|
|
|
|
|
#summary-sortable .step-card:last-child { margin-right: 0; }
|
|
|
|
|
#summary-sortable .step-card:not(:last-child)::after {
|
|
|
|
|
content: '→';
|
|
|
|
|
position: absolute;
|
|
|
|
|
right: -18px;
|
|
|
|
|
top: 50%;
|
|
|
|
|
transform: translateY(-50%);
|
|
|
|
|
color: #d1d5db;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
}
|
|
|
|
|
#summary-sortable .step-card:hover {
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
}
|
|
|
|
|
#summary-sortable .step-card:active { cursor: grabbing; }
|
|
|
|
|
#summary-sortable .step-card.sortable-ghost { opacity: 0.3; }
|
|
|
|
|
#summary-sortable .step-card.sortable-chosen {
|
|
|
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
|
|
|
transform: translateY(-2px);
|
|
|
|
|
}
|
2026-02-28 14:18:16 +09:00
|
|
|
</style>
|
|
|
|
|
@endpush
|
|
|
|
|
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
@push('scripts')
|
2026-02-28 14:21:01 +09:00
|
|
|
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
<script>
|
2026-02-28 14:18:16 +09:00
|
|
|
let quillInstance = null;
|
2026-02-28 14:55:15 +09:00
|
|
|
var summarySortableInstance = null;
|
2026-03-04 14:18:54 +09:00
|
|
|
const formBodyTemplates = @json($forms->pluck('body_template', 'id'));
|
2026-03-04 15:14:18 +09:00
|
|
|
const formCodes = @json($forms->pluck('code', 'id'));
|
2026-03-04 14:18:54 +09:00
|
|
|
const linesData = @json($lines);
|
2026-03-04 15:14:18 +09:00
|
|
|
let isExpenseForm = false;
|
2026-03-05 18:53:42 +09:00
|
|
|
let isCertForm = false;
|
|
|
|
|
let certFileId = null;
|
2026-02-28 14:18:16 +09:00
|
|
|
|
2026-03-04 14:21:07 +09:00
|
|
|
function escapeHtml(str) {
|
|
|
|
|
if (!str) return '';
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
div.appendChild(document.createTextNode(str));
|
|
|
|
|
return div.innerHTML;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 14:18:16 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 14:41:37 +09:00
|
|
|
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();
|
2026-03-04 14:18:54 +09:00
|
|
|
|
|
|
|
|
// 모달 내부 결재선 선택과 외부 드롭다운 동기화
|
|
|
|
|
const editorEl = document.getElementById('approval-line-editor');
|
|
|
|
|
if (editorEl && editorEl._x_dataStack) {
|
|
|
|
|
document.getElementById('quick-line-select').value = editorEl._x_dataStack[0].selectedLineId || '';
|
|
|
|
|
}
|
2026-02-28 14:41:37 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
2026-02-28 14:55:15 +09:00
|
|
|
if (summarySortableInstance) { summarySortableInstance.destroy(); summarySortableInstance = null; }
|
|
|
|
|
|
2026-02-28 14:41:37 +09:00
|
|
|
if (!steps || steps.length === 0) {
|
2026-02-28 14:48:16 +09:00
|
|
|
summaryEl.className = 'p-3 bg-gray-50 rounded-lg border border-gray-200 flex items-center';
|
|
|
|
|
summaryEl.innerHTML = '<span class="text-sm text-gray-400">결재선이 설정되지 않았습니다.</span>';
|
2026-02-28 14:41:37 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 14:48:16 +09:00
|
|
|
const typeCounters = { approval: 0, agreement: 0, reference: 0 };
|
2026-02-28 14:41:37 +09:00
|
|
|
const typeLabels = { approval: '결재', agreement: '합의', reference: '참조' };
|
2026-02-28 14:48:16 +09:00
|
|
|
const typeBg = { approval: 'bg-blue-50 border-blue-100', agreement: 'bg-green-50 border-green-100', reference: 'bg-gray-100 border-gray-200' };
|
|
|
|
|
const typeLabelColor = { approval: 'text-blue-600', agreement: 'text-green-600', reference: 'text-gray-500' };
|
|
|
|
|
|
|
|
|
|
const cards = [];
|
|
|
|
|
steps.forEach(function(s, i) {
|
|
|
|
|
typeCounters[s.step_type] = (typeCounters[s.step_type] || 0) + 1;
|
|
|
|
|
var count = typeCounters[s.step_type];
|
|
|
|
|
var label = typeLabels[s.step_type] || s.step_type;
|
|
|
|
|
var bg = typeBg[s.step_type] || typeBg.reference;
|
|
|
|
|
var labelColor = typeLabelColor[s.step_type] || typeLabelColor.reference;
|
|
|
|
|
var stepLabel = s.step_type === 'reference' ? label : count + '차 ' + label;
|
|
|
|
|
var position = s.position || '';
|
|
|
|
|
|
|
|
|
|
cards.push(
|
2026-02-28 14:55:15 +09:00
|
|
|
'<div class="step-card text-center px-3 py-2 rounded-lg border ' + bg + '" data-index="' + i + '" style="min-width: 72px;">' +
|
2026-03-04 14:21:07 +09:00
|
|
|
'<div class="text-xs font-medium ' + labelColor + '">' + escapeHtml(stepLabel) + '</div>' +
|
|
|
|
|
(position ? '<div class="text-xs text-gray-400 mt-0.5">' + escapeHtml(position) + '</div>' : '') +
|
|
|
|
|
'<div class="text-sm font-semibold text-gray-800 mt-0.5">' + escapeHtml(s.user_name) + '</div>' +
|
2026-02-28 14:48:16 +09:00
|
|
|
'</div>'
|
|
|
|
|
);
|
2026-02-28 14:41:37 +09:00
|
|
|
});
|
|
|
|
|
|
2026-02-28 14:48:16 +09:00
|
|
|
summaryEl.className = 'p-3 bg-gray-50 rounded-lg border border-gray-200';
|
2026-02-28 14:55:15 +09:00
|
|
|
summaryEl.innerHTML = '<div id="summary-sortable" class="flex flex-wrap items-center">' + cards.join('') + '</div>' +
|
|
|
|
|
(steps.length > 1 ? '<div class="text-xs text-gray-400 mt-2">드래그하여 순서를 변경할 수 있습니다</div>' : '');
|
|
|
|
|
|
|
|
|
|
initSummarySortable();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function initSummarySortable() {
|
|
|
|
|
if (summarySortableInstance) { summarySortableInstance.destroy(); summarySortableInstance = null; }
|
|
|
|
|
var el = document.getElementById('summary-sortable');
|
|
|
|
|
if (!el || typeof Sortable === 'undefined') return;
|
|
|
|
|
|
|
|
|
|
summarySortableInstance = Sortable.create(el, {
|
|
|
|
|
animation: 150,
|
|
|
|
|
ghostClass: 'sortable-ghost',
|
|
|
|
|
chosenClass: 'sortable-chosen',
|
|
|
|
|
onEnd: function(evt) {
|
|
|
|
|
if (evt.oldIndex === evt.newIndex) return;
|
|
|
|
|
var editorEl = document.getElementById('approval-line-editor');
|
|
|
|
|
if (!editorEl || !editorEl._x_dataStack) return;
|
|
|
|
|
var steps = editorEl._x_dataStack[0].steps;
|
|
|
|
|
var item = steps.splice(evt.oldIndex, 1)[0];
|
|
|
|
|
steps.splice(evt.newIndex, 0, item);
|
|
|
|
|
updateApprovalLineSummary();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-02-28 14:41:37 +09:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 14:18:54 +09:00
|
|
|
function applyQuickLine(lineId) {
|
|
|
|
|
const editorEl = document.getElementById('approval-line-editor');
|
|
|
|
|
if (!editorEl || !editorEl._x_dataStack) return;
|
|
|
|
|
const alpineData = editorEl._x_dataStack[0];
|
|
|
|
|
|
|
|
|
|
if (!lineId) {
|
|
|
|
|
alpineData.selectedLineId = '';
|
|
|
|
|
alpineData.steps = [];
|
|
|
|
|
} else {
|
|
|
|
|
alpineData.selectedLineId = lineId;
|
|
|
|
|
alpineData.loadLine();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setTimeout(updateApprovalLineSummary, 100);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 15:14:18 +09:00
|
|
|
function switchFormMode(formId) {
|
|
|
|
|
const code = formCodes[formId];
|
|
|
|
|
const expenseContainer = document.getElementById('expense-form-container');
|
2026-03-05 18:53:42 +09:00
|
|
|
const certContainer = document.getElementById('cert-form-container');
|
2026-03-04 15:14:18 +09:00
|
|
|
const bodyArea = document.getElementById('body-area');
|
|
|
|
|
|
2026-03-05 18:53:42 +09:00
|
|
|
expenseContainer.style.display = 'none';
|
|
|
|
|
certContainer.style.display = 'none';
|
|
|
|
|
bodyArea.style.display = 'none';
|
|
|
|
|
isExpenseForm = false;
|
|
|
|
|
isCertForm = false;
|
|
|
|
|
|
2026-03-04 15:14:18 +09:00
|
|
|
if (code === 'expense') {
|
|
|
|
|
isExpenseForm = true;
|
|
|
|
|
expenseContainer.style.display = '';
|
2026-03-05 18:53:42 +09:00
|
|
|
} else if (code === 'employment_cert') {
|
|
|
|
|
isCertForm = true;
|
|
|
|
|
certContainer.style.display = '';
|
|
|
|
|
certFileId = null;
|
|
|
|
|
const certUserId = document.getElementById('cert-user-id').value;
|
|
|
|
|
if (certUserId) loadCertInfo(certUserId);
|
2026-03-04 15:14:18 +09:00
|
|
|
} else {
|
|
|
|
|
bodyArea.style.display = '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 14:18:54 +09:00
|
|
|
function applyBodyTemplate(formId) {
|
2026-03-04 15:14:18 +09:00
|
|
|
// 먼저 폼 모드 전환
|
|
|
|
|
switchFormMode(formId);
|
|
|
|
|
|
|
|
|
|
// 전용 폼이면 제목만 자동 설정하고 body template 적용 건너뜀
|
2026-03-05 18:53:42 +09:00
|
|
|
if (isExpenseForm || isCertForm) {
|
2026-03-04 15:14:18 +09:00
|
|
|
const titleEl = document.getElementById('title');
|
|
|
|
|
if (!titleEl.value.trim()) {
|
|
|
|
|
const formSelect = document.getElementById('form_id');
|
|
|
|
|
titleEl.value = formSelect.options[formSelect.selectedIndex].text;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 14:18:54 +09:00
|
|
|
const template = formBodyTemplates[formId];
|
|
|
|
|
if (!template) return;
|
|
|
|
|
|
|
|
|
|
// edit에서는 항상 confirm (기존 본문이 있을 가능성 높음)
|
|
|
|
|
const bodyContent = getBodyContent();
|
|
|
|
|
if (bodyContent.trim()) {
|
|
|
|
|
if (!confirm('현재 본문 내용을 양식 템플릿으로 교체하시겠습니까?')) return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 14:51:18 +09:00
|
|
|
// 제목 자동 설정 (비어있을 때만)
|
|
|
|
|
const titleEl = document.getElementById('title');
|
|
|
|
|
if (!titleEl.value.trim()) {
|
|
|
|
|
const formSelect = document.getElementById('form_id');
|
|
|
|
|
titleEl.value = formSelect.options[formSelect.selectedIndex].text;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 14:18:54 +09:00
|
|
|
const useEditorEl = document.getElementById('useEditor');
|
|
|
|
|
if (!useEditorEl.checked) {
|
|
|
|
|
useEditorEl.checked = true;
|
|
|
|
|
toggleEditor();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (quillInstance) {
|
|
|
|
|
quillInstance.root.innerHTML = template;
|
|
|
|
|
}
|
|
|
|
|
document.getElementById('body').value = template;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 14:41:37 +09:00
|
|
|
document.addEventListener('keydown', function(e) {
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
const modal = document.getElementById('approval-line-modal');
|
|
|
|
|
if (modal && modal.style.display !== 'none') {
|
|
|
|
|
closeApprovalLineModal();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 기존 HTML body 자동 감지 → 편집기 자동 활성화 + 결재선 요약 초기화
|
2026-02-28 14:18:16 +09:00
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
2026-03-04 15:14:18 +09:00
|
|
|
// 초기 양식에 대한 폼 모드 전환
|
|
|
|
|
switchFormMode(document.getElementById('form_id').value);
|
|
|
|
|
|
2026-03-05 18:53:42 +09:00
|
|
|
// 재직증명서 기존 데이터 복원
|
|
|
|
|
if (isCertForm) {
|
|
|
|
|
const certContent = @json($approval->content ?? []);
|
|
|
|
|
if (certContent.name) {
|
|
|
|
|
document.getElementById('cert-name').value = certContent.name || '';
|
|
|
|
|
document.getElementById('cert-resident').value = certContent.resident_number || '';
|
|
|
|
|
document.getElementById('cert-address').value = certContent.address || '';
|
|
|
|
|
document.getElementById('cert-company').value = certContent.company_name || '';
|
|
|
|
|
document.getElementById('cert-business-num').value = certContent.business_num || '';
|
|
|
|
|
document.getElementById('cert-department').value = certContent.department || '';
|
|
|
|
|
document.getElementById('cert-position').value = certContent.position || '';
|
|
|
|
|
document.getElementById('cert-hire-date').value = certContent.hire_date || '';
|
|
|
|
|
document.getElementById('cert-issue-date').value = certContent.issue_date || '{{ now()->format("Y-m-d") }}';
|
|
|
|
|
|
|
|
|
|
// 용도 복원
|
|
|
|
|
const purposeSelect = document.getElementById('cert-purpose-select');
|
|
|
|
|
const purpose = certContent.purpose || '';
|
|
|
|
|
const predefined = ['은행 제출용', '관공서 제출용', '비자 신청용', '대출 신청용'];
|
|
|
|
|
if (predefined.includes(purpose)) {
|
|
|
|
|
purposeSelect.value = purpose;
|
|
|
|
|
} else if (purpose) {
|
|
|
|
|
purposeSelect.value = '__custom__';
|
|
|
|
|
document.getElementById('cert-purpose-custom-wrap').style.display = '';
|
|
|
|
|
document.getElementById('cert-purpose-custom').value = purpose;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 사원 셀렉트 복원
|
|
|
|
|
if (certContent.cert_user_id) {
|
|
|
|
|
document.getElementById('cert-user-id').value = certContent.cert_user_id;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 15:14:18 +09:00
|
|
|
// 전용 폼이 아닌 경우에만 Quill 편집기 자동 활성화
|
2026-03-05 18:53:42 +09:00
|
|
|
if (!isExpenseForm && !isCertForm) {
|
2026-03-04 15:14:18 +09:00
|
|
|
const existingBody = document.getElementById('body').value;
|
|
|
|
|
if (/<[a-z][\s\S]*>/i.test(existingBody)) {
|
|
|
|
|
document.getElementById('useEditor').checked = true;
|
|
|
|
|
toggleEditor();
|
|
|
|
|
}
|
2026-02-28 14:18:16 +09:00
|
|
|
}
|
2026-02-28 14:41:37 +09:00
|
|
|
|
|
|
|
|
// Alpine 초기화 후 기존 결재선 요약 표시
|
|
|
|
|
setTimeout(updateApprovalLineSummary, 200);
|
2026-03-04 14:18:54 +09:00
|
|
|
|
|
|
|
|
// 양식 변경 시 본문 템플릿 자동 채움
|
|
|
|
|
document.getElementById('form_id').addEventListener('change', function() {
|
|
|
|
|
applyBodyTemplate(this.value);
|
|
|
|
|
});
|
2026-02-28 14:18:16 +09:00
|
|
|
});
|
|
|
|
|
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
async function updateApproval(action) {
|
|
|
|
|
const title = document.getElementById('title').value.trim();
|
|
|
|
|
if (!title) {
|
|
|
|
|
showToast('제목을 입력해주세요.', 'warning');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 00:45:08 +09:00
|
|
|
const editorEl = document.getElementById('approval-line-editor');
|
|
|
|
|
const steps = editorEl._x_dataStack[0].getStepsData();
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
|
|
|
|
|
if (action === 'submit' && steps.filter(s => s.step_type !== 'reference').length === 0) {
|
|
|
|
|
showToast('결재자를 1명 이상 추가해주세요.', 'warning');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 18:53:42 +09:00
|
|
|
let formContent = {};
|
|
|
|
|
let formBody = getBodyContent();
|
2026-03-04 20:07:49 +09:00
|
|
|
let attachmentFileIds = [];
|
2026-03-05 18:53:42 +09:00
|
|
|
|
2026-03-04 15:14:18 +09:00
|
|
|
if (isExpenseForm) {
|
|
|
|
|
const expenseEl = document.getElementById('expense-form-container');
|
|
|
|
|
if (expenseEl && expenseEl._x_dataStack) {
|
2026-03-05 18:53:42 +09:00
|
|
|
formContent = expenseEl._x_dataStack[0].getFormData();
|
2026-03-04 20:07:49 +09:00
|
|
|
attachmentFileIds = expenseEl._x_dataStack[0].getFileIds();
|
2026-03-04 15:14:18 +09:00
|
|
|
}
|
2026-03-05 18:53:42 +09:00
|
|
|
formBody = null;
|
|
|
|
|
} else if (isCertForm) {
|
|
|
|
|
const purpose = getCertPurpose();
|
|
|
|
|
if (!purpose) {
|
|
|
|
|
showToast('사용용도를 입력해주세요.', 'warning');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
formContent = {
|
|
|
|
|
cert_user_id: document.getElementById('cert-user-id').value,
|
|
|
|
|
name: document.getElementById('cert-name').value,
|
|
|
|
|
resident_number: document.getElementById('cert-resident').value,
|
|
|
|
|
address: document.getElementById('cert-address').value,
|
|
|
|
|
department: document.getElementById('cert-department').value,
|
|
|
|
|
position: document.getElementById('cert-position').value,
|
|
|
|
|
hire_date: document.getElementById('cert-hire-date').value,
|
|
|
|
|
company_name: document.getElementById('cert-company').value,
|
|
|
|
|
business_num: document.getElementById('cert-business-num').value,
|
|
|
|
|
purpose: purpose,
|
|
|
|
|
issue_date: document.getElementById('cert-issue-date').value,
|
|
|
|
|
};
|
|
|
|
|
formBody = null;
|
|
|
|
|
|
|
|
|
|
// DOCX 생성
|
|
|
|
|
if (!certFileId) {
|
|
|
|
|
showToast('재직증명서 DOCX를 생성 중입니다...', 'info');
|
|
|
|
|
try {
|
|
|
|
|
const certResp = await fetch('/api/admin/approvals/generate-cert-docx', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
user_id: formContent.cert_user_id,
|
|
|
|
|
purpose: purpose,
|
|
|
|
|
address: formContent.address,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
const certData = await certResp.json();
|
|
|
|
|
if (certData.success) {
|
|
|
|
|
certFileId = certData.data.file_id;
|
|
|
|
|
} else {
|
|
|
|
|
showToast(certData.message || 'DOCX 생성 실패', 'error');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
showToast('DOCX 생성 중 오류가 발생했습니다.', 'error');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (certFileId) {
|
|
|
|
|
attachmentFileIds.push(certFileId);
|
|
|
|
|
}
|
2026-03-04 15:14:18 +09:00
|
|
|
}
|
|
|
|
|
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
const payload = {
|
|
|
|
|
form_id: document.getElementById('form_id').value,
|
|
|
|
|
title: title,
|
2026-03-05 18:53:42 +09:00
|
|
|
body: formBody,
|
|
|
|
|
content: formContent,
|
2026-03-04 20:07:49 +09:00
|
|
|
attachment_file_ids: attachmentFileIds,
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 18:53:42 +09:00
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// 재직증명서 관련 함수
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
async function loadCertInfo(userId) {
|
|
|
|
|
if (!userId) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(`/api/admin/approvals/cert-info/${userId}`, {
|
|
|
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
|
|
|
});
|
|
|
|
|
const data = await resp.json();
|
|
|
|
|
|
|
|
|
|
if (data.success) {
|
|
|
|
|
const d = data.data;
|
|
|
|
|
document.getElementById('cert-name').value = d.name || '';
|
|
|
|
|
document.getElementById('cert-resident').value = d.resident_number || '';
|
|
|
|
|
document.getElementById('cert-address').value = d.address || '';
|
|
|
|
|
document.getElementById('cert-company').value = d.company_name || '';
|
|
|
|
|
document.getElementById('cert-business-num').value = d.business_num || '';
|
|
|
|
|
document.getElementById('cert-department').value = d.department || '';
|
|
|
|
|
document.getElementById('cert-position').value = d.position || '';
|
|
|
|
|
document.getElementById('cert-hire-date').value = d.hire_date ? d.hire_date + ' ~' : '';
|
|
|
|
|
certFileId = null;
|
|
|
|
|
} else {
|
|
|
|
|
showToast(data.message || '사원 정보를 불러올 수 없습니다.', 'error');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
showToast('사원 정보 조회 중 오류가 발생했습니다.', 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onCertPurposeChange() {
|
|
|
|
|
const sel = document.getElementById('cert-purpose-select');
|
|
|
|
|
const customWrap = document.getElementById('cert-purpose-custom-wrap');
|
|
|
|
|
if (sel.value === '__custom__') {
|
|
|
|
|
customWrap.style.display = '';
|
|
|
|
|
document.getElementById('cert-purpose-custom').focus();
|
|
|
|
|
} else {
|
|
|
|
|
customWrap.style.display = 'none';
|
|
|
|
|
}
|
|
|
|
|
certFileId = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCertPurpose() {
|
|
|
|
|
const sel = document.getElementById('cert-purpose-select');
|
|
|
|
|
if (sel.value === '__custom__') {
|
|
|
|
|
return document.getElementById('cert-purpose-custom').value.trim();
|
|
|
|
|
}
|
|
|
|
|
return sel.value;
|
|
|
|
|
}
|
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 라우트 등록
2026-02-27 23:17:17 +09:00
|
|
|
</script>
|
|
|
|
|
@endpush
|