feat: [demo] MNG 데모 체험 관리 페이지 추가

- DemoTenantController: CRUD + 연장/전환/삭제
- Blade 뷰: index, demo-list, create-modal, show-modal
- 라우트: sales/demo-tenants 그룹 등록
- 메뉴: 영업관리 하위에 '데모 체험 관리' 추가
This commit is contained in:
김보곤
2026-03-14 08:36:44 +09:00
parent cdb12aecc4
commit 70f35e1e7b
6 changed files with 889 additions and 0 deletions

View File

@@ -0,0 +1,323 @@
<?php
namespace App\Http\Controllers\Sales;
use App\Http\Controllers\Controller;
use App\Models\Sales\SalesPartner;
use App\Models\Tenants\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
/**
* 데모 체험 관리 컨트롤러
*
* 영업파트너가 고객에게 SAM을 체험시키기 위한 데모 테넌트 관리
* - 고객 체험 테넌트 생성 (Tier 3)
* - 목록/상세 조회
* - 기간 연장, 정식 전환
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class DemoTenantController extends Controller
{
private const DEMO_TYPES = ['DEMO_SHOWCASE', 'DEMO_PARTNER', 'DEMO_TRIAL'];
private const DEFAULT_LIMITS = [
'max_items' => 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']));
}
}

View File

@@ -0,0 +1,136 @@
@extends('layouts.app')
@section('title', '데모 체험 관리')
@push('styles')
<style>
.refresh-btn [data-refresh-spin] { display: none; }
.refresh-btn [data-refresh-icon] { display: inline-block; }
.refresh-btn.htmx-request [data-refresh-spin] { display: inline-block; }
.refresh-btn.htmx-request [data-refresh-icon] { display: none; }
</style>
@endpush
@section('content')
<div class="space-y-6">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center">
<div>
<h1 class="text-2xl font-bold text-gray-800">데모 체험 관리</h1>
<p class="text-sm text-gray-500 mt-1">고객에게 SAM을 체험시키기 위한 데모 테넌트를 관리합니다</p>
</div>
<div class="flex items-center gap-2">
<button type="button"
hx-get="{{ route('sales.demo-tenants.create') }}"
hx-target="#modal-container"
hx-swap="innerHTML"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors shadow-sm">
<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="M12 4v16m8-8H4" />
</svg>
체험 생성
</button>
<button type="button"
hx-get="{{ route('sales.demo-tenants.refresh') }}"
hx-target="#demo-list-container"
hx-swap="innerHTML"
class="refresh-btn inline-flex items-center gap-1.5 px-3 py-2 text-sm text-gray-600 hover:text-blue-600 bg-white hover:bg-blue-50 border border-gray-300 rounded-lg transition-colors shadow-sm">
<svg data-refresh-spin class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<svg data-refresh-icon 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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
<div class="bg-white rounded-lg border border-gray-200 p-4">
<p class="text-sm text-gray-500">전체</p>
<p class="text-2xl font-bold text-gray-800">{{ $stats['total'] }}</p>
</div>
<div class="bg-white rounded-lg border border-green-200 p-4">
<p class="text-sm text-green-600">활성</p>
<p class="text-2xl font-bold text-green-700">{{ $stats['active'] }}</p>
</div>
<div class="bg-white rounded-lg border border-red-200 p-4">
<p class="text-sm text-red-600">만료</p>
<p class="text-2xl font-bold text-red-700">{{ $stats['expired'] }}</p>
</div>
<div class="bg-white rounded-lg border border-blue-200 p-4">
<p class="text-sm text-blue-600">전환 완료</p>
<p class="text-2xl font-bold text-blue-700">{{ $stats['converted'] }}</p>
</div>
<div class="bg-white rounded-lg border border-amber-200 p-4">
<p class="text-sm text-amber-600"> 만료</p>
<p class="text-2xl font-bold text-amber-700">{{ $stats['expiring_soon'] }}</p>
</div>
</div>
<!-- 데모 목록 -->
<div id="demo-list-container">
@include('sales.demo-tenants.partials.demo-list')
</div>
</div>
<!-- 모달 컨테이너 -->
<div id="modal-container"></div>
@endsection
@push('scripts')
<script>
// HTMX 이벤트 리스너
document.body.addEventListener('refreshDemoList', function() {
htmx.ajax('GET', '{{ route('sales.demo-tenants.refresh') }}', {target: '#demo-list-container', swap: 'innerHTML'});
});
document.body.addEventListener('closeModal', function() {
closeModal();
});
document.body.addEventListener('showToast', function(e) {
const msg = typeof e.detail === 'string' ? e.detail : (e.detail?.value || '');
if (msg) showToast(msg);
});
function showDetailModal(id) {
const modal = document.getElementById('modal-container');
htmx.ajax('GET', `/sales/demo-tenants/${id}`, {target: '#modal-container', swap: 'innerHTML'});
}
function closeModal() {
document.getElementById('modal-container').innerHTML = '';
document.body.style.overflow = '';
}
function showToast(message, type) {
const toast = document.createElement('div');
const isError = type === 'error';
toast.className = `fixed top-4 right-4 z-[100] px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium transition-all transform ${isError ? 'bg-red-600' : 'bg-emerald-600'}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 3000);
}
// HX-Trigger 이벤트 파싱
document.body.addEventListener('htmx:afterRequest', function(e) {
const trigger = e.detail.xhr?.getResponseHeader('HX-Trigger');
if (trigger) {
try {
const data = JSON.parse(trigger);
if (data.showToast) showToast(data.showToast, data.toastType);
if (data.refreshDemoList) {
htmx.ajax('GET', '{{ route('sales.demo-tenants.refresh') }}', {target: '#demo-list-container', swap: 'innerHTML'});
}
if (data.closeModal) closeModal();
} catch(ex) {}
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
});
</script>
@endpush

View File

@@ -0,0 +1,96 @@
{{-- 데모 체험 생성 모달 --}}
<div class="fixed inset-0 z-50 overflow-y-auto" x-data="{ submitting: false }">
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeModal()"></div>
<div class="flex min-h-full items-center justify-center p-4">
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-lg" @click.stop>
<!-- 헤더 -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-bold text-gray-900">고객 체험 테넌트 생성</h2>
<button type="button" onclick="closeModal()" class="text-gray-400 hover:text-gray-600">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- -->
<form hx-post="{{ route('sales.demo-tenants.store') }}"
hx-swap="none"
@htmx:before-request="submitting = true"
@htmx:after-request="submitting = false"
class="px-6 py-5 space-y-5">
@csrf
<!-- 회사명 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
회사명 <span class="text-red-500">*</span>
</label>
<input type="text" name="company_name" required maxlength="200"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="예: 테스트제조(주)">
</div>
<!-- 이메일 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">담당자 이메일</label>
<input type="email" name="email" maxlength="100"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="예: demo@company.com">
<p class="text-xs text-gray-400 mt-1">체험 안내 메일 발송용 (선택)</p>
</div>
<!-- 체험 기간 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
체험 기간 <span class="text-red-500">*</span>
</label>
<div class="flex items-center gap-3">
<select name="duration_days"
class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="14">14</option>
<option value="30" selected>30</option>
<option value="45">45</option>
<option value="60">60</option>
</select>
<span class="text-sm text-gray-500">최소 7 ~ 최대 60</span>
</div>
</div>
<!-- 프리셋 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">데이터 프리셋</label>
<select name="preset"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="manufacturing">제조업 샘플 데이터</option>
<option value="none"> 테넌트 (데이터 없음)</option>
</select>
<p class="text-xs text-gray-400 mt-1">제조업 프리셋: 거래처, 품목, 수주, 생산 샘플 데이터가 자동 생성됩니다</p>
</div>
<!-- 안내 -->
<div class="bg-blue-50 rounded-lg p-3">
<p class="text-xs text-blue-700">
<span class="font-semibold">안내:</span>
고객 체험 테넌트는 Tier 3 (DEMO_TRIAL) 생성되며,
체험 기간 종료 자동으로 읽기 전용 모드로 전환됩니다.
기간 연장은 1(최대 30) 가능합니다.
</p>
</div>
<!-- 버튼 -->
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
취소
</button>
<button type="submit" :disabled="submitting"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition disabled:opacity-50">
<span x-show="!submitting">생성</span>
<span x-show="submitting" x-cloak>생성 ...</span>
</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,90 @@
{{-- 데모 테넌트 목록 (HTMX 새로고침 대상) --}}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
@if($demos->isEmpty())
<div class="p-12 text-center">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p class="text-gray-500">등록된 데모 테넌트가 없습니다.</p>
<p class="text-sm text-gray-400 mt-1">상단의 '체험 생성' 버튼으로 데모를 만들어보세요.</p>
</div>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">회사명</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">유형</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">상태</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">남은 일수</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">만료일</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">생성일</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">작업</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($demos as $demo)
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-3">
<button type="button" onclick="showDetailModal({{ $demo->id }})"
class="text-blue-600 hover:text-blue-800 font-medium hover:underline text-left">
{{ $demo->company_name }}
</button>
</td>
<td class="px-4 py-3">
@switch($demo->tenant_type)
@case('DEMO_SHOWCASE')
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">쇼케이스</span>
@break
@case('DEMO_PARTNER')
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-700">파트너</span>
@break
@case('DEMO_TRIAL')
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-cyan-100 text-cyan-700">고객체험</span>
@break
@endswitch
</td>
<td class="px-4 py-3 text-center">
@switch($demo->demo_status)
@case('active')
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">활성</span>
@break
@case('expired')
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">만료</span>
@break
@case('converted')
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700">전환</span>
@break
@case('showcase')
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700">상시</span>
@break
@endswitch
</td>
<td class="px-4 py-3 text-center">
@if($demo->remaining_days !== null)
<span class="font-medium {{ $demo->remaining_days <= 7 ? 'text-red-600' : ($demo->remaining_days <= 14 ? 'text-amber-600' : 'text-gray-700') }}">
{{ $demo->remaining_days }}
</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-4 py-3 text-gray-600">
{{ $demo->demo_expires_at ? $demo->demo_expires_at->format('Y-m-d') : '-' }}
</td>
<td class="px-4 py-3 text-gray-600">
{{ $demo->created_at->format('Y-m-d') }}
</td>
<td class="px-4 py-3 text-center">
<button type="button" onclick="showDetailModal({{ $demo->id }})"
class="inline-flex items-center px-2.5 py-1 text-xs font-medium text-gray-600 hover:text-blue-600 bg-gray-100 hover:bg-blue-50 rounded transition-colors">
상세
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>

View File

@@ -0,0 +1,232 @@
{{-- 데모 테넌트 상세 모달 --}}
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeModal()"></div>
<div class="flex min-h-full items-center justify-center p-4">
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl" @click.stop>
<!-- 헤더 -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<div>
<h2 class="text-lg font-bold text-gray-900">{{ $tenant->company_name }}</h2>
<p class="text-sm text-gray-500">ID: {{ $tenant->id }}</p>
</div>
<div class="flex items-center gap-3">
@switch($tenant->demo_status)
@case('active')
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700">활성</span>
@break
@case('expired')
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-red-100 text-red-700">만료</span>
@break
@case('converted')
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700">전환 완료</span>
@break
@case('showcase')
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700">쇼케이스</span>
@break
@endswitch
<button type="button" onclick="closeModal()" class="text-gray-400 hover:text-gray-600">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="px-6 py-5 space-y-5 max-h-[70vh] overflow-y-auto">
<!-- 기본 정보 -->
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs text-gray-500">유형</p>
<p class="font-medium text-sm">
@switch($tenant->tenant_type)
@case('DEMO_SHOWCASE') 쇼케이스 (Tier 1) @break
@case('DEMO_PARTNER') 파트너 데모 (Tier 2) @break
@case('DEMO_TRIAL') 고객 체험 (Tier 3) @break
@endswitch
</p>
</div>
<div>
<p class="text-xs text-gray-500">만료일</p>
<p class="font-medium text-sm {{ $tenant->remaining_days !== null && $tenant->remaining_days <= 7 ? 'text-red-600' : '' }}">
{{ $tenant->demo_expires_at ? $tenant->demo_expires_at->format('Y-m-d H:i') : '없음 (상시)' }}
@if($tenant->remaining_days !== null)
<span class="text-xs text-gray-500">({{ $tenant->remaining_days }} 남음)</span>
@endif
</p>
</div>
<div>
<p class="text-xs text-gray-500">생성일</p>
<p class="font-medium text-sm">{{ $tenant->created_at->format('Y-m-d H:i') }}</p>
</div>
<div>
<p class="text-xs text-gray-500">담당자 이메일</p>
<p class="font-medium text-sm">{{ $tenant->parsed_options['demo_email'] ?? '-' }}</p>
</div>
</div>
<!-- 프리셋/제한 정보 -->
@if(!empty($tenant->parsed_options['demo_limits']))
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">리소스 제한</h3>
<div class="grid grid-cols-3 gap-3 text-sm">
@foreach($tenant->parsed_options['demo_limits'] as $key => $val)
<div class="flex justify-between">
<span class="text-gray-500">{{ str_replace(['max_', '_'], ['', ' '], $key) }}</span>
<span class="font-medium">{{ number_format($val) }}</span>
</div>
@endforeach
</div>
</div>
@endif
<!-- 데이터 현황 -->
<div class="bg-blue-50 rounded-lg p-4">
<h3 class="text-sm font-semibold text-blue-700 mb-3">데이터 현황</h3>
<div class="grid grid-cols-2 gap-3">
@foreach($dataCounts as $table => $info)
<div class="flex justify-between bg-white rounded px-3 py-2">
<span class="text-sm text-gray-600">{{ $info['label'] }}</span>
<span class="text-sm font-bold {{ $info['count'] > 0 ? 'text-blue-700' : 'text-gray-400' }}">
{{ number_format($info['count']) }}
</span>
</div>
@endforeach
</div>
</div>
<!-- 연장 이력 -->
@if(!empty($tenant->parsed_options['demo_extended']))
<div class="bg-amber-50 rounded-lg p-4">
<h3 class="text-sm font-semibold text-amber-700 mb-2">연장 이력</h3>
<p class="text-sm text-amber-600">
{{ $tenant->parsed_options['demo_extended_days'] ?? 14 }} 연장됨
({{ $tenant->parsed_options['demo_extended_at'] ?? '' }})
</p>
</div>
@endif
<!-- 전환 이력 -->
@if(!empty($tenant->parsed_options['converted_at']))
<div class="bg-emerald-50 rounded-lg p-4">
<h3 class="text-sm font-semibold text-emerald-700 mb-2">정식 전환 완료</h3>
<p class="text-sm text-emerald-600">
전환일: {{ $tenant->parsed_options['converted_at'] }}
</p>
</div>
@endif
</div>
<!-- 하단 액션 버튼 -->
<div class="flex justify-between items-center px-6 py-4 border-t border-gray-200">
<div>
@if($tenant->demo_status === 'active' && $tenant->tenant_type === 'DEMO_TRIAL' && empty($tenant->parsed_options['demo_extended']))
<button type="button" onclick="extendDemo({{ $tenant->id }})"
class="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-100 hover:bg-amber-200 rounded-lg transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
기간 연장
</button>
@endif
</div>
<div class="flex items-center gap-2">
@if(in_array($tenant->demo_status, ['active', 'expired']) && $tenant->tenant_type !== 'DEMO_SHOWCASE')
<button type="button" onclick="convertDemo({{ $tenant->id }})"
class="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition">
<svg class="w-3.5 h-3.5" 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>
정식 전환
</button>
@endif
@if(in_array($tenant->tenant_type, ['DEMO_SHOWCASE', 'DEMO_PARTNER', 'DEMO_TRIAL']))
<button type="button" onclick="deleteDemo({{ $tenant->id }}, '{{ addslashes($tenant->company_name) }}')"
class="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
삭제
</button>
@endif
<button type="button" onclick="closeModal()"
class="px-4 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-100 rounded-lg transition">
닫기
</button>
</div>
</div>
</div>
</div>
</div>
<script>
function extendDemo(id) {
if (!confirm('14일 연장하시겠습니까? (연장은 1회만 가능)')) return;
fetch(`/sales/demo-tenants/${id}/extend`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'HX-Request': 'true'
},
body: JSON.stringify({ days: 14 })
})
.then(r => {
const trigger = r.headers.get('HX-Trigger');
if (trigger) {
try {
const data = JSON.parse(trigger);
if (data.showToast) showToast(data.showToast, data.toastType);
if (data.refreshDemoList) htmx.ajax('GET', '{{ route('sales.demo-tenants.refresh') }}', {target: '#demo-list-container', swap: 'innerHTML'});
if (data.closeModal) closeModal();
} catch(ex) {}
}
})
.catch(() => showToast('오류가 발생했습니다.', 'error'));
}
function convertDemo(id) {
if (!confirm('정식 테넌트로 전환하시겠습니까?\n전환 후에는 데모 제한이 해제되고 되돌릴 수 없습니다.')) return;
fetch(`/sales/demo-tenants/${id}/convert`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'HX-Request': 'true'
}
})
.then(r => {
const trigger = r.headers.get('HX-Trigger');
if (trigger) {
try {
const data = JSON.parse(trigger);
if (data.showToast) showToast(data.showToast, data.toastType);
if (data.refreshDemoList) htmx.ajax('GET', '{{ route('sales.demo-tenants.refresh') }}', {target: '#demo-list-container', swap: 'innerHTML'});
if (data.closeModal) closeModal();
} catch(ex) {}
}
})
.catch(() => showToast('오류가 발생했습니다.', 'error'));
}
function deleteDemo(id, name) {
if (!confirm(`"${name}" 데모 테넌트를 삭제하시겠습니까?\n데이터가 있는 테넌트는 삭제할 수 없습니다.`)) return;
fetch(`/sales/demo-tenants/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'HX-Request': 'true'
}
})
.then(r => {
const trigger = r.headers.get('HX-Trigger');
if (trigger) {
try {
const data = JSON.parse(trigger);
if (data.showToast) showToast(data.showToast, data.toastType);
if (data.refreshDemoList) htmx.ajax('GET', '{{ route('sales.demo-tenants.refresh') }}', {target: '#demo-list-container', swap: 'innerHTML'});
if (data.closeModal) closeModal();
} catch(ex) {}
}
})
.catch(() => showToast('오류가 발생했습니다.', 'error'));
}
</script>

View File

@@ -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');