Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.18% covered (success)
95.18%
237 / 249
0.00% covered (danger)
0.00%
0 / 1
CRAP
0.00% covered (danger)
0.00%
0 / 1
DashboardController
95.18% covered (success)
95.18%
237 / 249
0.00% covered (danger)
0.00%
0 / 1
37
0.00% covered (danger)
0.00%
0 / 1
 index
95.18% covered (success)
95.18%
237 / 249
0.00% covered (danger)
0.00%
0 / 1
37
1<?php
2
3namespace App\Http\Controllers\admin;
4
5use App\Enums\OrderStatus;
6use App\Enums\PaymentStatus;
7use App\Http\Controllers\Controller;
8use App\Models\Account;
9use App\Models\admin\Customer;
10use App\Models\admin\Product;
11use App\Models\admin\Purchase;
12use App\Models\admin\PurchasePayment;
13use App\Models\admin\Sale;
14use App\Models\admin\SaleDetalis;
15use App\Models\admin\SalePayment;
16use App\Models\Order;
17use App\Models\OrderDetails;
18use App\Models\Payment;
19use App\Models\ProductionOrder;
20use App\Models\Transaction;
21use App\Services\Accounting\AccountingService;
22use App\Services\Location\BranchContext;
23use App\Services\Payment\PaymentManager;
24use Illuminate\Http\Response;
25use Illuminate\Support\Facades\DB;
26
27class DashboardController extends Controller
28{
29    /**
30     * Display the ERP-style admin dashboard (real data, mobile + desktop
31     * layouts). See resources/views/admin/dashboard/dashboard.blade.php.
32     *
33     * @return Response
34     */
35    public function index()
36    {
37        $data = [];
38
39        $today = today();
40        $monthStart = now()->startOfMonth();
41        $lastMonthStart = now()->subMonthNoOverflow()->startOfMonth();
42        $lastMonthEnd = now()->subMonthNoOverflow()->endOfMonth();
43        $branchId = app(BranchContext::class)->id();
44        $orders = fn () => Order::query()->when($branchId, fn ($query) => $query->where('branch_id', $branchId));
45        $payments = fn () => Payment::query()->when($branchId, fn ($query) => $query->whereHas('order', fn ($order) => $order->where('branch_id', $branchId)));
46        $purchases = fn () => Purchase::query()->when($branchId, fn ($query) => $query->where('branch_id', $branchId));
47        $purchasePayments = fn () => PurchasePayment::query()->when($branchId, fn ($query) => $query->whereHas('purchase', fn ($purchase) => $purchase->where('branch_id', $branchId)));
48        $transactions = fn () => Transaction::query()->when($branchId, fn ($query) => $query->whereHas('account', fn ($account) => $account->where('branch_id', $branchId)));
49
50        // ── Order status breakdown (kept from the previous dashboard) ──────
51        // Values updated to the new lifecycle (App\Enums\OrderStatus):
52        // Recived -> Confirmed, Success -> Delivered, Cancel -> Cancelled.
53        $data['pendingCount'] = $orders()->where('status', OrderStatus::Pending->value)->count();
54        $data['confirmedCount'] = $orders()->where('status', OrderStatus::Confirmed->value)->count();
55        $data['deliveredCount'] = $orders()->where('status', OrderStatus::Delivered->value)->count();
56        $data['cancelledCount'] = $orders()->where('status', OrderStatus::Cancelled->value)->count();
57
58        // ── "Today at a Glance" (mobile reference card) ────────────────────
59        // Combines both transaction systems: website checkout (Order) and
60        // POS counter sales (Sale/SalePayment) — a single source of truth
61        // for "Total Sales" instead of only one of the two.
62        $todayOrderSales = (float) $orders()->whereDate('created_at', $today)->sum('total');
63        $todaySalePos = $branchId ? 0.0 : (float) SalePayment::whereHas('sale', fn ($q) => $q->whereDate('created_at', $today))->sum('total');
64        $data['todaySales'] = $todayOrderSales + $todaySalePos;
65
66        $data['todayPurchase'] = (float) $purchasePayments()->whereHas('purchase', fn ($q) => $q->whereDate('created_at', $today))->sum('total');
67
68        $todayOrderCollected = (float) $payments()->whereDate('created_at', $today)->sum('paid');
69        $todaySaleCollected = $branchId ? 0.0 : (float) SalePayment::whereHas('sale', fn ($q) => $q->whereDate('created_at', $today))->sum('paid');
70        $data['todayCollection'] = $todayOrderCollected + $todaySaleCollected;
71
72        $data['todayPayment'] = (float) $purchasePayments()->whereHas('purchase', fn ($q) => $q->whereDate('created_at', $today))->sum('paid');
73
74        // ── Today's cash flow ───────────────────────────────────────────────
75        $data['cashIn'] = $data['todayCollection'];
76        $data['cashOut'] = $data['todayPayment'];
77        $data['netFlow'] = $data['cashIn'] - $data['cashOut'];
78
79        // ── Month totals + % change vs last month (desktop reference cards) ─
80        $monthSales = (float) $orders()->where('created_at', '>=', $monthStart)->sum('total')
81            + ($branchId ? 0.0 : (float) SalePayment::whereHas('sale', fn ($q) => $q->where('created_at', '>=', $monthStart))->sum('total'));
82        $lastMonthSales = (float) $orders()->whereBetween('created_at', [$lastMonthStart, $lastMonthEnd])->sum('total')
83            + ($branchId ? 0.0 : (float) SalePayment::whereHas('sale', fn ($q) => $q->whereBetween('created_at', [$lastMonthStart, $lastMonthEnd]))->sum('total'));
84
85        $monthPurchase = (float) $purchasePayments()->whereHas('purchase', fn ($q) => $q->where('created_at', '>=', $monthStart))->sum('total');
86        $lastMonthPurchase = (float) $purchasePayments()->whereHas('purchase', fn ($q) => $q->whereBetween('created_at', [$lastMonthStart, $lastMonthEnd]))->sum('total');
87
88        $totalCustomersNow = Customer::count();
89        $customersThisMonth = Customer::where('created_at', '>=', $monthStart)->count();
90        $customersLastMonth = Customer::whereBetween('created_at', [$lastMonthStart, $lastMonthEnd])->count();
91
92        $totalProductsNow = Product::count();
93        $productsThisMonth = Product::where('created_at', '>=', $monthStart)->count();
94        $productsLastMonth = Product::whereBetween('created_at', [$lastMonthStart, $lastMonthEnd])->count();
95
96        $pct = function ($current, $previous) {
97            if ($previous <= 0) {
98                return $current > 0 ? 100.0 : 0.0;
99            }
100
101            return round((($current - $previous) / $previous) * 100, 1);
102        };
103
104        $data['monthSales'] = $monthSales;
105        $data['monthSalesPct'] = $pct($monthSales, $lastMonthSales);
106
107        $data['monthPurchase'] = $monthPurchase;
108        $data['monthPurchasePct'] = $pct($monthPurchase, $lastMonthPurchase);
109
110        $data['totalCustomers'] = $totalCustomersNow;
111        $data['totalCustomersPct'] = $pct($customersThisMonth, $customersLastMonth);
112
113        $data['totalProducts'] = $totalProductsNow;
114        $data['totalProductsPct'] = $pct($productsThisMonth, $productsLastMonth);
115
116        // ── Legacy keys still referenced elsewhere on the page ─────────────
117        $data['totalRevenue'] = (float) $orders()->where('status', OrderStatus::Delivered->value)->sum('total');
118        $data['totalOrders'] = $orders()->count();
119        $data['totalDue'] = (float) $orders()->sum('due');
120        $data['outOfStockCount'] = Product::where('stock_status', 'out_of_stock')->count();
121        $data['totalSuppliers'] = totalSupplier();
122        $data['totalPurchase'] = (float) totalPurchase();
123
124        // ── 7-day sales trend (Sales Overview line chart) ──────────────────
125        $chartDays = [];
126        $chartSales = [];
127        for ($i = 6; $i >= 0; $i--) {
128            $day = now()->subDays($i);
129            $orderSum = (float) $orders()->whereDate('created_at', $day)->sum('total');
130            $saleSum = $branchId ? 0.0 : (float) SalePayment::whereHas('sale', fn ($q) => $q->whereDate('created_at', $day))->sum('total');
131            $chartDays[] = $day->format('D j');
132            $chartSales[] = round($orderSum + $saleSum, 2);
133        }
134        $data['chartDays'] = $chartDays;
135        $data['chartSales'] = $chartSales;
136
137        // ── Stock summary donut ─────────────────────────────────────────────
138        $inStock = Product::where('stock_status', 'in_stock')->where('stock', '>', 5)->count();
139        $lowStock = Product::whereIn('stock_status', ['in_stock', 'on_backorder'])->where('stock', '>', 0)->where('stock', '<=', 5)->count();
140        $outStock = Product::where(function ($q) {
141            $q->where('stock_status', 'out_of_stock')->orWhereNull('stock')->orWhere('stock', '<=', 0);
142        })->count();
143        $others = max($totalProductsNow - $inStock - $lowStock - $outStock, 0);
144
145        $data['stockInStock'] = $inStock;
146        $data['stockLow'] = $lowStock;
147        $data['stockOut'] = $outStock;
148        $data['stockOthers'] = $others;
149        $data['stockTotal'] = max($totalProductsNow, 1);
150
151        // ── Top selling products (POS sale_details + website order_details) ─
152        $posTop = $branchId
153            ? collect()
154            : SaleDetalis::select('product_id', DB::raw('SUM(qty) as qty_sold'), DB::raw('SUM(qty*selling_price) as revenue'))
155                ->groupBy('product_id')->get()->keyBy('product_id');
156
157        $webTop = OrderDetails::join('stocks', 'order_details.stock_id', '=', 'stocks.id')
158            ->when($branchId, fn ($query) => $query->where('order_details.branch_id', $branchId))
159            ->select('stocks.product_id', DB::raw('SUM(order_details.qty) as qty_sold'), DB::raw('SUM(order_details.total) as revenue'))
160            ->groupBy('stocks.product_id')->get()->keyBy('product_id');
161
162        $merged = [];
163        foreach ($posTop as $pid => $row) {
164            $merged[$pid] = ['qty' => (float) $row->qty_sold, 'revenue' => (float) $row->revenue];
165        }
166        foreach ($webTop as $pid => $row) {
167            if (! isset($merged[$pid])) {
168                $merged[$pid] = ['qty' => 0.0, 'revenue' => 0.0];
169            }
170            $merged[$pid]['qty'] += (float) $row->qty_sold;
171            $merged[$pid]['revenue'] += (float) $row->revenue;
172        }
173        uasort($merged, fn ($a, $b) => $b['revenue'] <=> $a['revenue']);
174        $topIds = array_slice(array_keys($merged), 0, 5);
175        $topProducts = Product::whereIn('id', $topIds)->get()->keyBy('id');
176
177        $data['topSellingProducts'] = collect($topIds)->map(function ($id) use ($merged, $topProducts) {
178            $p = $topProducts->get($id);
179            if (! $p) {
180                return null;
181            }
182
183            return (object) [
184                'product' => $p,
185                'qty' => $merged[$id]['qty'],
186                'revenue' => $merged[$id]['revenue'],
187            ];
188        })->filter()->values();
189
190        // ── Recent transactions (Sales + Purchases + Payments, merged) ─────
191        $recentSales = ($branchId ? collect() : Sale::with(['customer', 'sale_payment'])->latest()->take(5)->get())->map(fn ($s) => (object) [
192            'icon' => 'fa-cash-register',
193            'color' => '#22c55e',
194            'title' => 'Sale #' . $s->id,
195            'sub' => optional($s->customer)->name ?? 'Walking Customer',
196            'amount' => optional($s->sale_payment)->total ?? 0,
197            'time' => $s->created_at,
198        ]);
199        $recentPurchases = $purchases()->with(['supplier', 'purchase_payment'])->latest()->take(5)->get()->map(fn ($p) => (object) [
200            'icon' => 'fa-truck-loading',
201            'color' => '#3b82f6',
202            'title' => 'Purchase #' . $p->id,
203            'sub' => optional($p->supplier)->name ?? 'Supplier',
204            'amount' => optional($p->purchase_payment)->total ?? 0,
205            'time' => $p->created_at,
206        ]);
207        $recentPayments = $payments()->latest()->take(5)->get()->map(fn ($pay) => (object) [
208            'icon' => 'fa-hand-holding-usd',
209            'color' => '#f59e0b',
210            'title' => 'Payment Received',
211            'sub' => 'Order #' . $pay->order_id,
212            'amount' => $pay->paid,
213            'time' => $pay->created_at,
214        ]);
215
216        $data['recentTransactions'] = $recentSales->concat($recentPurchases)->concat($recentPayments)
217            ->sortByDesc('time')->take(6)->values();
218
219        // ── Recent customers with outstanding due ───────────────────────────
220        $recentCustomerIds = $branchId
221            ? $orders()->orderByDesc('created_at')->pluck('customer_id')->unique()->take(6)->values()
222            : Sale::orderByDesc('created_at')->pluck('customer_id')->unique()->take(6)->values();
223        $customersById = Customer::whereIn('id', $recentCustomerIds)->get()->keyBy('id');
224
225        $data['recentCustomers'] = $recentCustomerIds->map(function ($id) use ($branchId, $customersById, $orders) {
226            $c = $customersById->get($id);
227            if (! $c) {
228                return null;
229            }
230            $due = $branchId
231                ? (float) $orders()->where('customer_id', $id)->sum('due')
232                : (float) SalePayment::whereHas('sale', fn ($q) => $q->where('customer_id', $id))->sum('due');
233
234            return (object) ['customer' => $c, 'due' => $due];
235        })->filter()->values();
236
237        // ── Recent orders table (kept) ──────────────────────────────────────
238        $data['recentOrders'] = $orders()->with('customer')->latest()->take(8)->get();
239
240        // ── Low stock products (kept) ───────────────────────────────────────
241        $data['lowStockProducts'] = Product::where('stock_status', 'out_of_stock')
242            ->orWhere('stock', '<=', 5)
243            ->latest()
244            ->take(6)
245            ->get();
246
247        // ── Desktop widgets (replaces the old static "CashBook Report" mockup —
248        // every figure below is computed, not fabricated; each accounting/
249        // manufacturing-specific piece falls back to something real from
250        // Orders/Purchases when that feature is off, per §Phase 10 spec. ───────
251        $accountingOn = (bool) feature('accounting');
252        $accounting = $accountingOn ? app(AccountingService::class) : null;
253        $defaultAccount = $accountingOn ? $accounting->defaultAccount(branchId: $branchId) : null;
254
255        $bigStats = [];
256        if ($defaultAccount) {
257            $bigStats[] = ['label' => 'Opening Balance', 'value' => money($accounting->balance($defaultAccount, $monthStart->copy()->subDay())), 'caption' => 'As of ' . $monthStart->format('d M Y'), 'icon' => 'fa-wallet', 'c' => 'indigo'];
258        }
259        $bigStats[] = ['label' => 'This Month Sales', 'value' => money($monthSales), 'caption' => $monthStart->format('d M') . ' – ' . now()->format('d M Y'), 'icon' => 'fa-long-arrow-alt-down', 'c' => 'green', 'delta' => $pct($monthSales, $lastMonthSales)];
260        $bigStats[] = ['label' => 'This Month Purchase', 'value' => money($monthPurchase), 'caption' => $monthStart->format('d M') . ' – ' . now()->format('d M Y'), 'icon' => 'fa-long-arrow-alt-up', 'c' => 'red', 'delta' => $pct($monthPurchase, $lastMonthPurchase)];
261        if ($defaultAccount) {
262            $bigStats[] = ['label' => 'Balance Now', 'value' => money($accounting->balance($defaultAccount)), 'caption' => 'As of today', 'icon' => 'fa-piggy-bank', 'c' => 'purple'];
263        } else {
264            $bigStats[] = ['label' => 'Total Due', 'value' => money($data['totalDue']), 'caption' => 'Across all orders', 'icon' => 'fa-piggy-bank', 'c' => 'purple'];
265        }
266        $data['bigStats'] = $bigStats;
267
268        $miniStats = [
269            ['label' => 'Orders Today', 'value' => $orders()->whereDate('created_at', $today)->count(), 'icon' => 'fa-receipt', 'c' => 'indigo'],
270            ['label' => 'Total Products', 'value' => $totalProductsNow, 'icon' => 'fa-box-open', 'c' => 'green'],
271            ['label' => 'Low Stock Alerts', 'value' => $lowStock, 'icon' => 'fa-exclamation-triangle', 'c' => 'red'],
272            ['label' => 'Payment Methods', 'value' => app(PaymentManager::class)->enabled()->count(), 'icon' => 'fa-credit-card', 'c' => 'indigo'],
273        ];
274        if (feature('manufacturing')) {
275            $miniStats[] = ['label' => 'Open Production Orders', 'value' => ProductionOrder::whereIn('status', ['Draft', 'Planned', 'InProgress'])->when($branchId, fn ($query) => $query->where('branch_id', $branchId))->count(), 'icon' => 'fa-industry', 'c' => 'amber'];
276        }
277        if ($accountingOn) {
278            $miniStats[] = ['label' => 'Active Accounts', 'value' => Account::where('is_active', true)->count(), 'icon' => 'fa-wallet', 'c' => 'indigo'];
279        }
280        $data['miniStats'] = $miniStats;
281
282        // Month-to-date cash flow, day by day — real ledger data when
283        // Accounting is on (and an account is set up), otherwise the same
284        // Sales-vs-Purchase figures the rest of this dashboard already uses.
285        $flowLabels = [];
286        $flowIn = [];
287        $flowOut = [];
288        $daysSoFar = now()->day;
289
290        if ($defaultAccount) {
291            $ledgerByDay = [];
292            foreach ($accounting->ledger($defaultAccount, $monthStart, now()) as $row) {
293                $d = (int) $row['transaction']->date->format('j');
294                $signed = $row['transaction']->signedAmountFor($defaultAccount->id);
295                $ledgerByDay[$d]['in'] = ($ledgerByDay[$d]['in'] ?? 0) + max($signed, 0);
296                $ledgerByDay[$d]['out'] = ($ledgerByDay[$d]['out'] ?? 0) + max(-$signed, 0);
297            }
298            for ($d = 1; $d <= $daysSoFar; $d++) {
299                $flowLabels[] = (string) $d;
300                $flowIn[] = round($ledgerByDay[$d]['in'] ?? 0, 2);
301                $flowOut[] = round($ledgerByDay[$d]['out'] ?? 0, 2);
302            }
303        } else {
304            for ($d = 1; $d <= $daysSoFar; $d++) {
305                $day = $monthStart->copy()->addDays($d - 1);
306                $flowLabels[] = (string) $d;
307                $daySales = (float) $orders()->whereDate('created_at', $day)->sum('total')
308                    + ($branchId ? 0.0 : (float) SalePayment::whereHas('sale', fn ($q) => $q->whereDate('created_at', $day))->sum('total'));
309                $dayPurchase = (float) $purchasePayments()->whereHas('purchase', fn ($q) => $q->whereDate('created_at', $day))->sum('total');
310                $flowIn[] = round($daySales, 2);
311                $flowOut[] = round($dayPurchase, 2);
312            }
313        }
314        $data['flowLabels'] = $flowLabels;
315        $data['flowIn'] = $flowIn;
316        $data['flowOut'] = $flowOut;
317
318        // Category summary — real Accounting categories this month when the
319        // feature is on, otherwise the Sales/Purchase split everyone already sees above.
320        if ($accountingOn) {
321            $catTotals = $transactions()
322                ->whereIn('type', ['income', 'expense'])
323                ->where('date', '>=', $monthStart)
324                ->whereNull('reversed_at')
325                ->with('category')
326                ->get()
327                ->groupBy(fn ($t) => $t->category_id ?: ('_' . $t->type))
328                ->map(fn ($rows) => [
329                    'label' => $rows->first()->category->name ?? ucfirst($rows->first()->type),
330                    'type' => $rows->first()->type,
331                    'amt' => (float) $rows->sum('amount'),
332                ]);
333            $catSum = $catTotals->sum('amt') ?: 1;
334            $data['categorySummary'] = $catTotals->map(fn ($c) => $c + ['pct' => round($c['amt'] / $catSum * 100)])
335                ->sortByDesc('amt')->take(6)->values();
336        } else {
337            $catSum = ($monthSales + $monthPurchase) ?: 1;
338            $data['categorySummary'] = collect([
339                ['label' => 'Sales', 'type' => 'income', 'amt' => $monthSales, 'pct' => round($monthSales / $catSum * 100)],
340                ['label' => 'Purchase', 'type' => 'expense', 'amt' => $monthPurchase, 'pct' => round($monthPurchase / $catSum * 100)],
341            ]);
342        }
343
344        // Payment method mix — real gateway usage this month (empty if nothing paid online yet).
345        $paletteColors = ['#22c55e', '#3b82f6', '#8b5cf6', '#ef4444', '#f59e0b', '#06b6d4'];
346        $paymentRows = $payments()->where('created_at', '>=', $monthStart)->where('status', PaymentStatus::Paid->value)
347            ->selectRaw('payment_type, SUM(paid) as total, COUNT(*) as cnt')
348            ->groupBy('payment_type')->get();
349        $paymentTotal = (float) $paymentRows->sum('total') ?: 1;
350        $data['paymentMix'] = $paymentRows->values()->map(fn ($row, $i) => [
351            'label' => ucfirst(str_replace('_', ' ', $row->payment_type)),
352            'amt' => (float) $row->total,
353            'pct' => round(((float) $row->total / $paymentTotal) * 100),
354            'color' => $paletteColors[$i % count($paletteColors)],
355        ]);
356        $data['paymentTxnCount'] = (int) $paymentRows->sum('cnt');
357
358        // Top 5 expenses this month — only meaningful once Accounting is on.
359        $data['topExpenses'] = $accountingOn
360            ? $transactions()->where('type', 'expense')->whereNull('reversed_at')->where('date', '>=', $monthStart)
361                ->with('category')->orderByDesc('amount')->take(5)->get()
362                ->map(fn ($t) => ['label' => ($t->category->name ?? 'Expense') . ($t->party_name ? ' — ' . $t->party_name : ''), 'amt' => (float) $t->amount])
363            : collect();
364
365        // Yesterday's cash in/out, so "Today's Cash Flow" can show a real % change.
366        $yesterday = $today->copy()->subDay();
367        $yesterdayCashIn = (float) $payments()->whereDate('created_at', $yesterday)->sum('paid')
368            + ($branchId ? 0.0 : (float) SalePayment::whereHas('sale', fn ($q) => $q->whereDate('created_at', $yesterday))->sum('paid'));
369        $yesterdayCashOut = (float) $purchasePayments()->whereHas('purchase', fn ($q) => $q->whereDate('created_at', $yesterday))->sum('paid');
370        $data['cashInPct'] = $pct($data['cashIn'], $yesterdayCashIn);
371        $data['cashOutPct'] = $pct($data['cashOut'], $yesterdayCashOut);
372        $data['netFlowPct'] = $pct($data['netFlow'], $yesterdayCashIn - $yesterdayCashOut);
373
374        $data['accountingOn'] = $accountingOn;
375        $data['defaultAccount'] = $defaultAccount;
376
377        return view('admin.dashboard.dashboard')->with($data);
378    }
379}