user(); $query = Project::query() ->with(['personnel:id,name']); // Non-platform admins only see projects they are assigned to if ($user && !$user->hasRole('Super Admin')) { $query->whereHas('personnel', function ($q) use ($user) { $q->where('users.id', $user->id); }); } $projects = $query ->when($request->search, fn ($q, $s) => $q->where('name', 'like', "%{$s}%")->orWhere('code', 'like', "%{$s}%")) ->when($request->status, fn ($q, $s) => $q->where('status', $s)) ->latest() ->paginate(15) ->withQueryString() ->through(fn ($p) => $p->append(['capitalization_percentage', 'is_over_budget'])); return Inertia::render('ProjectManagement::Projects/Index', [ 'projects' => $projects, 'filters' => $request->only(['search', 'status']), 'statuses' => collect(ProjectStatus::cases())->map(fn ($s) => [ 'value' => $s->value, 'label' => $s->label(), ]), ]); } public function create() { $employees = User::whereIn('user_type', ['admin', 'employee'])->with('employeeProfile')->select('id', 'ulid', 'name', 'email')->get(); return Inertia::render('ProjectManagement::Projects/Create', [ 'employees' => $employees, ]); } public function store(Request $request) { $validated = $request->validate([ 'name' => 'required|string|max:255', 'client_name' => 'nullable|string|max:255', 'description' => 'nullable|string', 'location' => 'nullable|string|max:255', 'contract_value' => 'nullable|numeric|min:0', 'contract_duration' => 'nullable|integer|min:0', 'start_date' => 'nullable|date', 'target_end_date' => 'nullable|date|after_or_equal:start_date', ]); $project = Project::create($validated); // Assign PM if provided if ($request->pm_id) { $pmId = User::resolveUlidToId($request->pm_id); if ($pmId) { $project->personnel()->attach($pmId, ['role' => 'pm']); } } // Auto-seed default milestones $this->seedDefaultMilestones($project); return redirect()->route('projects.show', $project) ->with('success', "Project \"{$project->name}\" created."); } public function show(Project $project) { $project->load([ 'contractor:id,company_name', 'tasks:id,project_id,status', // Only load status for counts ]); $project->append(['capitalization_percentage', 'is_over_budget']); $taskStats = [ 'total' => $project->tasks->count(), 'pending' => $project->tasks->where('status', 'pending')->count(), 'in_progress' => $project->tasks->where('status', 'in_progress')->count(), 'completed' => $project->tasks->where('status', 'completed')->count(), 'closed' => $project->tasks->where('status', 'closed')->count(), ]; return Inertia::render('ProjectManagement::Projects/Overview', [ 'project' => $project, 'taskStats' => $taskStats, 'allowedTransitions' => collect($project->status->allowedTransitions())->map(fn ($s) => [ 'value' => $s->value, 'label' => $s->label(), ]), ]); } public function team(Project $project) { $project->load(['personnel:id,name,email']); $employees = User::whereIn('user_type', ['admin', 'employee']) ->with('employeeProfile') ->select('id', 'ulid', 'name', 'email') ->get(); return Inertia::render('ProjectManagement::Projects/Team', [ 'project' => $project, 'employees' => $employees, 'allowedTransitions' => collect($project->status->allowedTransitions())->map(fn ($s) => [ 'value' => $s->value, 'label' => $s->label(), ]), ]); } public function materials(Project $project) { // Materials available on-site (dispatched & received at project) $availableMaterials = ProjectInventory::where('project_id', $project->id) ->where('on_hand_qty', '>', 0) ->with([ 'material:id,ulid,name,sku,unit,unit_cost,category,type', 'material.components.component:id,name,unit' ]) ->get() ->map(fn ($inv) => [ 'id' => $inv->material->id, 'ulid' => $inv->material->ulid, 'name' => $inv->material->name, 'sku' => $inv->material->sku, 'unit' => $inv->material->unit, 'unit_cost' => $inv->material->unit_cost, 'category' => $inv->material->category, 'type' => $inv->material->type, 'components' => $inv->material->components, 'available_qty' => $inv->available_qty, 'on_hand_qty' => (float) $inv->on_hand_qty, 'allocated_qty' => (float) $inv->allocated_qty, ]); return Inertia::render('ProjectManagement::Projects/Materials', [ 'project' => $project, 'availableMaterials' => $availableMaterials, 'allowedTransitions' => collect($project->status->allowedTransitions())->map(fn ($s) => [ 'value' => $s->value, 'label' => $s->label(), ]), ]); } public function edit(Project $project) { $employees = User::whereIn('user_type', ['admin', 'employee'])->with('employeeProfile')->select('id', 'ulid', 'name', 'email')->get(); $project->load('personnel:id,name'); return Inertia::render('ProjectManagement::Projects/Edit', [ 'project' => $project, 'employees' => $employees, ]); } public function update(Request $request, Project $project) { $validated = $request->validate([ 'name' => 'required|string|max:255', 'client_name' => 'nullable|string|max:255', 'description' => 'nullable|string', 'location' => 'nullable|string|max:255', 'contract_value' => 'nullable|numeric|min:0', 'contract_duration' => 'nullable|integer|min:0', 'start_date' => 'nullable|date', 'target_end_date' => 'nullable|date|after_or_equal:start_date', ]); $project->update($validated); return redirect()->route('projects.show', $project) ->with('success', "Project \"{$project->name}\" updated."); } public function destroy(Project $project) { $name = $project->name; $project->delete(); return redirect()->route('projects.index') ->with('success', "Project \"{$name}\" deleted."); } public function transition(Request $request, Project $project) { $request->validate(['status' => 'required|string']); $newStatus = ProjectStatus::from($request->status); $oldStatus = $project->status->value; try { $project->transitionTo($newStatus); ProjectStatusChanged::dispatch($project, $oldStatus, $newStatus->value); } catch (\InvalidArgumentException $e) { return back()->with('error', $e->getMessage()); } return back()->with('success', "Project status changed to {$newStatus->label()}."); } public function addPersonnel(Request $request, Project $project) { $request->validate([ 'user_id' => 'required|string', 'role' => 'required|in:pm,engineer,laborer,member', ]); $userId = User::resolveUlidToId($request->user_id); if (!$userId) { return back()->with('error', 'User not found.'); } if ($project->personnel()->where('user_id', $userId)->exists()) { return back()->with('error', 'User is already assigned to this project.'); } $project->personnel()->attach($userId, ['role' => $request->role]); return back()->with('success', 'Team member added.'); } public function removePersonnel(Project $project, User $user) { $project->personnel()->detach($user->id); return back()->with('success', 'Team member removed.'); } private function seedDefaultMilestones(Project $project): void { $defaults = [ ['name' => 'Mobilization', 'weight_percentage' => 5], ['name' => 'Earthworks & Foundation', 'weight_percentage' => 15], ['name' => 'Structural Works', 'weight_percentage' => 25], ['name' => 'Roofing & Waterproofing', 'weight_percentage' => 15], ['name' => 'Architectural Finishing', 'weight_percentage' => 20], ['name' => 'MEP Rough-In', 'weight_percentage' => 10], ['name' => 'Final Inspection & Punch List', 'weight_percentage' => 5], ['name' => 'Turnover', 'weight_percentage' => 5], ]; $startDate = $project->start_date; $duration = $project->contract_duration; // in calendar days $count = count($defaults); foreach ($defaults as $i => $milestone) { $plannedDate = null; if ($startDate && $duration && $duration > 0) { $daysOffset = (int) round(($i + 1) / $count * $duration); $plannedDate = $startDate->copy()->addDays($daysOffset); } $project->milestones()->create([ 'name' => $milestone['name'], 'weight_percentage' => $milestone['weight_percentage'], 'sort_order' => $i, 'is_default' => true, 'planned_date' => $plannedDate, ]); } } }