feat:영업파트너 유치 현황 대시보드 탭 추가

- SalesDashboardController에 partnerActivity() 메서드 추가
- 유치 파트너 요약 통계 (파트너 수, 영업권, 계약, 예상수당)
- 파트너별 상세 활동 테이블 (펼침/접기 기능)
- 기존 대시보드에 탭 UI 통합 (내 활동 / 유치 파트너 현황)
- HTMX로 탭 콘텐츠 지연 로드

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-31 16:15:50 +09:00
parent ac9c156257
commit 9d00064165
4 changed files with 406 additions and 4 deletions

View File

@@ -311,6 +311,149 @@ public function getManagers(Request $request): JsonResponse
]);
}
/**
* 유치 파트너 활동 현황 (HTMX 탭 로드)
*/
public function partnerActivity(Request $request): View
{
$data = $this->getPartnerActivityData();
return view('sales.dashboard.partials.partner-activity', $data);
}
/**
* 유치 파트너 활동 데이터 조회
*/
private function getPartnerActivityData(): array
{
$currentUser = auth()->user();
$currentUserId = $currentUser->id;
// 직접 유치한 하위 파트너 목록 (parent_id가 현재 사용자인 사용자들)
$recruitedPartners = User::where('parent_id', $currentUserId)
->where('is_active', true)
->with(['userRoles.role'])
->get();
$partnerIds = $recruitedPartners->pluck('id')->toArray();
// 요약 통계 계산
$summaryStats = $this->calculatePartnerSummaryStats($partnerIds, $currentUserId);
// 파트너별 상세 활동 데이터
$partnerActivities = $this->getPartnerActivitiesDetail($recruitedPartners, $currentUserId);
return [
'summaryStats' => $summaryStats,
'partnerActivities' => $partnerActivities,
'recruitedPartners' => $recruitedPartners,
];
}
/**
* 유치 파트너 요약 통계 계산
*/
private function calculatePartnerSummaryStats(array $partnerIds, int $currentUserId): array
{
// 유치 파트너 수
$partnerCount = count($partnerIds);
// 하위 파트너들이 등록한 총 영업권(명함) 수
$totalProspects = TenantProspect::whereIn('registered_by', $partnerIds)->count();
// 하위 파트너들의 계약 성사 건수
$totalConversions = TenantProspect::whereIn('registered_by', $partnerIds)
->where('status', TenantProspect::STATUS_CONVERTED)
->count();
// 매니저로서 받을 수당 (내가 매니저로 지정된 수당 중 하위 파트너 관련)
$expectedCommission = SalesCommission::where('manager_user_id', $currentUserId)
->whereHas('partner', function ($query) use ($partnerIds) {
$query->whereIn('user_id', $partnerIds);
})
->sum('manager_commission');
return [
'partner_count' => $partnerCount,
'total_prospects' => $totalProspects,
'total_conversions' => $totalConversions,
'expected_commission' => $expectedCommission,
];
}
/**
* 파트너별 상세 활동 데이터
*/
private function getPartnerActivitiesDetail($recruitedPartners, int $currentUserId): array
{
$activities = [];
foreach ($recruitedPartners as $partner) {
// 파트너의 영업파트너 정보
$salesPartner = SalesPartner::where('user_id', $partner->id)->first();
// 파트너가 등록한 영업권 수
$prospectCount = TenantProspect::where('registered_by', $partner->id)->count();
// 진행 중인 영업권 (active 상태)
$activeProspects = TenantProspect::where('registered_by', $partner->id)
->where('status', TenantProspect::STATUS_ACTIVE)
->count();
// 계약 성사 건수
$conversions = TenantProspect::where('registered_by', $partner->id)
->where('status', TenantProspect::STATUS_CONVERTED)
->count();
// 이 파트너로 인한 나의 매니저 수당
$managerCommission = 0;
if ($salesPartner) {
$managerCommission = SalesCommission::where('manager_user_id', $currentUserId)
->where('partner_id', $salesPartner->id)
->sum('manager_commission');
}
// 최근 활동 내역 (최근 전환된 테넌트 5개)
$recentTenants = TenantProspect::where('registered_by', $partner->id)
->where('status', TenantProspect::STATUS_CONVERTED)
->with(['tenant'])
->orderBy('converted_at', 'desc')
->limit(5)
->get();
// 활동 상태 판단
$lastActivity = TenantProspect::where('registered_by', $partner->id)
->orderBy('updated_at', 'desc')
->first();
$status = 'inactive';
if ($lastActivity) {
$daysSinceActivity = now()->diffInDays($lastActivity->updated_at);
if ($daysSinceActivity <= 7) {
$status = 'active';
} elseif ($daysSinceActivity <= 30) {
$status = 'moderate';
}
}
// 역할 정보
$roles = $partner->userRoles->pluck('role.name')->filter()->toArray();
$roleLabel = !empty($roles) ? implode(', ', $roles) : '영업';
$activities[] = [
'partner' => $partner,
'role_label' => $roleLabel,
'prospect_count' => $prospectCount,
'active_prospects' => $activeProspects,
'conversions' => $conversions,
'manager_commission' => $managerCommission,
'status' => $status,
'recent_tenants' => $recentTenants,
];
}
return $activities;
}
/**
* 영업파트너 수당 정보 조회
*/

