447 lines
20 KiB
PHP
447 lines
20 KiB
PHP
<?php
|
|
|
|
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;
|
|
|
|
#[Title('Ringkasan')]
|
|
class Overview extends Component
|
|
{
|
|
use WithSubscribeNotification;
|
|
|
|
public array $outlets = [];
|
|
|
|
public array $selectedOutletIds = [];
|
|
|
|
public array $hourlyComparisonChart = [];
|
|
|
|
public array $todayCategoryChart = [];
|
|
|
|
public array $todayPaymentChart = [];
|
|
|
|
public array $todayCustomerTypeChart = [];
|
|
|
|
public array $todayTopSellingChart = [];
|
|
|
|
public array $todayTopCustomersChart = [];
|
|
|
|
public array $todayDiscountChart = [];
|
|
|
|
public array $stats = [];
|
|
|
|
public function mount(): void
|
|
{
|
|
/** @var \App\Models\User $user */
|
|
$user = auth()->user();
|
|
$this->outlets = $user->outlets()->get()->pluck('name', 'id')->toArray();
|
|
}
|
|
|
|
public function render(): View
|
|
{
|
|
/** @var \App\Models\User $user */
|
|
$user = auth()->user();
|
|
$outletIds = $this->selectedOutletIds;
|
|
|
|
if (count($this->outlets) === 1) {
|
|
$outletIds = $user->outlets()->pluck('outlets.id')->toArray();
|
|
}
|
|
|
|
$todayOrder = Order::when(! empty($outletIds), fn($q) => $q->whereIn('outlet_id', $outletIds))
|
|
->today()
|
|
->count();
|
|
|
|
$yesterdayOrder = Order::when(! empty($outletIds), fn($q) => $q->whereIn('outlet_id', $outletIds))
|
|
->yesterday()
|
|
->count();
|
|
|
|
$todayIncome = Order::when(! empty($outletIds), fn($q) => $q->whereIn('outlet_id', $outletIds))
|
|
->today()
|
|
->sum('total');
|
|
|
|
$yesterdayIncome = Order::when(! empty($outletIds), fn($q) => $q->whereIn('outlet_id', $outletIds))
|
|
->yesterday()
|
|
->sum('total');
|
|
|
|
$todayCogs = Order::when(! empty($outletIds), fn($q) => $q->whereIn('outlet_id', $outletIds))
|
|
->today()
|
|
->sum('cogs');
|
|
|
|
$yesterdayCogs = Order::when(! empty($outletIds), fn($q) => $q->whereIn('outlet_id', $outletIds))
|
|
->yesterday()
|
|
->sum('cogs');
|
|
|
|
$todayDiscount = Order::when(! empty($outletIds), fn($q) => $q->whereIn('outlet_id', $outletIds))
|
|
->today()
|
|
->sum(DB::raw('discount + COALESCE(voucher_discount, 0)'));
|
|
|
|
$yesterdayDiscount = Order::when(! empty($outletIds), fn($q) => $q->whereIn('outlet_id', $outletIds))
|
|
->yesterday()
|
|
->sum(DB::raw('discount + COALESCE(voucher_discount, 0)'));
|
|
|
|
$todayVoucher = Order::when(! empty($outletIds), fn($q) => $q->whereIn('outlet_id', $outletIds))
|
|
->today()
|
|
->whereNotNull('voucher_id')
|
|
->count();
|
|
|
|
$yesterdayVoucher = Order::when(! empty($outletIds), fn($q) => $q->whereIn('outlet_id', $outletIds))
|
|
->yesterday()
|
|
->whereNotNull('voucher_id')
|
|
->count();
|
|
|
|
$todayExpense = Expense::when(! empty($outletIds), fn($q) => $q->whereIn('outlet_id', $outletIds))
|
|
->today()
|
|
->sum('amount');
|
|
|
|
$yesterdayExpense = Expense::when(! empty($outletIds), fn($q) => $q->whereIn('outlet_id', $outletIds))
|
|
->yesterday()
|
|
->sum('amount');
|
|
|
|
$todayPurchase = Purchase::query()->today()->sum('total');
|
|
|
|
$yesterdayPurchase = Purchase::query()->yesterday()->sum('total');
|
|
|
|
$todayGrossProfit = $todayIncome - $todayCogs - $todayDiscount;
|
|
$yesterdayGrossProfit = $yesterdayIncome - $yesterdayCogs - $yesterdayDiscount;
|
|
|
|
$todayNetProfit = $todayGrossProfit - $todayExpense;
|
|
$yesterdayNetProfit = $yesterdayGrossProfit - $yesterdayExpense;
|
|
|
|
$todaySoldPerfume = OrderItem::where('orderable_type', Perfume::class)
|
|
->whereHas(
|
|
'order',
|
|
fn($q) => $q->today()->when(! empty($outletIds), fn($q2) => $q2->whereIn('outlet_id', $outletIds))
|
|
)
|
|
->sum('quantity');
|
|
|
|
$yesterdaySoldPerfume = OrderItem::where('orderable_type', Perfume::class)
|
|
->whereHas(
|
|
'order',
|
|
fn($q) => $q->yesterday()->when(! empty($outletIds), fn($q2) => $q2->whereIn('outlet_id', $outletIds))
|
|
)
|
|
->sum('quantity');
|
|
|
|
$todayTopPerfume = OrderItem::where('orderable_type', Perfume::class)
|
|
->whereHas(
|
|
'order',
|
|
fn($q) => $q->today()->when(! empty($outletIds), fn($q2) => $q2->whereIn('outlet_id', $outletIds))
|
|
)
|
|
->select('orderable_id', 'orderable_type', DB::raw('SUM(quantity) as total_sold'))
|
|
->groupBy('orderable_id', 'orderable_type')
|
|
->orderByDesc('total_sold')
|
|
->with('orderable')
|
|
->first();
|
|
|
|
$yesterdayTopPerfume = OrderItem::where('orderable_type', Perfume::class)
|
|
->whereHas(
|
|
'order',
|
|
fn($q) => $q->yesterday()->when(! empty($outletIds), fn($q2) => $q2->whereIn('outlet_id', $outletIds))
|
|
)
|
|
->select('orderable_id', 'orderable_type', DB::raw('SUM(quantity) as total_sold'))
|
|
->groupBy('orderable_id', 'orderable_type')
|
|
->orderByDesc('total_sold')
|
|
->with('orderable')
|
|
->first();
|
|
|
|
// 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: AOV
|
|
$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)
|
|
$hourFunc = DB::getDriverName() === 'sqlite' ? "CAST(strftime('%H', created_at) AS INTEGER)" : 'HOUR(created_at)';
|
|
|
|
$todayHourly = Order::whereDate('created_at', now())
|
|
->when(! empty($outletIds), fn($q) => $q->whereIn('outlet_id', $outletIds))
|
|
->select(DB::raw("$hourFunc 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("$hourFunc 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', '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],
|
|
];
|
|
|
|
$this->stats = array_values(array_filter([
|
|
$user->hasRole(['Developer', 'Owner']) ? [
|
|
'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,
|
|
'description' => 'Rata-rata nilai belanja pelanggan dalam satu kali checkout.',
|
|
] : null,
|
|
|
|
$user->hasRole(['Developer', 'Owner']) ? [
|
|
'title' => 'Profit Margin',
|
|
'value' => round($todayProfitMargin, 1) . '%',
|
|
'previous' => round($yesterdayProfitMargin, 1) . '%',
|
|
'trend' => $yesterdayProfitMargin > 0
|
|
? round($todayProfitMargin - $yesterdayProfitMargin, 1) . '%'
|
|
: '∞%',
|
|
'trendUp' => $todayProfitMargin > $yesterdayProfitMargin,
|
|
'description' => 'Persentase keuntungan bersih dibandingkan dengan total pendapatan.',
|
|
] : null,
|
|
|
|
[
|
|
'title' => 'Total Order',
|
|
'value' => $todayOrder,
|
|
'previous' => $yesterdayOrder,
|
|
'trend' => $yesterdayOrder > 0
|
|
? round((($todayOrder - $yesterdayOrder) / $yesterdayOrder) * 100, 1) . '%'
|
|
: '∞%',
|
|
'trendUp' => $todayOrder > $yesterdayOrder,
|
|
'description' => 'Total frekuensi transaksi atau pesanan yang masuk selama periode ini.',
|
|
],
|
|
|
|
[
|
|
'title' => 'Pendapatan',
|
|
'value' => formatCurrencyNumber($todayIncome, 'Rp'),
|
|
'previous' => formatCurrencyNumber($yesterdayIncome, 'Rp'),
|
|
'trend' => $yesterdayIncome > 0
|
|
? round((($todayIncome - $yesterdayIncome) / $yesterdayIncome) * 100, 1) . '%'
|
|
: '∞%',
|
|
'trendUp' => $todayIncome > $yesterdayIncome,
|
|
'formatted' => formatCurrencyNumber($todayIncome, 'Rp'),
|
|
'description' => 'Total nominal uang yang masuk dari penjualan (Omzet).',
|
|
],
|
|
|
|
$user->hasRole(['Developer', 'Owner']) ? [
|
|
'title' => 'HPP',
|
|
'value' => formatCurrencyNumber($todayCogs, 'Rp'),
|
|
'previous' => formatCurrencyNumber($yesterdayCogs, 'Rp'),
|
|
'trend' => $yesterdayCogs > 0
|
|
? round((($todayCogs - $yesterdayCogs) / $yesterdayCogs) * 100, 1) . '%'
|
|
: '∞%',
|
|
'trendUp' => $todayCogs > $yesterdayCogs,
|
|
'formatted' => formatCurrencyNumber($todayCogs, 'Rp'),
|
|
'description' => 'Harga Pokok Penjualan, yaitu total modal dari barang-barang yang terjual.',
|
|
] : null,
|
|
|
|
[
|
|
'title' => 'Member Baru',
|
|
'value' => $todayNewMembers . ' Orang',
|
|
'previous' => $yesterdayNewMembers . ' Orang',
|
|
'trend' => $yesterdayNewMembers > 0
|
|
? round((($todayNewMembers - $yesterdayNewMembers) / $yesterdayNewMembers) * 100, 1) . '%'
|
|
: '∞%',
|
|
'trendUp' => $todayNewMembers > $yesterdayNewMembers,
|
|
'description' => 'Jumlah pelanggan baru yang mendaftar menjadi member selama periode ini.',
|
|
],
|
|
|
|
[
|
|
'title' => 'Diskon',
|
|
'value' => formatCurrencyNumber($todayDiscount, 'Rp'),
|
|
'previous' => formatCurrencyNumber($yesterdayDiscount, 'Rp'),
|
|
'trend' => $yesterdayDiscount > 0
|
|
? round((($todayDiscount - $yesterdayDiscount) / $yesterdayDiscount) * 100, 1) . '%'
|
|
: '∞%',
|
|
'trendUp' => $todayDiscount > $yesterdayDiscount,
|
|
'formatted' => formatCurrencyNumber($todayDiscount, 'Rp'),
|
|
'description' => 'Total nilai potongan harga (diskon) yang diberikan kepada pelanggan.',
|
|
],
|
|
|
|
[
|
|
'title' => 'Voucher Terpakai',
|
|
'value' => $todayVoucher,
|
|
'previous' => $yesterdayVoucher,
|
|
'trend' => $yesterdayVoucher > 0
|
|
? round((($todayVoucher - $yesterdayVoucher) / $yesterdayVoucher) * 100, 1) . '%'
|
|
: '∞%',
|
|
'trendUp' => $todayVoucher > $yesterdayVoucher,
|
|
'description' => 'Jumlah transaksi yang menggunakan voucher diskon selama periode ini.',
|
|
],
|
|
|
|
[
|
|
'title' => 'Beban Toko',
|
|
'value' => formatCurrencyNumber($todayExpense, 'Rp'),
|
|
'previous' => formatCurrencyNumber($yesterdayExpense, 'Rp'),
|
|
'trend' => $yesterdayExpense > 0
|
|
? round((($todayExpense - $yesterdayExpense) / $yesterdayExpense) * 100, 1) . '%'
|
|
: '∞%',
|
|
'trendUp' => $todayExpense > $yesterdayExpense,
|
|
'description' => 'Total biaya operasional atau pengeluaran toko yang tercatat selama periode ini.',
|
|
],
|
|
|
|
$user->hasRole(['Developer', 'Owner']) ? [
|
|
'title' => 'Belanja Barang',
|
|
'value' => formatCurrencyNumber($todayPurchase, 'Rp'),
|
|
'previous' => formatCurrencyNumber($yesterdayPurchase, 'Rp'),
|
|
'trend' => $yesterdayPurchase > 0
|
|
? round((($todayPurchase - $yesterdayPurchase) / $yesterdayPurchase) * 100, 1) . '%'
|
|
: '∞%',
|
|
'trendUp' => $todayPurchase > $yesterdayPurchase,
|
|
'description' => 'Total pengeluaran untuk pembelian stok barang atau inventaris selama periode ini.',
|
|
] : null,
|
|
|
|
$user->hasRole(['Developer', 'Owner']) ? [
|
|
'title' => 'Laba Kotor',
|
|
'value' => formatCurrencyNumber($todayGrossProfit, 'Rp'),
|
|
'previous' => formatCurrencyNumber($yesterdayGrossProfit, 'Rp'),
|
|
'trend' => $yesterdayGrossProfit > 0
|
|
? round((($todayGrossProfit - $yesterdayGrossProfit) / $yesterdayGrossProfit) * 100, 1) . '%'
|
|
: '∞%',
|
|
'trendUp' => $todayGrossProfit > $yesterdayGrossProfit,
|
|
'description' => 'Pendapatan dikurangi dengan HPP dan Diskon.',
|
|
] : null,
|
|
|
|
$user->hasRole(['Developer', 'Owner']) ? [
|
|
'title' => 'Laba Bersih',
|
|
'value' => formatCurrencyNumber($todayNetProfit, 'Rp'),
|
|
'previous' => formatCurrencyNumber($yesterdayNetProfit, 'Rp'),
|
|
'trend' => $yesterdayNetProfit > 0
|
|
? round((($todayNetProfit - $yesterdayNetProfit) / $yesterdayNetProfit) * 100, 1) . '%'
|
|
: '∞%',
|
|
'trendUp' => $todayNetProfit > $yesterdayNetProfit,
|
|
'description' => 'Laba kotor dikurangi dengan beban operasional dan gaji.',
|
|
] : null,
|
|
|
|
[
|
|
'title' => 'Parfum Terjual',
|
|
'value' => $todaySoldPerfume . ' ml',
|
|
'previous' => $yesterdaySoldPerfume . ' ml',
|
|
'trend' => $yesterdaySoldPerfume > 0
|
|
? round((($todaySoldPerfume - $yesterdaySoldPerfume) / $yesterdaySoldPerfume) * 100, 1) . '%'
|
|
: '∞%',
|
|
'trendUp' => $todaySoldPerfume > $yesterdaySoldPerfume,
|
|
'description' => 'Total volume cairan parfum yang terjual dalam satuan mililiter (ml).',
|
|
],
|
|
|
|
[
|
|
'title' => 'Top Parfum',
|
|
'value' => optional($todayTopPerfume?->orderable)->name . ' (' . $todayTopPerfume?->total_sold . 'ml)' ?? '-',
|
|
'previous' => optional($yesterdayTopPerfume?->orderable)->name . '(' . $yesterdayTopPerfume?->total_sold . 'ml)' ?? '-',
|
|
'trend' => ($yesterdayTopPerfume && $yesterdayTopPerfume->total_sold > 0)
|
|
? round((($todayTopPerfume?->total_sold - $yesterdayTopPerfume?->total_sold) / $yesterdayTopPerfume?->total_sold) * 100, 1) . '%'
|
|
: '∞%',
|
|
'trendUp' => ($todayTopPerfume?->total_sold ?? 0) > ($yesterdayTopPerfume?->total_sold ?? 0),
|
|
'description' => 'Nama varian parfum yang paling banyak terjual hari ini.',
|
|
],
|
|
]));
|
|
|
|
return view('livewire.studio.dashboard.overview');
|
|
}
|
|
}
|