diff --git a/app/Http/Controllers/Sales/DemoTenantController.php b/app/Http/Controllers/Sales/DemoTenantController.php new file mode 100644 index 00000000..a7c5a8df --- /dev/null +++ b/app/Http/Controllers/Sales/DemoTenantController.php @@ -0,0 +1,323 @@ + 100, + 'max_orders' => 50, + 'max_productions' => 30, + 'max_users' => 5, + 'max_storage_gb' => 1, + 'max_ai_tokens' => 100000, + ]; + + /** + * 데모 체험 관리 메인 화면 + */ + public function index(Request $request): View + { + if ($request->header('HX-Request') && ! $request->header('HX-Boosted')) { + return response('', 200)->header('HX-Redirect', route('sales.demo-tenants.index')); + } + + $partner = SalesPartner::where('user_id', auth()->id())->first(); + $isAdmin = auth()->user()->isHqAdmin ?? false; + + $query = Tenant::withoutGlobalScopes() + ->whereIn('tenant_type', self::DEMO_TYPES); + + if (! $isAdmin && $partner) { + $query->where('demo_source_partner_id', $partner->id); + } + + $demos = $query->orderByDesc('created_at')->get()->map(function ($t) { + $t->demo_status = $this->getDemoStatus($t); + $t->remaining_days = $this->getRemainingDays($t->demo_expires_at); + $t->parsed_options = $t->options ?? []; + + return $t; + }); + + $stats = [ + 'total' => $demos->count(), + 'active' => $demos->where('demo_status', 'active')->count(), + 'expired' => $demos->where('demo_status', 'expired')->count(), + 'converted' => $demos->where('demo_status', 'converted')->count(), + 'expiring_soon' => $demos->filter(fn ($d) => $d->demo_status === 'active' && $d->remaining_days !== null && $d->remaining_days <= 7)->count(), + ]; + + return view('sales.demo-tenants.index', compact('demos', 'stats', 'partner', 'isAdmin')); + } + + /** + * 목록 부분 새로고침 (HTMX) + */ + public function refreshList(Request $request): View + { + $partner = SalesPartner::where('user_id', auth()->id())->first(); + $isAdmin = auth()->user()->isHqAdmin ?? false; + + $query = Tenant::withoutGlobalScopes() + ->whereIn('tenant_type', self::DEMO_TYPES); + + if (! $isAdmin && $partner) { + $query->where('demo_source_partner_id', $partner->id); + } + + $demos = $query->orderByDesc('created_at')->get()->map(function ($t) { + $t->demo_status = $this->getDemoStatus($t); + $t->remaining_days = $this->getRemainingDays($t->demo_expires_at); + $t->parsed_options = $t->options ?? []; + + return $t; + }); + + $stats = [ + 'total' => $demos->count(), + 'active' => $demos->where('demo_status', 'active')->count(), + 'expired' => $demos->where('demo_status', 'expired')->count(), + 'converted' => $demos->where('demo_status', 'converted')->count(), + 'expiring_soon' => $demos->filter(fn ($d) => $d->demo_status === 'active' && $d->remaining_days !== null && $d->remaining_days <= 7)->count(), + ]; + + return view('sales.demo-tenants.partials.demo-list', compact('demos', 'stats')); + } + + /** + * 상세 조회 모달 (HTMX) + */ + public function show(int $id): View + { + $tenant = $this->findAndAuthorize($id); + $tenant->demo_status = $this->getDemoStatus($tenant); + $tenant->remaining_days = $this->getRemainingDays($tenant->demo_expires_at); + $tenant->parsed_options = $tenant->options ?? []; + + // 데이터 건수 조회 + $dataCounts = []; + foreach (['clients' => '거래처', 'items' => '품목', 'orders' => '수주', 'quotes' => '견적'] as $table => $label) { + try { + $dataCounts[$table] = ['label' => $label, 'count' => DB::table($table)->where('tenant_id', $id)->count()]; + } catch (\Exception $e) { + $dataCounts[$table] = ['label' => $label, 'count' => 0]; + } + } + + return view('sales.demo-tenants.partials.show-modal', compact('tenant', 'dataCounts')); + } + + /** + * 생성 폼 모달 (HTMX) + */ + public function createForm(): View + { + return view('sales.demo-tenants.partials.create-modal'); + } + + /** + * 고객 체험 테넌트 생성 + */ + public function store(Request $request) + { + $request->validate([ + 'company_name' => 'required|string|max:200', + 'email' => 'nullable|email|max:100', + 'duration_days' => 'required|integer|min:7|max:60', + 'preset' => 'nullable|string|in:manufacturing,none', + ]); + + $partner = SalesPartner::where('user_id', auth()->id())->first(); + $preset = $request->input('preset', 'manufacturing'); + + $options = [ + 'demo_preset' => $preset === 'none' ? null : $preset, + 'demo_limits' => self::DEFAULT_LIMITS, + 'demo_email' => $request->input('email'), + 'created_by_user_id' => auth()->id(), + 'created_by_user_name' => auth()->user()->name ?? '', + ]; + + $tenant = new Tenant; + $tenant->forceFill([ + 'company_name' => $request->input('company_name'), + 'tenant_type' => 'DEMO_TRIAL', + 'tenant_st_code' => 'trial', + 'demo_expires_at' => now()->addDays($request->input('duration_days', 30)), + 'demo_source_partner_id' => $partner?->id, + 'options' => $options, + 'is_active' => true, + 'storage_limit' => 1073741824, + 'storage_used' => 0, + ]); + $tenant->save(); + + if ($request->header('HX-Request')) { + return response('', 200) + ->header('HX-Trigger', json_encode(['showToast' => '데모 체험 테넌트가 생성되었습니다.', 'refreshDemoList' => true])); + } + + return redirect()->route('sales.demo-tenants.index')->with('success', '데모 체험 테넌트가 생성되었습니다.'); + } + + /** + * 체험 기간 연장 + */ + public function extend(Request $request, int $id) + { + $tenant = $this->findAndAuthorize($id); + + if ($tenant->tenant_type !== 'DEMO_TRIAL') { + return $this->htmxError('고객 체험 유형만 연장 가능합니다.'); + } + + $opts = $tenant->options ?? []; + if (! empty($opts['demo_extended'])) { + return $this->htmxError('이미 연장된 체험입니다. 연장은 1회만 가능합니다.'); + } + + $days = $request->input('days', 14); + $opts['demo_extended'] = true; + $opts['demo_extended_at'] = now()->toDateTimeString(); + $opts['demo_extended_days'] = $days; + + $tenant->forceFill([ + 'demo_expires_at' => $tenant->demo_expires_at + ? $tenant->demo_expires_at->addDays($days) + : now()->addDays($days), + 'options' => $opts, + 'tenant_st_code' => 'trial', + ]); + $tenant->save(); + + return response('', 200) + ->header('HX-Trigger', json_encode(['showToast' => "{$days}일 연장되었습니다.", 'refreshDemoList' => true, 'closeModal' => true])); + } + + /** + * 정식 전환 + */ + public function convert(int $id) + { + $tenant = $this->findAndAuthorize($id); + + if (! in_array($tenant->tenant_type, self::DEMO_TYPES)) { + return $this->htmxError('데모 테넌트가 아닙니다.'); + } + + $opts = $tenant->options ?? []; + unset($opts['demo_preset'], $opts['demo_limits'], $opts['demo_read_only']); + $opts['converted_at'] = now()->toDateTimeString(); + $opts['converted_by_user_id'] = auth()->id(); + + $tenant->forceFill([ + 'tenant_type' => 'STD', + 'tenant_st_code' => 'active', + 'demo_expires_at' => null, + 'options' => $opts, + ]); + $tenant->save(); + + return response('', 200) + ->header('HX-Trigger', json_encode(['showToast' => '정식 테넌트로 전환되었습니다.', 'refreshDemoList' => true, 'closeModal' => true])); + } + + /** + * 데모 테넌트 삭제 + */ + public function destroy(int $id) + { + $tenant = $this->findAndAuthorize($id); + + if (! in_array($tenant->tenant_type, self::DEMO_TYPES)) { + return $this->htmxError('데모 테넌트만 삭제 가능합니다.'); + } + + // 데이터 존재 확인 + foreach (['orders', 'clients', 'items'] as $table) { + try { + if (DB::table($table)->where('tenant_id', $id)->exists()) { + return $this->htmxError('데이터가 존재하는 테넌트는 삭제할 수 없습니다.'); + } + } catch (\Exception $e) { + } + } + + $tenant->forceDelete(); + + return response('', 200) + ->header('HX-Trigger', json_encode(['showToast' => '데모 테넌트가 삭제되었습니다.', 'refreshDemoList' => true, 'closeModal' => true])); + } + + // ── helpers ── + + private function findAndAuthorize(int $id): Tenant + { + $tenant = Tenant::withoutGlobalScopes() + ->whereIn('tenant_type', self::DEMO_TYPES) + ->findOrFail($id); + + $isAdmin = auth()->user()->isHqAdmin ?? false; + if (! $isAdmin) { + $partner = SalesPartner::where('user_id', auth()->id())->first(); + if (! $partner || $tenant->demo_source_partner_id !== $partner->id) { + abort(403, '권한이 없습니다.'); + } + } + + return $tenant; + } + + private function getDemoStatus(Tenant $tenant): string + { + if ($tenant->tenant_type === 'DEMO_SHOWCASE') { + return 'showcase'; + } + if ($tenant->tenant_st_code === 'converted' || $tenant->tenant_type === 'STD') { + return 'converted'; + } + if ($tenant->tenant_st_code === 'expired') { + return 'expired'; + } + if ($tenant->demo_expires_at && $tenant->demo_expires_at->isPast()) { + return 'expired'; + } + + return 'active'; + } + + private function getRemainingDays($expiresAt): ?int + { + if (! $expiresAt) { + return null; + } + + return max(0, (int) now()->diffInDays($expiresAt, false)); + } + + private function htmxError(string $message) + { + return response('', 200) + ->header('HX-Trigger', json_encode(['showToast' => $message, 'toastType' => 'error'])); + } +} diff --git a/resources/views/sales/demo-tenants/index.blade.php b/resources/views/sales/demo-tenants/index.blade.php new file mode 100644 index 00000000..bc1c9dbb --- /dev/null +++ b/resources/views/sales/demo-tenants/index.blade.php @@ -0,0 +1,136 @@ +@extends('layouts.app') + +@section('title', '데모 체험 관리') + +@push('styles') + +@endpush + +@section('content') +
+ +
+
+

데모 체험 관리

+

고객에게 SAM을 체험시키기 위한 데모 테넌트를 관리합니다

+
+
+ + +
+
+ + +
+
+

전체

+

{{ $stats['total'] }}

+
+
+

활성

+

{{ $stats['active'] }}

+
+
+

만료

+

{{ $stats['expired'] }}

+
+
+

전환 완료

+

{{ $stats['converted'] }}

+
+
+

곧 만료

+

{{ $stats['expiring_soon'] }}

+
+
+ + +
+ @include('sales.demo-tenants.partials.demo-list') +
+
+ + + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/sales/demo-tenants/partials/create-modal.blade.php b/resources/views/sales/demo-tenants/partials/create-modal.blade.php new file mode 100644 index 00000000..72e57e73 --- /dev/null +++ b/resources/views/sales/demo-tenants/partials/create-modal.blade.php @@ -0,0 +1,96 @@ +{{-- 데모 체험 생성 모달 --}} +
+
+
+
+ +
+

고객 체험 테넌트 생성

+ +
+ + +
+ @csrf + + +
+ + +
+ + +
+ + +

체험 안내 메일 발송용 (선택)

+
+ + +
+ +
+ + 최소 7일 ~ 최대 60일 +
+
+ + +
+ + +

제조업 프리셋: 거래처, 품목, 수주, 생산 샘플 데이터가 자동 생성됩니다

+
+ + +
+

+ 안내: + 고객 체험 테넌트는 Tier 3 (DEMO_TRIAL)로 생성되며, + 체험 기간 종료 후 자동으로 읽기 전용 모드로 전환됩니다. + 기간 연장은 1회(최대 30일) 가능합니다. +

+
+ + +
+ + +
+
+
+
+
diff --git a/resources/views/sales/demo-tenants/partials/demo-list.blade.php b/resources/views/sales/demo-tenants/partials/demo-list.blade.php new file mode 100644 index 00000000..45a2e450 --- /dev/null +++ b/resources/views/sales/demo-tenants/partials/demo-list.blade.php @@ -0,0 +1,90 @@ +{{-- 데모 테넌트 목록 (HTMX 새로고침 대상) --}} +
+ @if($demos->isEmpty()) +
+ + + +

등록된 데모 테넌트가 없습니다.

+

상단의 '체험 생성' 버튼으로 새 데모를 만들어보세요.

+
+ @else +
+ + + + + + + + + + + + + + @foreach($demos as $demo) + + + + + + + + + + @endforeach + +
회사명유형상태남은 일수만료일생성일작업
+ + + @switch($demo->tenant_type) + @case('DEMO_SHOWCASE') + 쇼케이스 + @break + @case('DEMO_PARTNER') + 파트너 + @break + @case('DEMO_TRIAL') + 고객체험 + @break + @endswitch + + @switch($demo->demo_status) + @case('active') + 활성 + @break + @case('expired') + 만료 + @break + @case('converted') + 전환 + @break + @case('showcase') + 상시 + @break + @endswitch + + @if($demo->remaining_days !== null) + + {{ $demo->remaining_days }}일 + + @else + - + @endif + + {{ $demo->demo_expires_at ? $demo->demo_expires_at->format('Y-m-d') : '-' }} + + {{ $demo->created_at->format('Y-m-d') }} + + +
+
+ @endif +
diff --git a/resources/views/sales/demo-tenants/partials/show-modal.blade.php b/resources/views/sales/demo-tenants/partials/show-modal.blade.php new file mode 100644 index 00000000..9549a630 --- /dev/null +++ b/resources/views/sales/demo-tenants/partials/show-modal.blade.php @@ -0,0 +1,232 @@ +{{-- 데모 테넌트 상세 모달 --}} +
+
+
+
+ +
+
+

{{ $tenant->company_name }}

+

ID: {{ $tenant->id }}

+
+
+ @switch($tenant->demo_status) + @case('active') + 활성 + @break + @case('expired') + 만료 + @break + @case('converted') + 전환 완료 + @break + @case('showcase') + 쇼케이스 + @break + @endswitch + +
+
+ +
+ +
+
+

유형

+

+ @switch($tenant->tenant_type) + @case('DEMO_SHOWCASE') 쇼케이스 (Tier 1) @break + @case('DEMO_PARTNER') 파트너 데모 (Tier 2) @break + @case('DEMO_TRIAL') 고객 체험 (Tier 3) @break + @endswitch +

+
+
+

만료일

+

+ {{ $tenant->demo_expires_at ? $tenant->demo_expires_at->format('Y-m-d H:i') : '없음 (상시)' }} + @if($tenant->remaining_days !== null) + ({{ $tenant->remaining_days }}일 남음) + @endif +

+
+
+

생성일

+

{{ $tenant->created_at->format('Y-m-d H:i') }}

+
+
+

담당자 이메일

+

{{ $tenant->parsed_options['demo_email'] ?? '-' }}

+
+
+ + + @if(!empty($tenant->parsed_options['demo_limits'])) +
+

리소스 제한

+
+ @foreach($tenant->parsed_options['demo_limits'] as $key => $val) +
+ {{ str_replace(['max_', '_'], ['', ' '], $key) }} + {{ number_format($val) }} +
+ @endforeach +
+
+ @endif + + +
+

데이터 현황

+
+ @foreach($dataCounts as $table => $info) +
+ {{ $info['label'] }} + + {{ number_format($info['count']) }}건 + +
+ @endforeach +
+
+ + + @if(!empty($tenant->parsed_options['demo_extended'])) +
+

연장 이력

+

+ {{ $tenant->parsed_options['demo_extended_days'] ?? 14 }}일 연장됨 + ({{ $tenant->parsed_options['demo_extended_at'] ?? '' }}) +

+
+ @endif + + + @if(!empty($tenant->parsed_options['converted_at'])) +
+

정식 전환 완료

+

+ 전환일: {{ $tenant->parsed_options['converted_at'] }} +

+
+ @endif +
+ + +
+
+ @if($tenant->demo_status === 'active' && $tenant->tenant_type === 'DEMO_TRIAL' && empty($tenant->parsed_options['demo_extended'])) + + @endif +
+
+ @if(in_array($tenant->demo_status, ['active', 'expired']) && $tenant->tenant_type !== 'DEMO_SHOWCASE') + + @endif + @if(in_array($tenant->tenant_type, ['DEMO_SHOWCASE', 'DEMO_PARTNER', 'DEMO_TRIAL'])) + + @endif + +
+
+
+
+
+ + diff --git a/routes/web.php b/routes/web.php index f2ce9bc5..51ffea57 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1669,6 +1669,18 @@ // 매니저 목록/검색은 리소스 라우트 앞에 정의됨 (912줄 위치) + // 데모 체험 관리 + Route::prefix('demo-tenants')->name('demo-tenants.')->group(function () { + Route::get('/', [\App\Http\Controllers\Sales\DemoTenantController::class, 'index'])->name('index'); + Route::get('/refresh', [\App\Http\Controllers\Sales\DemoTenantController::class, 'refreshList'])->name('refresh'); + Route::get('/create', [\App\Http\Controllers\Sales\DemoTenantController::class, 'createForm'])->name('create'); + Route::post('/', [\App\Http\Controllers\Sales\DemoTenantController::class, 'store'])->name('store'); + Route::get('/{id}', [\App\Http\Controllers\Sales\DemoTenantController::class, 'show'])->name('show'); + Route::post('/{id}/extend', [\App\Http\Controllers\Sales\DemoTenantController::class, 'extend'])->name('extend'); + Route::post('/{id}/convert', [\App\Http\Controllers\Sales\DemoTenantController::class, 'convert'])->name('convert'); + Route::delete('/{id}', [\App\Http\Controllers\Sales\DemoTenantController::class, 'destroy'])->name('destroy'); + }); + // 가격 시뮬레이터 Route::get('price-simulator', [PriceSimulatorController::class, 'index'])->name('price-simulator.index');