diff --git a/app/Http/Controllers/Finance/SettlementController.php b/app/Http/Controllers/Finance/SettlementController.php index 3d79c6da..c1146732 100644 --- a/app/Http/Controllers/Finance/SettlementController.php +++ b/app/Http/Controllers/Finance/SettlementController.php @@ -4,7 +4,9 @@ use App\Http\Controllers\Controller; use App\Models\Sales\SalesCommission; +use App\Models\Sales\SalesContractProduct; use App\Models\Sales\SalesPartner; +use App\Models\Sales\SalesTenantManagement; use App\Services\SalesCommissionService; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -169,7 +171,91 @@ public function consultingTab(Request $request): View */ public function customerTab(Request $request): View { - return view('finance.settlement.partials.customer-tab'); + $query = SalesTenantManagement::with([ + 'tenant', + 'tenantProspect', + 'salesPartner.user', + 'manager', + 'commissions', + ])->where('hq_status', '!=', SalesTenantManagement::HQ_STATUS_PENDING); + + // 필터: 검색 (회사명) + if ($search = $request->input('search')) { + $query->where(function ($q) use ($search) { + $q->whereHas('tenant', fn ($tq) => $tq->where('company_name', 'like', "%{$search}%")) + ->orWhereHas('tenantProspect', fn ($pq) => $pq->where('company_name', 'like', "%{$search}%")); + }); + } + + // 필터: 개발 상태 + if ($hqStatus = $request->input('hq_status')) { + $query->where('hq_status', $hqStatus); + } + + // 필터: 담당 파트너 + if ($partnerId = $request->input('partner_id')) { + $query->where('sales_partner_id', $partnerId); + } + + // 필터: 수금 상태 + $paymentStatus = $request->input('payment_status'); + + $managements = $query->orderByDesc('id')->paginate(20)->withQueryString(); + + // 수금 상태 필터 (컬렉션 레벨) + if ($paymentStatus) { + $managements->setCollection( + $managements->getCollection()->filter(function ($mgmt) use ($paymentStatus) { + $depositPaid = $mgmt->deposit_status === 'paid'; + $balancePaid = $mgmt->balance_status === 'paid'; + return match ($paymentStatus) { + 'fully_paid' => $depositPaid && $balancePaid, + 'partial' => ($depositPaid || $balancePaid) && !($depositPaid && $balancePaid), + 'unpaid' => !$depositPaid && !$balancePaid, + default => true, + }; + }) + ); + } + + // 구독료 일괄 조회 (N+1 방지) + $tenantIds = $managements->getCollection() + ->pluck('tenant_id') + ->filter() + ->unique() + ->values() + ->toArray(); + + $subscriptionFees = []; + if (!empty($tenantIds)) { + $subscriptionFees = SalesContractProduct::whereIn('tenant_id', $tenantIds) + ->selectRaw('tenant_id, SUM(subscription_fee) as total_subscription_fee') + ->groupBy('tenant_id') + ->pluck('total_subscription_fee', 'tenant_id') + ->toArray(); + } + + // 파트너 목록 (필터용) + $partners = SalesPartner::with('user') + ->active() + ->orderBy('partner_code') + ->get(); + + // hqStatusLabels에서 pending 제외 + $hqStatusLabels = collect(SalesTenantManagement::$hqStatusLabels) + ->except(SalesTenantManagement::HQ_STATUS_PENDING) + ->toArray(); + + // 통계 카드 + $customerStats = $this->getCustomerStats(); + + return view('finance.settlement.partials.customer-tab', compact( + 'managements', + 'subscriptionFees', + 'partners', + 'hqStatusLabels', + 'customerStats', + )); } /** @@ -317,6 +403,48 @@ public function paymentStats(Request $request): View|Response )); } + /** + * 고객사정산 통계 데이터 + */ + private function getCustomerStats(): array + { + $baseQuery = SalesTenantManagement::where('hq_status', '!=', SalesTenantManagement::HQ_STATUS_PENDING); + + // 총 개발비 + $totalFee = (clone $baseQuery)->sum('total_registration_fee'); + $totalCount = (clone $baseQuery)->count(); + + // 수금완료 (deposit + balance 모두 paid인 건의 합계) + $collectedAmount = (clone $baseQuery) + ->where('deposit_status', 'paid') + ->sum('deposit_amount') + + (clone $baseQuery) + ->where('balance_status', 'paid') + ->sum('balance_amount'); + + // 미수금 + $uncollectedAmount = $totalFee - $collectedAmount; + + // 개발 진행 중 (handover 제외) + $inProgressCount = (clone $baseQuery) + ->where('hq_status', '!=', SalesTenantManagement::HQ_STATUS_HANDOVER) + ->count(); + + // 구독 전환 (handover + tenant active) + $subscriptionCount = SalesTenantManagement::where('hq_status', SalesTenantManagement::HQ_STATUS_HANDOVER) + ->whereHas('tenant', fn ($q) => $q->whereNull('deleted_at')) + ->count(); + + return [ + 'total_fee' => $totalFee, + 'total_count' => $totalCount, + 'collected_amount' => $collectedAmount, + 'uncollected_amount' => max(0, $uncollectedAmount), + 'in_progress_count' => $inProgressCount, + 'subscription_count' => $subscriptionCount, + ]; + } + /** * 통합 통계 데이터 */ diff --git a/resources/views/finance/settlement/index.blade.php b/resources/views/finance/settlement/index.blade.php index 6fa155c8..ccd7111d 100644 --- a/resources/views/finance/settlement/index.blade.php +++ b/resources/views/finance/settlement/index.blade.php @@ -369,54 +369,6 @@ function onTenantSelect(managementId) { }); } - // 고객사정산 탭 Alpine 컴포넌트 - function customerSettlementManager() { - return { - items: [], stats: { totalSales: 0, totalCommission: 0, totalNet: 0, settledAmount: 0 }, - loading: false, saving: false, showModal: false, modalMode: 'add', editingId: null, - searchTerm: '', filterStatus: 'all', - form: { period: '', customer: '', totalSales: 0, commission: 0, expense: 0, netAmount: 0, status: 'pending', settledDate: '', memo: '' }, - formatCurrency(val) { return Number(val || 0).toLocaleString(); }, - filteredItems() { - return this.items.filter(item => { - const matchSearch = !this.searchTerm || (item.customer || '').includes(this.searchTerm); - const matchStatus = this.filterStatus === 'all' || item.status === this.filterStatus; - return matchSearch && matchStatus; - }); - }, - async fetchData() { - this.loading = true; - try { - const res = await fetch('/finance/customer-settlements/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 } : { period: new Date().toISOString().slice(0,7), customer: '', totalSales: 0, commission: 0, expense: 0, netAmount: 0, status: 'pending', settledDate: '', memo: '' }; - this.showModal = true; - }, - async saveItem() { - if (!this.form.customer) { alert('고객사를 입력해주세요.'); return; } - this.form.netAmount = (parseInt(this.form.totalSales)||0) - (parseInt(this.form.commission)||0) - (parseInt(this.form.expense)||0); - this.saving = true; - try { - const url = this.modalMode === 'add' ? '/finance/customer-settlements/store' : '/finance/customer-settlements/' + this.editingId; - 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(this.form) }); - 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/customer-settlements/' + id, { method: 'DELETE', headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content } }); - this.fetchData(); - } - }; - } - // 구독관리 탭 Alpine 컴포넌트 function subscriptionManager() { return { diff --git a/resources/views/finance/settlement/partials/customer-tab.blade.php b/resources/views/finance/settlement/partials/customer-tab.blade.php index d6e10cbd..bfe137b9 100644 --- a/resources/views/finance/settlement/partials/customer-tab.blade.php +++ b/resources/views/finance/settlement/partials/customer-tab.blade.php @@ -1,44 +1,77 @@ -{{-- 고객사정산 탭 (Blade + Alpine.js) --}} -
총 매출
-0원
-정산금액
-0원
+총 개발비
+{{ number_format($customerStats['total_fee']) }}원
+{{ $customerStats['total_count'] }}건
+수금완료
+{{ number_format($customerStats['collected_amount']) }}원
+미수금
+{{ number_format($customerStats['uncollected_amount']) }}원
+개발 진행 중
+{{ $customerStats['in_progress_count'] }}건
정산완료
-0원
-수수료 합계
-0원
+구독 전환
+{{ $customerStats['subscription_count'] }}건
| 정산월 | -고객사 | -매출액 | -수수료 | -비용 | -정산금액 | -상태 | -관리 | +고객사 | +담당파트너 | +담당매니저 | +개발비 총액 | +1차(계약금) | +2차(잔금) | +구독료 | +개발상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 로딩 중... | |||||||||||||||
| 데이터가 없습니다. | |||||||||||||||
| - | - | - | - | - | - | - + {{-- 고객사 --}} + |
+ {{ $companyName }}
+ @if ($tenantActive)
+ 활성
+ @elseif ($mgmt->tenant)
+ 비활성
+ @else
+ 가망
+ @endif
|
+
+ {{-- 담당파트너 --}}
+ + {{ $mgmt->salesPartner?->user?->name ?? '-' }} + | + + {{-- 담당매니저 --}} ++ {{ $mgmt->manager?->name ?? '-' }} + | + + {{-- 개발비 총액 --}} ++ @if ($mgmt->total_registration_fee > 0) + {{ number_format($mgmt->total_registration_fee) }}원 + @else + - + @endif + | + + {{-- 1차(계약금) --}} +
+ @if ($mgmt->deposit_amount > 0)
+ {{ number_format($mgmt->deposit_amount) }}원
+ @if ($mgmt->deposit_status === 'paid')
+ 완료
+ @else
+ 대기
+ @endif
+ @if ($mgmt->deposit_paid_date)
+ {{ $mgmt->deposit_paid_date->format('Y-m-d') }}
+ @endif
+ @else
+ -
+ @endif
+ |
+
+ {{-- 2차(잔금) --}}
+
+ @if ($mgmt->balance_amount > 0)
+ {{ number_format($mgmt->balance_amount) }}원
+ @if ($mgmt->balance_status === 'paid')
+ 완료
+ @else
+ 대기
+ @endif
+ @if ($mgmt->balance_paid_date)
+ {{ $mgmt->balance_paid_date->format('Y-m-d') }}
+ @endif
+ @else
+ -
+ @endif
+ |
+
+ {{-- 구독료 --}}
+
+ @if ($monthlyFee > 0)
+ 월 {{ number_format($monthlyFee) }}원
+ @if ($firstSubscriptionAt)
+ 첫입금 {{ \Carbon\Carbon::parse($firstSubscriptionAt)->format('Y-m-d') }}
+ @endif
+ @else
+ -
+ @endif
+ |
+
+ {{-- 개발상태 --}}
- - + @php + $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', + }; + @endphp + + {{ $mgmt->hq_status_label }} + | |
| + 개발이 시작된 고객사가 없습니다. + | +|||||||||||||||