diff --git a/app/Http/Controllers/Sales/TenantProspectController.php b/app/Http/Controllers/Sales/TenantProspectController.php new file mode 100644 index 00000000..1c34d558 --- /dev/null +++ b/app/Http/Controllers/Sales/TenantProspectController.php @@ -0,0 +1,212 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('sales.prospects.index')); + } + + $filters = [ + 'search' => $request->get('search'), + 'status' => $request->get('status'), + 'registered_by' => $request->get('registered_by'), + ]; + + $prospects = $this->service->getProspects($filters)->paginate(20); + $stats = $this->service->getStats(); + + return view('sales.prospects.index', compact('prospects', 'stats')); + } + + /** + * 등록 폼 + */ + public function create(Request $request): View|Response + { + // HTMX 요청이면 JavaScript 로드를 위해 전체 페이지로 리다이렉트 + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('sales.prospects.create')); + } + + return view('sales.prospects.create'); + } + + /** + * 등록 처리 + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'business_number' => 'required|string|max:20', + 'company_name' => 'required|string|max:100', + 'ceo_name' => 'nullable|string|max:50', + 'contact_phone' => 'nullable|string|max:20', + 'contact_email' => 'nullable|email|max:100', + 'address' => 'nullable|string|max:500', + 'business_card' => 'nullable|image|max:5120', + 'memo' => 'nullable|string|max:1000', + ]); + + // 등록 가능 여부 확인 + $checkResult = $this->service->canRegister($validated['business_number']); + if (!$checkResult['can_register']) { + return redirect()->back() + ->withInput() + ->with('error', $checkResult['reason']); + } + + // 등록자는 현재 로그인 사용자 + $validated['registered_by'] = auth()->id(); + + $this->service->register( + $validated, + $request->file('business_card') + ); + + return redirect()->route('sales.prospects.index') + ->with('success', '명함이 등록되었습니다. 2개월간 영업권이 유효합니다.'); + } + + /** + * 상세 페이지 + */ + public function show(int $id): View + { + $prospect = TenantProspect::with(['registeredBy', 'tenant', 'convertedBy']) + ->findOrFail($id); + + return view('sales.prospects.show', compact('prospect')); + } + + /** + * 수정 폼 + */ + public function edit(int $id): View + { + $prospect = TenantProspect::findOrFail($id); + + // 이미 전환된 경우 수정 불가 + if ($prospect->isConverted()) { + return redirect()->route('sales.prospects.show', $id) + ->with('error', '이미 테넌트로 전환된 영업권은 수정할 수 없습니다.'); + } + + return view('sales.prospects.edit', compact('prospect')); + } + + /** + * 수정 처리 + */ + public function update(Request $request, int $id) + { + $prospect = TenantProspect::findOrFail($id); + + // 이미 전환된 경우 수정 불가 + if ($prospect->isConverted()) { + return redirect()->route('sales.prospects.show', $id) + ->with('error', '이미 테넌트로 전환된 영업권은 수정할 수 없습니다.'); + } + + $validated = $request->validate([ + 'company_name' => 'required|string|max:100', + 'ceo_name' => 'nullable|string|max:50', + 'contact_phone' => 'nullable|string|max:20', + 'contact_email' => 'nullable|email|max:100', + 'address' => 'nullable|string|max:500', + 'business_card' => 'nullable|image|max:5120', + 'memo' => 'nullable|string|max:1000', + ]); + + $this->service->update( + $prospect, + $validated, + $request->file('business_card') + ); + + return redirect()->route('sales.prospects.show', $id) + ->with('success', '영업권 정보가 수정되었습니다.'); + } + + /** + * 삭제 처리 + */ + public function destroy(int $id) + { + $prospect = TenantProspect::findOrFail($id); + + // 이미 전환된 경우 삭제 불가 + if ($prospect->isConverted()) { + return redirect()->route('sales.prospects.index') + ->with('error', '이미 테넌트로 전환된 영업권은 삭제할 수 없습니다.'); + } + + // 본인 또는 관리자만 삭제 가능 + if ($prospect->registered_by !== auth()->id()) { + // TODO: 관리자 권한 체크 추가 + } + + $prospect->delete(); + + return redirect()->route('sales.prospects.index') + ->with('success', '영업권이 삭제되었습니다.'); + } + + /** + * 테넌트 전환 + */ + public function convert(int $id) + { + $prospect = TenantProspect::findOrFail($id); + + // 이미 전환된 경우 + if ($prospect->isConverted()) { + return redirect()->route('sales.prospects.show', $id) + ->with('error', '이미 테넌트로 전환되었습니다.'); + } + + // 만료된 경우 + if ($prospect->isExpired()) { + return redirect()->route('sales.prospects.show', $id) + ->with('error', '만료된 영업권은 전환할 수 없습니다.'); + } + + $tenant = $this->service->convertToTenant($prospect, auth()->id()); + + return redirect()->route('sales.prospects.show', $id) + ->with('success', "테넌트로 전환되었습니다. (테넌트 ID: {$tenant->id})"); + } + + /** + * 사업자번호 중복 체크 (AJAX) + */ + public function checkBusinessNumber(Request $request) + { + $businessNumber = $request->get('business_number'); + $excludeId = $request->get('exclude_id'); + + $result = $this->service->canRegister($businessNumber, $excludeId); + + return response()->json($result); + } +} diff --git a/app/Models/Sales/TenantProspect.php b/app/Models/Sales/TenantProspect.php new file mode 100644 index 00000000..d6adb3b2 --- /dev/null +++ b/app/Models/Sales/TenantProspect.php @@ -0,0 +1,225 @@ + 'datetime', + 'expires_at' => 'datetime', + 'cooldown_ends_at' => 'datetime', + 'converted_at' => 'datetime', + ]; + + /** + * 등록한 영업파트너 + */ + public function registeredBy(): BelongsTo + { + return $this->belongsTo(User::class, 'registered_by'); + } + + /** + * 전환된 테넌트 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_id'); + } + + /** + * 전환 처리자 + */ + public function convertedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'converted_by'); + } + + /** + * 영업권 유효 여부 + */ + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE && now()->lt($this->expires_at); + } + + /** + * 영업권 만료 여부 + */ + public function isExpired(): bool + { + return $this->status === self::STATUS_EXPIRED || now()->gte($this->expires_at); + } + + /** + * 테넌트 전환 완료 여부 + */ + public function isConverted(): bool + { + return $this->status === self::STATUS_CONVERTED; + } + + /** + * 쿨다운 중 여부 + */ + public function isInCooldown(): bool + { + return $this->isExpired() && now()->lt($this->cooldown_ends_at); + } + + /** + * 재등록 가능 여부 + */ + public function canReRegister(): bool + { + return $this->isExpired() && now()->gte($this->cooldown_ends_at); + } + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + if ($this->isConverted()) { + return '계약완료'; + } + + if ($this->isActive()) { + return '영업중'; + } + + if ($this->isInCooldown()) { + return '쿨다운'; + } + + return '만료'; + } + + /** + * 상태 색상 (Tailwind CSS) + */ + public function getStatusColorAttribute(): string + { + if ($this->isConverted()) { + return 'bg-green-100 text-green-800'; + } + + if ($this->isActive()) { + return 'bg-blue-100 text-blue-800'; + } + + if ($this->isInCooldown()) { + return 'bg-yellow-100 text-yellow-800'; + } + + return 'bg-gray-100 text-gray-800'; + } + + /** + * 남은 일수 + */ + public function getRemainingDaysAttribute(): int + { + if (!$this->isActive()) { + return 0; + } + + return max(0, now()->diffInDays($this->expires_at, false)); + } + + /** + * 명함 이미지 URL + */ + public function getBusinessCardUrlAttribute(): ?string + { + if (!$this->business_card_path) { + return null; + } + + return Storage::disk('tenant')->url($this->business_card_path); + } + + /** + * 명함 이미지 존재 여부 + */ + public function hasBusinessCard(): bool + { + return $this->business_card_path && Storage::disk('tenant')->exists($this->business_card_path); + } + + /** + * 스코프: 유효한 영업권 + */ + public function scopeActive($query) + { + return $query->where('status', self::STATUS_ACTIVE) + ->where('expires_at', '>', now()); + } + + /** + * 스코프: 만료된 영업권 + */ + public function scopeExpired($query) + { + return $query->where(function ($q) { + $q->where('status', self::STATUS_EXPIRED) + ->orWhere('expires_at', '<=', now()); + }); + } + + /** + * 스코프: 전환 완료 + */ + public function scopeConverted($query) + { + return $query->where('status', self::STATUS_CONVERTED); + } + + /** + * 스코프: 특정 영업파트너의 영업권 + */ + public function scopeByPartner($query, int $userId) + { + return $query->where('registered_by', $userId); + } +} diff --git a/app/Models/Tenants/Tenant.php b/app/Models/Tenants/Tenant.php index 75cd556a..8fd6ea9e 100644 --- a/app/Models/Tenants/Tenant.php +++ b/app/Models/Tenants/Tenant.php @@ -40,6 +40,14 @@ class Tenant extends Model 'admin_memo', // 삭제 정보 'deleted_by', + // 영업파트너 영업권 관련 + 'registered_by', + 'business_card_path', + 'prospect_status', + 'prospect_registered_at', + 'prospect_expires_at', + 'cooldown_ends_at', + 'converted_at', ]; protected $casts = [ diff --git a/app/Services/Sales/TenantProspectService.php b/app/Services/Sales/TenantProspectService.php new file mode 100644 index 00000000..61b64922 --- /dev/null +++ b/app/Services/Sales/TenantProspectService.php @@ -0,0 +1,253 @@ +copy()->addMonths(TenantProspect::VALIDITY_MONTHS); + $cooldownEndsAt = $expiresAt->copy()->addMonths(TenantProspect::COOLDOWN_MONTHS); + + // 명함 이미지 저장 + $businessCardPath = null; + if ($businessCard) { + $businessCardPath = $this->uploadBusinessCard($businessCard, $data['registered_by']); + } + + return TenantProspect::create([ + 'business_number' => $data['business_number'], + 'company_name' => $data['company_name'], + 'ceo_name' => $data['ceo_name'] ?? null, + 'contact_phone' => $data['contact_phone'] ?? null, + 'contact_email' => $data['contact_email'] ?? null, + 'address' => $data['address'] ?? null, + 'registered_by' => $data['registered_by'], + 'business_card_path' => $businessCardPath, + 'status' => TenantProspect::STATUS_ACTIVE, + 'registered_at' => $now, + 'expires_at' => $expiresAt, + 'cooldown_ends_at' => $cooldownEndsAt, + 'memo' => $data['memo'] ?? null, + ]); + }); + } + + /** + * 영업권 정보 수정 + */ + public function update(TenantProspect $prospect, array $data, ?UploadedFile $businessCard = null): TenantProspect + { + return DB::transaction(function () use ($prospect, $data, $businessCard) { + $updateData = [ + 'company_name' => $data['company_name'], + 'ceo_name' => $data['ceo_name'] ?? null, + 'contact_phone' => $data['contact_phone'] ?? null, + 'contact_email' => $data['contact_email'] ?? null, + 'address' => $data['address'] ?? null, + 'memo' => $data['memo'] ?? null, + ]; + + // 명함 이미지 교체 + if ($businessCard) { + // 기존 이미지 삭제 + if ($prospect->business_card_path) { + Storage::disk('tenant')->delete($prospect->business_card_path); + } + $updateData['business_card_path'] = $this->uploadBusinessCard($businessCard, $prospect->registered_by); + } + + $prospect->update($updateData); + + return $prospect->fresh(); + }); + } + + /** + * 테넌트로 전환 + */ + public function convertToTenant(TenantProspect $prospect, int $convertedBy): Tenant + { + return DB::transaction(function () use ($prospect, $convertedBy) { + // 테넌트 생성 + $tenant = Tenant::create([ + 'company_name' => $prospect->company_name, + 'business_num' => $prospect->business_number, + 'ceo_name' => $prospect->ceo_name, + 'phone' => $prospect->contact_phone, + 'email' => $prospect->contact_email, + 'address' => $prospect->address, + 'tenant_st_code' => 'trial', + 'tenant_type' => 'customer', + 'created_by' => $convertedBy, + ]); + + // 영업권 상태 업데이트 + $prospect->update([ + 'status' => TenantProspect::STATUS_CONVERTED, + 'tenant_id' => $tenant->id, + 'converted_at' => now(), + 'converted_by' => $convertedBy, + ]); + + return $tenant; + }); + } + + /** + * 영업권 만료 처리 (배치용) + */ + public function expireOldProspects(): int + { + return TenantProspect::where('status', TenantProspect::STATUS_ACTIVE) + ->where('expires_at', '<=', now()) + ->update(['status' => TenantProspect::STATUS_EXPIRED]); + } + + /** + * 사업자번호로 등록 가능 여부 확인 + */ + public function canRegister(string $businessNumber, ?int $excludeId = null): array + { + $query = TenantProspect::where('business_number', $businessNumber); + + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + // 이미 전환된 경우 + $converted = (clone $query)->where('status', TenantProspect::STATUS_CONVERTED)->first(); + if ($converted) { + return [ + 'can_register' => false, + 'reason' => '이미 테넌트로 전환된 회사입니다.', + 'prospect' => $converted, + ]; + } + + // 유효한 영업권이 있는 경우 + $active = (clone $query)->active()->first(); + if ($active) { + return [ + 'can_register' => false, + 'reason' => "이미 {$active->registeredBy->name}님이 영업권을 보유 중입니다. (만료: {$active->expires_at->format('Y-m-d')})", + 'prospect' => $active, + ]; + } + + // 쿨다운 중인 경우 + $inCooldown = (clone $query) + ->where('status', TenantProspect::STATUS_EXPIRED) + ->where('cooldown_ends_at', '>', now()) + ->first(); + + if ($inCooldown) { + return [ + 'can_register' => false, + 'reason' => "쿨다운 기간 중입니다. (등록 가능: {$inCooldown->cooldown_ends_at->format('Y-m-d')})", + 'prospect' => $inCooldown, + ]; + } + + return [ + 'can_register' => true, + 'reason' => null, + 'prospect' => null, + ]; + } + + /** + * 목록 조회 + */ + public function getProspects(array $filters = []) + { + $query = TenantProspect::with(['registeredBy', 'tenant']); + + // 검색 + if (!empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('company_name', 'like', "%{$search}%") + ->orWhere('business_number', 'like', "%{$search}%") + ->orWhere('ceo_name', 'like', "%{$search}%") + ->orWhere('contact_phone', 'like', "%{$search}%"); + }); + } + + // 상태 필터 + if (!empty($filters['status'])) { + if ($filters['status'] === 'active') { + $query->active(); + } elseif ($filters['status'] === 'expired') { + $query->where('status', TenantProspect::STATUS_EXPIRED); + } elseif ($filters['status'] === 'converted') { + $query->converted(); + } + } + + // 특정 영업파트너 + if (!empty($filters['registered_by'])) { + $query->byPartner($filters['registered_by']); + } + + return $query->orderBy('created_at', 'desc'); + } + + /** + * 통계 조회 + */ + public function getStats(?int $partnerId = null): array + { + $baseQuery = TenantProspect::query(); + + if ($partnerId) { + $baseQuery->byPartner($partnerId); + } + + return [ + 'total' => (clone $baseQuery)->count(), + 'active' => (clone $baseQuery)->active()->count(), + 'expired' => (clone $baseQuery)->where('status', TenantProspect::STATUS_EXPIRED)->count(), + 'converted' => (clone $baseQuery)->converted()->count(), + ]; + } + + /** + * 명함 이미지 업로드 + */ + private function uploadBusinessCard(UploadedFile $file, int $userId): string + { + $storedName = Str::uuid() . '.' . $file->getClientOriginalExtension(); + $filePath = "prospects/{$userId}/{$storedName}"; + + Storage::disk('tenant')->put($filePath, file_get_contents($file)); + + return $filePath; + } + + /** + * 명함 이미지 삭제 + */ + public function deleteBusinessCard(TenantProspect $prospect): bool + { + if ($prospect->business_card_path && Storage::disk('tenant')->exists($prospect->business_card_path)) { + Storage::disk('tenant')->delete($prospect->business_card_path); + $prospect->update(['business_card_path' => null]); + return true; + } + + return false; + } +} diff --git a/resources/views/sales/prospects/create.blade.php b/resources/views/sales/prospects/create.blade.php index 0c2716df..fb0ca70d 100644 --- a/resources/views/sales/prospects/create.blade.php +++ b/resources/views/sales/prospects/create.blade.php @@ -1,6 +1,6 @@ @extends('layouts.app') -@section('title', '가망고객 등록') +@section('title', '명함 등록') @section('content')
@@ -12,16 +12,42 @@ 목록으로 -

가망고객 등록

+

명함 등록

+

명함 등록 시 2개월간 영업권이 부여됩니다

+ + @if(session('error')) +
+ {{ session('error') }} +
+ @endif + -
+ @csrf + +
+ +
+ + +
+

+ @error('business_number') +

{{ $message }}

+ @enderror +
+
- + @error('company_name') @@ -30,43 +56,24 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
- - 대표자명 +
-
- - -
-
-
-
-
- -
- - -
@@ -75,35 +82,27 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
-
-
- - - @error('manager_id') -

{{ $message }}

- @enderror -
+
+ + +

JPG, PNG 형식 (최대 5MB)

+
-
- - -
+
+ + +
+ +
+

영업권 안내

+
    +
  • 등록일로부터 2개월간 영업권이 유효합니다.
  • +
  • 유효기간 내 테넌트로 전환 시 영업 실적으로 인정됩니다.
  • +
  • 만료 후 1개월간 쿨다운 기간이 적용됩니다.
  • +
  • 쿨다운 이후 다른 영업파트너가 등록할 수 있습니다.
  • +
@@ -118,4 +117,42 @@ class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
+ +@push('scripts') + +@endpush @endsection diff --git a/resources/views/sales/prospects/edit.blade.php b/resources/views/sales/prospects/edit.blade.php index 1c43e5ce..dbc58900 100644 --- a/resources/views/sales/prospects/edit.blade.php +++ b/resources/views/sales/prospects/edit.blade.php @@ -1,29 +1,44 @@ @extends('layouts.app') -@section('title', '가망고객 수정') +@section('title', '영업권 수정') @section('content')
- + - 목록으로 + 상세로 돌아가기 -

가망고객 수정

-

{{ $prospect->company_name }}

+

영업권 수정

+

{{ $prospect->company_name }} ({{ $prospect->business_number }})

+ + @if(session('error')) +
+ {{ session('error') }} +
+ @endif + -
+ @csrf @method('PUT') + +
+ + +

사업자번호는 수정할 수 없습니다

+
+
- + @error('company_name') @@ -32,44 +47,24 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
- - 대표자명 +
-
- - -
-
-
-
-
- -
- - -
@@ -79,20 +74,45 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
- - + + @if($prospect->hasBusinessCard()) +
+ 현재 명함 +

새 이미지를 업로드하면 기존 이미지가 교체됩니다

+
+ @endif + +

JPG, PNG 형식 (최대 5MB)

+
+ +
+ + +
+ + +
+

영업권 상태

+
+
상태
+
+ + {{ $prospect->status_label }} + +
+
등록일
+
{{ $prospect->registered_at->format('Y-m-d') }}
+
만료일
+
{{ $prospect->expires_at->format('Y-m-d') }}
+
등록자
+
{{ $prospect->registeredBy?->name ?? '-' }}
+
- 취소 diff --git a/resources/views/sales/prospects/index.blade.php b/resources/views/sales/prospects/index.blade.php index e3384690..72c0a95e 100644 --- a/resources/views/sales/prospects/index.blade.php +++ b/resources/views/sales/prospects/index.blade.php @@ -1,53 +1,41 @@ @extends('layouts.app') -@section('title', '가망고객 관리') +@section('title', '명함등록 (영업권)') @section('content')
-

가망고객 관리

-

영업 가망고객을 관리합니다

+

명함등록 (영업권)

+

명함을 등록하여 2개월간 영업권을 확보하세요

- 가망고객 등록 + 명함 등록
-
+
전체
{{ number_format($stats['total']) }}
-
-
리드
-
{{ number_format($stats['lead']) }}
-
-
가망
-
{{ number_format($stats['prospect']) }}
+
영업중
+
{{ number_format($stats['active']) }}
-
-
협상중
-
{{ number_format($stats['negotiation']) }}
+
+
만료
+
{{ number_format($stats['expired']) }}
계약완료
-
{{ number_format($stats['contracted']) }}
-
-
-
총 계약금액
-
{{ number_format($stats['total_contract']) }}
-
-
-
총 수수료
-
{{ number_format($stats['total_commission']) }}
+
{{ number_format($stats['converted']) }}
@@ -58,27 +46,15 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition
-
-
-
+ @endif - + + @if($prospect->memo)
-

상담 기록

- @if($prospect->consultations->isNotEmpty()) -
- @foreach($prospect->consultations->take(10) as $consultation) -
-
-
- {{ $consultation->manager?->name ?? '알 수 없음' }} - {{ $consultation->created_at->format('Y-m-d H:i') }} -
- - {{ $consultation->consultation_type_label }} - -
-

{{ $consultation->log_text }}

-
- @endforeach +

메모

+

{{ $prospect->memo }}

+
+ @endif + + +
+ @if($prospect->isActive()) +
+

영업권 유효

+

+ {{ $prospect->expires_at->format('Y-m-d') }}까지 영업권이 유효합니다. + 남은 기간: {{ $prospect->remaining_days }}일 +

+
+ @elseif($prospect->isInCooldown()) +
+

쿨다운 기간

+

+ 영업권이 만료되었습니다. + {{ $prospect->cooldown_ends_at->format('Y-m-d') }} 이후 다른 영업파트너가 재등록할 수 있습니다. +

+
+ @elseif($prospect->isConverted()) +
+

테넌트 전환 완료

+

+ {{ $prospect->converted_at?->format('Y-m-d') }}에 테넌트로 전환되었습니다. +

@else -

등록된 상담 기록이 없습니다.

+
+

영업권 만료

+

+ 영업권이 만료되었습니다. 쿨다운 기간이 종료되어 재등록이 가능합니다. +

+
@endif
diff --git a/routes/web.php b/routes/web.php index 73eafd82..66dc6fde 100644 --- a/routes/web.php +++ b/routes/web.php @@ -802,8 +802,10 @@ Route::get('managers/{id}/documents/{documentId}/download', [\App\Http\Controllers\Sales\SalesManagerController::class, 'downloadDocument'])->name('managers.documents.download'); Route::delete('managers/{id}/documents/{documentId}', [\App\Http\Controllers\Sales\SalesManagerController::class, 'deleteDocument'])->name('managers.documents.delete'); - // 가망고객 관리 - Route::resource('prospects', \App\Http\Controllers\Sales\SalesProspectController::class); + // 명함등록 (영업권) 관리 + Route::resource('prospects', \App\Http\Controllers\Sales\TenantProspectController::class); + Route::post('prospects/{id}/convert', [\App\Http\Controllers\Sales\TenantProspectController::class, 'convert'])->name('prospects.convert'); + Route::post('prospects/check-business-number', [\App\Http\Controllers\Sales\TenantProspectController::class, 'checkBusinessNumber'])->name('prospects.check-business-number'); // 영업 실적 관리 Route::resource('records', \App\Http\Controllers\Sales\SalesRecordController::class);