Enhance admin analytics with ARR, revenue trends, churn, and overdue invoices
Add Annual Recurring Revenue stat, 12-month revenue trend with progress bars, customer growth chart, 6-month churn rate tracker with status indicators, and overdue invoices table with days-overdue coloring. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Admin;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Invoice;
|
use App\Models\Invoice;
|
||||||
|
use App\Models\PaymentTransaction;
|
||||||
use App\Models\Plan;
|
use App\Models\Plan;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -118,6 +119,61 @@ class DashboardController extends Controller
|
|||||||
->where('paid_at', '>=', now()->startOfMonth())
|
->where('paid_at', '>=', now()->startOfMonth())
|
||||||
->sum('total');
|
->sum('total');
|
||||||
|
|
||||||
|
// ARR (Annual Recurring Revenue)
|
||||||
|
$arr = (float) $mrr * 12;
|
||||||
|
|
||||||
|
// Monthly Revenue Trend (last 12 months)
|
||||||
|
$revenueByMonth = PaymentTransaction::query()
|
||||||
|
->where('status', 'completed')
|
||||||
|
->where('created_at', '>=', now()->subMonths(12))
|
||||||
|
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, SUM(amount) as total")
|
||||||
|
->groupBy('month')
|
||||||
|
->orderBy('month')
|
||||||
|
->get()
|
||||||
|
->map(fn ($row) => ['month' => $row->month, 'total' => (float) $row->total]);
|
||||||
|
|
||||||
|
// Customer Growth (last 12 months - new signups per month)
|
||||||
|
$customerGrowth = User::role('customer')
|
||||||
|
->where('created_at', '>=', now()->subMonths(12))
|
||||||
|
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count")
|
||||||
|
->groupBy('month')
|
||||||
|
->orderBy('month')
|
||||||
|
->get()
|
||||||
|
->map(fn ($row) => ['month' => $row->month, 'count' => (int) $row->count]);
|
||||||
|
|
||||||
|
// Churn Rate (subscriptions cancelled vs total in last 6 months)
|
||||||
|
$churnData = [];
|
||||||
|
for ($i = 5; $i >= 0; $i--) {
|
||||||
|
$monthStart = now()->subMonths($i)->startOfMonth();
|
||||||
|
$monthEnd = now()->subMonths($i)->endOfMonth();
|
||||||
|
|
||||||
|
$totalAtStart = Subscription::query()
|
||||||
|
->where('created_at', '<', $monthStart)
|
||||||
|
->where(function ($query) use ($monthStart): void {
|
||||||
|
$query->whereNull('cancelled_at')
|
||||||
|
->orWhere('cancelled_at', '>', $monthStart);
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$cancelled = Subscription::query()
|
||||||
|
->whereBetween('cancelled_at', [$monthStart, $monthEnd])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$churnData[] = [
|
||||||
|
'month' => $monthStart->format('Y-m'),
|
||||||
|
'rate' => $totalAtStart > 0 ? round(($cancelled / $totalAtStart) * 100, 1) : 0,
|
||||||
|
'cancelled' => $cancelled,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overdue Invoices (detailed list)
|
||||||
|
$overdueInvoices = Invoice::query()
|
||||||
|
->where('status', 'overdue')
|
||||||
|
->with('user:id,name,email')
|
||||||
|
->latest('due_date')
|
||||||
|
->take(10)
|
||||||
|
->get(['id', 'user_id', 'number', 'total', 'due_date', 'status']);
|
||||||
|
|
||||||
return Inertia::render('Admin/Dashboard', [
|
return Inertia::render('Admin/Dashboard', [
|
||||||
'totalCustomers' => $totalCustomers,
|
'totalCustomers' => $totalCustomers,
|
||||||
'mrr' => (float) $mrr,
|
'mrr' => (float) $mrr,
|
||||||
@@ -133,6 +189,11 @@ class DashboardController extends Controller
|
|||||||
'revenueByServiceType' => $revenueByServiceType,
|
'revenueByServiceType' => $revenueByServiceType,
|
||||||
'newCustomersThisMonth' => $newCustomersThisMonth,
|
'newCustomersThisMonth' => $newCustomersThisMonth,
|
||||||
'revenueThisMonth' => (float) $revenueThisMonth,
|
'revenueThisMonth' => (float) $revenueThisMonth,
|
||||||
|
'arr' => $arr,
|
||||||
|
'revenueByMonth' => $revenueByMonth,
|
||||||
|
'customerGrowth' => $customerGrowth,
|
||||||
|
'churnData' => $churnData,
|
||||||
|
'overdueInvoices' => $overdueInvoices,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Link } from '@inertiajs/vue3'
|
||||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||||
import StatCard from '@/Components/StatCard.vue'
|
import StatCard from '@/Components/StatCard.vue'
|
||||||
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
|
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
|
||||||
@@ -47,6 +49,36 @@ interface RevenueByServiceType {
|
|||||||
invoice_count: number
|
invoice_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RevenueMonth {
|
||||||
|
month: string
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomerGrowthMonth {
|
||||||
|
month: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChurnMonth {
|
||||||
|
month: string
|
||||||
|
rate: number
|
||||||
|
cancelled: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverdueInvoice {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
number: string
|
||||||
|
total: string
|
||||||
|
due_date: string
|
||||||
|
status: string
|
||||||
|
user: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
totalCustomers: number
|
totalCustomers: number
|
||||||
mrr: number
|
mrr: number
|
||||||
@@ -62,6 +94,11 @@ interface Props {
|
|||||||
revenueByServiceType: RevenueByServiceType[]
|
revenueByServiceType: RevenueByServiceType[]
|
||||||
newCustomersThisMonth: number
|
newCustomersThisMonth: number
|
||||||
revenueThisMonth: number
|
revenueThisMonth: number
|
||||||
|
arr: number
|
||||||
|
revenueByMonth: RevenueMonth[]
|
||||||
|
customerGrowth: CustomerGrowthMonth[]
|
||||||
|
churnData: ChurnMonth[]
|
||||||
|
overdueInvoices: OverdueInvoice[]
|
||||||
}
|
}
|
||||||
|
|
||||||
defineOptions({ layout: AdminLayout })
|
defineOptions({ layout: AdminLayout })
|
||||||
@@ -78,6 +115,12 @@ function formatDate(dateStr: string): string {
|
|||||||
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
|
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMonthLabel(monthStr: string): string {
|
||||||
|
const [year, month] = monthStr.split('-')
|
||||||
|
const date = new Date(parseInt(year), parseInt(month) - 1)
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
function resolveServiceTypeColor(type: string): string {
|
function resolveServiceTypeColor(type: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
vps: 'primary',
|
vps: 'primary',
|
||||||
@@ -107,6 +150,33 @@ function formatServiceType(type: string): string {
|
|||||||
}
|
}
|
||||||
return map[type] ?? type
|
return map[type] ?? type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveChurnColor(rate: number): string {
|
||||||
|
if (rate < 2) return 'success'
|
||||||
|
if (rate <= 5) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysOverdue(dueDateStr: string): number {
|
||||||
|
const due = new Date(dueDateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = Math.floor((now.getTime() - due.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
return Math.max(0, diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxRevenue = computed<number>(() => {
|
||||||
|
if (props.revenueByMonth.length === 0) return 1
|
||||||
|
return Math.max(...props.revenueByMonth.map(r => r.total), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalCustomerGrowth = computed<number>(() => {
|
||||||
|
return props.customerGrowth.reduce((sum, row) => sum + row.count, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxCustomerGrowth = computed<number>(() => {
|
||||||
|
if (props.customerGrowth.length === 0) return 1
|
||||||
|
return Math.max(...props.customerGrowth.map(r => r.count), 1)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -140,12 +210,42 @@ function formatServiceType(type: string): string {
|
|||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" sm="6" lg="3">
|
||||||
|
<StatCard
|
||||||
|
title="Annual Recurring Revenue"
|
||||||
|
:stats="formatCurrency(arr)"
|
||||||
|
icon="tabler-calendar-dollar"
|
||||||
|
color="info"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
<VCol cols="12" sm="6" lg="3">
|
<VCol cols="12" sm="6" lg="3">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Active Services"
|
title="Active Services"
|
||||||
:stats="activeServices"
|
:stats="activeServices"
|
||||||
icon="tabler-server"
|
icon="tabler-server"
|
||||||
color="info"
|
color="warning"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Row 1b: Secondary Metrics -->
|
||||||
|
<VRow class="mb-2">
|
||||||
|
<VCol cols="12" sm="6" lg="3">
|
||||||
|
<StatCard
|
||||||
|
title="Total Revenue"
|
||||||
|
:stats="formatCurrency(totalRevenue)"
|
||||||
|
icon="tabler-trending-up"
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" sm="6" lg="3">
|
||||||
|
<StatCard
|
||||||
|
title="Revenue This Month"
|
||||||
|
:stats="formatCurrency(revenueThisMonth)"
|
||||||
|
icon="tabler-report-money"
|
||||||
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
@@ -157,9 +257,254 @@ function formatServiceType(type: string): string {
|
|||||||
color="warning"
|
color="warning"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" sm="6" lg="3">
|
||||||
|
<StatCard
|
||||||
|
title="Overdue Accounts"
|
||||||
|
:stats="`${overdueCount} (${formatCurrency(overdueAmount)})`"
|
||||||
|
icon="tabler-alert-circle"
|
||||||
|
:color="overdueCount > 0 ? 'error' : 'success'"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
|
|
||||||
<!-- Row 2: Recent Subscriptions & Recent Invoices -->
|
<!-- Row 2: Revenue Trend (12 Months) -->
|
||||||
|
<VRow class="mb-2">
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="d-flex align-center justify-space-between">
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-chart-bar" size="22" />
|
||||||
|
<span>Revenue Trend (12 Months)</span>
|
||||||
|
</div>
|
||||||
|
<VChip size="small" color="success" variant="tonal">
|
||||||
|
Payment Transactions
|
||||||
|
</VChip>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<VCardText v-if="revenueByMonth.length === 0" class="text-center py-8">
|
||||||
|
<VIcon icon="tabler-chart-bar-off" size="48" color="disabled" class="mb-2" />
|
||||||
|
<div class="text-medium-emphasis">No revenue data available for the last 12 months.</div>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VTable v-else density="comfortable" hover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 120px;">Month</th>
|
||||||
|
<th style="width: 140px;" class="text-end">Revenue</th>
|
||||||
|
<th>Trend</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in revenueByMonth" :key="row.month">
|
||||||
|
<td class="text-body-2 font-weight-medium">
|
||||||
|
{{ formatMonthLabel(row.month) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-end text-body-2 font-weight-semibold">
|
||||||
|
{{ formatCurrency(row.total) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VProgressLinear
|
||||||
|
:model-value="(row.total / maxRevenue) * 100"
|
||||||
|
color="success"
|
||||||
|
height="20"
|
||||||
|
rounded
|
||||||
|
class="my-1"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<span class="text-caption font-weight-medium">
|
||||||
|
{{ Math.round((row.total / maxRevenue) * 100) }}%
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</VProgressLinear>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Row 3: Customer Growth + Churn Rate side by side -->
|
||||||
|
<VRow class="mb-2">
|
||||||
|
<!-- Customer Growth -->
|
||||||
|
<VCol cols="12" lg="6">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="d-flex align-center justify-space-between">
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-user-plus" size="22" />
|
||||||
|
<span>Customer Growth (12 Months)</span>
|
||||||
|
</div>
|
||||||
|
<VChip size="small" color="primary" variant="tonal">
|
||||||
|
{{ totalCustomerGrowth }} total
|
||||||
|
</VChip>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<VCardText v-if="customerGrowth.length === 0" class="text-center py-8">
|
||||||
|
<VIcon icon="tabler-users-minus" size="48" color="disabled" class="mb-2" />
|
||||||
|
<div class="text-medium-emphasis">No customer signups in the last 12 months.</div>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VTable v-else density="comfortable" hover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 120px;">Month</th>
|
||||||
|
<th style="width: 80px;" class="text-end">Signups</th>
|
||||||
|
<th>Growth</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in customerGrowth" :key="row.month">
|
||||||
|
<td class="text-body-2 font-weight-medium">
|
||||||
|
{{ formatMonthLabel(row.month) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-end text-body-2 font-weight-semibold">
|
||||||
|
{{ row.count }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VProgressLinear
|
||||||
|
:model-value="(row.count / maxCustomerGrowth) * 100"
|
||||||
|
color="primary"
|
||||||
|
height="18"
|
||||||
|
rounded
|
||||||
|
class="my-1"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Churn Rate -->
|
||||||
|
<VCol cols="12" lg="6">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="d-flex align-center justify-space-between">
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-user-minus" size="22" />
|
||||||
|
<span>Churn Rate (6 Months)</span>
|
||||||
|
</div>
|
||||||
|
<VChip size="small" color="warning" variant="tonal">
|
||||||
|
Monthly
|
||||||
|
</VChip>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<VTable density="comfortable" hover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Month</th>
|
||||||
|
<th class="text-end">Cancelled</th>
|
||||||
|
<th class="text-end">Churn Rate</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in churnData" :key="row.month">
|
||||||
|
<td class="text-body-2 font-weight-medium">
|
||||||
|
{{ formatMonthLabel(row.month) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-end text-body-2">
|
||||||
|
{{ row.cancelled }}
|
||||||
|
</td>
|
||||||
|
<td class="text-end text-body-2 font-weight-semibold">
|
||||||
|
{{ row.rate }}%
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VChip
|
||||||
|
:color="resolveChurnColor(row.rate)"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
:icon="row.rate < 2 ? 'tabler-circle-check' : row.rate <= 5 ? 'tabler-alert-triangle' : 'tabler-alert-circle'"
|
||||||
|
size="14"
|
||||||
|
class="me-1"
|
||||||
|
/>
|
||||||
|
{{ row.rate < 2 ? 'Healthy' : row.rate <= 5 ? 'Watch' : 'High' }}
|
||||||
|
</VChip>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Row 4: Overdue Invoices -->
|
||||||
|
<VRow class="mb-2">
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="d-flex align-center justify-space-between">
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-alert-circle" size="22" color="error" />
|
||||||
|
<span>Overdue Invoices</span>
|
||||||
|
</div>
|
||||||
|
<VChip v-if="overdueInvoices.length > 0" size="small" color="error" variant="tonal">
|
||||||
|
{{ overdueInvoices.length }} overdue
|
||||||
|
</VChip>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<VCardText v-if="overdueInvoices.length === 0" class="text-center py-8">
|
||||||
|
<VAvatar color="success" variant="tonal" size="56" class="mb-3">
|
||||||
|
<VIcon icon="tabler-circle-check" size="28" />
|
||||||
|
</VAvatar>
|
||||||
|
<div class="text-body-1 font-weight-medium">No overdue invoices</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis">All invoices are current.</div>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VTable v-else density="comfortable" hover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Invoice #</th>
|
||||||
|
<th class="text-end">Amount</th>
|
||||||
|
<th>Due Date</th>
|
||||||
|
<th class="text-end">Days Overdue</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="invoice in overdueInvoices" :key="invoice.id">
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
v-if="invoice.user"
|
||||||
|
:href="`/customers/${invoice.user.id}`"
|
||||||
|
class="text-body-2 font-weight-medium text-primary text-decoration-none"
|
||||||
|
>
|
||||||
|
{{ invoice.user.name }}
|
||||||
|
</Link>
|
||||||
|
<span v-else class="text-body-2 text-medium-emphasis">Unknown</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
:href="`/invoices/${invoice.id}`"
|
||||||
|
class="text-body-2 font-weight-medium text-primary text-decoration-none"
|
||||||
|
>
|
||||||
|
{{ invoice.number }}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td class="text-end text-body-2 font-weight-semibold text-error">
|
||||||
|
{{ formatCurrency(invoice.total) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-body-2">
|
||||||
|
{{ formatDate(invoice.due_date) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<VChip
|
||||||
|
:color="daysOverdue(invoice.due_date) > 30 ? 'error' : daysOverdue(invoice.due_date) > 14 ? 'warning' : 'info'"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ daysOverdue(invoice.due_date) }} days
|
||||||
|
</VChip>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Row 5: Recent Subscriptions & Recent Invoices -->
|
||||||
<VRow class="mb-2">
|
<VRow class="mb-2">
|
||||||
<!-- Recent Subscriptions -->
|
<!-- Recent Subscriptions -->
|
||||||
<VCol cols="12" lg="6">
|
<VCol cols="12" lg="6">
|
||||||
@@ -287,7 +632,7 @@ function formatServiceType(type: string): string {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
|
|
||||||
<!-- Row 3: Popular Plans & Quick Stats -->
|
<!-- Row 6: Popular Plans & Revenue by Type -->
|
||||||
<VRow>
|
<VRow>
|
||||||
<!-- Popular Plans -->
|
<!-- Popular Plans -->
|
||||||
<VCol cols="12" lg="6">
|
<VCol cols="12" lg="6">
|
||||||
@@ -340,68 +685,8 @@ function formatServiceType(type: string): string {
|
|||||||
</VCard>
|
</VCard>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
<!-- Quick Stats & Revenue by Type -->
|
<!-- Revenue by Service Type -->
|
||||||
<VCol cols="12" lg="6">
|
<VCol cols="12" lg="6">
|
||||||
<!-- Quick Stats -->
|
|
||||||
<VCard class="mb-4">
|
|
||||||
<VCardTitle class="d-flex align-center gap-2">
|
|
||||||
<VIcon icon="tabler-chart-bar" size="22" />
|
|
||||||
<span>Quick Stats</span>
|
|
||||||
</VCardTitle>
|
|
||||||
|
|
||||||
<VCardText>
|
|
||||||
<VRow>
|
|
||||||
<VCol cols="6">
|
|
||||||
<div class="d-flex align-center gap-3 mb-4">
|
|
||||||
<VAvatar color="success" variant="tonal" rounded size="40">
|
|
||||||
<VIcon icon="tabler-trending-up" size="22" />
|
|
||||||
</VAvatar>
|
|
||||||
<div>
|
|
||||||
<div class="text-caption text-medium-emphasis">Total Revenue</div>
|
|
||||||
<div class="text-body-1 font-weight-semibold">{{ formatCurrency(totalRevenue) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VCol>
|
|
||||||
<VCol cols="6">
|
|
||||||
<div class="d-flex align-center gap-3 mb-4">
|
|
||||||
<VAvatar color="primary" variant="tonal" rounded size="40">
|
|
||||||
<VIcon icon="tabler-user-plus" size="22" />
|
|
||||||
</VAvatar>
|
|
||||||
<div>
|
|
||||||
<div class="text-caption text-medium-emphasis">New This Month</div>
|
|
||||||
<div class="text-body-1 font-weight-semibold">{{ newCustomersThisMonth }} customers</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VCol>
|
|
||||||
<VCol cols="6">
|
|
||||||
<div class="d-flex align-center gap-3">
|
|
||||||
<VAvatar color="info" variant="tonal" rounded size="40">
|
|
||||||
<VIcon icon="tabler-report-money" size="22" />
|
|
||||||
</VAvatar>
|
|
||||||
<div>
|
|
||||||
<div class="text-caption text-medium-emphasis">Revenue This Month</div>
|
|
||||||
<div class="text-body-1 font-weight-semibold">{{ formatCurrency(revenueThisMonth) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VCol>
|
|
||||||
<VCol cols="6">
|
|
||||||
<div class="d-flex align-center gap-3">
|
|
||||||
<VAvatar :color="overdueCount > 0 ? 'error' : 'success'" variant="tonal" rounded size="40">
|
|
||||||
<VIcon :icon="overdueCount > 0 ? 'tabler-alert-circle' : 'tabler-circle-check'" size="22" />
|
|
||||||
</VAvatar>
|
|
||||||
<div>
|
|
||||||
<div class="text-caption text-medium-emphasis">Overdue Accounts</div>
|
|
||||||
<div class="text-body-1 font-weight-semibold" :class="overdueCount > 0 ? 'text-error' : ''">
|
|
||||||
{{ overdueCount }} ({{ formatCurrency(overdueAmount) }})
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VCol>
|
|
||||||
</VRow>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
|
|
||||||
<!-- Revenue by Service Type -->
|
|
||||||
<VCard>
|
<VCard>
|
||||||
<VCardTitle class="d-flex align-center gap-2">
|
<VCardTitle class="d-flex align-center gap-2">
|
||||||
<VIcon icon="tabler-report-analytics" size="22" />
|
<VIcon icon="tabler-report-analytics" size="22" />
|
||||||
|
|||||||
Reference in New Issue
Block a user