feat: Add multiple new charts to the dashboard overview for enhanced sales and customer analytics.
This commit is contained in:
parent
4cb94e3ed5
commit
3de603f4c2
@ -10,6 +10,7 @@
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Outlet;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Payroll;
|
||||
use App\Models\Perfume;
|
||||
use App\Models\Product;
|
||||
@ -19,6 +20,7 @@
|
||||
use App\Models\Voucher;
|
||||
use App\Traits\Notification\WithSubscribeNotification;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
@ -39,43 +41,42 @@ class Analysis extends Component
|
||||
|
||||
public array $selectedOutletIds = [];
|
||||
|
||||
public array $countingData = [];
|
||||
public array $revenueTrendChart = [];
|
||||
|
||||
public array $orders = [];
|
||||
public array $hourlySalesChart = [];
|
||||
|
||||
public array $expenses = [];
|
||||
public array $categoryDistributionChart = [];
|
||||
|
||||
public array $topPerfumes = [];
|
||||
public array $paymentMethodChart = [];
|
||||
|
||||
public array $topProducts = [];
|
||||
public array $outletPerformanceChart = [];
|
||||
|
||||
public array $topBottles = [];
|
||||
public array $memberGrowthChart = [];
|
||||
|
||||
public function mount()
|
||||
public array $dayOfWeekSalesChart = [];
|
||||
|
||||
public array $customerTypeChart = [];
|
||||
|
||||
public array $transactionTrendChart = [];
|
||||
|
||||
public array $aovTrendChart = [];
|
||||
|
||||
public array $expenseBreakdownChart = [];
|
||||
|
||||
public array $revenueVsCostChart = [];
|
||||
|
||||
public array $customerRetentionChart = [];
|
||||
|
||||
public array $voucherUsageTrendChart = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->outlets = auth()->user()->outlets()->get()->pluck('name', 'id')->toArray();
|
||||
|
||||
$this->selectedOutletIds = [];
|
||||
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function updatedPeriod(string $period)
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function updatedSelectedOutletIds()
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function updatedRange($range)
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
protected function loadData()
|
||||
public function render(): View
|
||||
{
|
||||
$outletIds = $this->selectedOutletIds;
|
||||
$period = $this->period;
|
||||
@ -104,7 +105,7 @@ protected function loadData()
|
||||
}
|
||||
};
|
||||
|
||||
$this->countingData = [
|
||||
$countingData = [
|
||||
[
|
||||
'title' => 'Outlet',
|
||||
'value' => Outlet::when($dateQuery || ($startDate && $endDate), $filterDate)->count(),
|
||||
@ -188,12 +189,41 @@ protected function loadData()
|
||||
$grossProfit = $totalIncome - $totalCogs - $totalDiscount;
|
||||
$netProfit = $grossProfit - $totalExpense - $totalPayroll;
|
||||
|
||||
$this->orders = array_filter([
|
||||
$totalOrdersCount = (clone $orderQuery)->count();
|
||||
$totalItemsCount = OrderItem::whereHas('order', fn ($q) => $q->when(! empty($outletIds), fn ($q2) => $q2->whereIn('outlet_id', $outletIds))->when($dateQuery || ($startDate && $endDate), $filterDate))->sum('quantity');
|
||||
|
||||
$aov = $totalOrdersCount > 0 ? $totalIncome / $totalOrdersCount : 0;
|
||||
$profitMargin = $totalIncome > 0 ? ($netProfit / $totalIncome) * 100 : 0;
|
||||
$itemsPerOrder = $totalOrdersCount > 0 ? $totalItemsCount / $totalOrdersCount : 0;
|
||||
|
||||
$customerOrders = (clone $orderQuery)->select('customer_id', DB::raw('count(*) as count'))
|
||||
->whereNotNull('customer_id')
|
||||
->groupBy('customer_id')
|
||||
->get();
|
||||
$repeatCustomers = $customerOrders->filter(fn ($c) => $c->count > 1)->count();
|
||||
$totalCustomers = $customerOrders->count();
|
||||
$loyaltyRate = $totalCustomers > 0 ? ($repeatCustomers / $totalCustomers) * 100 : 0;
|
||||
|
||||
$orders = array_filter([
|
||||
[
|
||||
'title' => 'Average Order Value (AOV)',
|
||||
'value' => formatCurrencyNumber($aov, 'Rp'),
|
||||
],
|
||||
auth()->user()->hasRole(['Developer', 'Owner']) ? [
|
||||
'title' => 'Profit Margin',
|
||||
'value' => round($profitMargin, 1).'%',
|
||||
] : null,
|
||||
[
|
||||
'title' => 'Loyalty Rate',
|
||||
'value' => round($loyaltyRate, 1).'%',
|
||||
],
|
||||
[
|
||||
'title' => 'Item per Transaksi',
|
||||
'value' => round($itemsPerOrder, 1),
|
||||
],
|
||||
[
|
||||
'title' => 'Total Order',
|
||||
'value' => formatCurrencyNumber(
|
||||
(clone $orderQuery)->count()
|
||||
),
|
||||
'value' => formatCurrencyNumber($totalOrdersCount),
|
||||
],
|
||||
[
|
||||
'title' => 'Pendapatan',
|
||||
@ -251,7 +281,7 @@ protected function loadData()
|
||||
],
|
||||
]);
|
||||
|
||||
$this->expenses = array_filter([
|
||||
$expenses = array_filter([
|
||||
[
|
||||
'title' => 'Beban Toko',
|
||||
'value' => formatCurrencyNumber(
|
||||
@ -283,7 +313,7 @@ protected function loadData()
|
||||
],
|
||||
]);
|
||||
|
||||
$this->topPerfumes = OrderItem::where('orderable_type', Perfume::class)
|
||||
$topPerfumes = OrderItem::where('orderable_type', Perfume::class)
|
||||
->whereHas('order', function ($q) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) {
|
||||
$q->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->when($dateQuery || ($startDate && $endDate), $filterDate);
|
||||
@ -302,7 +332,7 @@ protected function loadData()
|
||||
])
|
||||
->toArray();
|
||||
|
||||
$this->topProducts = OrderItem::where('orderable_type', Product::class)
|
||||
$topProducts = OrderItem::where('orderable_type', Product::class)
|
||||
->whereHas('order', function ($q) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) {
|
||||
$q->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->when($dateQuery || ($startDate && $endDate), $filterDate);
|
||||
@ -321,7 +351,7 @@ protected function loadData()
|
||||
])
|
||||
->toArray();
|
||||
|
||||
$this->topBottles = OrderItem::where('orderable_type', Bottle::class)
|
||||
$topBottles = OrderItem::where('orderable_type', Bottle::class)
|
||||
->whereHas('order', function ($q) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) {
|
||||
$q->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->when($dateQuery || ($startDate && $endDate), $filterDate);
|
||||
@ -339,12 +369,209 @@ protected function loadData()
|
||||
'total_sales' => formatCurrencyNumber($item->total_sales_value, 'Rp'),
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
// --- Chart Data Logic ---
|
||||
|
||||
// 1. Revenue & Profit Trend
|
||||
$trendDates = collect(range(29, 0))->map(fn ($i) => now()->subDays($i)->format('Y-m-d'));
|
||||
$ordersTrend = Order::query()
|
||||
->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->where('created_at', '>=', now()->subDays(30)->startOfDay())
|
||||
->select(DB::raw('DATE(created_at) as date'), DB::raw('SUM(total) as revenue'), DB::raw('SUM(total - cogs - discount) as gross_profit'), DB::raw('count(*) as count'))
|
||||
->groupBy('date')
|
||||
->get()
|
||||
->keyBy('date');
|
||||
|
||||
$expensesTrend = Expense::query()
|
||||
->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->where('created_at', '>=', now()->subDays(30)->startOfDay())
|
||||
->select(DB::raw('DATE(created_at) as date'), DB::raw('SUM(amount) as total_expense'))
|
||||
->groupBy('date')
|
||||
->get()
|
||||
->keyBy('date');
|
||||
|
||||
$this->revenueTrendChart = [
|
||||
'labels' => $trendDates->map(fn ($d) => Carbon::parse($d)->format('d M'))->toArray(),
|
||||
'revenue' => $trendDates->map(fn ($d) => (int) ($ordersTrend->get($d)?->revenue ?? 0))->toArray(),
|
||||
'profit' => $trendDates->map(fn ($d) => (int) (($ordersTrend->get($d)?->gross_profit ?? 0) - ($expensesTrend->get($d)?->total_expense ?? 0)))->toArray(),
|
||||
];
|
||||
|
||||
// 2. busiest hour
|
||||
$hourlySales = (clone $orderQuery)
|
||||
->select(DB::raw('HOUR(created_at) as hour'), DB::raw('count(*) as count'))
|
||||
->groupBy('hour')
|
||||
->orderBy('hour')
|
||||
->get()
|
||||
->keyBy('hour');
|
||||
|
||||
$this->hourlySalesChart = [
|
||||
'labels' => collect(range(0, 23))->map(fn ($h) => str_pad($h, 2, '0', STR_PAD_LEFT).':00')->toArray(),
|
||||
'data' => collect(range(0, 23))->map(fn ($h) => $hourlySales->get($h)?->count ?? 0)->toArray(),
|
||||
];
|
||||
|
||||
// 3. Product Category Distribution
|
||||
$perfumeSales = OrderItem::where('orderable_type', Perfume::class)
|
||||
->whereHas('order', function ($q) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) {
|
||||
$q->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->when($dateQuery || ($startDate && $endDate), $filterDate);
|
||||
})->sum(DB::raw('unit_price * quantity'));
|
||||
|
||||
$productSales = OrderItem::where('orderable_type', Product::class)
|
||||
->whereHas('order', function ($q) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) {
|
||||
$q->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->when($dateQuery || ($startDate && $endDate), $filterDate);
|
||||
})->sum(DB::raw('unit_price * quantity'));
|
||||
|
||||
$bottleSales = OrderItem::where('orderable_type', Bottle::class)
|
||||
->whereHas('order', function ($q) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) {
|
||||
$q->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->when($dateQuery || ($startDate && $endDate), $filterDate);
|
||||
})->sum(DB::raw('unit_price * quantity'));
|
||||
|
||||
$this->categoryDistributionChart = [
|
||||
'labels' => ['Parfum', 'Produk', 'Botol'],
|
||||
'data' => [(int) $perfumeSales, (int) $productSales, (int) $bottleSales],
|
||||
];
|
||||
|
||||
// 4. Payment Method
|
||||
$paymentMethods = Payment::whereHas('order', function ($q) use ($outletIds, $dateQuery, $startDate, $endDate, $filterDate) {
|
||||
$q->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->when($dateQuery || ($startDate && $endDate), $filterDate);
|
||||
})
|
||||
->select('method', DB::raw('SUM(amount) as total'))
|
||||
->groupBy('method')
|
||||
->get();
|
||||
|
||||
$this->paymentMethodChart = [
|
||||
'labels' => $paymentMethods->map(fn ($pm) => $pm->method?->label() ?? 'Unknown')->toArray(),
|
||||
'data' => $paymentMethods->pluck('total')->toArray(),
|
||||
];
|
||||
|
||||
// 5. Outlet Performance
|
||||
$outletPerformance = Order::query()
|
||||
->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->when($dateQuery || ($startDate && $endDate), $filterDate)
|
||||
->select('outlet_id', DB::raw('SUM(total) as revenue'))
|
||||
->groupBy('outlet_id')
|
||||
->with('outlet:id,name')
|
||||
->get();
|
||||
|
||||
$this->outletPerformanceChart = [
|
||||
'labels' => $outletPerformance->map(fn ($op) => $op->outlet?->name ?? 'Outlet #'.$op->outlet_id)->toArray(),
|
||||
'data' => $outletPerformance->pluck('revenue')->toArray(),
|
||||
];
|
||||
|
||||
// 6. Member Growth (Area Chart) - Cumulative
|
||||
$memberGrowth = User::whereHas('customer')
|
||||
->select(DB::raw('DATE(created_at) as date'), DB::raw('count(*) as count'))
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
$cumulativeCount = 0;
|
||||
$memberChartData = $memberGrowth->map(function ($item) use (&$cumulativeCount) {
|
||||
$cumulativeCount += $item->count;
|
||||
|
||||
return [
|
||||
'date' => Carbon::parse($item->date)->format('d M Y'),
|
||||
'count' => $cumulativeCount,
|
||||
];
|
||||
});
|
||||
|
||||
$this->memberGrowthChart = [
|
||||
'labels' => $memberChartData->pluck('date')->toArray(),
|
||||
'data' => $memberChartData->pluck('count')->toArray(),
|
||||
];
|
||||
|
||||
// 7. Day of Week Sales
|
||||
$dayOfWeekSales = (clone $orderQuery)
|
||||
->select(DB::raw('DAYOFWEEK(created_at) as day'), DB::raw('SUM(total) as revenue'))
|
||||
->groupBy('day')
|
||||
->get()
|
||||
->keyBy('day');
|
||||
|
||||
$days = [
|
||||
1 => 'Minggu',
|
||||
2 => 'Senin',
|
||||
3 => 'Selasa',
|
||||
4 => 'Rabu',
|
||||
5 => 'Kamis',
|
||||
6 => 'Jumat',
|
||||
7 => 'Sabtu',
|
||||
];
|
||||
|
||||
$this->dayOfWeekSalesChart = [
|
||||
'labels' => array_values($days),
|
||||
'data' => collect(range(1, 7))->map(fn ($d) => (int) ($dayOfWeekSales->get($d)?->revenue ?? 0))->toArray(),
|
||||
];
|
||||
|
||||
// 8. Customer Type (Member vs Guest)
|
||||
$memberOrders = (clone $orderQuery)->whereNotNull('customer_id')->count();
|
||||
$guestOrders = (clone $orderQuery)->whereNull('customer_id')->count();
|
||||
|
||||
$this->customerTypeChart = [
|
||||
'labels' => ['Member', 'Guest (Umum)'],
|
||||
'data' => [$memberOrders, $guestOrders],
|
||||
];
|
||||
|
||||
// 9. Transaction Trend (Daily Count)
|
||||
$this->transactionTrendChart = [
|
||||
'labels' => $trendDates->map(fn ($d) => Carbon::parse($d)->format('d M'))->toArray(),
|
||||
'data' => $trendDates->map(fn ($d) => (int) ($ordersTrend->get($d)?->count ?? 0))->toArray(),
|
||||
];
|
||||
|
||||
// 10. AOV Trend (Daily Revenue / Daily Count)
|
||||
$this->aovTrendChart = [
|
||||
'labels' => $trendDates->map(fn ($d) => Carbon::parse($d)->format('d M'))->toArray(),
|
||||
'data' => $trendDates->map(function ($d) use ($ordersTrend) {
|
||||
$rev = $ordersTrend->get($d)?->revenue ?? 0;
|
||||
$count = $ordersTrend->get($d)?->count ?? 0;
|
||||
|
||||
return $count > 0 ? (int) ($rev / $count) : 0;
|
||||
})->toArray(),
|
||||
];
|
||||
|
||||
// 11. Expense Breakdown (Operational vs Payroll)
|
||||
$this->expenseBreakdownChart = [
|
||||
'labels' => ['Beban Operasional', 'Gaji (Payroll)'],
|
||||
'data' => [(int) $totalExpense, (int) $totalPayroll],
|
||||
];
|
||||
|
||||
// 12. Revenue vs Total Cost (COGS + Expense + Payroll)
|
||||
$this->revenueVsCostChart = [
|
||||
'labels' => ['Pendapatan', 'Total Biaya (HPP + Beban + Gaji)'],
|
||||
'data' => [(int) $totalIncome, (int) ($totalCogs + $totalExpense + $totalPayroll)],
|
||||
];
|
||||
|
||||
// 13. Customer Retention (New vs Returning Transactions)
|
||||
$this->customerRetentionChart = [
|
||||
'labels' => ['Pelanggan Setia (Repeat)', 'Pelanggan Baru/Sekali'],
|
||||
'data' => [$repeatCustomers, max(0, $totalCustomers - $repeatCustomers)],
|
||||
];
|
||||
|
||||
// 14. Voucher Usage Trend
|
||||
$vouchersTrend = Order::query()
|
||||
->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->where('created_at', '>=', now()->subDays(30)->startOfDay())
|
||||
->whereNotNull('voucher_id')
|
||||
->select(DB::raw('DATE(created_at) as date'), DB::raw('count(*) as count'))
|
||||
->groupBy('date')
|
||||
->get()
|
||||
->keyBy('date');
|
||||
|
||||
$this->voucherUsageTrendChart = [
|
||||
'labels' => $trendDates->map(fn ($d) => Carbon::parse($d)->format('d M'))->toArray(),
|
||||
'data' => $trendDates->map(fn ($d) => (int) ($vouchersTrend->get($d)?->count ?? 0))->toArray(),
|
||||
];
|
||||
|
||||
return view('livewire.studio.dashboard.analysis', [
|
||||
'pageTitle' => 'Analisa',
|
||||
'countingData' => $countingData,
|
||||
'orders' => $orders,
|
||||
'expenses' => $expenses,
|
||||
'topPerfumes' => $topPerfumes,
|
||||
'topProducts' => $topProducts,
|
||||
'topBottles' => $topBottles,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,12 +2,17 @@
|
||||
|
||||
namespace App\Livewire\Studio\Dashboard;
|
||||
|
||||
use App\Models\Bottle;
|
||||
use App\Models\Expense;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Perfume;
|
||||
use App\Models\Product;
|
||||
use App\Models\Purchase;
|
||||
use App\Models\User;
|
||||
use App\Traits\Notification\WithSubscribeNotification;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
@ -21,23 +26,28 @@ class Overview extends Component
|
||||
|
||||
public array $selectedOutletIds = [];
|
||||
|
||||
public array $stats = [];
|
||||
public array $hourlyComparisonChart = [];
|
||||
|
||||
public function mount()
|
||||
public array $todayCategoryChart = [];
|
||||
|
||||
public array $todayPaymentChart = [];
|
||||
|
||||
public array $todayCustomerTypeChart = [];
|
||||
|
||||
public array $todayTopSellingChart = [];
|
||||
|
||||
public array $todayTopCustomersChart = [];
|
||||
|
||||
public array $todayDiscountChart = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->outlets = auth()->user()->outlets()->get()->pluck('name', 'id')->toArray();
|
||||
|
||||
$this->selectedOutletIds = [];
|
||||
|
||||
$this->loadStats();
|
||||
}
|
||||
|
||||
public function updatedSelectedOutletIds()
|
||||
{
|
||||
$this->loadStats();
|
||||
}
|
||||
|
||||
protected function loadStats()
|
||||
public function render(): View
|
||||
{
|
||||
$outletIds = $this->selectedOutletIds;
|
||||
|
||||
@ -137,7 +147,160 @@ protected function loadStats()
|
||||
->with('orderable')
|
||||
->first();
|
||||
|
||||
$this->stats = array_filter([
|
||||
// New Card: Members Joined
|
||||
$todayNewMembers = User::whereHas('customer')->whereDate('created_at', now())->count();
|
||||
$yesterdayNewMembers = User::whereHas('customer')->whereDate('created_at', now()->yesterday())->count();
|
||||
|
||||
// New Card: Low Stock Alert
|
||||
$lowStockQuery = function ($query) use ($outletIds) {
|
||||
return $query->whereHas('outlets', function ($q) use ($outletIds) {
|
||||
$q->when(! empty($outletIds), fn ($q2) => $q2->whereIn('outlets.id', $outletIds))
|
||||
->where('stock', '<=', 10);
|
||||
});
|
||||
};
|
||||
$lowStockCount = Perfume::where($lowStockQuery)->count() +
|
||||
Product::where($lowStockQuery)->count() +
|
||||
Bottle::where($lowStockQuery)->count();
|
||||
|
||||
$todayAov = $todayOrder > 0 ? $todayIncome / $todayOrder : 0;
|
||||
$yesterdayAov = $yesterdayOrder > 0 ? $yesterdayIncome / $yesterdayOrder : 0;
|
||||
|
||||
$todayProfitMargin = $todayIncome > 0 ? ($todayNetProfit / $todayIncome) * 100 : 0;
|
||||
$yesterdayProfitMargin = $yesterdayIncome > 0 ? ($yesterdayNetProfit / $yesterdayIncome) * 100 : 0;
|
||||
|
||||
// --- Chart Data Logic ---
|
||||
|
||||
// 1. Hourly Sales Comparison (Today vs Yesterday)
|
||||
$todayHourly = Order::whereDate('created_at', now())
|
||||
->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->select(DB::raw('HOUR(created_at) as hour'), DB::raw('count(*) as count'))
|
||||
->groupBy('hour')
|
||||
->get()->keyBy('hour');
|
||||
|
||||
$yesterdayHourly = Order::whereDate('created_at', now()->yesterday())
|
||||
->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->select(DB::raw('HOUR(created_at) as hour'), DB::raw('count(*) as count'))
|
||||
->groupBy('hour')
|
||||
->get()->keyBy('hour');
|
||||
|
||||
$this->hourlyComparisonChart = [
|
||||
'labels' => collect(range(0, 23))->map(fn ($h) => str_pad($h, 2, '0', STR_PAD_LEFT).':00')->toArray(),
|
||||
'today' => collect(range(0, 23))->map(fn ($h) => $todayHourly->get($h)?->count ?? 0)->toArray(),
|
||||
'yesterday' => collect(range(0, 23))->map(fn ($h) => $yesterdayHourly->get($h)?->count ?? 0)->toArray(),
|
||||
];
|
||||
|
||||
// 2. Today's Distribution (Category)
|
||||
$categorySales = OrderItem::whereHas('order', function ($q) use ($outletIds) {
|
||||
$q->today()->when(! empty($outletIds), fn ($q2) => $q2->whereIn('outlet_id', $outletIds));
|
||||
})
|
||||
->select('orderable_type', DB::raw('SUM(unit_price * quantity) as total'))
|
||||
->groupBy('orderable_type')
|
||||
->get()->keyBy('orderable_type');
|
||||
|
||||
$this->todayCategoryChart = [
|
||||
'labels' => ['Parfum', 'Produk Jadi', 'Botol'],
|
||||
'data' => [
|
||||
(int) ($categorySales->get(Perfume::class)?->total ?? 0),
|
||||
(int) ($categorySales->get(Product::class)?->total ?? 0),
|
||||
(int) ($categorySales->get(Bottle::class)?->total ?? 0),
|
||||
],
|
||||
];
|
||||
|
||||
// 3. Today's Payment Methods
|
||||
$paymentMethods = Payment::whereHas('order', function ($q) use ($outletIds) {
|
||||
$q->today()->when(! empty($outletIds), fn ($q2) => $q2->whereIn('outlet_id', $outletIds));
|
||||
})
|
||||
->select('method', DB::raw('SUM(amount) as total'))
|
||||
->groupBy('method')
|
||||
->get();
|
||||
|
||||
$this->todayPaymentChart = [
|
||||
'labels' => $paymentMethods->map(fn ($pm) => $pm->method?->label() ?? 'Unknown')->toArray(),
|
||||
'data' => $paymentMethods->pluck('total')->toArray(),
|
||||
];
|
||||
|
||||
// 4. Today's Customer Type
|
||||
$todayMemberOrders = Order::query()
|
||||
->today()
|
||||
->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->whereNotNull('customer_id')->count();
|
||||
$todayGuestOrders = Order::query()
|
||||
->today()
|
||||
->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->whereNull('customer_id')->count();
|
||||
|
||||
$this->todayCustomerTypeChart = [
|
||||
'labels' => ['Member', 'Guest (Umum)'],
|
||||
'data' => [$todayMemberOrders, $todayGuestOrders],
|
||||
];
|
||||
|
||||
// 5. Today's Top 5 Selling Items
|
||||
$todayTopItems = OrderItem::whereHas('order', function ($q) use ($outletIds) {
|
||||
$q->today()->when(! empty($outletIds), fn ($q2) => $q2->whereIn('outlet_id', $outletIds));
|
||||
})
|
||||
->with('orderable')
|
||||
->select('orderable_id', 'orderable_type', DB::raw('SUM(quantity) as total_sold'))
|
||||
->groupBy('orderable_id', 'orderable_type')
|
||||
->orderByDesc('total_sold')
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
$this->todayTopSellingChart = [
|
||||
'labels' => $todayTopItems->map(fn ($item) => $item->orderable?->name ?? 'Unknown')->toArray(),
|
||||
'data' => $todayTopItems->pluck('total_sold')->map(fn ($val) => (int) $val)->toArray(),
|
||||
];
|
||||
|
||||
// 6. Today's Top 5 Customers
|
||||
$todayTopCustomers = Order::query()
|
||||
->today()
|
||||
->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->whereNotNull('customer_id')
|
||||
->select('customer_id', DB::raw('SUM(total) as total_spent'))
|
||||
->groupBy('customer_id')
|
||||
->orderByDesc('total_spent')
|
||||
->take(5)
|
||||
->with('customer')
|
||||
->get();
|
||||
|
||||
$this->todayTopCustomersChart = [
|
||||
'labels' => $todayTopCustomers->map(fn ($o) => $o->customer?->user?->employee?->full_name ?? $o->customer?->user?->name ?? 'Customer #'.$o->customer_id)->toArray(),
|
||||
'data' => $todayTopCustomers->pluck('total_spent')->map(fn ($val) => (int) $val)->toArray(),
|
||||
];
|
||||
|
||||
// 7. Today's Discount Distribution (With Discount vs Regular)
|
||||
$withDiscount = Order::query()
|
||||
->today()
|
||||
->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->where('discount', '>', 0)->count();
|
||||
$noDiscount = Order::query()
|
||||
->today()
|
||||
->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
|
||||
->where('discount', '<=', 0)->count();
|
||||
|
||||
$this->todayDiscountChart = [
|
||||
'labels' => ['Pakai Diskon', 'Harga Normal'],
|
||||
'data' => [$withDiscount, $noDiscount],
|
||||
];
|
||||
|
||||
$stats = array_filter([
|
||||
[
|
||||
'title' => 'Average Order Value (AOV)',
|
||||
'value' => formatCurrencyNumber($todayAov, 'Rp'),
|
||||
'previous' => formatCurrencyNumber($yesterdayAov, 'Rp'),
|
||||
'trend' => $yesterdayAov > 0
|
||||
? round((($todayAov - $yesterdayAov) / $yesterdayAov) * 100, 1).'%'
|
||||
: '∞%',
|
||||
'trendUp' => $todayAov > $yesterdayAov,
|
||||
],
|
||||
[
|
||||
'title' => 'Profit Margin',
|
||||
'value' => round($todayProfitMargin, 1).'%',
|
||||
'previous' => round($yesterdayProfitMargin, 1).'%',
|
||||
'trend' => $yesterdayProfitMargin > 0
|
||||
? round($todayProfitMargin - $yesterdayProfitMargin, 1).'%'
|
||||
: '∞%',
|
||||
'trendUp' => $todayProfitMargin > $yesterdayProfitMargin,
|
||||
],
|
||||
[
|
||||
'title' => 'Total Order',
|
||||
'value' => $todayOrder,
|
||||
@ -169,6 +332,23 @@ protected function loadStats()
|
||||
'formatted' => formatCurrencyNumber($todayCogs, 'Rp'),
|
||||
] : null,
|
||||
|
||||
[
|
||||
'title' => 'Member Baru',
|
||||
'value' => $todayNewMembers.' Orang',
|
||||
'previous' => $yesterdayNewMembers.' Orang',
|
||||
'trend' => $yesterdayNewMembers > 0
|
||||
? round((($todayNewMembers - $yesterdayNewMembers) / $yesterdayNewMembers) * 100, 1).'%'
|
||||
: '∞%',
|
||||
'trendUp' => $todayNewMembers > $yesterdayNewMembers,
|
||||
],
|
||||
[
|
||||
'title' => 'Stok Tipis (<= 10)',
|
||||
'value' => $lowStockCount.' Item',
|
||||
'previous' => '-',
|
||||
'trend' => 'Perlu Restock',
|
||||
'trendUp' => $lowStockCount === 0,
|
||||
],
|
||||
|
||||
[
|
||||
'title' => 'Diskon',
|
||||
'value' => formatCurrencyNumber($todayDiscount, 'Rp'),
|
||||
@ -247,10 +427,9 @@ protected function loadStats()
|
||||
'trendUp' => ($todayTopPerfume?->total_sold ?? 0) > ($yesterdayTopPerfume?->total_sold ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.studio.dashboard.overview');
|
||||
return view('livewire.studio.dashboard.overview', [
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,6 +72,104 @@
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8" x-data="analysisCharts">
|
||||
<!-- 1. Revenue & Profit Trend -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Trend Pendapatan & Laba (30 Hari Terakhir)</flux:heading>
|
||||
<div class="h-80"><canvas id="revenueTrendChart"></canvas></div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 6. Member Growth (Area Chart) - Cumulative -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Pertumbuhan Member (Akumulasi)</flux:heading>
|
||||
<div class="h-80"><canvas id="memberGrowthChart"></canvas></div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 2. busiest hour -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Jam Sibuk (Total Order per Jam)</flux:heading>
|
||||
<div class="h-80"><canvas id="hourlySalesChart"></canvas></div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 5. Outlet Performance -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Performa Outlet (Pendapatan)</flux:heading>
|
||||
<div class="h-80"><canvas id="outletPerformanceChart"></canvas></div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 3. Product Category Distribution -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Distribusi Penjualan</flux:heading>
|
||||
<div class="h-80 flex items-center justify-center">
|
||||
<canvas id="categoryDistributionChart"></canvas>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 4. Payment Method -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Metode Pembayaran</flux:heading>
|
||||
<div class="h-80 flex items-center justify-center">
|
||||
<canvas id="paymentMethodChart"></canvas>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 7. Day of Week Sales -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Performa Penjualan per Hari</flux:heading>
|
||||
<div class="h-80"><canvas id="dayOfWeekSalesChart"></canvas></div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 8. Customer Type Distribution -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Segmentasi Pelanggan (Member vs Guest)</flux:heading>
|
||||
<div class="h-80 flex items-center justify-center">
|
||||
<canvas id="customerTypeChart"></canvas>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 9. Transaction Trend -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Trend Volume Transaksi (30 Hari)</flux:heading>
|
||||
<div class="h-80"><canvas id="transactionTrendChart"></canvas></div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 10. AOV Trend -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Trend Rata-rata Belanja (AOV)</flux:heading>
|
||||
<div class="h-80"><canvas id="aovTrendChart"></canvas></div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 11. Expense Breakdown -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Komposisi Biaya Operasional</flux:heading>
|
||||
<div class="h-80 flex items-center justify-center">
|
||||
<canvas id="expenseBreakdownChart"></canvas>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 12. Revenue vs Cost -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Omzet vs Total Biaya</flux:heading>
|
||||
<div class="h-80 flex items-center justify-center">
|
||||
<canvas id="revenueVsCostChart"></canvas>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 13. Customer Retention -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Analisa Pelanggan (Baru vs Lama)</flux:heading>
|
||||
<div class="h-80 flex items-center justify-center">
|
||||
<canvas id="customerRetentionChart"></canvas>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 14. Voucher Activity -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Efektivitas Voucher</flux:heading>
|
||||
<div class="h-80"><canvas id="voucherUsageTrendChart"></canvas></div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6 mt-2">
|
||||
<div>
|
||||
<flux:heading>Top 5 Parfum</flux:heading>
|
||||
@ -158,3 +256,591 @@
|
||||
</div>
|
||||
</div>
|
||||
</flux:main>
|
||||
|
||||
@assets
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
@endassets
|
||||
|
||||
@script
|
||||
<script>
|
||||
Alpine.data('analysisCharts', () => ({
|
||||
charts: {},
|
||||
|
||||
init() {
|
||||
// Memastikan DOM siap dan library tersedia sebelum inisialisasi awal
|
||||
const start = () => {
|
||||
if (typeof Chart !== 'undefined') {
|
||||
this.initAll();
|
||||
} else {
|
||||
setTimeout(start, 50);
|
||||
}
|
||||
};
|
||||
|
||||
this.$nextTick(() => start());
|
||||
|
||||
// Watchers untuk pembaruan data reaktif
|
||||
this.$watch('$wire.revenueTrendChart', () => this.updateRevenueTrend());
|
||||
this.$watch('$wire.memberGrowthChart', () => this.updateMemberGrowth());
|
||||
this.$watch('$wire.hourlySalesChart', () => this.updateHourlySales());
|
||||
this.$watch('$wire.outletPerformanceChart', () => this.updateOutletPerformance());
|
||||
this.$watch('$wire.categoryDistributionChart', () => this.updateCategoryDistribution());
|
||||
this.$watch('$wire.paymentMethodChart', () => this.updatePaymentMethod());
|
||||
this.$watch('$wire.dayOfWeekSalesChart', () => this.updateDayOfWeekSales());
|
||||
this.$watch('$wire.customerTypeChart', () => this.updateCustomerType());
|
||||
this.$watch('$wire.transactionTrendChart', () => this.updateTransactionTrend());
|
||||
this.$watch('$wire.aovTrendChart', () => this.updateAovTrend());
|
||||
this.$watch('$wire.expenseBreakdownChart', () => this.updateExpenseBreakdown());
|
||||
this.$watch('$wire.revenueVsCostChart', () => this.updateRevenueVsCost());
|
||||
this.$watch('$wire.customerRetentionChart', () => this.updateCustomerRetention());
|
||||
this.$watch('$wire.voucherUsageTrendChart', () => this.updateVoucherUsage());
|
||||
},
|
||||
|
||||
initAll() {
|
||||
console.log('Rendering Analysis Charts...');
|
||||
this.initRevenueTrend();
|
||||
this.initMemberGrowth();
|
||||
this.initHourlySales();
|
||||
this.initOutletPerformance();
|
||||
this.initCategoryDistribution();
|
||||
this.initPaymentMethod();
|
||||
this.initDayOfWeekSales();
|
||||
this.initCustomerType();
|
||||
this.initTransactionTrend();
|
||||
this.initAovTrend();
|
||||
this.initExpenseBreakdown();
|
||||
this.initRevenueVsCost();
|
||||
this.initCustomerRetention();
|
||||
this.initVoucherUsage();
|
||||
|
||||
this.isInitialized = true;
|
||||
},
|
||||
|
||||
initRevenueTrend() {
|
||||
const ctx = document.getElementById('revenueTrendChart');
|
||||
if (!ctx) return;
|
||||
|
||||
const data = $wire.revenueTrendChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.revenue = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: this.getRevenueData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
getRevenueData() {
|
||||
const data = $wire.revenueTrendChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
label: 'Pendapatan',
|
||||
data: data.revenue || [],
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Laba Bersih',
|
||||
data: data.profit || [],
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
updateRevenueTrend() {
|
||||
if (this.charts.revenue) {
|
||||
this.charts.revenue.data = this.getRevenueData();
|
||||
this.charts.revenue.update();
|
||||
} else {
|
||||
this.initRevenueTrend();
|
||||
}
|
||||
},
|
||||
|
||||
initMemberGrowth() {
|
||||
const ctx = document.getElementById('memberGrowthChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.memberGrowthChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.memberGrowth = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: this.getMemberGrowthData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
getMemberGrowthData() {
|
||||
const data = $wire.memberGrowthChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
label: 'Total Member',
|
||||
data: data.data || [],
|
||||
borderColor: '#8b5cf6',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.2
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateMemberGrowth() {
|
||||
if (this.charts.memberGrowth) {
|
||||
this.charts.memberGrowth.data = this.getMemberGrowthData();
|
||||
this.charts.memberGrowth.update();
|
||||
} else {
|
||||
this.initMemberGrowth();
|
||||
}
|
||||
},
|
||||
|
||||
initHourlySales() {
|
||||
const ctx = document.getElementById('hourlySalesChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.hourlySalesChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.hourlySales = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: this.getHourlySalesData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getHourlySalesData() {
|
||||
const data = $wire.hourlySalesChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
label: 'Jumlah Order',
|
||||
data: data.data || [],
|
||||
backgroundColor: '#f59e0b'
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateHourlySales() {
|
||||
if (this.charts.hourlySales) {
|
||||
this.charts.hourlySales.data = this.getHourlySalesData();
|
||||
this.charts.hourlySales.update();
|
||||
} else {
|
||||
this.initHourlySales();
|
||||
}
|
||||
},
|
||||
|
||||
initOutletPerformance() {
|
||||
const ctx = document.getElementById('outletPerformanceChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.outletPerformanceChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.outletPerformance = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: this.getOutletPerformanceData(),
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getOutletPerformanceData() {
|
||||
const data = $wire.outletPerformanceChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
label: 'Pendapatan',
|
||||
data: data.data || [],
|
||||
backgroundColor: '#ef4444'
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateOutletPerformance() {
|
||||
if (this.charts.outletPerformance) {
|
||||
this.charts.outletPerformance.data = this.getOutletPerformanceData();
|
||||
this.charts.outletPerformance.update();
|
||||
} else {
|
||||
this.initOutletPerformance();
|
||||
}
|
||||
},
|
||||
|
||||
initCategoryDistribution() {
|
||||
const ctx = document.getElementById('categoryDistributionChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.categoryDistributionChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.categoryDist = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: this.getCategoryDistributionData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getCategoryDistributionData() {
|
||||
const data = $wire.categoryDistributionChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
data: data.data || [],
|
||||
backgroundColor: ['#10b981', '#3b82f6', '#f59e0b']
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateCategoryDistribution() {
|
||||
if (this.charts.categoryDist) {
|
||||
this.charts.categoryDist.data = this.getCategoryDistributionData();
|
||||
this.charts.categoryDist.update();
|
||||
} else {
|
||||
this.initCategoryDistribution();
|
||||
}
|
||||
},
|
||||
|
||||
initPaymentMethod() {
|
||||
const ctx = document.getElementById('paymentMethodChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.paymentMethodChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.paymentMethod = new Chart(ctx, {
|
||||
type: 'pie',
|
||||
data: this.getPaymentMethodData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getPaymentMethodData() {
|
||||
const data = $wire.paymentMethodChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
data: data.data || [],
|
||||
backgroundColor: ['#10b981', '#3b82f6', '#8b5cf6', '#6b7280']
|
||||
}]
|
||||
};
|
||||
},
|
||||
updatePaymentMethod() {
|
||||
if (this.charts.paymentMethod) {
|
||||
this.charts.paymentMethod.data = this.getPaymentMethodData();
|
||||
this.charts.paymentMethod.update();
|
||||
} else {
|
||||
this.initPaymentMethod();
|
||||
}
|
||||
},
|
||||
|
||||
initDayOfWeekSales() {
|
||||
const ctx = document.getElementById('dayOfWeekSalesChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.dayOfWeekSalesChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.dayOfWeek = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: this.getDayOfWeekData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getDayOfWeekData() {
|
||||
const data = $wire.dayOfWeekSalesChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
label: 'Pendapatan',
|
||||
data: data.data || [],
|
||||
backgroundColor: '#6366f1'
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateDayOfWeekSales() {
|
||||
if (this.charts.dayOfWeek) {
|
||||
this.charts.dayOfWeek.data = this.getDayOfWeekData();
|
||||
this.charts.dayOfWeek.update();
|
||||
} else {
|
||||
this.initDayOfWeekSales();
|
||||
}
|
||||
},
|
||||
|
||||
initCustomerType() {
|
||||
const ctx = document.getElementById('customerTypeChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.customerTypeChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.customerType = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: this.getCustomerTypeData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getCustomerTypeData() {
|
||||
const data = $wire.customerTypeChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
data: data.data || [],
|
||||
backgroundColor: ['#8b5cf6', '#94a3b8']
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateCustomerType() {
|
||||
if (this.charts.customerType) {
|
||||
this.charts.customerType.data = this.getCustomerTypeData();
|
||||
this.charts.customerType.update();
|
||||
} else {
|
||||
this.initCustomerType();
|
||||
}
|
||||
},
|
||||
|
||||
initTransactionTrend() {
|
||||
const ctx = document.getElementById('transactionTrendChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.transactionTrendChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.transactionTrend = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: this.getTransactionTrendData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getTransactionTrendData() {
|
||||
const data = $wire.transactionTrendChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
label: 'Jumlah Transaksi',
|
||||
data: data.data || [],
|
||||
borderColor: '#f43f5e',
|
||||
backgroundColor: 'rgba(244, 63, 94, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateTransactionTrend() {
|
||||
if (this.charts.transactionTrend) {
|
||||
this.charts.transactionTrend.data = this.getTransactionTrendData();
|
||||
this.charts.transactionTrend.update();
|
||||
} else {
|
||||
this.initTransactionTrend();
|
||||
}
|
||||
},
|
||||
|
||||
initAovTrend() {
|
||||
const ctx = document.getElementById('aovTrendChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.aovTrendChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.aovTrend = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: this.getAovTrendData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getAovTrendData() {
|
||||
const data = $wire.aovTrendChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
label: 'Rata-rata Keranjang (AOV)',
|
||||
data: data.data || [],
|
||||
borderColor: '#0ea5e9',
|
||||
backgroundColor: 'rgba(14, 165, 233, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateAovTrend() {
|
||||
if (this.charts.aovTrend) {
|
||||
this.charts.aovTrend.data = this.getAovTrendData();
|
||||
this.charts.aovTrend.update();
|
||||
} else {
|
||||
this.initAovTrend();
|
||||
}
|
||||
},
|
||||
|
||||
initExpenseBreakdown() {
|
||||
const ctx = document.getElementById('expenseBreakdownChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.expenseBreakdownChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.expenseBreakdown = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: this.getExpenseBreakdownData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getExpenseBreakdownData() {
|
||||
const data = $wire.expenseBreakdownChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
data: data.data || [],
|
||||
backgroundColor: ['#fb923c', '#4ade80']
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateExpenseBreakdown() {
|
||||
if (this.charts.expenseBreakdown) {
|
||||
this.charts.expenseBreakdown.data = this.getExpenseBreakdownData();
|
||||
this.charts.expenseBreakdown.update();
|
||||
} else {
|
||||
this.initExpenseBreakdown();
|
||||
}
|
||||
},
|
||||
|
||||
initRevenueVsCost() {
|
||||
const ctx = document.getElementById('revenueVsCostChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.revenueVsCostChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.revenueVsCost = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: this.getRevenueVsCostData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getRevenueVsCostData() {
|
||||
const data = $wire.revenueVsCostChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
label: 'Total Nilai',
|
||||
data: data.data || [],
|
||||
backgroundColor: ['#10b981', '#ef4444']
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateRevenueVsCost() {
|
||||
if (this.charts.revenueVsCost) {
|
||||
this.charts.revenueVsCost.data = this.getRevenueVsCostData();
|
||||
this.charts.revenueVsCost.update();
|
||||
} else {
|
||||
this.initRevenueVsCost();
|
||||
}
|
||||
},
|
||||
|
||||
initCustomerRetention() {
|
||||
const ctx = document.getElementById('customerRetentionChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.customerRetentionChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.customerRetention = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: this.getCustomerRetentionData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getCustomerRetentionData() {
|
||||
const data = $wire.customerRetentionChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
label: 'Jumlah Pelanggan',
|
||||
data: data.data || [],
|
||||
backgroundColor: ['#8b5cf6', '#ec4899']
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateCustomerRetention() {
|
||||
if (this.charts.customerRetention) {
|
||||
this.charts.customerRetention.data = this.getCustomerRetentionData();
|
||||
this.charts.customerRetention.update();
|
||||
} else {
|
||||
this.initCustomerRetention();
|
||||
}
|
||||
},
|
||||
|
||||
initVoucherUsage() {
|
||||
const ctx = document.getElementById('voucherUsageTrendChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.voucherUsageTrendChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.voucherUsage = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: this.getVoucherUsageData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getVoucherUsageData() {
|
||||
const data = $wire.voucherUsageTrendChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
label: 'Voucher Terpakai',
|
||||
data: data.data || [],
|
||||
borderColor: '#f59e0b',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateVoucherUsage() {
|
||||
if (this.charts.voucherUsage) {
|
||||
this.charts.voucherUsage.data = this.getVoucherUsageData();
|
||||
this.charts.voucherUsage.update();
|
||||
} else {
|
||||
this.initVoucherUsage();
|
||||
}
|
||||
}
|
||||
}));
|
||||
</script>
|
||||
@endscript
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<flux:heading size="xl" level="1">Haloo, {{ auth()->user()?->employee?->full_name }}</flux:heading>
|
||||
<flux:text class="mb-6 mt-2 text-base">Selamat datang di Studio Yadi Parfum.</flux:text>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="mt-6" x-data="overviewCharts">
|
||||
@if (count($outlets) > 1)
|
||||
<div class="mb-6 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<flux:checkbox.group wire:model.live="selectedOutletIds" variant="buttons">
|
||||
@ -33,5 +33,371 @@ class="flex items-center gap-1 font-medium text-sm @if ($stat['trendUp']) text-g
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mt-8">
|
||||
<!-- 1. Today vs Yesterday Hourly Comparison -->
|
||||
<flux:card class="md:col-span-2 xl:col-span-2" wire:ignore>
|
||||
<flux:heading class="mb-4">Perbandingan Order per Jam (Hari Ini vs Kemarin)</flux:heading>
|
||||
<div class="h-80">
|
||||
<canvas id="hourlyComparisonChart"></canvas>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 2. Today Category Distribution -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Distribusi Penjualan</flux:heading>
|
||||
<div class="h-80 flex items-center justify-center">
|
||||
<canvas id="todayCategoryChart"></canvas>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 3. Today Payment Methods -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Metode Pembayaran</flux:heading>
|
||||
<div class="h-80 flex items-center justify-center">
|
||||
<canvas id="todayPaymentChart"></canvas>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 4. Today Customer Type -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Member vs Guest</flux:heading>
|
||||
<div class="h-80 flex items-center justify-center">
|
||||
<canvas id="todayCustomerTypeChart"></canvas>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 5. Today Top Selling Items -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">5 Item Terlaris</flux:heading>
|
||||
<div class="h-80 flex items-center justify-center">
|
||||
<canvas id="todayTopSellingChart"></canvas>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 6. Today Top Customers -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Top 5 Pelanggan</flux:heading>
|
||||
<div class="h-80 flex items-center justify-center">
|
||||
<canvas id="todayTopCustomersChart"></canvas>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<!-- 7. Today Discount Distribution -->
|
||||
<flux:card wire:ignore>
|
||||
<flux:heading class="mb-4">Normal vs Diskon</flux:heading>
|
||||
<div class="h-80 flex items-center justify-center">
|
||||
<canvas id="todayDiscountChart"></canvas>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
</flux:main>
|
||||
|
||||
@assets
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
@endassets
|
||||
|
||||
@script
|
||||
<script>
|
||||
Alpine.data('overviewCharts', () => ({
|
||||
charts: {},
|
||||
isInitialized: false,
|
||||
|
||||
init() {
|
||||
const start = () => {
|
||||
if (typeof Chart !== 'undefined') {
|
||||
this.initAll();
|
||||
} else {
|
||||
setTimeout(start, 50);
|
||||
}
|
||||
};
|
||||
|
||||
this.$nextTick(() => start());
|
||||
|
||||
this.$watch('$wire.hourlyComparisonChart', () => this.updateHourlyComparison());
|
||||
this.$watch('$wire.todayCategoryChart', () => this.updateCategoryDist());
|
||||
this.$watch('$wire.todayPaymentChart', () => this.updatePaymentMethod());
|
||||
this.$watch('$wire.todayCustomerTypeChart', () => this.updateCustomerType());
|
||||
this.$watch('$wire.todayTopSellingChart', () => this.updateTopSelling());
|
||||
this.$watch('$wire.todayTopCustomersChart', () => this.updateTopCustomers());
|
||||
this.$watch('$wire.todayDiscountChart', () => this.updateDiscountDist());
|
||||
},
|
||||
|
||||
initAll() {
|
||||
this.initHourlyComparison();
|
||||
this.initCategoryDist();
|
||||
this.initPaymentMethod();
|
||||
this.initCustomerType();
|
||||
this.initTopSelling();
|
||||
this.initTopCustomers();
|
||||
this.initDiscountDist();
|
||||
this.isInitialized = true;
|
||||
},
|
||||
|
||||
initHourlyComparison() {
|
||||
const ctx = document.getElementById('hourlyComparisonChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.hourlyComparisonChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.hourly = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: this.getHourlyData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
getHourlyData() {
|
||||
const data = $wire.hourlyComparisonChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
label: 'Hari Ini',
|
||||
data: data.today || [],
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Kemarin',
|
||||
data: data.yesterday || [],
|
||||
borderColor: '#94a3b8',
|
||||
backgroundColor: 'rgba(148, 163, 184, 0.05)',
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
updateHourlyComparison() {
|
||||
if (this.charts.hourly) {
|
||||
this.charts.hourly.data = this.getHourlyData();
|
||||
this.charts.hourly.update();
|
||||
} else {
|
||||
this.initHourlyComparison();
|
||||
}
|
||||
},
|
||||
|
||||
initCategoryDist() {
|
||||
const ctx = document.getElementById('todayCategoryChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.todayCategoryChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.category = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: this.getCategoryData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getCategoryData() {
|
||||
const data = $wire.todayCategoryChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
data: data.data || [],
|
||||
backgroundColor: ['#10b981', '#3b82f6', '#f59e0b']
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateCategoryDist() {
|
||||
if (this.charts.category) {
|
||||
this.charts.category.data = this.getCategoryData();
|
||||
this.charts.category.update();
|
||||
} else {
|
||||
this.initCategoryDist();
|
||||
}
|
||||
},
|
||||
|
||||
initPaymentMethod() {
|
||||
const ctx = document.getElementById('todayPaymentChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.todayPaymentChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.payment = new Chart(ctx, {
|
||||
type: 'pie',
|
||||
data: this.getPaymentData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getPaymentData() {
|
||||
const data = $wire.todayPaymentChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
data: data.data || [],
|
||||
backgroundColor: ['#10b981', '#3b82f6', '#8b5cf6', '#6b7280']
|
||||
}]
|
||||
};
|
||||
},
|
||||
updatePaymentMethod() {
|
||||
if (this.charts.payment) {
|
||||
this.charts.payment.data = this.getPaymentData();
|
||||
this.charts.payment.update();
|
||||
} else {
|
||||
this.initPaymentMethod();
|
||||
}
|
||||
},
|
||||
|
||||
initCustomerType() {
|
||||
const ctx = document.getElementById('todayCustomerTypeChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.todayCustomerTypeChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.customerType = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: this.getCustomerTypeData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getCustomerTypeData() {
|
||||
const data = $wire.todayCustomerTypeChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
data: data.data || [],
|
||||
backgroundColor: ['#8b5cf6', '#94a3b8']
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateCustomerType() {
|
||||
if (this.charts.customerType) {
|
||||
this.charts.customerType.data = this.getCustomerTypeData();
|
||||
this.charts.customerType.update();
|
||||
} else {
|
||||
this.initCustomerType();
|
||||
}
|
||||
},
|
||||
|
||||
initTopSelling() {
|
||||
const ctx = document.getElementById('todayTopSellingChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.todayTopSellingChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.topSelling = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: this.getTopSellingData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getTopSellingData() {
|
||||
const data = $wire.todayTopSellingChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
data: data.data || [],
|
||||
backgroundColor: ['#f43f5e', '#fbbf24', '#10b981', '#3b82f6', '#8b5cf6']
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateTopSelling() {
|
||||
if (this.charts.topSelling) {
|
||||
this.charts.topSelling.data = this.getTopSellingData();
|
||||
this.charts.topSelling.update();
|
||||
} else {
|
||||
this.initTopSelling();
|
||||
}
|
||||
},
|
||||
|
||||
initTopCustomers() {
|
||||
const ctx = document.getElementById('todayTopCustomersChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.todayTopCustomersChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.topCustomers = new Chart(ctx, {
|
||||
type: 'pie',
|
||||
data: this.getTopCustomersData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getTopCustomersData() {
|
||||
const data = $wire.todayTopCustomersChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
data: data.data || [],
|
||||
backgroundColor: ['#6366f1', '#ec4899', '#f59e0b', '#14b8a6', '#94a3b8']
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateTopCustomers() {
|
||||
if (this.charts.topCustomers) {
|
||||
this.charts.topCustomers.data = this.getTopCustomersData();
|
||||
this.charts.topCustomers.update();
|
||||
} else {
|
||||
this.initTopCustomers();
|
||||
}
|
||||
},
|
||||
|
||||
initDiscountDist() {
|
||||
const ctx = document.getElementById('todayDiscountChart');
|
||||
if (!ctx) return;
|
||||
const data = $wire.todayDiscountChart;
|
||||
if (!data || !data.labels) return;
|
||||
|
||||
this.charts.discountDist = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: this.getDiscountData(),
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
},
|
||||
getDiscountData() {
|
||||
const data = $wire.todayDiscountChart;
|
||||
return {
|
||||
labels: data.labels || [],
|
||||
datasets: [{
|
||||
data: data.data || [],
|
||||
backgroundColor: ['#f87171', '#4ade80']
|
||||
}]
|
||||
};
|
||||
},
|
||||
updateDiscountDist() {
|
||||
if (this.charts.discountDist) {
|
||||
this.charts.discountDist.data = this.getDiscountData();
|
||||
this.charts.discountDist.update();
|
||||
} else {
|
||||
this.initDiscountDist();
|
||||
}
|
||||
}
|
||||
}));
|
||||
</script>
|
||||
@endscript
|
||||
|
||||
Loading…
Reference in New Issue
Block a user