diff --git a/app/Http/Controllers/Api/Admin/ApprovalApiController.php b/app/Http/Controllers/Api/Admin/ApprovalApiController.php index c9b1deaf..f4da88b3 100644 --- a/app/Http/Controllers/Api/Admin/ApprovalApiController.php +++ b/app/Http/Controllers/Api/Admin/ApprovalApiController.php @@ -367,6 +367,63 @@ public function lines(): JsonResponse return response()->json(['success' => true, 'data' => $lines]); } + /** + * 결재선 템플릿 생성 + */ + public function storeLine(Request $request): JsonResponse + { + $request->validate([ + 'name' => 'required|string|max:100', + 'steps' => 'required|array|min:1', + 'steps.*.user_id' => 'required|exists:users,id', + 'steps.*.step_type' => 'required|in:approval,agreement,reference', + 'is_default' => 'boolean', + ]); + + $line = $this->service->createLine($request->all()); + + return response()->json([ + 'success' => true, + 'message' => '결재선이 저장되었습니다.', + 'data' => $line, + ], 201); + } + + /** + * 결재선 템플릿 수정 + */ + public function updateLine(Request $request, int $id): JsonResponse + { + $request->validate([ + 'name' => 'required|string|max:100', + 'steps' => 'required|array|min:1', + 'steps.*.user_id' => 'required|exists:users,id', + 'steps.*.step_type' => 'required|in:approval,agreement,reference', + 'is_default' => 'boolean', + ]); + + $line = $this->service->updateLine($id, $request->all()); + + return response()->json([ + 'success' => true, + 'message' => '결재선이 수정되었습니다.', + 'data' => $line, + ]); + } + + /** + * 결재선 템플릿 삭제 + */ + public function destroyLine(int $id): JsonResponse + { + $this->service->deleteLine($id); + + return response()->json([ + 'success' => true, + 'message' => '결재선이 삭제되었습니다.', + ]); + } + /** * 양식 목록 */ diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php index 22269c28..5d396f9b 100644 --- a/app/Services/ApprovalService.php +++ b/app/Services/ApprovalService.php @@ -560,6 +560,53 @@ public function getApprovalLines(): Collection return ApprovalLine::orderBy('name')->get(); } + /** + * 결재선 템플릿 생성 + */ + public function createLine(array $data): ApprovalLine + { + $tenantId = session('selected_tenant_id'); + $steps = $this->enrichLineSteps($data['steps']); + + return ApprovalLine::create([ + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'steps' => $steps, + 'is_default' => $data['is_default'] ?? false, + 'created_by' => auth()->id(), + 'updated_by' => auth()->id(), + ]); + } + + /** + * 결재선 템플릿 수정 + */ + public function updateLine(int $id, array $data): ApprovalLine + { + $line = ApprovalLine::findOrFail($id); + $steps = $this->enrichLineSteps($data['steps']); + + $line->update([ + 'name' => $data['name'], + 'steps' => $steps, + 'is_default' => $data['is_default'] ?? false, + 'updated_by' => auth()->id(), + ]); + + return $line->fresh(); + } + + /** + * 결재선 템플릿 삭제 + */ + public function deleteLine(int $id): bool + { + $line = ApprovalLine::findOrFail($id); + $line->update(['deleted_by' => auth()->id()]); + + return $line->delete(); + } + /** * 양식 목록 */ @@ -645,6 +692,27 @@ public function getBadgeCounts(int $userId): array // Private 헬퍼 // ========================================================================= + /** + * 결재선 steps에 user_name, department, position 스냅샷 보강 + */ + private function enrichLineSteps(array $steps): array + { + $tenantId = session('selected_tenant_id'); + + return collect($steps)->map(function ($step) use ($tenantId) { + $user = User::find($step['user_id']); + $profile = $user?->tenantProfiles()->where('tenant_id', $tenantId)->first(); + + return [ + 'user_id' => $step['user_id'], + 'user_name' => $user?->name ?? '', + 'department' => $profile?->department?->name ?? $step['department'] ?? '', + 'position' => $profile?->position_label ?? $step['position'] ?? '', + 'step_type' => $step['step_type'] ?? 'approval', + ]; + })->toArray(); + } + /** * 문서번호 채번 (APR-YYMMDD-001) */ diff --git a/resources/views/approvals/drafts.blade.php b/resources/views/approvals/drafts.blade.php index 23221ba0..1f655872 100644 --- a/resources/views/approvals/drafts.blade.php +++ b/resources/views/approvals/drafts.blade.php @@ -6,9 +6,15 @@

기안함

