isBiddingManager($user) || $this->isContractorUser($user), 403, 'Only Project Manager, executive, or invited contractor roles can access bidding.' ); $query = BidPackage::with(['project:id,name,code', 'creator:id,name', 'invitations:id,bid_package_id,ulid,contractor_id,status']) ->withCount(['invitations', 'submissions']); if ($search = $request->search) { $query->where('title', 'like', "%{$search}%"); } if ($status = $request->status) { $query->where('status', $status); } if ($mode = $request->evaluation_mode) { $query->where('evaluation_mode', $mode); } if ($projectUlid = $request->project) { $projectId = Project::resolveUlidToId($projectUlid); $query->where('project_id', $projectId); } // Contractor Admin users should ONLY see published (non-draft) bid packages where their company is invited if (! $this->isBiddingManager($user) && $this->isContractorUser($user)) { $query->where('status', '!=', BidPackageStatus::Draft->value) ->whereHas('invitations', function ($q) use ($user) { $q->where('contractor_id', $user->contractor_id); }); } $packages = $query->latest()->paginate(15)->withQueryString(); return Inertia::render('BiddingManagement::Bids/Index', [ 'packages' => $packages, 'filters' => $request->only(['search', 'status', 'evaluation_mode', 'project']), 'projects' => Project::select('id', 'ulid', 'name', 'code')->get(), ]); } public function create() { $this->authorizePackageManagement(); return Inertia::render('BiddingManagement::Bids/Create', [ 'projects' => Project::where('current_wizard_step', '>=', 7)->select('id', 'ulid', 'name', 'code')->get(), ]); } public function store(Request $request) { $this->authorizePackageManagement(); $validated = $request->validate([ 'project_id' => 'required|string', 'title' => 'required|string|max:255', 'description' => 'nullable|string', 'evaluation_mode' => 'required|in:simple,scored', 'submission_deadline' => 'nullable|date', 'validity_period' => 'nullable|date', 'instructions' => 'nullable|string', 'criteria' => 'nullable|array', 'criteria.*.name' => 'required_with:criteria|string|max:100', 'criteria.*.weight' => 'required_with:criteria|numeric|min:0|max:100', 'criteria.*.description' => 'nullable|string', ]); $validated['project_id'] = Project::resolveUlidToId($validated['project_id']); $project = Project::find($validated['project_id']); abort_unless($project, 422, 'The selected project could not be found.'); abort_unless( $project->current_wizard_step >= 7, 422, 'The project must finish initialization before a bid package can be created.' ); abort_unless( $this->isExecutive(Auth::user()) || $project->personnel()->where('users.id', Auth::id())->wherePivot('role', 'pm')->exists(), 403, 'Only the assigned Project Manager or an executive can create this bid package.' ); $validated['created_by'] = Auth::id(); $criteria = $validated['criteria'] ?? []; unset($validated['criteria']); if ($validated['evaluation_mode'] === 'scored' && count($criteria) > 0) { $totalWeight = array_sum(array_column($criteria, 'weight')); if ($totalWeight > 100) { return back()->withErrors(['criteria' => "Total criteria weight ({$totalWeight}%) exceeds 100%."]); } } $package = BidPackage::create($validated); foreach ($criteria as $index => $criterion) { $package->criteria()->create([ 'name' => $criterion['name'], 'weight' => $criterion['weight'], 'description' => $criterion['description'] ?? null, 'sort_order' => $index, ]); } return redirect()->route('bids.show', $package)->with('success', 'Bid package created.'); } public function show(BidPackage $bid) { $user = Auth::user(); abort_unless( $this->isBiddingManager($user) || $this->isContractorUser($user), 403, 'Only Project Manager, executive, or invited contractor roles can access bidding.' ); if (! $this->isBiddingManager($user) && $this->isContractorUser($user)) { abort_if($bid->status === BidPackageStatus::Draft, 403, 'This bid package is currently in draft state.'); abort_unless($bid->invitations()->where('contractor_id', $user->contractor_id)->exists(), 403, 'Your company has not been invited to this bid package.'); } $bid->load([ 'project:id,ulid,name,code', 'creator:id,name', 'criteria', 'invitations.contractor:id,ulid,company_name,specialization', 'invitations.submission.scores', 'invitations.submission.documents', 'award.submission.invitation.contractor:id,company_name', 'award.awardedBy:id,name', 'documents', ]); $contractors = Contractor::where('status', 'active') ->select('id', 'ulid', 'company_name', 'specialization') ->get(); return Inertia::render('BiddingManagement::Bids/Show', [ 'package' => $bid, 'contractors' => $contractors, ]); } public function edit(BidPackage $bid) { $this->authorizePackageManagement(); abort_unless($bid->status === BidPackageStatus::Draft, 403, 'Only draft packages can be edited.'); $bid->load('criteria'); return Inertia::render('BiddingManagement::Bids/Edit', [ 'package' => $bid, 'projects' => Project::where('current_wizard_step', '>=', 7)->select('id', 'ulid', 'name', 'code')->get(), ]); } public function update(Request $request, BidPackage $bid) { $this->authorizePackageManagement(); abort_unless($bid->status === BidPackageStatus::Draft, 403, 'Only draft packages can be edited.'); $validated = $request->validate([ 'title' => 'required|string|max:255', 'description' => 'nullable|string', 'evaluation_mode' => 'required|in:simple,scored', 'submission_deadline' => 'nullable|date', 'validity_period' => 'nullable|date', 'instructions' => 'nullable|string', 'criteria' => 'nullable|array', 'criteria.*.name' => 'required_with:criteria|string|max:100', 'criteria.*.weight' => 'required_with:criteria|numeric|min:0|max:100', 'criteria.*.description' => 'nullable|string', ]); $criteria = $validated['criteria'] ?? []; unset($validated['criteria']); $bid->update($validated); // Replace criteria wholesale $bid->criteria()->delete(); foreach ($criteria as $index => $criterion) { $bid->criteria()->create([ 'name' => $criterion['name'], 'weight' => $criterion['weight'], 'description' => $criterion['description'] ?? null, 'sort_order' => $index, ]); } return redirect()->route('bids.show', $bid)->with('success', 'Bid package updated.'); } public function destroy(BidPackage $bid) { $this->authorizePackageManagement(); abort_unless($bid->status === BidPackageStatus::Draft, 403, 'Only draft packages can be deleted.'); $bid->delete(); return redirect()->route('bids.index')->with('success', 'Bid package deleted.'); } public function publish(BidPackage $bid) { $this->authorizePackageManagement(); try { $bid->transitionTo(BidPackageStatus::Open); BidPackageOpened::dispatch($bid); } catch (\InvalidArgumentException $e) { return back()->with('error', $e->getMessage()); } return back()->with('success', 'Bid package is now open for submissions.'); } public function startEvaluation(BidPackage $bid) { $this->authorizePackageManagement(); try { $bid->transitionTo(BidPackageStatus::Evaluating); } catch (\InvalidArgumentException $e) { return back()->with('error', $e->getMessage()); } return back()->with('success', 'Evaluation phase started.'); } public function cancel(BidPackage $bid) { $this->authorizePackageManagement(); try { $bid->transitionTo(BidPackageStatus::Cancelled); } catch (\InvalidArgumentException $e) { return back()->with('error', $e->getMessage()); } return back()->with('success', 'Bid package cancelled.'); } private function authorizePackageManagement(): void { $this->authorizeBiddingManagement(); } private function isContractorUser($user): bool { return $user !== null && ! $this->isBiddingManager($user) && ($user->user_type === 'contractor' || $user->contractor_id !== null); } }