diff --git a/app/Http/Controllers/Sales/SalesDevelopmentApprovalController.php b/app/Http/Controllers/Sales/SalesDevelopmentApprovalController.php new file mode 100644 index 00000000..821bfa21 --- /dev/null +++ b/app/Http/Controllers/Sales/SalesDevelopmentApprovalController.php @@ -0,0 +1,193 @@ +user()->isAdmin()) { + abort(403, '접근 권한이 없습니다.'); + } + + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('sales.development.approvals.index')); + } + + $search = $request->get('search'); + + // 통계 + $stats = $this->service->getStats(); + + // 3개 목록 + $pendingItems = $this->service->getPendingApprovals($search); + $progressItems = $this->service->getInProgressItems($search); + $completedItems = $this->service->getCompletedItems($search); + + // 본사 진행 상태 정보 (뷰에서 사용) + $hqStatuses = SalesTenantManagement::$hqStatusLabels; + $hqStatusOrder = SalesTenantManagement::$hqStatusOrder; + + return view('sales.development.approvals', compact( + 'stats', + 'pendingItems', + 'progressItems', + 'completedItems', + 'hqStatuses', + 'hqStatusOrder' + )); + } + + /** + * 승인 처리 + */ + public function approve(Request $request, int $id) + { + // 권한 체크 + if (!auth()->user()->isAdmin()) { + abort(403, '접근 권한이 없습니다.'); + } + + try { + $management = $this->service->approve($id); + $tenantName = $management->tenant?->company_name ?? '알 수 없음'; + + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => true, + 'message' => "{$tenantName}의 개발이 승인되었습니다.", + ]); + } + + return redirect()->route('sales.development.approvals.index') + ->with('success', "{$tenantName}의 개발이 승인되었습니다."); + } catch (\InvalidArgumentException $e) { + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + + return redirect()->back()->with('error', $e->getMessage()); + } + } + + /** + * 반려 처리 + */ + public function reject(Request $request, int $id) + { + // 권한 체크 + if (!auth()->user()->isAdmin()) { + abort(403, '접근 권한이 없습니다.'); + } + + $validated = $request->validate([ + 'rejection_reason' => 'required|string|max:1000', + ]); + + try { + $management = $this->service->reject($id, $validated['rejection_reason']); + $tenantName = $management->tenant?->company_name ?? '알 수 없음'; + + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => true, + 'message' => "{$tenantName}이(가) 반려되었습니다.", + ]); + } + + return redirect()->route('sales.development.approvals.index') + ->with('success', "{$tenantName}이(가) 반려되었습니다."); + } catch (\InvalidArgumentException $e) { + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + + return redirect()->back()->with('error', $e->getMessage()); + } + } + + /** + * 상태 변경 + */ + public function updateStatus(Request $request, int $id) + { + // 권한 체크 + if (!auth()->user()->isAdmin()) { + abort(403, '접근 권한이 없습니다.'); + } + + $validated = $request->validate([ + 'status' => 'required|string', + ]); + + try { + $management = $this->service->updateHqStatus($id, $validated['status']); + $tenantName = $management->tenant?->company_name ?? '알 수 없음'; + $statusLabel = SalesTenantManagement::$hqStatusLabels[$validated['status']] ?? $validated['status']; + + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => true, + 'message' => "{$tenantName}의 상태가 '{$statusLabel}'(으)로 변경되었습니다.", + ]); + } + + return redirect()->route('sales.development.approvals.index') + ->with('success', "{$tenantName}의 상태가 '{$statusLabel}'(으)로 변경되었습니다."); + } catch (\InvalidArgumentException $e) { + if ($request->header('HX-Request')) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + + return redirect()->back()->with('error', $e->getMessage()); + } + } + + /** + * 상세 정보 모달 + */ + public function detail(int $id): View + { + // 권한 체크 + if (!auth()->user()->isAdmin()) { + abort(403, '접근 권한이 없습니다.'); + } + + $management = $this->service->getDetail($id); + $hqStatuses = SalesTenantManagement::$hqStatusLabels; + $hqStatusOrder = SalesTenantManagement::$hqStatusOrder; + + return view('sales.development.partials.detail-modal', compact( + 'management', + 'hqStatuses', + 'hqStatusOrder' + )); + } +} diff --git a/app/Services/Sales/SalesDevelopmentApprovalService.php b/app/Services/Sales/SalesDevelopmentApprovalService.php new file mode 100644 index 00000000..b597e1bf --- /dev/null +++ b/app/Services/Sales/SalesDevelopmentApprovalService.php @@ -0,0 +1,217 @@ +where('sales_progress', 100) + ->where('manager_progress', 100) + ->where('hq_status', SalesTenantManagement::HQ_STATUS_PENDING) + ->count(); + + // 개발 진행 중 (review ~ int_test) + $progressStatuses = [ + SalesTenantManagement::HQ_STATUS_REVIEW, + SalesTenantManagement::HQ_STATUS_PLANNING, + SalesTenantManagement::HQ_STATUS_CODING, + SalesTenantManagement::HQ_STATUS_DEV_TEST, + SalesTenantManagement::HQ_STATUS_DEV_DONE, + SalesTenantManagement::HQ_STATUS_INT_TEST, + ]; + $inProgressCount = SalesTenantManagement::query() + ->where('sales_progress', 100) + ->where('manager_progress', 100) + ->whereIn('hq_status', $progressStatuses) + ->count(); + + // 오늘 완료 (handover 상태이고 오늘 업데이트된) + $todayCompletedCount = SalesTenantManagement::query() + ->where('hq_status', SalesTenantManagement::HQ_STATUS_HANDOVER) + ->whereDate('updated_at', today()) + ->count(); + + // 총 완료 + $totalCompletedCount = SalesTenantManagement::query() + ->where('hq_status', SalesTenantManagement::HQ_STATUS_HANDOVER) + ->count(); + + return [ + 'pending' => $pendingCount, + 'in_progress' => $inProgressCount, + 'today_completed' => $todayCompletedCount, + 'total_completed' => $totalCompletedCount, + ]; + } + + /** + * 승인 대기 목록 조회 + */ + public function getPendingApprovals(?string $search = null, int $perPage = 10): LengthAwarePaginator + { + $query = SalesTenantManagement::query() + ->with(['tenant', 'salesPartner', 'manager']) + ->where('sales_progress', 100) + ->where('manager_progress', 100) + ->where('hq_status', SalesTenantManagement::HQ_STATUS_PENDING); + + // 검색 + if ($search) { + $query->whereHas('tenant', function ($q) use ($search) { + $q->where('company_name', 'like', "%{$search}%") + ->orWhere('business_number', 'like', "%{$search}%"); + }); + } + + return $query->latest('updated_at')->paginate($perPage, ['*'], 'pending_page'); + } + + /** + * 개발 진행 중 목록 조회 + */ + public function getInProgressItems(?string $search = null, int $perPage = 10): LengthAwarePaginator + { + $progressStatuses = [ + SalesTenantManagement::HQ_STATUS_REVIEW, + SalesTenantManagement::HQ_STATUS_PLANNING, + SalesTenantManagement::HQ_STATUS_CODING, + SalesTenantManagement::HQ_STATUS_DEV_TEST, + SalesTenantManagement::HQ_STATUS_DEV_DONE, + SalesTenantManagement::HQ_STATUS_INT_TEST, + ]; + + $query = SalesTenantManagement::query() + ->with(['tenant', 'salesPartner', 'manager']) + ->where('sales_progress', 100) + ->where('manager_progress', 100) + ->whereIn('hq_status', $progressStatuses); + + // 검색 + if ($search) { + $query->whereHas('tenant', function ($q) use ($search) { + $q->where('company_name', 'like', "%{$search}%") + ->orWhere('business_number', 'like', "%{$search}%"); + }); + } + + return $query->latest('updated_at')->paginate($perPage, ['*'], 'progress_page'); + } + + /** + * 완료 목록 조회 + */ + public function getCompletedItems(?string $search = null, int $perPage = 10): LengthAwarePaginator + { + $query = SalesTenantManagement::query() + ->with(['tenant', 'salesPartner', 'manager']) + ->where('hq_status', SalesTenantManagement::HQ_STATUS_HANDOVER); + + // 검색 + if ($search) { + $query->whereHas('tenant', function ($q) use ($search) { + $q->where('company_name', 'like', "%{$search}%") + ->orWhere('business_number', 'like', "%{$search}%"); + }); + } + + return $query->latest('updated_at')->paginate($perPage, ['*'], 'completed_page'); + } + + /** + * 개발 승인 처리 (pending → review) + */ + public function approve(int $id): SalesTenantManagement + { + $management = SalesTenantManagement::findOrFail($id); + + // 승인 조건 확인 + if ($management->sales_progress < 100 || $management->manager_progress < 100) { + throw new \InvalidArgumentException('영업/매니저 진행률이 100%가 아닙니다.'); + } + + if ($management->hq_status !== SalesTenantManagement::HQ_STATUS_PENDING) { + throw new \InvalidArgumentException('이미 승인된 항목입니다.'); + } + + $management->update([ + 'hq_status' => SalesTenantManagement::HQ_STATUS_REVIEW, + ]); + + return $management->fresh(); + } + + /** + * 반려 처리 + */ + public function reject(int $id, string $reason): SalesTenantManagement + { + $management = SalesTenantManagement::findOrFail($id); + + if ($management->hq_status !== SalesTenantManagement::HQ_STATUS_PENDING) { + throw new \InvalidArgumentException('승인 대기 상태가 아닙니다.'); + } + + // notes 필드에 반려 사유 추가 + $currentNotes = $management->notes ?? ''; + $rejectionNote = '[반려 ' . now()->format('Y-m-d H:i') . '] ' . $reason; + $newNotes = $currentNotes ? $currentNotes . "\n" . $rejectionNote : $rejectionNote; + + $management->update([ + 'notes' => $newNotes, + ]); + + return $management->fresh(); + } + + /** + * 본사 진행 상태 업데이트 + */ + public function updateHqStatus(int $id, string $status): SalesTenantManagement + { + $management = SalesTenantManagement::findOrFail($id); + + // 유효한 상태인지 확인 + if (!array_key_exists($status, SalesTenantManagement::$hqStatusLabels)) { + throw new \InvalidArgumentException('유효하지 않은 상태입니다.'); + } + + // pending 상태에서는 review로만 변경 가능 (승인 처리) + if ($management->hq_status === SalesTenantManagement::HQ_STATUS_PENDING && $status !== SalesTenantManagement::HQ_STATUS_REVIEW) { + throw new \InvalidArgumentException('승인 대기 상태에서는 검토 상태로만 변경 가능합니다. 먼저 승인 처리를 해주세요.'); + } + + $management->update([ + 'hq_status' => $status, + ]); + + return $management->fresh(); + } + + /** + * 상세 정보 조회 + */ + public function getDetail(int $id): SalesTenantManagement + { + return SalesTenantManagement::with([ + 'tenant', + 'salesPartner', + 'manager', + 'contractProducts.product', + ])->findOrFail($id); + } +} diff --git a/database/seeders/DevelopmentApprovalMenuSeeder.php b/database/seeders/DevelopmentApprovalMenuSeeder.php new file mode 100644 index 00000000..5343cc63 --- /dev/null +++ b/database/seeders/DevelopmentApprovalMenuSeeder.php @@ -0,0 +1,81 @@ + 영업파트너 승인 아래에 "개발 승인" 메뉴 추가 + */ +class DevelopmentApprovalMenuSeeder extends Seeder +{ + public function run(): void + { + $tenantId = 1; + + // 영업관리 메뉴 ID 찾기 + $salesParentId = Menu::where('tenant_id', $tenantId) + ->where('name', '영업관리') + ->whereNull('parent_id') + ->value('id'); + + if (!$salesParentId) { + $this->command->error('영업관리 메뉴를 찾을 수 없습니다.'); + return; + } + + // 기존 개발 승인 메뉴 확인 + $existingMenu = Menu::where('tenant_id', $tenantId) + ->where('name', '개발 승인') + ->where('parent_id', $salesParentId) + ->first(); + + if ($existingMenu) { + $this->command->info('개발 승인 메뉴가 이미 존재합니다.'); + return; + } + + // 영업파트너 승인 메뉴의 sort_order 확인 + $approvalMenu = Menu::where('tenant_id', $tenantId) + ->where('name', '영업파트너 승인') + ->where('parent_id', $salesParentId) + ->first(); + + $sortOrder = 6; // 기본값 + if ($approvalMenu) { + $sortOrder = $approvalMenu->sort_order + 1; + + // 기존 메뉴들의 sort_order를 뒤로 밀기 + Menu::where('tenant_id', $tenantId) + ->where('parent_id', $salesParentId) + ->where('sort_order', '>=', $sortOrder) + ->increment('sort_order'); + } + + // 개발 승인 메뉴 생성 + Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $salesParentId, + 'name' => '개발 승인', + 'url' => '/sales/development/approvals', + 'icon' => 'code', + 'sort_order' => $sortOrder, + 'is_active' => true, + ]); + + $this->command->info("개발 승인 메뉴 생성 완료 (sort_order: {$sortOrder})"); + + // 결과 출력 + $this->command->info(''); + $this->command->info('=== 영업관리 하위 메뉴 ==='); + $children = Menu::where('parent_id', $salesParentId) + ->orderBy('sort_order') + ->get(['name', 'url', 'sort_order']); + + foreach ($children as $child) { + $this->command->info("{$child->sort_order}. {$child->name} ({$child->url})"); + } + } +} diff --git a/resources/views/sales/development/approvals.blade.php b/resources/views/sales/development/approvals.blade.php new file mode 100644 index 00000000..43de16e2 --- /dev/null +++ b/resources/views/sales/development/approvals.blade.php @@ -0,0 +1,247 @@ +@extends('layouts.app') + +@section('title', '개발 승인') + +@section('content') +
영업 완료된 고객의 개발 진행 상태를 관리합니다
+로딩 중...
+{{ $tenant?->business_number ?? '-' }}
+| 상품명 | +가입비 | +월 구독료 | +
|---|---|---|
| {{ $cp->product?->name ?? '-' }} | +{{ number_format($cp->registration_fee ?? 0) }}원 | +{{ number_format($cp->subscription_fee ?? 0) }}원 | +
{{ $management->notes }}
++ 의 개발 승인을 반려합니다. +
+ +