- - + 새 기안 - +
+ + + + 새 기안 + +
@@ -43,6 +49,102 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
+ + + @endsection @push('scripts') @@ -148,6 +250,398 @@ function renderPagination(data) { area.innerHTML = html; } +// ========================================================================= +// 결재선 관리 모달 +// ========================================================================= + +let lineManagerState = 'list'; +let editingLineId = null; +let lineSteps = []; +let lineDepartments = []; +let lineExpandedDepts = {}; +const csrfToken = '{{ csrf_token() }}'; + +function openLineManager() { + document.getElementById('lineManagerModal').classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + switchToLineList(); + loadLineList(); + loadLineDepartments(); +} + +function closeLineManager() { + document.getElementById('lineManagerModal').classList.add('hidden'); + document.body.style.overflow = ''; +} + +function switchToLineList() { + lineManagerState = 'list'; + editingLineId = null; + lineSteps = []; + document.getElementById('lineListView').classList.remove('hidden'); + document.getElementById('lineEditView').classList.add('hidden'); + document.getElementById('lineEditFooter').classList.add('hidden'); + document.getElementById('lineBackBtn').classList.add('hidden'); + document.getElementById('lineManagerTitle').textContent = '결재선 관리'; +} + +function switchToLineEdit() { + lineManagerState = 'edit'; + document.getElementById('lineListView').classList.add('hidden'); + document.getElementById('lineEditView').classList.remove('hidden'); + document.getElementById('lineEditFooter').classList.remove('hidden'); + document.getElementById('lineBackBtn').classList.remove('hidden'); + document.getElementById('lineManagerTitle').textContent = editingLineId ? '결재선 수정' : '새 결재선'; + renderLineSteps(); + renderLineDeptList(); +} + +function loadLineList() { + const body = document.getElementById('lineListBody'); + body.innerHTML = '
'; + + fetch('/api/admin/approvals/lines', { + headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken } + }) + .then(r => r.json()) + .then(data => { + const lines = data.data || []; + if (!lines.length) { + body.innerHTML = '
등록된 결재선이 없습니다.
'; + return; + } + body.innerHTML = lines.map(line => { + const stepsArr = line.steps || []; + const summary = stepsArr.map(s => s.user_name || '?').join(' → '); + const typeCounts = { + approval: stepsArr.filter(s => s.step_type === 'approval').length, + agreement: stepsArr.filter(s => s.step_type === 'agreement').length, + reference: stepsArr.filter(s => s.step_type === 'reference').length, + }; + const typeLabel = [ + typeCounts.approval ? `결재 ${typeCounts.approval}` : '', + typeCounts.agreement ? `합의 ${typeCounts.agreement}` : '', + typeCounts.reference ? `참조 ${typeCounts.reference}` : '', + ].filter(Boolean).join(', '); + + return `
+
+
+ ${escapeHtml(line.name)} + ${line.is_default ? '기본' : ''} +
+
${escapeHtml(summary)}
+
${typeLabel} (${stepsArr.length}단계)
+
+
+ + +
+
`; + }).join(''); + }) + .catch(() => { + body.innerHTML = '
목록을 불러올 수 없습니다.
'; + }); +} + +function loadLineDepartments() { + if (lineDepartments.length > 0) return; + fetch('/api/admin/tenant-users/list', { + headers: { 'Accept': 'application/json' } + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + lineDepartments = data.data; + lineDepartments.forEach(d => { + lineExpandedDepts[d.department_id ?? 'none'] = true; + }); + } + }); +} + +function openLineEdit(id = null) { + editingLineId = id; + lineSteps = []; + document.getElementById('lineNameInput').value = ''; + + if (id) { + // 기존 결재선 로드 + fetch('/api/admin/approvals/lines', { + headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken } + }) + .then(r => r.json()) + .then(data => { + const line = (data.data || []).find(l => l.id === id); + if (line) { + document.getElementById('lineNameInput').value = line.name; + lineSteps = (line.steps || []).map((s, i) => ({ + _key: i + 1, + user_id: s.user_id, + user_name: s.user_name || '', + department: s.department || '', + position: s.position || '', + step_type: s.step_type || 'approval', + })); + } + switchToLineEdit(); + }); + } else { + switchToLineEdit(); + } +} + +function backToLineList() { + switchToLineList(); + loadLineList(); +} + +function saveLine() { + const name = document.getElementById('lineNameInput').value.trim(); + if (!name) { + if (typeof showToast === 'function') showToast('결재선 이름을 입력하세요.', 'warning'); + return; + } + if (lineSteps.length === 0) { + if (typeof showToast === 'function') showToast('결재자를 1명 이상 추가하세요.', 'warning'); + return; + } + + const payload = { + name: name, + steps: lineSteps.map(s => ({ + user_id: s.user_id, + step_type: s.step_type, + })), + is_default: false, + }; + + const url = editingLineId + ? `/api/admin/approvals/lines/${editingLineId}` + : '/api/admin/approvals/lines'; + const method = editingLineId ? 'PUT' : 'POST'; + + fetch(url, { + method: method, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': csrfToken, + }, + body: JSON.stringify(payload), + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + if (typeof showToast === 'function') showToast(data.message, 'success'); + backToLineList(); + } else { + const msg = data.message || '저장에 실패했습니다.'; + if (typeof showToast === 'function') showToast(msg, 'error'); + } + }) + .catch(() => { + if (typeof showToast === 'function') showToast('저장 중 오류가 발생했습니다.', 'error'); + }); +} + +function deleteLine(id, name) { + if (!confirm(`"${name}" 결재선을 삭제하시겠습니까?`)) return; + + fetch(`/api/admin/approvals/lines/${id}`, { + method: 'DELETE', + headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }, + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + if (typeof showToast === 'function') showToast(data.message, 'success'); + loadLineList(); + } else { + if (typeof showToast === 'function') showToast(data.message || '삭제 실패', 'error'); + } + }); +} + +function addLineStep(user, deptName) { + if (lineSteps.some(s => s.user_id === user.id)) { + if (typeof showToast === 'function') showToast('이미 추가된 결재자입니다.', 'warning'); + return; + } + lineSteps.push({ + _key: Date.now(), + user_id: user.id, + user_name: user.name, + department: deptName || '', + position: user.position || user.job_title || '', + step_type: 'approval', + }); + renderLineSteps(); + renderLineDeptList(); +} + +function removeLineStep(index) { + lineSteps.splice(index, 1); + renderLineSteps(); + renderLineDeptList(); +} + +function changeLineStepType(index, value) { + lineSteps[index].step_type = value; + updateLineSummary(); +} + +function moveLineStep(index, direction) { + const newIndex = index + direction; + if (newIndex < 0 || newIndex >= lineSteps.length) return; + const temp = lineSteps[index]; + lineSteps[index] = lineSteps[newIndex]; + lineSteps[newIndex] = temp; + renderLineSteps(); +} + +function renderLineSteps() { + const container = document.getElementById('lineStepList'); + const emptyEl = document.getElementById('lineStepEmpty'); + + if (lineSteps.length === 0) { + container.innerHTML = ''; + container.classList.add('hidden'); + emptyEl.classList.remove('hidden'); + } else { + emptyEl.classList.add('hidden'); + container.classList.remove('hidden'); + + container.innerHTML = lineSteps.map((step, i) => { + const info = [step.department, step.position].filter(Boolean).join(' / '); + return `
+
+ + +
+ ${i + 1} +
+ ${escapeHtml(step.user_name)} +
${escapeHtml(info)}
+
+ + +
`; + }).join(''); + } + + updateLineSummary(); +} + +function renderLineDeptList() { + const container = document.getElementById('lineDeptList'); + const query = (document.getElementById('lineUserSearch')?.value || '').trim().toLowerCase(); + + let depts = lineDepartments; + if (query) { + depts = depts.map(dept => { + const deptMatch = dept.department_name.toLowerCase().includes(query); + const matched = dept.users.filter(u => + u.name.toLowerCase().includes(query) || + (u.position && u.position.toLowerCase().includes(query)) + ); + if (deptMatch) return dept; + if (matched.length > 0) return { ...dept, users: matched }; + return null; + }).filter(Boolean); + } + + if (!depts.length) { + container.innerHTML = '
인원 정보가 없습니다.
'; + return; + } + + container.innerHTML = depts.map(dept => { + const deptKey = dept.department_id ?? 'none'; + const expanded = lineExpandedDepts[deptKey] !== false; + return `
+ +
+ ${dept.users.map(user => { + const added = lineSteps.some(s => s.user_id === user.id); + return `
+
+ ${escapeHtml(user.name)} + ${escapeHtml(user.position || user.job_title || '')} +
+ +
`; + }).join('')} +
+
`; + }).join(''); +} + +function toggleLineDept(key) { + lineExpandedDepts[key] = !lineExpandedDepts[key]; + const el = document.getElementById('lineDept-' + key); + if (el) el.classList.toggle('hidden'); +} + +function updateLineSummary() { + const counts = { approval: 0, agreement: 0, reference: 0 }; + lineSteps.forEach(s => { if (counts[s.step_type] !== undefined) counts[s.step_type]++; }); + const total = lineSteps.length; + document.getElementById('lineSummary').textContent = + `결재: ${counts.approval}명 | 합의: ${counts.agreement}명 | 참조: ${counts.reference}명 — 합계 ${total}명`; +} + +function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +// 인원 검색 디바운스 +document.addEventListener('DOMContentLoaded', function() { + const searchInput = document.getElementById('lineUserSearch'); + if (searchInput) { + let timer; + searchInput.addEventListener('input', function() { + clearTimeout(timer); + timer = setTimeout(() => renderLineDeptList(), 200); + }); + } +}); + function confirmDelete(id, title) { if (!confirm(`"${title}" 문서를 삭제하시겠습니까?`)) return; diff --git a/routes/api.php b/routes/api.php index 2fbc7446..e9509ac9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -906,6 +906,9 @@ Route::get('/completed', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'completed'])->name('completed'); Route::get('/references', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'references'])->name('references'); Route::get('/lines', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'lines'])->name('lines'); + Route::post('/lines', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'storeLine'])->name('lines.store'); + Route::put('/lines/{id}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'updateLine'])->name('lines.update'); + Route::delete('/lines/{id}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'destroyLine'])->name('lines.destroy'); Route::get('/forms', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'forms'])->name('forms'); Route::get('/badge-counts', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'badgeCounts'])->name('badge-counts');