feat:구독관리 탭 실제 구독자 데이터 표시 (sales_contract_products 기반)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-19 15:45:14 +09:00
parent 7c93fd414e
commit 7e741884d6
3 changed files with 148 additions and 161 deletions

View File

@@ -269,7 +269,40 @@ public function customerTab(Request $request): View
*/
public function subscriptionTab(Request $request): View
{
return view('finance.settlement.partials.subscription-tab');
// subscription_fee > 0인 계약상품이 있는 관리건 조회
$query = SalesTenantManagement::with([
'tenant', 'tenantProspect', 'salesPartner.user',
'manager', 'contractProducts.product', 'contractProducts.category',
])
->contracted()
->whereHas('contractProducts', fn($q) => $q->where('subscription_fee', '>', 0));
// 검색 필터
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->whereHas('tenant', fn($t) => $t->where('company_name', 'like', "%{$search}%"))
->orWhereHas('tenantProspect', fn($t) => $t->where('company_name', 'like', "%{$search}%"));
});
}
// 상태 필터
if ($status = $request->input('status')) {
if ($status !== 'all') {
$query->where('status', $status);
}
}
$managements = $query->orderBy('contracted_at', 'desc')->get();
// 통계 계산
$stats = [
'activeCount' => $managements->count(),
'monthlyRecurring' => $managements->sum(fn($m) => $m->contractProducts->sum('subscription_fee')),
'totalProducts' => $managements->sum(fn($m) => $m->contractProducts->where('subscription_fee', '>', 0)->count()),
];
$stats['yearlyRecurring'] = $stats['monthlyRecurring'] * 12;
return view('finance.settlement.partials.subscription-tab', compact('managements', 'stats'));
}
/**

View File

@@ -369,55 +369,6 @@ function onTenantSelect(managementId) {
});
}
// 구독관리 탭 Alpine 컴포넌트
function subscriptionManager() {
return {
items: [], stats: { activeCount: 0, monthlyRecurring: 0, yearlyRecurring: 0, totalUsers: 0 },
loading: false, saving: false, showModal: false, modalMode: 'add', editingId: null,
searchTerm: '', filterStatus: 'all', filterPlan: 'all',
form: { customer: '', plan: 'Starter', monthlyFee: 0, billingCycle: 'monthly', startDate: '', nextBilling: '', status: 'active', users: 0, memo: '' },
formatCurrency(val) { return Number(val || 0).toLocaleString(); },
filteredItems() {
return this.items.filter(item => {
const matchSearch = !this.searchTerm || (item.customer || '').toLowerCase().includes(this.searchTerm.toLowerCase());
const matchStatus = this.filterStatus === 'all' || item.status === this.filterStatus;
const matchPlan = this.filterPlan === 'all' || item.plan === this.filterPlan;
return matchSearch && matchStatus && matchPlan;
});
},
async fetchData() {
this.loading = true;
try {
const res = await fetch('/finance/subscriptions/list');
const data = await res.json();
if (data.success) { this.items = data.data; this.stats = data.stats; }
} finally { this.loading = false; }
},
openModal(mode, item = null) {
this.modalMode = mode; this.editingId = item?.id || null;
this.form = item ? { ...item } : { customer: '', plan: 'Starter', monthlyFee: 0, billingCycle: 'monthly', startDate: new Date().toISOString().split('T')[0], nextBilling: '', status: 'active', users: 0, memo: '' };
this.showModal = true;
},
async saveItem() {
if (!this.form.customer || !this.form.monthlyFee) { alert('필수 항목을 입력해주세요.'); return; }
this.saving = true;
try {
const url = this.modalMode === 'add' ? '/finance/subscriptions/store' : '/finance/subscriptions/' + this.editingId;
const body = { ...this.form, monthlyFee: parseInt(this.form.monthlyFee) || 0, users: parseInt(this.form.users) || 0 };
const res = await fetch(url, { method: this.modalMode === 'add' ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content }, body: JSON.stringify(body) });
const data = await res.json();
if (!res.ok) { alert(data.errors ? Object.values(data.errors).flat().join('\n') : data.message || '저장 실패'); return; }
this.showModal = false; this.fetchData();
} finally { this.saving = false; }
},
async deleteItem(id) {
if (!confirm('정말 삭제하시겠습니까?')) return;
await fetch('/finance/subscriptions/' + id, { method: 'DELETE', headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content } });
this.fetchData();
}
};
}
// 초기 탭이 기본(commission)이 아닌 경우 HTMX 콘텐츠 자동 로드
document.addEventListener('DOMContentLoaded', function() {
const initialTab = '{{ $initialTab }}';

View File

@@ -1,54 +1,52 @@
{{-- 구독관리 (Blade + Alpine.js) --}}
<div x-data="subscriptionManager()" x-init="fetchData()">
{{-- 구독관리 (서버 렌더링 Blade) --}}
<div class="space-y-6">
{{-- 통계 카드 --}}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm p-4 border-l-4 border-gray-400">
<p class="text-sm text-gray-500">활성 구독</p>
<p class="text-xl font-bold text-gray-700" x-text="stats.activeCount + '개'">0</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-white rounded-lg shadow-sm p-4 border-l-4 border-teal-500">
<p class="text-sm text-gray-500">활성 구독</p>
<p class="text-xl font-bold text-teal-600">{{ $stats['activeCount'] }}</p>
</div>
<div class="bg-white rounded-lg shadow-sm p-4 border-l-4 border-indigo-500">
<p class="text-sm text-gray-500"> 반복 수익(MRR)</p>
<p class="text-xl font-bold text-teal-600" x-text="formatCurrency(stats.monthlyRecurring) + '원'">0</p>
<p class="text-xl font-bold text-indigo-600">{{ number_format($stats['monthlyRecurring']) }}</p>
</div>
<div class="bg-white rounded-lg shadow-sm p-4 border-l-4 border-emerald-500">
<p class="text-sm text-gray-500"> 반복 수익(ARR)</p>
<p class="text-xl font-bold text-emerald-600" x-text="formatCurrency(stats.yearlyRecurring) + '원'">0</p>
<p class="text-xl font-bold text-emerald-600">{{ number_format($stats['yearlyRecurring']) }}</p>
</div>
<div class="bg-white rounded-lg shadow-sm p-4 border-l-4 border-gray-400">
<p class="text-sm text-gray-500"> 사용자</p>
<p class="text-xl font-bold text-gray-700" x-text="stats.totalUsers + '명'">0</p>
<p class="text-sm text-gray-500">구독 상품</p>
<p class="text-xl font-bold text-gray-700">{{ $stats['totalProducts'] }}</p>
</div>
</div>
{{-- 필터 + 등록 버튼 --}}
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<div class="flex flex-wrap items-end gap-4">
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium text-gray-700 mb-1">검색</label>
<input type="text" x-model="searchTerm" placeholder="고객사 검색" class="w-full rounded-lg border-gray-300 focus:border-teal-500 focus:ring-teal-500">
</div>
{{-- 검색/필터 --}}
<div class="bg-white rounded-lg shadow-sm p-4">
<form hx-get="{{ route('finance.settlement.subscription') }}"
hx-target="#subscription-content"
hx-trigger="submit"
class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">플랜</label>
<select x-model="filterPlan" class="rounded-lg border-gray-300 focus:border-teal-500 focus:ring-teal-500">
<option value="all">전체</option>
<option value="Starter">Starter</option>
<option value="Business">Business</option>
<option value="Enterprise">Enterprise</option>
</select>
<label class="block text-sm font-medium text-gray-700 mb-1">검색</label>
<input type="text" name="search" placeholder="고객사명"
value="{{ request('search') }}"
class="w-full rounded-lg border-gray-300 focus:border-teal-500 focus:ring-teal-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select x-model="filterStatus" class="rounded-lg border-gray-300 focus:border-teal-500 focus:ring-teal-500">
<select name="status" class="w-full rounded-lg border-gray-300 focus:border-teal-500 focus:ring-teal-500">
<option value="all">전체</option>
<option value="active">활성</option>
<option value="trial">체험</option>
<option value="cancelled">해지</option>
<option value="contracted" {{ request('status') == 'contracted' ? 'selected' : '' }}>계약완료</option>
<option value="onboarding" {{ request('status') == 'onboarding' ? 'selected' : '' }}>온보딩</option>
<option value="active" {{ request('status') == 'active' ? 'selected' : '' }}>활성</option>
</select>
</div>
<button @click="openModal('add')" class="px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white rounded-lg transition-colors">
+ 등록
</button>
</div>
<div class="flex items-end">
<button type="submit" class="w-full px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white rounded-lg transition-colors">
조회
</button>
</div>
</form>
</div>
{{-- 테이블 --}}
@@ -57,97 +55,102 @@
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<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-right 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-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-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 tracking-wider">고객사</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">담당 파트너</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">구독 상품</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> 구독료</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">계약일</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">개발상태</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">입금상태</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<template x-if="loading">
<tr><td colspan="8" class="px-4 py-8 text-center text-gray-400">로딩 ...</td></tr>
</template>
<template x-if="!loading && filteredItems().length === 0">
<tr><td colspan="8" class="px-4 py-8 text-center text-gray-400">데이터가 없습니다.</td></tr>
</template>
<template x-for="item in filteredItems()" :key="item.id">
<tbody class="bg-white divide-y divide-gray-200">
@forelse ($managements as $mgmt)
@php
$companyName = $mgmt->tenant?->company_name ?? $mgmt->tenantProspect?->company_name ?? '-';
$partnerName = $mgmt->salesPartner?->user?->name ?? '-';
$subscriptionProducts = $mgmt->contractProducts->where('subscription_fee', '>', 0);
$monthlyTotal = $subscriptionProducts->sum('subscription_fee');
$statusColor = match ($mgmt->hq_status) {
'review' => 'bg-purple-100 text-purple-700',
'planning' => 'bg-blue-100 text-blue-700',
'coding' => 'bg-indigo-100 text-indigo-700',
'dev_test' => 'bg-cyan-100 text-cyan-700',
'dev_done' => 'bg-teal-100 text-teal-700',
'int_test' => 'bg-amber-100 text-amber-700',
'handover' => 'bg-green-100 text-green-700',
default => 'bg-gray-100 text-gray-700',
};
$contractStatusColor = match ($mgmt->status) {
'contracted' => 'bg-blue-100 text-blue-700',
'onboarding' => 'bg-amber-100 text-amber-700',
'active' => 'bg-green-100 text-green-700',
default => 'bg-gray-100 text-gray-700',
};
$contractStatusLabel = \App\Models\Sales\SalesTenantManagement::$statusLabels[$mgmt->status] ?? $mgmt->status;
@endphp
<tr class="hover:bg-gray-50">
{{-- 고객사 --}}
<td class="px-4 py-3">
<div class="text-sm font-medium text-gray-900" x-text="item.customer"></div>
<div x-show="item.memo" class="text-xs text-gray-400" x-text="item.memo"></div>
<div class="text-sm font-medium text-gray-900">{{ $companyName }}</div>
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium {{ $contractStatusColor }}">{{ $contractStatusLabel }}</span>
</td>
{{-- 담당 파트너 --}}
<td class="px-4 py-3 text-sm text-gray-900">{{ $partnerName }}</td>
{{-- 구독 상품 --}}
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded text-xs font-medium"
:class="{'bg-gray-100 text-gray-700': item.plan === 'Starter', 'bg-blue-100 text-blue-700': item.plan === 'Business', 'bg-purple-100 text-purple-700': item.plan === 'Enterprise'}"
x-text="item.plan"></span>
<div class="space-y-1">
@foreach ($subscriptionProducts as $cp)
<div class="flex items-center gap-2">
<span class="text-sm text-gray-900">{{ $cp->product?->name ?? $cp->category?->name ?? '상품' }}</span>
<span class="text-xs text-gray-500">{{ number_format($cp->subscription_fee) }}/</span>
</div>
@endforeach
</div>
</td>
<td class="px-4 py-3 text-sm text-right font-medium text-teal-600" x-text="formatCurrency(item.monthlyFee) + '원'"></td>
<td class="px-4 py-3 text-sm text-center text-gray-600" x-text="item.billingCycle === 'monthly' ? '월간' : '연간'"></td>
<td class="px-4 py-3 text-sm text-center text-gray-600" x-text="item.nextBilling || '-'"></td>
<td class="px-4 py-3 text-sm text-center text-gray-600" x-text="item.users + '명'"></td>
<td class="px-4 py-3 text-center">
<span class="px-2 py-0.5 rounded-full text-xs font-medium"
:class="{'bg-emerald-100 text-emerald-700': item.status === 'active', 'bg-blue-100 text-blue-700': item.status === 'trial', 'bg-rose-100 text-rose-700': item.status === 'cancelled', 'bg-amber-100 text-amber-700': item.status === 'paused'}"
x-text="{'active':'활성','trial':'체험','cancelled':'해지','paused':'일시정지'}[item.status] || item.status"></span>
{{-- 구독료 합계 --}}
<td class="px-4 py-3 text-sm text-right font-medium text-teal-600">
{{ number_format($monthlyTotal) }}
</td>
{{-- 계약일 --}}
<td class="px-4 py-3 text-sm text-center text-gray-600">
{{ $mgmt->contracted_at ? \Carbon\Carbon::parse($mgmt->contracted_at)->format('Y-m-d') : '-' }}
</td>
{{-- 개발상태 --}}
<td class="px-4 py-3 text-center">
<button @click="openModal('edit', item)" class="p-1 text-gray-400 hover:text-blue-500" title="수정">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button @click="deleteItem(item.id)" class="p-1 text-gray-400 hover:text-red-500" title="삭제">
<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="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>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {{ $statusColor }}">
{{ $mgmt->hq_status_label }}
</span>
</td>
{{-- 입금상태 --}}
<td class="px-4 py-3 text-center">
<div class="flex flex-col items-center gap-1">
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium {{ $mgmt->deposit_status === 'paid' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700' }}">
계약금 {{ $mgmt->deposit_status === 'paid' ? '완료' : '대기' }}
</span>
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium {{ $mgmt->balance_status === 'paid' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700' }}">
잔금 {{ $mgmt->balance_status === 'paid' ? '완료' : '대기' }}
</span>
</div>
</td>
</tr>
</template>
@empty
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-500">
구독 상품이 있는 고객사가 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- 모달 --}}
<div x-show="showModal" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto" @click.outside="showModal = false">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900" x-text="modalMode === 'add' ? '구독 등록' : '구독 수정'"></h3>
<button @click="showModal = false" class="p-1 hover:bg-gray-100 rounded-lg">
<svg class="w-5 h-5 text-gray-500" 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 class="space-y-4">
<div><label class="block text-sm font-medium text-gray-700 mb-1">고객사 *</label><input type="text" x-model="form.customer" class="w-full px-3 py-2 border rounded-lg"></div>
<div class="grid grid-cols-2 gap-4">
<div><label class="block text-sm font-medium text-gray-700 mb-1">플랜</label>
<select x-model="form.plan" class="w-full px-3 py-2 border rounded-lg"><option value="Starter">Starter</option><option value="Business">Business</option><option value="Enterprise">Enterprise</option></select>
</div>
<div><label class="block text-sm font-medium text-gray-700 mb-1"> 요금 *</label><input type="number" x-model="form.monthlyFee" class="w-full px-3 py-2 border rounded-lg text-right"></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="block text-sm font-medium text-gray-700 mb-1">결제주기</label>
<select x-model="form.billingCycle" class="w-full px-3 py-2 border rounded-lg"><option value="monthly">월간</option><option value="yearly">연간</option></select>
</div>
<div><label class="block text-sm font-medium text-gray-700 mb-1">사용자 </label><input type="number" x-model="form.users" class="w-full px-3 py-2 border rounded-lg text-right"></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="block text-sm font-medium text-gray-700 mb-1">시작일</label><input type="date" x-model="form.startDate" class="w-full px-3 py-2 border rounded-lg"></div>
<div><label class="block text-sm font-medium text-gray-700 mb-1">다음 결제일</label><input type="date" x-model="form.nextBilling" class="w-full px-3 py-2 border rounded-lg"></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select x-model="form.status" class="w-full px-3 py-2 border rounded-lg"><option value="active">활성</option><option value="trial">체험</option><option value="paused">일시정지</option><option value="cancelled">해지</option></select>
</div>
<div><label class="block text-sm font-medium text-gray-700 mb-1">메모</label><input type="text" x-model="form.memo" class="w-full px-3 py-2 border rounded-lg"></div>
</div>
</div>
<div class="flex gap-3 mt-6">
<button @click="showModal = false" class="flex-1 px-4 py-2 border text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button @click="saveItem()" :disabled="saving" class="flex-1 px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white rounded-lg disabled:opacity-50" x-text="saving ? '저장 중...' : '저장'">저장</button>
</div>
</div>
</div>
</div>