View File

@@ -17,7 +17,7 @@
@endpush
@section('content')
<div class="space-y-6">
<div class="space-y-6" x-data="{ activeTab: 'my-activity' }">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-800">영업관리 대시보드</h1>
@@ -38,9 +38,57 @@
</div>
</div>
<!-- 대시보드 데이터 컨테이너 (HTMX로 새로고침되는 영역) -->
<div id="dashboard-data" class="space-y-6">
@include('sales.dashboard.partials.data-container')
<!-- 네비게이션 -->
<div class="border-b border-gray-200">
<nav class="flex gap-4" aria-label="Tabs">
<button type="button"
@click="activeTab = 'my-activity'"
:class="activeTab === 'my-activity'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
활동
</button>
<button type="button"
@click="activeTab = 'partner-activity'"
hx-get="{{ route('sales.salesmanagement.dashboard.partner-activity') }}"
hx-target="#partner-activity-content"
hx-trigger="click once"
hx-swap="innerHTML"
:class="activeTab === 'partner-activity'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
유치 파트너 현황
</button>
</nav>
</div>
<!-- 콘텐츠: 활동 -->
<div x-show="activeTab === 'my-activity'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div id="dashboard-data" class="space-y-6">
@include('sales.dashboard.partials.data-container')
</div>
</div>
<!-- 콘텐츠: 유치 파트너 현황 -->
<div x-show="activeTab === 'partner-activity'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div id="partner-activity-content" class="space-y-6">
<!-- HTMX로 로드되는 콘텐츠 -->
<div class="flex items-center justify-center py-12">
<svg class="w-8 h-8 animate-spin text-blue-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="ml-3 text-gray-500">로딩 ...</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,210 @@
{{-- 유치 파트너 활동 현황 --}}
<div class="space-y-6">
{{-- 요약 카드 섹션 --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<!-- 유치 파트너 -->
<div class="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-3">
<span class="text-sm text-gray-500">유치 파트너</span>
<div class="p-2 bg-indigo-50 rounded-lg">
<svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
</div>
<p class="text-3xl font-bold text-gray-900">{{ $summaryStats['partner_count'] }}</p>
<p class="text-xs text-gray-400 mt-1">직접 유치한 파트너</p>
</div>
<!-- 영업권 -->
<div class="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-3">
<span class="text-sm text-gray-500"> 영업권</span>
<div class="p-2 bg-blue-50 rounded-lg">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
</div>
</div>
<p class="text-3xl font-bold text-gray-900">{{ $summaryStats['total_prospects'] }}</p>
<p class="text-xs text-gray-400 mt-1">파트너들이 등록한 명함</p>
</div>
<!-- 계약 -->
<div class="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-3">
<span class="text-sm text-gray-500"> 계약</span>
<div class="p-2 bg-green-50 rounded-lg">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p class="text-3xl font-bold text-green-600">{{ $summaryStats['total_conversions'] }}</p>
<p class="text-xs text-gray-400 mt-1">계약 성사 건수</p>
</div>
<!-- 예상 수당 -->
<div class="bg-gradient-to-br from-amber-50 to-orange-50 border border-amber-200 rounded-xl p-5 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-3">
<span class="text-sm text-amber-700">예상 수당</span>
<div class="p-2 bg-amber-100 rounded-lg">
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p class="text-3xl font-bold text-amber-700">{{ number_format($summaryStats['expected_commission']) }}</p>
<p class="text-xs text-amber-600 mt-1">매니저 수당 합계</p>
</div>
</div>
{{-- 하위 파트너 활동 테이블 --}}
<div class="bg-white rounded-xl shadow-sm">
<div class="p-6 border-b border-gray-100">
<div class="flex items-center gap-3">
<div class="p-2 bg-indigo-100 rounded-lg">
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
</div>
<div>
<h2 class="text-xl font-bold text-gray-800">유치 파트너 활동 현황</h2>
<p class="text-sm text-gray-500">직접 유치한 파트너들의 영업 활동을 확인하세요</p>
</div>
</div>
</div>
@if(count($partnerActivities) === 0)
<div class="text-center py-16">
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<p class="text-gray-600 font-medium mb-1">유치한 파트너가 없습니다</p>
<p class="text-sm text-gray-400">파트너를 유치하면 이곳에서 활동 현황을 확인할 있습니다</p>
</div>
@else
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">파트너</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">역할</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">영업권</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">진행중</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">성공</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">예상수당</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">상태</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100" x-data="{ expandedPartner: null }">
@foreach($partnerActivities as $activity)
@php
$statusColors = [
'active' => 'bg-green-100 text-green-800',
'moderate' => 'bg-yellow-100 text-yellow-800',
'inactive' => 'bg-gray-100 text-gray-800',
];
$statusLabels = [
'active' => '활동중',
'moderate' => '보통',
'inactive' => '비활동',
];
@endphp
<!-- 파트너 메인 -->
<tr class="hover:bg-gray-50 cursor-pointer transition-colors"
@click="expandedPartner = expandedPartner === {{ $activity['partner']->id }} ? null : {{ $activity['partner']->id }}">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<button type="button" class="text-gray-400 hover:text-gray-600 transition-transform"
:class="{ 'rotate-90': expandedPartner === {{ $activity['partner']->id }} }">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<div>
<p class="font-semibold text-gray-900">{{ $activity['partner']->name }}</p>
<p class="text-xs text-gray-500">{{ $activity['partner']->email }}</p>
</div>
</div>
</td>
<td class="px-6 py-4 text-center">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ $activity['role_label'] }}
</span>
</td>
<td class="px-6 py-4 text-center">
<span class="font-semibold text-gray-900">{{ $activity['prospect_count'] }}</span>
</td>
<td class="px-6 py-4 text-center">
<span class="font-semibold text-blue-600">{{ $activity['active_prospects'] }}</span>
</td>
<td class="px-6 py-4 text-center">
<span class="font-semibold text-green-600">{{ $activity['conversions'] }}</span>
</td>
<td class="px-6 py-4 text-right">
<span class="font-semibold text-amber-600">{{ number_format($activity['manager_commission']) }}</span>
</td>
<td class="px-6 py-4 text-center">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $statusColors[$activity['status']] ?? $statusColors['inactive'] }}">
{{ $statusLabels[$activity['status']] ?? '비활동' }}
</span>
</td>
</tr>
<!-- 확장 영역: 최근 테넌트 목록 -->
<tr x-show="expandedPartner === {{ $activity['partner']->id }}"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak>
<td colspan="7" class="px-6 py-4 bg-gray-50">
@if($activity['recent_tenants']->isEmpty())
<div class="text-center py-4 text-sm text-gray-500">
아직 계약 성사 내역이 없습니다
</div>
@else
<div class="ml-8 space-y-2">
<p class="text-xs font-semibold text-gray-500 uppercase mb-3">최근 계약 성사 내역</p>
@foreach($activity['recent_tenants'] as $prospect)
<div class="flex items-center justify-between bg-white rounded-lg px-4 py-3 border border-gray-200">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-green-500"></div>
<div>
<p class="font-medium text-gray-900">{{ $prospect->company_name }}</p>
<p class="text-xs text-gray-500">{{ $prospect->tenant?->company_name ?? '-' }}</p>
</div>
</div>
<div class="flex items-center gap-4">
{{-- 진행 단계 표시 --}}
<div class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-green-500" title="상담"></span>
<span class="w-2 h-2 rounded-full bg-green-500" title="견적"></span>
<span class="w-2 h-2 rounded-full bg-green-500" title="계약"></span>
<span class="w-2 h-2 rounded-full bg-green-500" title="완료"></span>
</div>
<span class="text-xs text-gray-500">
{{ $prospect->converted_at?->format('Y-m-d') ?? '-' }}
</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
계약완료
</span>
</div>
</div>
@endforeach
</div>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>

View File

@@ -880,6 +880,7 @@
Route::get('salesmanagement/dashboard', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'index'])->name('salesmanagement.dashboard');
Route::get('salesmanagement/dashboard/refresh', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'refresh'])->name('salesmanagement.dashboard.refresh');
Route::get('salesmanagement/dashboard/tenants', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'refreshTenantList'])->name('salesmanagement.dashboard.tenants');
Route::get('salesmanagement/dashboard/partner-activity', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'partnerActivity'])->name('salesmanagement.dashboard.partner-activity');
// 영업파트너 승인 (본사 관리자 전용) - resource 전에 정의해야 함
Route::get('managers/approvals', [\App\Http\Controllers\Sales\SalesManagerController::class, 'approvals'])->name('managers.approvals');