From 3e5ffef2d8c7780674e49ccbda89e5f44de01775 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Mon, 22 Jun 2026 11:48:13 +0700 Subject: [PATCH] feat: implement DashboardService and refactor active scope usage in services --- .../Controllers/Admin/DashboardController.php | 622 +----------------- app/Http/Controllers/HomeController.php | 11 +- app/Models/Category.php | 6 + app/Models/Product.php | 31 + app/Services/Finance/PayrollService.php | 2 +- app/Services/Manage/CuttingService.php | 2 +- app/Services/Manage/OrderService.php | 2 +- app/Services/System/DashboardService.php | 579 ++++++++++++++++ 8 files changed, 649 insertions(+), 606 deletions(-) create mode 100644 app/Services/System/DashboardService.php diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php index b1ad00c..098fae5 100644 --- a/app/Http/Controllers/Admin/DashboardController.php +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -2,37 +2,18 @@ namespace App\Http\Controllers\Admin; -use App\Enums\CuttingStatus; -use App\Enums\EmployeeAdvanceStatus; -use App\Enums\EmploymentStatus; -use App\Enums\OrderChannel; -use App\Enums\OrderStatus; -use App\Enums\PaymentType; use App\Http\Controllers\Controller; -use App\Models\Attendance; -use App\Models\CashAccount; -use App\Models\CashTransaction; -use App\Models\Category; -use App\Models\Cutting; -use App\Models\CuttingResult; -use App\Models\Employee; -use App\Models\EmployeeAdvance; -use App\Models\Expense; -use App\Models\LeaveRequest; -use App\Models\Order; -use App\Models\OrderItem; -use App\Models\Payroll; -use App\Models\PayrollPeriod; -use App\Models\Product; -use App\Models\ProductVariant; -use App\Models\Purchase; -use App\Models\RawMaterialPrice; +use App\Services\System\DashboardService; use Carbon\Carbon; use Inertia\Inertia; use Inertia\Response; class DashboardController extends Controller { + public function __construct( + private readonly DashboardService $dashboardService, + ) {} + public function index(): Response { $now = Carbon::now(); @@ -40,576 +21,29 @@ public function index(): Response $endOfMonth = $now->copy()->endOfMonth(); return Inertia::render('admin/Dashboard', [ - 'rawMaterialStock' => $this->getRawMaterialStock(), - 'productStock' => $this->getProductStock(), - 'lowStockProducts' => $this->getLowStockProducts(), - 'lowStockMaterials' => $this->getLowStockMaterials(), - 'topSuppliers' => $this->getTopSuppliers(), - 'topCustomers' => $this->getTopCustomers(), - 'topProducts' => $this->getTopProducts(), - 'purchaseSummary' => $this->getPurchaseSummary(), - 'cuttingSummary' => $this->getCuttingSummary(), - 'cuttingByStatus' => $this->getCuttingByStatus(), - 'orderStats' => $this->getOrderStats(), - 'revenueSummary' => $this->getRevenueSummary(), - 'marketplaceSummary' => $this->getMarketplaceSummary(), - 'cashAccounts' => $this->getCashAccounts(), - 'monthlyCashFlow' => $this->getMonthlyCashFlow(), - 'monthlyExpenses' => $this->getMonthlyExpenses($startOfMonth, $endOfMonth), - 'kasbonSummary' => $this->getKasbonSummary(), - 'payrollSummary' => $this->getPayrollSummary($startOfMonth, $endOfMonth), - 'leaveRequestSummary' => $this->getLeaveRequestSummary($startOfMonth, $endOfMonth), - 'employeeSummary' => $this->getEmployeeSummary(), - 'attendanceToday' => $this->getAttendanceToday(), - 'monthlyRevenueTrend' => $this->getMonthlyRevenueTrend(), - 'monthlyPurchaseTrend' => $this->getMonthlyPurchaseTrend(), + 'rawMaterialStock' => $this->dashboardService->getRawMaterialStock(), + 'productStock' => $this->dashboardService->getProductStock(), + 'lowStockProducts' => $this->dashboardService->getLowStockProducts(), + 'lowStockMaterials' => $this->dashboardService->getLowStockMaterials(), + 'topSuppliers' => $this->dashboardService->getTopSuppliers(), + 'topCustomers' => $this->dashboardService->getTopCustomers(), + 'topProducts' => $this->dashboardService->getTopProducts(), + 'purchaseSummary' => $this->dashboardService->getPurchaseSummary(), + 'cuttingSummary' => $this->dashboardService->getCuttingSummary(), + 'cuttingByStatus' => $this->dashboardService->getCuttingByStatus(), + 'orderStats' => $this->dashboardService->getOrderStats(), + 'revenueSummary' => $this->dashboardService->getRevenueSummary(), + 'marketplaceSummary' => $this->dashboardService->getMarketplaceSummary(), + 'cashAccounts' => $this->dashboardService->getCashAccounts(), + 'monthlyCashFlow' => $this->dashboardService->getMonthlyCashFlow(), + 'monthlyExpenses' => $this->dashboardService->getMonthlyExpenses($startOfMonth, $endOfMonth), + 'kasbonSummary' => $this->dashboardService->getKasbonSummary(), + 'payrollSummary' => $this->dashboardService->getPayrollSummary($startOfMonth, $endOfMonth), + 'leaveRequestSummary' => $this->dashboardService->getLeaveRequestSummary($startOfMonth, $endOfMonth), + 'employeeSummary' => $this->dashboardService->getEmployeeSummary(), + 'attendanceToday' => $this->dashboardService->getAttendanceToday(), + 'monthlyRevenueTrend' => $this->dashboardService->getMonthlyRevenueTrend(), + 'monthlyPurchaseTrend' => $this->dashboardService->getMonthlyPurchaseTrend(), ]); } - - private function getRawMaterialStock(): array - { - $data = RawMaterialPrice::query() - ->selectRaw('SUM(stock) as total_stock, SUM(stock * price) as total_value') - ->first(); - - $byUnit = RawMaterialPrice::query() - ->join('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id') - ->selectRaw('raw_materials.unit, SUM(raw_material_prices.stock) as total_stock') - ->groupBy('raw_materials.unit') - ->get() - ->mapWithKeys(fn ($item) => [ - $item->unit => (float) $item->total_stock, - ]) - ->toArray(); - - return [ - 'total_stock' => (float) ($data->total_stock ?? 0), - 'total_value' => (int) ($data->total_value ?? 0), - 'by_unit' => $byUnit, - ]; - } - - private function getProductStock(): array - { - $data = ProductVariant::query() - ->selectRaw('SUM(stock) as total_stock, SUM(reject_stock) as total_reject, COUNT(*) as total_variants') - ->first(); - - $totalStock = (int) ($data->total_stock ?? 0); - - $totalValue = Cutting::query() - ->whereNotNull('cost_per_unit') - ->join('cutting_results', 'cuttings.id', '=', 'cutting_results.cutting_id') - ->selectRaw('SUM(cutting_results.warehouse_stock * cuttings.cost_per_unit) as total_value') - ->value('total_value'); - - $totalProducts = Product::query()->count(); - $totalCategories = Category::query()->count(); - - return [ - 'total_stock' => $totalStock, - 'total_reject' => (int) ($data->total_reject ?? 0), - 'total_value' => (int) ($totalValue ?? 0), - 'total_variants' => (int) ($data->total_variants ?? 0), - 'total_products' => $totalProducts, - 'total_categories' => $totalCategories, - ]; - } - - private function getLowStockProducts(): array - { - return ProductVariant::query() - ->where('stock', '<=', ProductVariant::minStock()) - ->where('stock', '>', 0) - ->join('products', 'product_variants.product_id', '=', 'products.id') - ->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, product_variants.stock") - ->orderBy('stock') - ->limit(5) - ->get() - ->map(fn ($item) => [ - 'name' => $item->full_name, - 'stock' => (int) $item->stock, - ]) - ->toArray(); - } - - private function getLowStockMaterials(): array - { - return RawMaterialPrice::query() - ->where('stock', '<=', 5) - ->where('stock', '>', 0) - ->join('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id') - ->selectRaw("CONCAT(raw_materials.name, ' - ', raw_material_prices.variant) as full_name, raw_material_prices.stock, raw_materials.unit") - ->orderBy('stock') - ->limit(5) - ->get() - ->map(fn ($item) => [ - 'name' => $item->full_name, - 'stock' => (float) $item->stock, - 'unit' => $item->unit, - ]) - ->toArray(); - } - - private function getTopSuppliers(): array - { - return Purchase::query() - ->join('suppliers', 'purchases.supplier_id', '=', 'suppliers.id') - ->selectRaw('suppliers.name, SUM(purchases.total) as total_amount, COUNT(purchases.id) as purchase_count') - ->groupBy('suppliers.id', 'suppliers.name') - ->orderByDesc('total_amount') - ->limit(5) - ->get() - ->map(fn ($item) => [ - 'name' => $item->name, - 'total_amount' => (int) $item->total_amount, - 'purchase_count' => (int) $item->purchase_count, - ]) - ->toArray(); - } - - private function getTopCustomers(): array - { - return Order::query() - ->where('status', OrderStatus::COMPLETED) - ->join('customers', 'orders.customer_id', '=', 'customers.id') - ->selectRaw('customers.name, SUM(orders.total_amount) as total_amount, COUNT(orders.id) as order_count') - ->groupBy('customers.id', 'customers.name') - ->orderByDesc('total_amount') - ->limit(5) - ->get() - ->map(fn ($item) => [ - 'name' => $item->name, - 'total_amount' => (int) $item->total_amount, - 'order_count' => (int) $item->order_count, - ]) - ->toArray(); - } - - private function getTopProducts(): array - { - return OrderItem::query() - ->join('orders', 'order_items.order_id', '=', 'orders.id') - ->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id') - ->join('products', 'product_variants.product_id', '=', 'products.id') - ->where('orders.status', OrderStatus::COMPLETED) - ->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, SUM(order_items.quantity) as total_qty, SUM(order_items.subtotal) as total_revenue") - ->groupBy('order_items.product_variant_id', 'products.name', 'product_variants.name') - ->orderByDesc('total_qty') - ->limit(5) - ->get() - ->map(fn ($item) => [ - 'name' => $item->full_name, - 'total_qty' => (int) $item->total_qty, - 'total_revenue' => (int) $item->total_revenue, - ]) - ->toArray(); - } - - private function getPurchaseSummary(): array - { - $data = Purchase::query() - ->selectRaw('COUNT(*) as total_purchases, SUM(total) as total_spent, SUM(discount) as total_discount') - ->first(); - - return [ - 'total_purchases' => (int) ($data->total_purchases ?? 0), - 'total_spent' => (int) ($data->total_spent ?? 0), - 'total_discount' => (int) ($data->total_discount ?? 0), - ]; - } - - private function getCuttingSummary(): array - { - $totalCost = Cutting::query() - ->selectRaw('COALESCE(SUM(total_material_cost), 0) + COALESCE(SUM(sewing_cost), 0) + COALESCE(SUM(other_cost), 0) as total_cost') - ->value('total_cost'); - - $totalResults = CuttingResult::query() - ->selectRaw('SUM(cutting_result) as total_cutting, SUM(warehouse_stock) as total_warehouse, SUM(cutting_reject) as total_reject') - ->first(); - - return [ - 'total_cost' => (int) ($totalCost ?? 0), - 'total_cutting' => (int) ($totalResults->total_cutting ?? 0), - 'total_warehouse' => (int) ($totalResults->total_warehouse ?? 0), - 'total_reject' => (int) ($totalResults->total_reject ?? 0), - ]; - } - - private function getCuttingByStatus(): array - { - return Cutting::query() - ->selectRaw('status, COUNT(*) as count') - ->groupBy('status') - ->get() - ->map(fn ($item) => [ - 'status' => $item->status instanceof CuttingStatus ? $item->status->value : $item->status, - 'label' => $item->status instanceof CuttingStatus ? $item->status->label() : CuttingStatus::from($item->status)->label(), - 'count' => (int) $item->count, - ]) - ->toArray(); - } - - private function getOrderStats(): array - { - $byChannel = Order::query() - ->selectRaw('channel, COUNT(*) as count, SUM(total_amount) as total') - ->groupBy('channel') - ->get() - ->map(fn ($item) => [ - 'channel' => $item->channel instanceof OrderChannel ? $item->channel->value : $item->channel, - 'label' => $item->channel instanceof OrderChannel ? $item->channel->label() : OrderChannel::from($item->channel)->label(), - 'count' => (int) $item->count, - 'total' => (int) $item->total, - ]); - - $byPaymentType = Order::query() - ->selectRaw('payment_type, COUNT(*) as count, SUM(total_amount) as total') - ->groupBy('payment_type') - ->get() - ->map(fn ($item) => [ - 'payment_type' => $item->payment_type instanceof PaymentType ? $item->payment_type->value : $item->payment_type, - 'label' => $item->payment_type instanceof PaymentType ? $item->payment_type->label() : PaymentType::from($item->payment_type)->label(), - 'count' => (int) $item->count, - 'total' => (int) $item->total, - ]); - - $byMarketing = Order::query() - ->whereNotNull('marketing_id') - ->join('users', 'orders.marketing_id', '=', 'users.id') - ->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id') - ->selectRaw('COALESCE(user_profiles.full_name, users.username) as name, COUNT(*) as count, SUM(orders.total_amount) as total') - ->groupBy('orders.marketing_id', 'users.username', 'user_profiles.full_name') - ->orderByDesc('total') - ->limit(5) - ->get() - ->map(fn ($item) => [ - 'name' => $item->name, - 'count' => (int) $item->count, - 'total' => (int) $item->total, - ]); - - $byStatus = Order::query() - ->selectRaw('status, COUNT(*) as count') - ->groupBy('status') - ->get() - ->map(fn ($item) => [ - 'status' => $item->status instanceof OrderStatus ? $item->status->value : $item->status, - 'label' => $item->status instanceof OrderStatus ? $item->status->label() : OrderStatus::from($item->status)->label(), - 'count' => (int) $item->count, - ]); - - return [ - 'by_channel' => $byChannel->toArray(), - 'by_payment_type' => $byPaymentType->toArray(), - 'by_marketing' => $byMarketing->toArray(), - 'by_status' => $byStatus->toArray(), - ]; - } - - private function getRevenueSummary(): array - { - $data = Order::query() - ->where('status', OrderStatus::COMPLETED) - ->selectRaw('SUM(total_amount) as total_revenue, SUM(discount) as total_discount, SUM(shipping_cost) as total_shipping, COUNT(*) as total_orders, AVG(total_amount) as avg_order') - ->first(); - - $totalMarketplaceFees = Order::query() - ->where('status', OrderStatus::COMPLETED) - ->whereNotNull('marketplace_settings_snapshot') - ->get() - ->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0)); - - return [ - 'total_revenue' => (int) ($data->total_revenue ?? 0), - 'total_discount' => (int) ($data->total_discount ?? 0), - 'total_marketplace_fees' => $totalMarketplaceFees, - 'total_potongan' => (int) ($data->total_discount ?? 0) + $totalMarketplaceFees, - 'total_shipping' => (int) ($data->total_shipping ?? 0), - 'total_orders' => (int) ($data->total_orders ?? 0), - 'avg_order' => (int) ($data->avg_order ?? 0), - ]; - } - - private function getMarketplaceSummary(): array - { - $marketplaceOrders = Order::query() - ->where('status', OrderStatus::COMPLETED) - ->whereIn('channel', [OrderChannel::SHOPEE, OrderChannel::TIKTOK]) - ->whereNotNull('marketplace_settings_snapshot') - ->get(); - - $totalRevenue = $marketplaceOrders->sum('total_amount'); - $totalFees = $marketplaceOrders->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0)); - $totalNet = $marketplaceOrders->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['net_amount'] ?? 0)); - $totalOrders = $marketplaceOrders->count(); - - return [ - 'total_revenue' => (int) $totalRevenue, - 'total_fees' => (int) $totalFees, - 'total_net' => (int) $totalNet, - 'total_orders' => (int) $totalOrders, - ]; - } - - private function getCashAccounts(): array - { - return CashAccount::query() - ->selectRaw('name, balance') - ->get() - ->map(fn ($item) => [ - 'name' => $item->name, - 'balance' => (int) $item->balance, - ]) - ->toArray(); - } - - private function getMonthlyCashFlow(): array - { - $months = collect(); - for ($i = 5; $i >= 0; $i--) { - $date = Carbon::now()->subMonths($i); - $months->push([ - 'year' => $date->year, - 'month' => $date->month, - 'label' => $date->translatedFormat('M Y'), - ]); - } - - $result = $months->map(function ($month) { - $start = Carbon::create($month['year'], $month['month'], 1)->startOfMonth(); - $end = $start->copy()->endOfMonth(); - - $data = CashTransaction::query() - ->whereBetween('created_at', [$start, $end]) - ->selectRaw(" - COALESCE(SUM(CASE WHEN type = 'deposit' THEN amount ELSE 0 END), 0) as deposits, - COALESCE(SUM(CASE WHEN type = 'withdrawal' THEN amount ELSE 0 END), 0) as withdrawals - ") - ->first(); - - return [ - 'label' => $month['label'], - 'deposits' => (int) $data->deposits, - 'withdrawals' => (int) $data->withdrawals, - ]; - }); - - return $result->toArray(); - } - - private function getMonthlyExpenses(Carbon $startOfMonth, Carbon $endOfMonth): array - { - $data = Expense::query() - ->whereBetween('created_at', [$startOfMonth, $endOfMonth]) - ->selectRaw('COALESCE(SUM(amount), 0) as total, COUNT(*) as count') - ->first(); - - return [ - 'total' => (int) ($data->total ?? 0), - 'count' => (int) ($data->count ?? 0), - ]; - } - - private function getKasbonSummary(): array - { - $pending = EmployeeAdvance::query() - ->where('status', EmployeeAdvanceStatus::PENDING) - ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') - ->first(); - - $approved = EmployeeAdvance::query() - ->where('status', EmployeeAdvanceStatus::APPROVED) - ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') - ->first(); - - $paid = EmployeeAdvance::query() - ->where('status', EmployeeAdvanceStatus::PAID) - ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') - ->first(); - - return [ - 'pending' => [ - 'count' => (int) ($pending->count ?? 0), - 'total' => (int) ($pending->total ?? 0), - ], - 'approved' => [ - 'count' => (int) ($approved->count ?? 0), - 'total' => (int) ($approved->total ?? 0), - ], - 'paid' => [ - 'count' => (int) ($paid->count ?? 0), - 'total' => (int) ($paid->total ?? 0), - ], - 'total' => (int) ($pending->total ?? 0) + (int) ($approved->total ?? 0) + (int) ($paid->total ?? 0), - ]; - } - - private function getPayrollSummary(Carbon $startOfMonth, Carbon $endOfMonth): array - { - $period = PayrollPeriod::query() - ->where('year', $startOfMonth->year) - ->where('month', $startOfMonth->month) - ->first(); - - if (! $period) { - return [ - 'has_period' => false, - 'total_employees' => 0, - 'total_amount' => 0, - 'paid_amount' => 0, - 'unpaid_amount' => 0, - 'paid_count' => 0, - 'unpaid_count' => 0, - ]; - } - - $payrolls = Payroll::query() - ->where('payroll_period_id', $period->id) - ->selectRaw(" - COUNT(*) as total_employees, - COALESCE(SUM(total_amount), 0) as total_amount, - COALESCE(SUM(CASE WHEN status = 'paid' THEN total_amount ELSE 0 END), 0) as paid_amount, - COALESCE(SUM(CASE WHEN status = 'unpaid' THEN total_amount ELSE 0 END), 0) as unpaid_amount, - SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid_count, - SUM(CASE WHEN status = 'unpaid' THEN 1 ELSE 0 END) as unpaid_count - ") - ->first(); - - return [ - 'has_period' => true, - 'period_status' => $period->status->value, - 'total_employees' => (int) ($payrolls->total_employees ?? 0), - 'total_amount' => (int) ($payrolls->total_amount ?? 0), - 'paid_amount' => (int) ($payrolls->paid_amount ?? 0), - 'unpaid_amount' => (int) ($payrolls->unpaid_amount ?? 0), - 'paid_count' => (int) ($payrolls->paid_count ?? 0), - 'unpaid_count' => (int) ($payrolls->unpaid_count ?? 0), - ]; - } - - private function getLeaveRequestSummary(Carbon $startOfMonth, Carbon $endOfMonth): array - { - $data = LeaveRequest::query() - ->where(function ($q) use ($startOfMonth, $endOfMonth) { - $q->whereBetween('start_date', [$startOfMonth, $endOfMonth]) - ->orWhereBetween('end_date', [$startOfMonth, $endOfMonth]); - }) - ->selectRaw(" - COUNT(*) as total, - SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count, - SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count, - SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_count - ") - ->first(); - - return [ - 'total' => (int) ($data->total ?? 0), - 'pending' => (int) ($data->pending_count ?? 0), - 'approved' => (int) ($data->approved_count ?? 0), - 'rejected' => (int) ($data->rejected_count ?? 0), - ]; - } - - private function getEmployeeSummary(): array - { - $total = Employee::query()->count(); - - $byStatus = Employee::query() - ->selectRaw('employment_status, COUNT(*) as count') - ->groupBy('employment_status') - ->get() - ->map(fn ($item) => [ - 'status' => $item->employment_status instanceof EmploymentStatus ? $item->employment_status->value : $item->employment_status, - 'label' => $item->employment_status instanceof EmploymentStatus ? $item->employment_status->label() : EmploymentStatus::from($item->employment_status)->label(), - 'count' => (int) $item->count, - ]); - - return [ - 'total' => $total, - 'by_status' => $byStatus->toArray(), - ]; - } - - private function getAttendanceToday(): array - { - $today = Carbon::today(); - - $totalEmployees = Employee::query()->count(); - - $present = Attendance::query() - ->where('attendance_date', $today) - ->distinct('employee_id') - ->count('employee_id'); - - return [ - 'total_employees' => $totalEmployees, - 'present' => $present, - 'absent' => max(0, $totalEmployees - $present), - ]; - } - - private function getMonthlyRevenueTrend(): array - { - $months = collect(); - for ($i = 5; $i >= 0; $i--) { - $date = Carbon::now()->subMonths($i); - $months->push([ - 'year' => $date->year, - 'month' => $date->month, - 'label' => $date->translatedFormat('M Y'), - ]); - } - - $result = $months->map(function ($month) { - $start = Carbon::create($month['year'], $month['month'], 1)->startOfMonth(); - $end = $start->copy()->endOfMonth(); - - $data = Order::query() - ->where('status', OrderStatus::COMPLETED) - ->whereBetween('created_at', [$start, $end]) - ->selectRaw('COALESCE(SUM(total_amount), 0) as total, COUNT(*) as count') - ->first(); - - return [ - 'label' => $month['label'], - 'total' => (int) $data->total, - 'count' => (int) $data->count, - ]; - }); - - return $result->toArray(); - } - - private function getMonthlyPurchaseTrend(): array - { - $months = collect(); - for ($i = 5; $i >= 0; $i--) { - $date = Carbon::now()->subMonths($i); - $months->push([ - 'year' => $date->year, - 'month' => $date->month, - 'label' => $date->translatedFormat('M Y'), - ]); - } - - $result = $months->map(function ($month) { - $start = Carbon::create($month['year'], $month['month'], 1)->startOfMonth(); - $end = $start->copy()->endOfMonth(); - - $data = Purchase::query() - ->whereBetween('created_at', [$start, $end]) - ->selectRaw('COALESCE(SUM(total), 0) as total, COUNT(*) as count') - ->first(); - - return [ - 'label' => $month['label'], - 'total' => (int) $data->total, - 'count' => (int) $data->count, - ]; - }); - - return $result->toArray(); - } } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 29fa019..f08d3ed 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -22,16 +22,9 @@ public function __construct( public function index(): Response { - $categories = Category::whereHas('products')->get(['id', 'name', 'slug']); + $categories = Category::getActiveWithProducts(); - $products = Product::where('is_active', true) - ->with([ - 'categories', - 'variants' => fn ($query) => $query - ->with('media') - ->orderBy('created_at'), - ]) - ->get(); + $products = Product::getActiveWithVariantsAndCategories(); $products = $products->map(function ($product) { $product->variants->each(function ($variant) { diff --git a/app/Models/Category.php b/app/Models/Category.php index f71eddd..ef8a375 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -4,6 +4,7 @@ use App\Models\Concerns\InteractsWithActivityLog; use Illuminate\Database\Eloquent\Attributes\Guarded; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -20,4 +21,9 @@ public function products(): BelongsToMany { return $this->belongsToMany(Product::class, 'product_categories'); } + + public static function getActiveWithProducts(): Collection + { + return self::whereHas('products')->get(['id', 'name', 'slug']); + } } diff --git a/app/Models/Product.php b/app/Models/Product.php index 8fb0500..781b56f 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -4,6 +4,9 @@ use App\Models\Concerns\InteractsWithActivityLog; use Illuminate\Database\Eloquent\Attributes\Guarded; +use Illuminate\Database\Eloquent\Attributes\Scope; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -11,6 +14,10 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\Sluggable\Attributes\Sluggable; +/** + * @method static \Illuminate\Database\Eloquent\Builder active() + * @method static \Illuminate\Database\Eloquent\Builder inactive() + */ #[Guarded(['id'])] #[Sluggable(from: 'name', to: 'slug')] class Product extends Model @@ -33,4 +40,28 @@ public function variants(): HasMany { return $this->hasMany(ProductVariant::class); } + + #[Scope] + public function active(Builder $query): void + { + $query->where('is_active', true); + } + + #[Scope] + public function inactive(Builder $query): void + { + $query->where('is_active', false); + } + + public static function getActiveWithVariantsAndCategories(): Collection + { + return self::active() + ->with([ + 'categories', + 'variants' => fn ($query) => $query + ->with('media') + ->orderBy('created_at'), + ]) + ->get(); + } } diff --git a/app/Services/Finance/PayrollService.php b/app/Services/Finance/PayrollService.php index 28bdcdd..fc1480f 100644 --- a/app/Services/Finance/PayrollService.php +++ b/app/Services/Finance/PayrollService.php @@ -340,7 +340,7 @@ private function payrollEligibleEmployees(PayrollPeriod $period): Collection return Employee::query() ->with('user.roles') ->whereHas('user', function (Builder $query): void { - $query->where('is_active', true) + $query->active() ->whereDoesntHave('roles', fn (Builder $query) => $query->whereIn('name', [ Role::DEVELOPER->value, Role::OWNER->value, diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index 5556cfc..c2d3d03 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -183,7 +183,7 @@ public function productCatalog(?Cutting $cutting = null, ?User $user = null): Co ->orderBy('created_at'), ]) ->where(function (Builder $query) use ($selectedVariantIds): void { - $query->where('is_active', true); + $query->active(); if ($selectedVariantIds !== []) { $query->orWhereHas( diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php index cc3a5c4..8655e43 100644 --- a/app/Services/Manage/OrderService.php +++ b/app/Services/Manage/OrderService.php @@ -143,7 +143,7 @@ public function catalogItems(?Order $order = null, ?User $user = null): Collecti ->orderBy('created_at'), ]) ->where(function (Builder $query) use ($orderVariantIds): void { - $query->where('is_active', true); + $query->active(); if ($orderVariantIds !== []) { $query->orWhereHas( diff --git a/app/Services/System/DashboardService.php b/app/Services/System/DashboardService.php new file mode 100644 index 0000000..caed90b --- /dev/null +++ b/app/Services/System/DashboardService.php @@ -0,0 +1,579 @@ +selectRaw('SUM(stock) as total_stock, SUM(stock * price) as total_value') + ->first(); + + $byUnit = RawMaterialPrice::query() + ->join('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id') + ->selectRaw('raw_materials.unit, SUM(raw_material_prices.stock) as total_stock') + ->groupBy('raw_materials.unit') + ->get() + ->mapWithKeys(fn ($item) => [ + $item->unit => (float) $item->total_stock, + ]) + ->toArray(); + + return [ + 'total_stock' => (float) ($data->total_stock ?? 0), + 'total_value' => (int) ($data->total_value ?? 0), + 'by_unit' => $byUnit, + ]; + } + + public function getProductStock(): array + { + $data = ProductVariant::query() + ->selectRaw('SUM(stock) as total_stock, SUM(reject_stock) as total_reject, COUNT(*) as total_variants') + ->first(); + + $totalStock = (int) ($data->total_stock ?? 0); + + $totalValue = Cutting::query() + ->whereNotNull('cost_per_unit') + ->join('cutting_results', 'cuttings.id', '=', 'cutting_results.cutting_id') + ->selectRaw('SUM(cutting_results.warehouse_stock * cuttings.cost_per_unit) as total_value') + ->value('total_value'); + + $totalProducts = Product::query()->count(); + $totalCategories = Category::query()->count(); + + return [ + 'total_stock' => $totalStock, + 'total_reject' => (int) ($data->total_reject ?? 0), + 'total_value' => (int) ($totalValue ?? 0), + 'total_variants' => (int) ($data->total_variants ?? 0), + 'total_products' => $totalProducts, + 'total_categories' => $totalCategories, + ]; + } + + public function getLowStockProducts(): array + { + return ProductVariant::query() + ->where('stock', '<=', ProductVariant::minStock()) + ->where('stock', '>', 0) + ->join('products', 'product_variants.product_id', '=', 'products.id') + ->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, product_variants.stock") + ->orderBy('stock') + ->limit(5) + ->get() + ->map(fn ($item) => [ + 'name' => $item->full_name, + 'stock' => (int) $item->stock, + ]) + ->toArray(); + } + + public function getLowStockMaterials(): array + { + return RawMaterialPrice::query() + ->where('stock', '<=', 5) + ->where('stock', '>', 0) + ->join('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id') + ->selectRaw("CONCAT(raw_materials.name, ' - ', raw_material_prices.variant) as full_name, raw_material_prices.stock, raw_materials.unit") + ->orderBy('stock') + ->limit(5) + ->get() + ->map(fn ($item) => [ + 'name' => $item->full_name, + 'stock' => (float) $item->stock, + 'unit' => $item->unit, + ]) + ->toArray(); + } + + public function getTopSuppliers(): array + { + return Purchase::query() + ->join('suppliers', 'purchases.supplier_id', '=', 'suppliers.id') + ->selectRaw('suppliers.name, SUM(purchases.total) as total_amount, COUNT(purchases.id) as purchase_count') + ->groupBy('suppliers.id', 'suppliers.name') + ->orderByDesc('total_amount') + ->limit(5) + ->get() + ->map(fn ($item) => [ + 'name' => $item->name, + 'total_amount' => (int) $item->total_amount, + 'purchase_count' => (int) $item->purchase_count, + ]) + ->toArray(); + } + + public function getTopCustomers(): array + { + return Order::query() + ->where('status', OrderStatus::COMPLETED) + ->join('customers', 'orders.customer_id', '=', 'customers.id') + ->selectRaw('customers.name, SUM(orders.total_amount) as total_amount, COUNT(orders.id) as order_count') + ->groupBy('customers.id', 'customers.name') + ->orderByDesc('total_amount') + ->limit(5) + ->get() + ->map(fn ($item) => [ + 'name' => $item->name, + 'total_amount' => (int) $item->total_amount, + 'order_count' => (int) $item->order_count, + ]) + ->toArray(); + } + + public function getTopProducts(): array + { + return OrderItem::query() + ->join('orders', 'order_items.order_id', '=', 'orders.id') + ->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id') + ->join('products', 'product_variants.product_id', '=', 'products.id') + ->where('orders.status', OrderStatus::COMPLETED) + ->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, SUM(order_items.quantity) as total_qty, SUM(order_items.subtotal) as total_revenue") + ->groupBy('order_items.product_variant_id', 'products.name', 'product_variants.name') + ->orderByDesc('total_qty') + ->limit(5) + ->get() + ->map(fn ($item) => [ + 'name' => $item->full_name, + 'total_qty' => (int) $item->total_qty, + 'total_revenue' => (int) $item->total_revenue, + ]) + ->toArray(); + } + + public function getPurchaseSummary(): array + { + $data = Purchase::query() + ->selectRaw('COUNT(*) as total_purchases, SUM(total) as total_spent, SUM(discount) as total_discount') + ->first(); + + return [ + 'total_purchases' => (int) ($data->total_purchases ?? 0), + 'total_spent' => (int) ($data->total_spent ?? 0), + 'total_discount' => (int) ($data->total_discount ?? 0), + ]; + } + + public function getCuttingSummary(): array + { + $totalCost = Cutting::query() + ->selectRaw('COALESCE(SUM(total_material_cost), 0) + COALESCE(SUM(sewing_cost), 0) + COALESCE(SUM(other_cost), 0) as total_cost') + ->value('total_cost'); + + $totalResults = CuttingResult::query() + ->selectRaw('SUM(cutting_result) as total_cutting, SUM(warehouse_stock) as total_warehouse, SUM(cutting_reject) as total_reject') + ->first(); + + return [ + 'total_cost' => (int) ($totalCost ?? 0), + 'total_cutting' => (int) ($totalResults->total_cutting ?? 0), + 'total_warehouse' => (int) ($totalResults->total_warehouse ?? 0), + 'total_reject' => (int) ($totalResults->total_reject ?? 0), + ]; + } + + public function getCuttingByStatus(): array + { + return Cutting::query() + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->get() + ->map(fn ($item) => [ + 'status' => $item->status instanceof CuttingStatus ? $item->status->value : $item->status, + 'label' => $item->status instanceof CuttingStatus ? $item->status->label() : CuttingStatus::from($item->status)->label(), + 'count' => (int) $item->count, + ]) + ->toArray(); + } + + public function getOrderStats(): array + { + $byChannel = Order::query() + ->selectRaw('channel, COUNT(*) as count, SUM(total_amount) as total') + ->groupBy('channel') + ->get() + ->map(fn ($item) => [ + 'channel' => $item->channel instanceof OrderChannel ? $item->channel->value : $item->channel, + 'label' => $item->channel instanceof OrderChannel ? $item->channel->label() : OrderChannel::from($item->channel)->label(), + 'count' => (int) $item->count, + 'total' => (int) $item->total, + ]); + + $byPaymentType = Order::query() + ->selectRaw('payment_type, COUNT(*) as count, SUM(total_amount) as total') + ->groupBy('payment_type') + ->get() + ->map(fn ($item) => [ + 'payment_type' => $item->payment_type instanceof PaymentType ? $item->payment_type->value : $item->payment_type, + 'label' => $item->payment_type instanceof PaymentType ? $item->payment_type->label() : PaymentType::from($item->payment_type)->label(), + 'count' => (int) $item->count, + 'total' => (int) $item->total, + ]); + + $byMarketing = Order::query() + ->whereNotNull('marketing_id') + ->join('users', 'orders.marketing_id', '=', 'users.id') + ->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id') + ->selectRaw('COALESCE(user_profiles.full_name, users.username) as name, COUNT(*) as count, SUM(orders.total_amount) as total') + ->groupBy('orders.marketing_id', 'users.username', 'user_profiles.full_name') + ->orderByDesc('total') + ->limit(5) + ->get() + ->map(fn ($item) => [ + 'name' => $item->name, + 'count' => (int) $item->count, + 'total' => (int) $item->total, + ]); + + $byStatus = Order::query() + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->get() + ->map(fn ($item) => [ + 'status' => $item->status instanceof OrderStatus ? $item->status->value : $item->status, + 'label' => $item->status instanceof OrderStatus ? $item->status->label() : OrderStatus::from($item->status)->label(), + 'count' => (int) $item->count, + ]); + + return [ + 'by_channel' => $byChannel->toArray(), + 'by_payment_type' => $byPaymentType->toArray(), + 'by_marketing' => $byMarketing->toArray(), + 'by_status' => $byStatus->toArray(), + ]; + } + + public function getRevenueSummary(): array + { + $data = Order::query() + ->where('status', OrderStatus::COMPLETED) + ->selectRaw('SUM(total_amount) as total_revenue, SUM(discount) as total_discount, SUM(shipping_cost) as total_shipping, COUNT(*) as total_orders, AVG(total_amount) as avg_order') + ->first(); + + $totalMarketplaceFees = Order::query() + ->where('status', OrderStatus::COMPLETED) + ->whereNotNull('marketplace_settings_snapshot') + ->get() + ->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0)); + + return [ + 'total_revenue' => (int) ($data->total_revenue ?? 0), + 'total_discount' => (int) ($data->total_discount ?? 0), + 'total_marketplace_fees' => $totalMarketplaceFees, + 'total_potongan' => (int) ($data->total_discount ?? 0) + $totalMarketplaceFees, + 'total_shipping' => (int) ($data->total_shipping ?? 0), + 'total_orders' => (int) ($data->total_orders ?? 0), + 'avg_order' => (int) ($data->avg_order ?? 0), + ]; + } + + public function getMarketplaceSummary(): array + { + $marketplaceOrders = Order::query() + ->where('status', OrderStatus::COMPLETED) + ->whereIn('channel', [OrderChannel::SHOPEE, OrderChannel::TIKTOK]) + ->whereNotNull('marketplace_settings_snapshot') + ->get(); + + $totalRevenue = $marketplaceOrders->sum('total_amount'); + $totalFees = $marketplaceOrders->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0)); + $totalNet = $marketplaceOrders->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['net_amount'] ?? 0)); + $totalOrders = $marketplaceOrders->count(); + + return [ + 'total_revenue' => (int) $totalRevenue, + 'total_fees' => (int) $totalFees, + 'total_net' => (int) $totalNet, + 'total_orders' => (int) $totalOrders, + ]; + } + + public function getCashAccounts(): array + { + return CashAccount::query() + ->selectRaw('name, balance') + ->get() + ->map(fn ($item) => [ + 'name' => $item->name, + 'balance' => (int) $item->balance, + ]) + ->toArray(); + } + + public function getMonthlyCashFlow(): array + { + $months = collect(); + for ($i = 5; $i >= 0; $i--) { + $date = Carbon::now()->subMonths($i); + $months->push([ + 'year' => $date->year, + 'month' => $date->month, + 'label' => $date->translatedFormat('M Y'), + ]); + } + + $result = $months->map(function ($month) { + $start = Carbon::create($month['year'], $month['month'], 1)->startOfMonth(); + $end = $start->copy()->endOfMonth(); + + $data = CashTransaction::query() + ->whereBetween('created_at', [$start, $end]) + ->selectRaw(" + COALESCE(SUM(CASE WHEN type = 'deposit' THEN amount ELSE 0 END), 0) as deposits, + COALESCE(SUM(CASE WHEN type = 'withdrawal' THEN amount ELSE 0 END), 0) as withdrawals + ") + ->first(); + + return [ + 'label' => $month['label'], + 'deposits' => (int) $data->deposits, + 'withdrawals' => (int) $data->withdrawals, + ]; + }); + + return $result->toArray(); + } + + public function getMonthlyExpenses(Carbon $startOfMonth, Carbon $endOfMonth): array + { + $data = Expense::query() + ->whereBetween('created_at', [$startOfMonth, $endOfMonth]) + ->selectRaw('COALESCE(SUM(amount), 0) as total, COUNT(*) as count') + ->first(); + + return [ + 'total' => (int) ($data->total ?? 0), + 'count' => (int) ($data->count ?? 0), + ]; + } + + public function getKasbonSummary(): array + { + $pending = EmployeeAdvance::query() + ->where('status', EmployeeAdvanceStatus::PENDING) + ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->first(); + + $approved = EmployeeAdvance::query() + ->where('status', EmployeeAdvanceStatus::APPROVED) + ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->first(); + + $paid = EmployeeAdvance::query() + ->where('status', EmployeeAdvanceStatus::PAID) + ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->first(); + + return [ + 'pending' => [ + 'count' => (int) ($pending->count ?? 0), + 'total' => (int) ($pending->total ?? 0), + ], + 'approved' => [ + 'count' => (int) ($approved->count ?? 0), + 'total' => (int) ($approved->total ?? 0), + ], + 'paid' => [ + 'count' => (int) ($paid->count ?? 0), + 'total' => (int) ($paid->total ?? 0), + ], + 'total' => (int) ($pending->total ?? 0) + (int) ($approved->total ?? 0) + (int) ($paid->total ?? 0), + ]; + } + + public function getPayrollSummary(Carbon $startOfMonth, Carbon $endOfMonth): array + { + $period = PayrollPeriod::query() + ->where('year', $startOfMonth->year) + ->where('month', $startOfMonth->month) + ->first(); + + if (! $period) { + return [ + 'has_period' => false, + 'total_employees' => 0, + 'total_amount' => 0, + 'paid_amount' => 0, + 'unpaid_amount' => 0, + 'paid_count' => 0, + 'unpaid_count' => 0, + ]; + } + + $payrolls = Payroll::query() + ->where('payroll_period_id', $period->id) + ->selectRaw(" + COUNT(*) as total_employees, + COALESCE(SUM(total_amount), 0) as total_amount, + COALESCE(SUM(CASE WHEN status = 'paid' THEN total_amount ELSE 0 END), 0) as paid_amount, + COALESCE(SUM(CASE WHEN status = 'unpaid' THEN total_amount ELSE 0 END), 0) as unpaid_amount, + SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid_count, + SUM(CASE WHEN status = 'unpaid' THEN 1 ELSE 0 END) as unpaid_count + ") + ->first(); + + return [ + 'has_period' => true, + 'period_status' => $period->status->value, + 'total_employees' => (int) ($payrolls->total_employees ?? 0), + 'total_amount' => (int) ($payrolls->total_amount ?? 0), + 'paid_amount' => (int) ($payrolls->paid_amount ?? 0), + 'unpaid_amount' => (int) ($payrolls->unpaid_amount ?? 0), + 'paid_count' => (int) ($payrolls->paid_count ?? 0), + 'unpaid_count' => (int) ($payrolls->unpaid_count ?? 0), + ]; + } + + public function getLeaveRequestSummary(Carbon $startOfMonth, Carbon $endOfMonth): array + { + $data = LeaveRequest::query() + ->where(function ($q) use ($startOfMonth, $endOfMonth) { + $q->whereBetween('start_date', [$startOfMonth, $endOfMonth]) + ->orWhereBetween('end_date', [$startOfMonth, $endOfMonth]); + }) + ->selectRaw(" + COUNT(*) as total, + SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count, + SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count, + SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_count + ") + ->first(); + + return [ + 'total' => (int) ($data->total ?? 0), + 'pending' => (int) ($data->pending_count ?? 0), + 'approved' => (int) ($data->approved_count ?? 0), + 'rejected' => (int) ($data->rejected_count ?? 0), + ]; + } + + public function getEmployeeSummary(): array + { + $total = Employee::query()->count(); + + $byStatus = Employee::query() + ->selectRaw('employment_status, COUNT(*) as count') + ->groupBy('employment_status') + ->get() + ->map(fn ($item) => [ + 'status' => $item->employment_status instanceof EmploymentStatus ? $item->employment_status->value : $item->employment_status, + 'label' => $item->employment_status instanceof EmploymentStatus ? $item->employment_status->label() : EmploymentStatus::from($item->employment_status)->label(), + 'count' => (int) $item->count, + ]); + + return [ + 'total' => $total, + 'by_status' => $byStatus->toArray(), + ]; + } + + public function getAttendanceToday(): array + { + $today = Carbon::today(); + + $totalEmployees = Employee::query()->count(); + + $present = Attendance::query() + ->where('attendance_date', $today) + ->distinct('employee_id') + ->count('employee_id'); + + return [ + 'total_employees' => $totalEmployees, + 'present' => $present, + 'absent' => max(0, $totalEmployees - $present), + ]; + } + + public function getMonthlyRevenueTrend(): array + { + $months = collect(); + for ($i = 5; $i >= 0; $i--) { + $date = Carbon::now()->subMonths($i); + $months->push([ + 'year' => $date->year, + 'month' => $date->month, + 'label' => $date->translatedFormat('M Y'), + ]); + } + + $result = $months->map(function ($month) { + $start = Carbon::create($month['year'], $month['month'], 1)->startOfMonth(); + $end = $start->copy()->endOfMonth(); + + $data = Order::query() + ->where('status', OrderStatus::COMPLETED) + ->whereBetween('created_at', [$start, $end]) + ->selectRaw('COALESCE(SUM(total_amount), 0) as total, COUNT(*) as count') + ->first(); + + return [ + 'label' => $month['label'], + 'total' => (int) $data->total, + 'count' => (int) $data->count, + ]; + }); + + return $result->toArray(); + } + + public function getMonthlyPurchaseTrend(): array + { + $months = collect(); + for ($i = 5; $i >= 0; $i--) { + $date = Carbon::now()->subMonths($i); + $months->push([ + 'year' => $date->year, + 'month' => $date->month, + 'label' => $date->translatedFormat('M Y'), + ]); + } + + $result = $months->map(function ($month) { + $start = Carbon::create($month['year'], $month['month'], 1)->startOfMonth(); + $end = $start->copy()->endOfMonth(); + + $data = Purchase::query() + ->whereBetween('created_at', [$start, $end]) + ->selectRaw('COALESCE(SUM(total), 0) as total, COUNT(*) as count') + ->first(); + + return [ + 'label' => $month['label'], + 'total' => (int) $data->total, + 'count' => (int) $data->count, + ]; + }); + + return $result->toArray(); + } +}