feat: [demo] MNG 데모 체험 관리 페이지 추가
- DemoTenantController: CRUD + 연장/전환/삭제 - Blade 뷰: index, demo-list, create-modal, show-modal - 라우트: sales/demo-tenants 그룹 등록 - 메뉴: 영업관리 하위에 '데모 체험 관리' 추가
This commit is contained in:
323
app/Http/Controllers/Sales/DemoTenantController.php
Normal file
323
app/Http/Controllers/Sales/DemoTenantController.php
Normal 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']));
|
||||
}
|
||||
}
|
||||
136
resources/views/sales/demo-tenants/index.blade.php
Normal file
136
resources/views/sales/demo-tenants/index.blade.php
Normal 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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
232
resources/views/sales/demo-tenants/partials/show-modal.blade.php
Normal file
232
resources/views/sales/demo-tenants/partials/show-modal.blade.php
Normal 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>
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user