diff --git a/app/Http/Controllers/Sales/SalesManagerController.php b/app/Http/Controllers/Sales/SalesManagerController.php index 5ee1c506..83887083 100644 --- a/app/Http/Controllers/Sales/SalesManagerController.php +++ b/app/Http/Controllers/Sales/SalesManagerController.php @@ -3,7 +3,10 @@ namespace App\Http\Controllers\Sales; use App\Http\Controllers\Controller; -use App\Models\Sales\SalesManager; +use App\Models\Role; +use App\Models\Sales\SalesManagerDocument; +use App\Models\User; +use App\Services\Sales\SalesManagerService; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\View\View; @@ -13,6 +16,10 @@ */ class SalesManagerController extends Controller { + public function __construct( + private SalesManagerService $service + ) {} + /** * 목록 페이지 */ @@ -22,33 +29,15 @@ public function index(Request $request): View|Response return response('', 200)->header('HX-Redirect', route('sales.managers.index')); } - $query = SalesManager::query()->active(); - - // 검색 - if ($search = $request->get('search')) { - $query->where(function ($q) use ($search) { - $q->where('name', 'like', "%{$search}%") - ->orWhere('member_id', 'like', "%{$search}%") - ->orWhere('email', 'like', "%{$search}%") - ->orWhere('phone', 'like', "%{$search}%"); - }); - } - - // 역할 필터 - if ($role = $request->get('role')) { - $query->where('role', $role); - } - - $managers = $query->orderBy('name')->paginate(20); - - // 통계 - $stats = [ - 'total' => SalesManager::active()->count(), - 'operators' => SalesManager::active()->where('role', 'operator')->count(), - 'sales_admins' => SalesManager::active()->where('role', 'sales_admin')->count(), - 'managers' => SalesManager::active()->where('role', 'manager')->count(), + $filters = [ + 'search' => $request->get('search'), + 'role' => $request->get('role'), + 'approval_status' => $request->get('approval_status'), ]; + $managers = $this->service->getSalesManagers($filters)->paginate(20); + $stats = $this->service->getStats(); + return view('sales.managers.index', compact('managers', 'stats')); } @@ -57,12 +46,20 @@ public function index(Request $request): View|Response */ public function create(): View { - $parents = SalesManager::active() - ->whereIn('role', ['operator', 'sales_admin']) - ->orderBy('name') + $tenantId = session('selected_tenant_id', 1); + + // 영업 관련 역할 목록 + $roles = Role::where('tenant_id', $tenantId) + ->whereIn('name', ['sales_operator', 'sales_admin', 'sales_manager']) ->get(); - return view('sales.managers.create', compact('parents')); + // 상위 관리자 후보 + $parents = $this->service->getParentCandidates(); + + // 문서 타입 목록 + $documentTypes = SalesManagerDocument::DOCUMENT_TYPES; + + return view('sales.managers.create', compact('roles', 'parents', 'documentTypes')); } /** @@ -71,20 +68,36 @@ public function create(): View public function store(Request $request) { $validated = $request->validate([ - 'member_id' => 'required|string|max:50|unique:sales_managers,member_id', - 'password' => 'required|string|min:4', + 'user_id' => 'nullable|string|max:50|unique:users,user_id', 'name' => 'required|string|max:100', + 'email' => 'required|email|max:255|unique:users,email', 'phone' => 'nullable|string|max:20', - 'email' => 'nullable|email|max:100', - 'parent_id' => 'nullable|exists:sales_managers,id', - 'role' => 'required|in:operator,sales_admin,manager', - 'remarks' => 'nullable|string', + 'password' => 'required|string|min:4|confirmed', + 'parent_id' => 'nullable|exists:users,id', + 'role_ids' => 'required|array|min:1', + 'role_ids.*' => 'exists:roles,id', + 'documents' => 'nullable|array', + 'documents.*.file' => 'nullable|file|max:10240', // 10MB + 'documents.*.document_type' => 'nullable|string', + 'documents.*.description' => 'nullable|string|max:500', ]); - SalesManager::create($validated); + // 문서 배열 구성 + $documents = []; + if ($request->hasFile('documents')) { + foreach ($request->file('documents') as $index => $file) { + $documents[] = [ + 'file' => $file, + 'document_type' => $request->input("documents.{$index}.document_type", 'other'), + 'description' => $request->input("documents.{$index}.description"), + ]; + } + } + + $this->service->createSalesManager($validated, $documents); return redirect()->route('sales.managers.index') - ->with('success', '담당자가 등록되었습니다.'); + ->with('success', '영업담당자 등록 신청이 완료되었습니다. 본사 승인 후 활성화됩니다.'); } /** @@ -92,7 +105,7 @@ public function store(Request $request) */ public function show(int $id): View { - $manager = SalesManager::with(['parent', 'children', 'registeredProspects', 'records']) + $manager = User::with(['parent', 'children', 'userRoles.role', 'salesDocuments', 'approver']) ->findOrFail($id); return view('sales.managers.show', compact('manager')); @@ -103,14 +116,26 @@ public function show(int $id): View */ public function edit(int $id): View { - $manager = SalesManager::findOrFail($id); - $parents = SalesManager::active() - ->whereIn('role', ['operator', 'sales_admin']) - ->where('id', '!=', $id) - ->orderBy('name') + $manager = User::with(['userRoles.role', 'salesDocuments'])->findOrFail($id); + $tenantId = session('selected_tenant_id', 1); + + // 영업 관련 역할 목록 + $roles = Role::where('tenant_id', $tenantId) + ->whereIn('name', ['sales_operator', 'sales_admin', 'sales_manager']) ->get(); - return view('sales.managers.edit', compact('manager', 'parents')); + // 상위 관리자 후보 + $parents = $this->service->getParentCandidates($id); + + // 현재 역할 ID 목록 + $currentRoleIds = $manager->userRoles->pluck('role_id')->toArray(); + + // 문서 타입 목록 + $documentTypes = SalesManagerDocument::DOCUMENT_TYPES; + + return view('sales.managers.edit', compact( + 'manager', 'roles', 'parents', 'currentRoleIds', 'documentTypes' + )); } /** @@ -118,24 +143,35 @@ public function edit(int $id): View */ public function update(Request $request, int $id) { - $manager = SalesManager::findOrFail($id); + $manager = User::findOrFail($id); $validated = $request->validate([ 'name' => 'required|string|max:100', + 'email' => 'required|email|max:255|unique:users,email,' . $id, 'phone' => 'nullable|string|max:20', - 'email' => 'nullable|email|max:100', - 'parent_id' => 'nullable|exists:sales_managers,id', - 'role' => 'required|in:operator,sales_admin,manager', - 'remarks' => 'nullable|string', - 'password' => 'nullable|string|min:4', + 'password' => 'nullable|string|min:4|confirmed', + 'parent_id' => 'nullable|exists:users,id', + 'role_ids' => 'required|array|min:1', + 'role_ids.*' => 'exists:roles,id', + 'documents' => 'nullable|array', + 'documents.*.file' => 'nullable|file|max:10240', + 'documents.*.document_type' => 'nullable|string', + 'documents.*.description' => 'nullable|string|max:500', ]); - // 비밀번호가 비어있으면 제외 - if (empty($validated['password'])) { - unset($validated['password']); + // 문서 배열 구성 + $documents = []; + if ($request->hasFile('documents')) { + foreach ($request->file('documents') as $index => $file) { + $documents[] = [ + 'file' => $file, + 'document_type' => $request->input("documents.{$index}.document_type", 'other'), + 'description' => $request->input("documents.{$index}.description"), + ]; + } } - $manager->update($validated); + $this->service->updateSalesManager($manager, $validated, $documents); return redirect()->route('sales.managers.index') ->with('success', '담당자 정보가 수정되었습니다.'); @@ -146,10 +182,63 @@ public function update(Request $request, int $id) */ public function destroy(int $id) { - $manager = SalesManager::findOrFail($id); + $manager = User::findOrFail($id); $manager->update(['is_active' => false]); return redirect()->route('sales.managers.index') ->with('success', '담당자가 비활성화되었습니다.'); } + + /** + * 승인 처리 + */ + public function approve(int $id) + { + $manager = User::findOrFail($id); + $this->service->approve($manager, auth()->id()); + + return redirect()->back() + ->with('success', '영업담당자가 승인되었습니다.'); + } + + /** + * 반려 처리 + */ + public function reject(Request $request, int $id) + { + $validated = $request->validate([ + 'rejection_reason' => 'required|string|max:1000', + ]); + + $manager = User::findOrFail($id); + $this->service->reject($manager, auth()->id(), $validated['rejection_reason']); + + return redirect()->back() + ->with('success', '영업담당자가 반려되었습니다.'); + } + + /** + * 서류 다운로드 + */ + public function downloadDocument(int $id, int $documentId) + { + $document = SalesManagerDocument::where('user_id', $id) + ->findOrFail($documentId); + + return $document->download(); + } + + /** + * 서류 삭제 + */ + public function deleteDocument(int $id, int $documentId) + { + $document = SalesManagerDocument::where('user_id', $id) + ->findOrFail($documentId); + + $this->service->deleteDocument($document); + + return redirect()->back() + ->with('success', '서류가 삭제되었습니다.'); + } } diff --git a/app/Models/Sales/SalesManagerDocument.php b/app/Models/Sales/SalesManagerDocument.php new file mode 100644 index 00000000..6897f351 --- /dev/null +++ b/app/Models/Sales/SalesManagerDocument.php @@ -0,0 +1,146 @@ + 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + /** + * 문서 타입 목록 + */ + public const DOCUMENT_TYPES = [ + 'id_card' => '신분증', + 'business_license' => '사업자등록증', + 'contract' => '계약서', + 'bank_account' => '통장사본', + 'other' => '기타', + ]; + + /** + * 사용자 (영업담당자) + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 업로더 + */ + public function uploader(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaded_by'); + } + + /** + * 문서 타입 라벨 + */ + public function getDocumentTypeLabelAttribute(): string + { + return self::DOCUMENT_TYPES[$this->document_type] ?? $this->document_type; + } + + /** + * 파일 크기 포맷팅 + */ + public function getFormattedSizeAttribute(): string + { + $bytes = $this->file_size; + + if ($bytes >= 1073741824) { + return number_format($bytes / 1073741824, 2) . ' GB'; + } elseif ($bytes >= 1048576) { + return number_format($bytes / 1048576, 2) . ' MB'; + } elseif ($bytes >= 1024) { + return number_format($bytes / 1024, 2) . ' KB'; + } + + return $bytes . ' bytes'; + } + + /** + * 파일 확장자 + */ + public function getExtensionAttribute(): string + { + return pathinfo($this->original_name, PATHINFO_EXTENSION); + } + + /** + * 이미지 여부 + */ + public function isImage(): bool + { + return str_starts_with($this->mime_type ?? '', 'image/'); + } + + /** + * 스토리지 전체 경로 + */ + public function getStoragePath(): string + { + return Storage::disk('tenant')->path($this->file_path); + } + + /** + * 파일 존재 여부 + */ + public function existsInStorage(): bool + { + return Storage::disk('tenant')->exists($this->file_path); + } + + /** + * 파일 URL + */ + public function getUrl(): ?string + { + if (!$this->existsInStorage()) { + return null; + } + + return Storage::disk('tenant')->url($this->file_path); + } + + /** + * 파일 다운로드 응답 + */ + public function download() + { + if (!$this->existsInStorage()) { + abort(404, '파일을 찾을 수 없습니다.'); + } + + return Storage::disk('tenant')->download($this->file_path, $this->original_name); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index a2e74bfe..495c51ac 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -31,6 +31,11 @@ class User extends Authenticatable 'role', 'is_active', 'is_super_admin', + 'parent_id', + 'approval_status', + 'approved_by', + 'approved_at', + 'rejection_reason', ]; /** @@ -62,9 +67,92 @@ protected function casts(): array 'is_active' => 'boolean', 'is_super_admin' => 'boolean', 'must_change_password' => 'boolean', + 'approved_at' => 'datetime', ]; } + /** + * 상위 관리자 (영업담당자 계층 구조) + */ + public function parent(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(User::class, 'parent_id'); + } + + /** + * 하위 관리자 목록 + */ + public function children(): HasMany + { + return $this->hasMany(User::class, 'parent_id'); + } + + /** + * 승인자 + */ + public function approver(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + /** + * 영업담당자 첨부 서류 + */ + public function salesDocuments(): HasMany + { + return $this->hasMany(\App\Models\Sales\SalesManagerDocument::class, 'user_id'); + } + + /** + * 승인 대기 상태인지 확인 + */ + public function isPendingApproval(): bool + { + return $this->approval_status === 'pending'; + } + + /** + * 승인됨 상태인지 확인 + */ + public function isApproved(): bool + { + return $this->approval_status === 'approved'; + } + + /** + * 반려됨 상태인지 확인 + */ + public function isRejected(): bool + { + return $this->approval_status === 'rejected'; + } + + /** + * 승인 상태 라벨 + */ + public function getApprovalStatusLabelAttribute(): string + { + return match ($this->approval_status) { + 'pending' => '승인대기', + 'approved' => '승인완료', + 'rejected' => '반려', + default => $this->approval_status, + }; + } + + /** + * 승인 상태별 색상 클래스 + */ + public function getApprovalStatusColorAttribute(): string + { + return match ($this->approval_status) { + 'pending' => 'bg-yellow-100 text-yellow-800', + 'approved' => 'bg-green-100 text-green-800', + 'rejected' => 'bg-red-100 text-red-800', + default => 'bg-gray-100 text-gray-800', + }; + } + /** * 관계: 테넌트들 (Many-to-Many via user_tenants) */ diff --git a/app/Services/Sales/SalesManagerService.php b/app/Services/Sales/SalesManagerService.php new file mode 100644 index 00000000..3b960cfd --- /dev/null +++ b/app/Services/Sales/SalesManagerService.php @@ -0,0 +1,344 @@ + $data['user_id'] ?? null, + 'name' => $data['name'], + 'email' => $data['email'], + 'phone' => $data['phone'] ?? null, + 'password' => Hash::make($data['password']), + 'is_active' => false, // 승인 전까지 비활성 + 'parent_id' => $data['parent_id'] ?? null, + 'approval_status' => 'pending', // 승인 대기 상태 + 'must_change_password' => true, + ]); + + // 2. 테넌트 연결 + $user->tenants()->attach($tenantId, [ + 'is_active' => true, + 'is_default' => true, + 'joined_at' => now(), + ]); + + // 3. 역할 할당 + if (!empty($data['role_ids'])) { + $this->syncRoles($user, $tenantId, $data['role_ids']); + } + + // 4. 부서 할당 + if (!empty($data['department_ids'])) { + $this->syncDepartments($user, $tenantId, $data['department_ids']); + } + + // 5. 첨부 서류 저장 + if (!empty($documents)) { + $this->uploadDocuments($user, $tenantId, $documents); + } + + return $user; + }); + } + + /** + * 영업담당자 수정 + */ + public function updateSalesManager(User $user, array $data, array $documents = []): User + { + return DB::transaction(function () use ($user, $data, $documents) { + $tenantId = session('selected_tenant_id', 1); + + // 1. 기본 정보 업데이트 + $updateData = [ + 'name' => $data['name'], + 'email' => $data['email'], + 'phone' => $data['phone'] ?? null, + 'parent_id' => $data['parent_id'] ?? null, + ]; + + // 비밀번호 변경 시에만 업데이트 + if (!empty($data['password'])) { + $updateData['password'] = Hash::make($data['password']); + } + + $user->update($updateData); + + // 2. 역할 동기화 + if (isset($data['role_ids'])) { + $this->syncRoles($user, $tenantId, $data['role_ids']); + } + + // 3. 부서 동기화 + if (isset($data['department_ids'])) { + $this->syncDepartments($user, $tenantId, $data['department_ids']); + } + + // 4. 새 첨부 서류 저장 + if (!empty($documents)) { + $this->uploadDocuments($user, $tenantId, $documents); + } + + return $user->fresh(); + }); + } + + /** + * 영업담당자 승인 + */ + public function approve(User $user, int $approverId): User + { + $user->update([ + 'approval_status' => 'approved', + 'approved_by' => $approverId, + 'approved_at' => now(), + 'is_active' => true, + 'rejection_reason' => null, + ]); + + return $user; + } + + /** + * 영업담당자 반려 + */ + public function reject(User $user, int $approverId, string $reason): User + { + $user->update([ + 'approval_status' => 'rejected', + 'approved_by' => $approverId, + 'approved_at' => now(), + 'rejection_reason' => $reason, + 'is_active' => false, + ]); + + return $user; + } + + /** + * 역할 동기화 + */ + public function syncRoles(User $user, int $tenantId, array $roleIds): void + { + // 기존 역할 삭제 + UserRole::withTrashed() + ->where('user_id', $user->id) + ->where('tenant_id', $tenantId) + ->forceDelete(); + + // 새 역할 추가 + foreach ($roleIds as $roleId) { + UserRole::create([ + 'user_id' => $user->id, + 'tenant_id' => $tenantId, + 'role_id' => $roleId, + 'assigned_at' => now(), + ]); + } + } + + /** + * 부서 동기화 + */ + public function syncDepartments(User $user, int $tenantId, array $departmentIds): void + { + // 기존 부서 삭제 + DepartmentUser::withTrashed() + ->where('user_id', $user->id) + ->where('tenant_id', $tenantId) + ->forceDelete(); + + // 새 부서 추가 + foreach ($departmentIds as $index => $departmentId) { + DepartmentUser::create([ + 'user_id' => $user->id, + 'tenant_id' => $tenantId, + 'department_id' => $departmentId, + 'is_primary' => $index === 0, + 'joined_at' => now(), + 'created_by' => auth()->id(), + ]); + } + } + + /** + * 서류 업로드 + */ + public function uploadDocuments(User $user, int $tenantId, array $documents): array + { + $uploaded = []; + + foreach ($documents as $doc) { + if (!isset($doc['file']) || !$doc['file'] instanceof UploadedFile) { + continue; + } + + $file = $doc['file']; + $documentType = $doc['document_type'] ?? 'other'; + $description = $doc['description'] ?? null; + + // 파일 저장 + $storedName = Str::uuid() . '.' . $file->getClientOriginalExtension(); + $filePath = "sales-managers/{$user->id}/{$storedName}"; + + Storage::disk('tenant')->put($filePath, file_get_contents($file)); + + // DB 저장 + $document = SalesManagerDocument::create([ + 'tenant_id' => $tenantId, + 'user_id' => $user->id, + 'file_path' => $filePath, + 'original_name' => $file->getClientOriginalName(), + 'stored_name' => $storedName, + 'mime_type' => $file->getMimeType(), + 'file_size' => $file->getSize(), + 'document_type' => $documentType, + 'description' => $description, + 'uploaded_by' => auth()->id(), + ]); + + $uploaded[] = $document; + } + + return $uploaded; + } + + /** + * 서류 삭제 + */ + public function deleteDocument(SalesManagerDocument $document): bool + { + // 물리 파일 삭제 + if ($document->existsInStorage()) { + Storage::disk('tenant')->delete($document->file_path); + } + + // DB 삭제 + $document->deleted_by = auth()->id(); + $document->save(); + + return $document->delete(); + } + + /** + * 영업담당자 목록 조회 (역할 기반) + */ + public function getSalesManagers(array $filters = []) + { + $tenantId = session('selected_tenant_id', 1); + + // 영업관련 역할을 가진 사용자 조회 + $query = User::query() + ->whereHas('userRoles', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) + ->whereHas('role', function ($rq) { + $rq->whereIn('name', ['sales_operator', 'sales_admin', 'sales_manager']); + }); + }) + ->with(['parent', 'userRoles.role', 'salesDocuments']); + + // 검색 + if (!empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('user_id', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%") + ->orWhere('phone', 'like', "%{$search}%"); + }); + } + + // 역할 필터 + if (!empty($filters['role'])) { + $roleName = $filters['role']; + $query->whereHas('userRoles', function ($q) use ($tenantId, $roleName) { + $q->where('tenant_id', $tenantId) + ->whereHas('role', function ($rq) use ($roleName) { + $rq->where('name', $roleName); + }); + }); + } + + // 승인 상태 필터 + if (!empty($filters['approval_status'])) { + $query->where('approval_status', $filters['approval_status']); + } + + return $query->orderBy('name'); + } + + /** + * 상위 관리자 목록 (운영자, 영업관리) + */ + public function getParentCandidates(?int $excludeId = null) + { + $tenantId = session('selected_tenant_id', 1); + + $query = User::query() + ->where('is_active', true) + ->where('approval_status', 'approved') + ->whereHas('userRoles', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) + ->whereHas('role', function ($rq) { + $rq->whereIn('name', ['sales_operator', 'sales_admin']); + }); + }); + + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + return $query->orderBy('name')->get(); + } + + /** + * 통계 조회 + */ + public function getStats(): array + { + $tenantId = session('selected_tenant_id', 1); + + $baseQuery = User::query() + ->whereHas('userRoles', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) + ->whereHas('role', function ($rq) { + $rq->whereIn('name', ['sales_operator', 'sales_admin', 'sales_manager']); + }); + }); + + return [ + 'total' => (clone $baseQuery)->count(), + 'pending' => (clone $baseQuery)->where('approval_status', 'pending')->count(), + 'approved' => (clone $baseQuery)->where('approval_status', 'approved')->count(), + 'operators' => (clone $baseQuery) + ->whereHas('userRoles.role', fn($q) => $q->where('name', 'sales_operator')) + ->count(), + 'sales_admins' => (clone $baseQuery) + ->whereHas('userRoles.role', fn($q) => $q->where('name', 'sales_admin')) + ->count(), + 'managers' => (clone $baseQuery) + ->whereHas('userRoles.role', fn($q) => $q->where('name', 'sales_manager')) + ->count(), + ]; + } +} diff --git a/database/seeders/SalesRoleSeeder.php b/database/seeders/SalesRoleSeeder.php new file mode 100644 index 00000000..3239eeed --- /dev/null +++ b/database/seeders/SalesRoleSeeder.php @@ -0,0 +1,47 @@ + 'sales_operator', + 'description' => '영업 운영자 - 전체 영업 조직 관리', + ], + [ + 'name' => 'sales_admin', + 'description' => '영업 관리자 - 하위 매니저 관리', + ], + [ + 'name' => 'sales_manager', + 'description' => '영업 매니저 - 가망고객 관리 및 영업 활동', + ], + ]; + + foreach ($roles as $roleData) { + Role::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'name' => $roleData['name'], + ], + [ + 'description' => $roleData['description'], + 'guard_name' => 'web', + ] + ); + } + + $this->command->info('영업관리 역할이 생성되었습니다.'); + } +} diff --git a/resources/views/sales/managers/create.blade.php b/resources/views/sales/managers/create.blade.php index 6476a7ba..168f33fd 100644 --- a/resources/views/sales/managers/create.blade.php +++ b/resources/views/sales/managers/create.blade.php @@ -3,7 +3,7 @@ @section('title', '영업담당자 등록') @section('content') -