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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
좌측에서 결재자를 추가하세요
+
+
+
+
+
+
+ 결재: 0명 | 합의: 0명 | 참조: 0명 — 합계 0명
+
+
+
+
+
+
+
+
+
@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');