weatherService = $weatherService; } public function index(Request $request) { $projectQuery = $request->query('project'); // Fetch all projects for the selector $projects = Project::orderBy('name') ->get(['id', 'ulid', 'name', 'code', 'status']); // Find selected project (by ULID or ID) $selectedProject = null; if ($projectQuery) { $selectedProject = Project::where('ulid', $projectQuery) ->orWhere('id', $projectQuery) ->first(); } // Fetch Weather $weather = $this->getWeatherData($selectedProject); // Use the Project model's tenant scope as the source of truth for all // dashboard data. This keeps Contractor Admin dashboards isolated to // their own contractor projects while preserving executive visibility. $visibleProjectIds = Project::query()->pluck('projects.id')->all(); $user = Auth::user(); // Fetch Blockers $blockers = $this->getBlockersData($selectedProject, $visibleProjectIds); // Fetch Activities $activities = $this->getActivitiesData($user, $selectedProject, $visibleProjectIds); // Fetch Resources $resources = $this->getResourcesData($selectedProject, $visibleProjectIds); // Fetch Role-Tailored DB Analytics $roleAnalytics = $this->getRoleAnalyticsData($user, $selectedProject, $visibleProjectIds); return Inertia::render('Dashboard', [ 'projects' => $projects, 'selectedProject' => $selectedProject ? [ 'id' => $selectedProject->id, 'ulid' => $selectedProject->ulid, 'name' => $selectedProject->name, 'code' => $selectedProject->code, 'location' => $selectedProject->location, 'status' => $selectedProject->status?->value ?? $selectedProject->status, ] : null, 'weather' => $weather, 'activities' => $activities, 'blockers' => $blockers, 'resources' => $resources, 'roleAnalytics' => $roleAnalytics, ]); } /** * Resolve weather data for global or project-specific view. */ private function getWeatherData(?Project $project): array { $location = $project ? ($project->location ?? 'New York, NY') : null; if (!$location) { $warehouse = \Modules\MaterialLogistics\Models\Warehouse::where('code', 'WH-CEN-01')->first(); $location = $warehouse->address ?? 'New York, NY'; } $cacheKey = 'weather_' . md5($location . ($project ? '_proj' : '_global')); return Cache::remember($cacheKey, 1800, function () use ($location, $project) { $weather = $this->weatherService->getWeather($location); if (!$project) { $weather['forecast'] = 'Global overview based at Central Depot. ' . ($weather['forecast'] ?? ''); } return $weather; }); } /** * Get list of critical blockers from TaskDelays and DailyReportIssues. */ private function getBlockersData(?Project $project, array $visibleProjectIds): array { $blockersList = []; // 1. Fetch Task Delays with scoped relation columns $delayQuery = TaskDelay::with(['task:id,ulid,project_id,name', 'task.project:id,ulid,name']) ->whereHas('task', function ($q) use ($project, $visibleProjectIds) { if ($project) { $q->where('project_id', $project->id); } else { $q->whereIn('project_id', $visibleProjectIds); } }) ->orderByDesc('delay_date') ->take(10); foreach ($delayQuery->get() as $delay) { $projName = $delay->task->project->name ?? 'Unknown Project'; $projUlid = $delay->task->project->ulid ?? null; $urgency = ($delay->lost_hours >= 8) ? 'critical' : 'high'; // Map reason_type to RFI / Material / Safety / Equipment / Regulatory $type = match ($delay->reason_type?->value ?? $delay->reason_type) { 'supply' => 'Material', 'manpower' => 'Labor', 'equipment' => 'Equipment', 'permit' => 'Regulatory', 'weather' => 'Safety', // weather delays represent safety concerns for high work default => 'RFI', }; $blockersList[] = [ 'id' => 'delay_' . $delay->id, 'project_ulid' => $projUlid, 'task_ulid' => $delay->task->ulid ?? null, 'type' => $type, 'title' => sprintf('[%s] %s: %s', $projName, $delay->task->name, $delay->notes ?? 'Delay recorded'), 'urgency' => $urgency, 'date' => $delay->delay_date?->format('Y-m-d') ?? now()->format('Y-m-d'), ]; } // 2. Fetch Daily Report Issues with scoped relation columns $issueQuery = DailyReportIssue::with(['dailyReport:id,project_id', 'dailyReport.project:id,ulid,name']) ->whereHas('dailyReport', function ($q) use ($project, $visibleProjectIds) { if ($project) { $q->where('project_id', $project->id); } else { $q->whereIn('project_id', $visibleProjectIds); } }) ->orderByDesc('created_at') ->take(10); foreach ($issueQuery->get() as $issue) { $projName = $issue->dailyReport->project->name ?? 'Unknown Project'; $projUlid = $issue->dailyReport->project->ulid ?? null; $severity = strtolower($issue->severity ?? 'medium'); $urgency = in_array($severity, ['high', 'critical']) ? 'critical' : 'high'; $type = match (strtolower($issue->category ?? '')) { 'safety' => 'Safety', 'rfi' => 'RFI', 'material' => 'Material', 'equipment' => 'Equipment', default => 'Safety', }; $blockersList[] = [ 'id' => 'issue_' . $issue->id, 'project_ulid' => $projUlid, 'daily_report_id' => $issue->daily_report_id, 'type' => $type, 'title' => sprintf('[%s] %s', $projName, $issue->description ?? $issue->issue_description ?? 'Site Issue reported'), 'urgency' => $urgency, 'status' => $issue->status ?? 'open', 'supervisor_name' => $issue->supervisor_name, 'date' => $issue->created_at?->format('Y-m-d') ?? now()->format('Y-m-d'), ]; } // Sort blockers by critical urgency first, then date descending usort($blockersList, function ($a, $b) { if ($a['urgency'] === 'critical' && $b['urgency'] !== 'critical') { return -1; } if ($b['urgency'] === 'critical' && $a['urgency'] !== 'critical') { return 1; } return strcmp($b['date'], $a['date']); }); // Cap at 6 blockers return array_slice($blockersList, 0, 6); } /** * Fetch activity feed from TaskActivity and DailyReports (or contractor-specific events for contractors). */ private function getActivitiesData(?\App\Models\User $user, ?Project $project, array $visibleProjectIds): array { $activitiesList = []; // If the user is a contractor, fetch only their contractor-specific bid invitations, submissions, and invoice claims $isContractorUser = $user && ($user->user_type === 'contractor' || $user->contractor_id !== null || $user->hasRole('Main Contractor Admin') || $user->hasRole('Contractor Admin')); if ($isContractorUser && $user->contractor_id) { // 1. Contractor Invoices $invoices = \Modules\ContractorManagement\Models\ContractorInvoice::with('project') ->where('contractor_id', $user->contractor_id) ->where(function ($q) use ($project, $visibleProjectIds) { if ($project) { $q->where('project_id', $project->id); } elseif (!empty($visibleProjectIds)) { $q->whereIn('project_id', $visibleProjectIds); } }) ->orderByDesc('updated_at') ->take(5) ->get(); foreach ($invoices as $inv) { $projCode = $inv->project->code ?? ''; $statusVal = $inv->status instanceof \Modules\ContractorManagement\Enums\InvoiceStatus ? $inv->status->value : (string) ($inv->status ?? ''); $activitiesList[] = [ 'id' => 'inv_act_' . $inv->id, 'time' => $inv->updated_at?->diffForHumans() ?? 'Recently', 'timestamp' => $inv->updated_at?->timestamp ?? 0, 'title' => sprintf('Invoice %s status: %s (%s)', $inv->invoice_number, str_replace('_', ' ', $statusVal), $projCode), 'status' => $statusVal === 'paid' ? 'completed' : 'info', ]; } // Sort combined list by timestamp descending usort($activitiesList, function ($a, $b) { return $b['timestamp'] <=> $a['timestamp']; }); return array_slice($activitiesList, 0, 10); } if (empty($visibleProjectIds) && !$project) { return []; } // 1. Task Activities (Internal Managers & Site Team) $taskActivityQuery = TaskActivity::with(['task.project', 'user']) ->whereHas('task', function ($q) use ($project, $visibleProjectIds) { if ($project) { $q->where('project_id', $project->id); } else { $q->whereIn('project_id', $visibleProjectIds); } }) ->orderByDesc('created_at') ->take(15); foreach ($taskActivityQuery->get() as $act) { $projCode = $act->task->project->code ?? ''; $userName = $act->user->name ?? 'System'; $activitiesList[] = [ 'id' => 'task_act_' . $act->id, 'time' => $act->created_at?->diffForHumans() ?? 'Just now', 'timestamp' => $act->created_at?->timestamp ?? 0, 'title' => sprintf('%s updated %s: %s (%s)', $userName, $act->task->name, $act->description, $projCode), 'status' => $act->type ?? 'info', ]; } // 2. Daily Report Submissions $dailyReportQuery = DailyReport::with(['project', 'user']) ->where(function ($q) use ($project, $visibleProjectIds) { if ($project) { $q->where('project_id', $project->id); } else { $q->whereIn('project_id', $visibleProjectIds); } }) ->orderByDesc('report_date') ->take(10); foreach ($dailyReportQuery->get() as $report) { $projName = $report->project->name ?? 'Unknown'; $userName = $report->user->name ?? 'Reporter'; $activitiesList[] = [ 'id' => 'report_' . $report->id, 'time' => $report->report_date?->diffForHumans() ?? 'Recently', 'timestamp' => $report->report_date?->timestamp ?? 0, 'title' => sprintf('%s submitted Daily Report %s for %s', $userName, $report->report_number, $projName), 'status' => 'completed', ]; } // 3. Task Status transitions (if no TaskActivity records exist, fetch tasks that changed status recently) if (empty($activitiesList)) { $tasksQuery = Task::with('project') ->where(function ($q) use ($project, $visibleProjectIds) { if ($project) { $q->where('project_id', $project->id); } else { $q->whereIn('project_id', $visibleProjectIds); } }) ->orderByDesc('updated_at') ->take(5); foreach ($tasksQuery->get() as $task) { $statusLabel = $task->status?->label() ?? $task->status; $activitiesList[] = [ 'id' => 'task_status_' . $task->id, 'time' => $task->updated_at?->diffForHumans() ?? 'Recently', 'timestamp' => $task->updated_at?->timestamp ?? 0, 'title' => sprintf('Task "%s" is in status %s (%s)', $task->name, $statusLabel, $task->project->code ?? ''), 'status' => strtolower($statusLabel) === 'completed' ? 'completed' : 'in_progress', ]; } } // Sort combined list by timestamp descending usort($activitiesList, function ($a, $b) { return $b['timestamp'] <=> $a['timestamp']; }); return array_slice($activitiesList, 0, 10); } /** * Compile deployed labor and current equipment from active project data. */ private function getResourcesData(?Project $project, array $visibleProjectIds): array { $laborExpected = 0; $laborActual = 0; $trades = []; $equipmentActive = 0; $equipmentMaintenance = 0; $equipmentIdle = 0; $equipmentList = []; // Global resource roll-ups should represent active execution only. // A selected project intentionally narrows the dashboard to that project. $projectIds = $project ? [$project->id] : Project::whereIn('projects.id', $visibleProjectIds) ->whereIn('status', ['active', 'planning', 'in_progress']) ->pluck('id') ->toArray(); // Labor totals come from every Daily Report labor entry for the active // project set. This intentionally includes all stored report records. $laborLogs = DailyReportLabor::whereHas('dailyReport', function ($query) use ($projectIds) { $query->whereIn('project_id', $projectIds); })->get(); foreach ($laborLogs as $log) { $laborActual += (int) $log->workers_count; $tradeName = $log->trade ?? 'General Labor'; $trades[$tradeName] = ($trades[$tradeName] ?? 0) + (int) $log->workers_count; } // Read equipment entries from every daily report in the project set. // ResourceSummary must represent all stored daily-report records, not // only the latest report for each project. $reports = DailyReport::whereIn('project_id', $projectIds) ->with('equipment') ->get(); foreach ($reports as $report) { foreach ($report->equipment as $log) { $status = strtolower($log->status ?? 'active'); if (str_contains($status, 'active') || str_contains($status, 'use') || str_contains($status, 'operat')) { $equipmentActive++; $eqStatus = 'active'; } elseif (str_contains($status, 'main') || str_contains($status, 'repair') || str_contains($status, 'break')) { $equipmentMaintenance++; $eqStatus = 'maintenance'; } else { $equipmentIdle++; $eqStatus = 'idle'; } $equipmentList[] = [ 'name' => $log->equipment_name ?? 'Equipment', 'status' => $eqStatus, ]; } } if ($laborActual > 0) { $laborExpected = max($laborActual, (int) ceil($laborActual * 1.15)); } return [ 'labor' => [ 'expected' => $laborExpected, 'actual' => $laborActual, 'trades' => $trades ], 'equipment' => [ 'active' => $equipmentActive, 'maintenance' => $equipmentMaintenance, 'idle' => $equipmentIdle, 'list' => $equipmentList ] ]; } /** * Compute role-tailored analytics metrics from database tables. */ private function getRoleAnalyticsData(?\App\Models\User $user, ?Project $project, array $visibleProjectIds): array { $roleName = $user ? ($user->roles->first()?->name ?? 'User') : 'User'; $userType = $user->user_type ?? 'employee'; $pQuery = function ($query) use ($project) { if ($project) { $query->where('project_id', $project->id); } }; // 1. Executive / Super Admin / Admin Analytics (Single-Pass Aggregations) $invoicesQuery = \Modules\FinancialManagement\Models\FinancialInvoice::query(); if ($project) { $invoicesQuery->where('project_id', $project->id); } else { $invoicesQuery->whereIn('project_id', $visibleProjectIds); } $invoiceStats = (clone $invoicesQuery) ->selectRaw("SUM(subtotal) as total_billed, SUM(CASE WHEN status = 'paid' THEN paid_amount ELSE 0 END) as total_paid, SUM(CASE WHEN status IN ('approved', 'sent', 'payment_sent', 'paid') THEN retention_amount ELSE 0 END) as total_retention_fallback") ->first(); $totalBilled = (float) ($invoiceStats->total_billed ?? 0); $totalPaid = (float) ($invoiceStats->total_paid ?? 0); $retentionFallback = (float) ($invoiceStats->total_retention_fallback ?? 0); $retentionLedgerQuery = \Modules\FinancialManagement\Models\RetentionEntry::query(); if ($project) { $retentionLedgerQuery->where('project_id', $project->id); } else { $retentionLedgerQuery->whereIn('project_id', $visibleProjectIds); } $retentionStats = (clone $retentionLedgerQuery) ->selectRaw("SUM(CASE WHEN type = 'debit' THEN amount ELSE 0 END) as held_debit, SUM(CASE WHEN type = 'credit' AND status IN ('posted', 'paid') THEN amount ELSE 0 END) as released_credit") ->first(); $heldDebit = (float) ($retentionStats->held_debit ?? 0); $releasedCredit = (float) ($retentionStats->released_credit ?? 0); $totalRetention = max(0, $heldDebit - $releasedCredit); if ($totalRetention == 0 && $retentionFallback > 0) { $totalRetention = $retentionFallback; } $pendingApprovalsQuery = \Modules\ApprovalWorkflow\Models\ApprovalChain::whereIn('status', ['in_review', 'pending']); if ($user->hasRole('Project Manager') && !($user->user_type === 'admin' || $user->hasRole(['Super Admin', 'admin', 'Main Contractor Admin']))) { $pendingApprovalsQuery->where(function ($q) { $q->whereNull('type') ->orWhere('type', '!=', 'project_estimation'); })->where('approvable_type', '!=', Project::class); } $pendingApprovalsCount = $pendingApprovalsQuery->count(); $projectsAnalyticsQuery = Project::query(); if ($project) { $projectsAnalyticsQuery->whereKey($project->id); } else { $projectsAnalyticsQuery->whereIn('projects.id', $visibleProjectIds); } $projectStats = (clone $projectsAnalyticsQuery) ->selectRaw("COUNT(*) as total_count, SUM(CASE WHEN status IN ('planning', 'in_progress') THEN 1 ELSE 0 END) as active_count") ->first(); $totalProjectsCount = (int) ($projectStats->total_count ?? 0); $activeProjectsCount = (int) ($projectStats->active_count ?? 0); // 2. PM Milestone & Progress Analytics (Scoped Relation Columns) $milestonesQuery = \Modules\ProjectManagement\Models\ProjectMilestone::with('tasks:id,milestone_id,status'); if ($project) { $milestonesQuery->where('project_id', $project->id); } else { $milestonesQuery->whereIn('project_id', $visibleProjectIds); } $allMilestones = $milestonesQuery->get(); $totalMilestones = $allMilestones->count(); $completedMilestones = 0; $inProgressMilestones = 0; foreach ($allMilestones as $m) { $taskCount = $m->tasks->count(); $completedTaskCount = $m->tasks->filter(function ($t) { $st = is_object($t->status) ? $t->status->value : $t->status; return in_array(strtolower($st ?? ''), ['completed', 'closed', 'done']); })->count(); $isCompleted = ($m->actual_date !== null) || ($taskCount > 0 && $completedTaskCount === $taskCount); if ($isCompleted) { $completedMilestones++; } else { $hasActivity = $m->planned_date !== null || $m->tasks->contains(function ($t) { $st = is_object($t->status) ? $t->status->value : $t->status; return in_array(strtolower($st ?? ''), ['in_progress', 'completed', 'closed', 'done']); }); if ($hasActivity) { $inProgressMilestones++; } } } // 3. Warehouse & Technical Inventory (Cached Short TTL) $totalItems = Cache::remember('master_materials_count', 300, fn () => \Modules\MasterData\Models\Material::count()); $totalWarehouses = Cache::remember('master_warehouses_count', 300, fn () => \Modules\MaterialLogistics\Models\Warehouse::count()); $totalDocuments = \Modules\DocumentManagement\Models\Document::whereIn('project_id', $project ? [$project->id] : $visibleProjectIds)->count(); $pendingCashAdvances = \Modules\FinancialManagement\Models\CashAdvance::where('status', 'pending')->count(); // 5. Unconfirmed Payments $pendingInvoicesList = \Modules\FinancialManagement\Models\FinancialInvoice::with('project:id,name,code') ->whereIn('project_id', $visibleProjectIds) ->where('status', 'payment_sent') ->get(['id', 'ulid', 'invoice_number', 'project_id', 'total_amount', 'status']) ->map(fn ($inv) => [ 'id' => 'inv_' . $inv->id, 'ulid' => $inv->ulid, 'number' => $inv->invoice_number, 'project_name' => $inv->project?->name ?? 'Project', 'amount' => (float) $inv->total_amount, 'type' => 'invoice', 'confirm_url' => route('finance.confirm-payment', $inv->ulid), 'view_url' => route('finance.show', $inv->ulid), ]); $pendingRetentionsList = \Modules\FinancialManagement\Models\RetentionEntry::with('project:id,name,code') ->whereIn('project_id', $visibleProjectIds) ->where('type', 'credit') ->whereIn('status', ['payment_sent', 'submitted']) ->get(['id', 'ulid', 'project_id', 'amount', 'status', 'description']) ->map(fn ($ret) => [ 'id' => 'ret_' . $ret->id, 'ulid' => $ret->ulid, 'number' => 'Retention Release', 'project_name' => $ret->project?->name ?? 'Project', 'amount' => (float) $ret->amount, 'type' => 'retention', 'status' => is_object($ret->status) ? ($ret->status->value ?? (string) $ret->status) : (string) $ret->status, 'confirm_url' => route('retention.confirm', $ret->ulid), 'view_url' => route('retention.index'), ]); $mergedPayments = $pendingInvoicesList->concat($pendingRetentionsList); $rolesList = $user?->getRoleNames()->map(fn($r) => strtolower($r))->all() ?? [strtolower($roleName)]; $isExecutiveRole = in_array('super admin', $rolesList) || in_array('admin', $rolesList); $isContractorRole = in_array('main contractor admin', $rolesList) || collect($rolesList)->contains(fn($r) => str_contains($r, 'contractor')); $isPMRole = in_array('project manager', $rolesList) || in_array('project_manager', $rolesList); if ($isExecutiveRole || ($userType === 'admin' && !$isContractorRole && !$isPMRole)) { $dashboardType = 'executive'; } elseif ($isPMRole) { $dashboardType = 'pm'; } elseif ($userType === 'contractor' || $isContractorRole) { $dashboardType = 'contractor'; } else { $dashboardType = 'site'; } return [ 'role' => $roleName, 'roles' => $user?->getRoleNames()->values()->all() ?? [], 'user_type' => $userType, 'dashboard_type' => $dashboardType, 'financials' => [ 'total_billed' => $totalBilled, 'total_paid' => $totalPaid, 'total_retention' => $totalRetention, 'outstanding_balance' => max(0, $totalBilled - $totalPaid), ], 'projects' => [ 'total' => $totalProjectsCount, 'active' => $activeProjectsCount, ], 'approvals' => [ 'pending' => $pendingApprovalsCount, ], 'milestones' => [ 'total' => $totalMilestones, 'completed' => $completedMilestones, 'in_progress' => $inProgressMilestones, 'completion_rate' => $totalMilestones > 0 ? round(($completedMilestones / $totalMilestones) * 100, 1) : 0, ], 'logistics' => [ 'catalog_items' => $totalItems, 'warehouses' => $totalWarehouses, 'documents' => $totalDocuments, 'pending_cash_advances' => $pendingCashAdvances, ], 'unconfirmed_payments' => [ 'count' => $mergedPayments->count(), 'total_amount' => (float) $mergedPayments->sum('amount'), 'items' => $mergedPayments->values()->all(), ], ]; } }