292 lines
10 KiB
PHP
292 lines
10 KiB
PHP
<?php
|
|
|
|
namespace Modules\ProjectManagement\Http\Controllers;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\User;
|
|
use Illuminate\Http\Request;
|
|
use Inertia\Inertia;
|
|
use Modules\MasterData\Models\Material;
|
|
use Modules\MaterialLogistics\Models\ProjectInventory;
|
|
use Modules\ProjectManagement\Enums\DelayReason;
|
|
use Modules\ProjectManagement\Enums\ProjectStatus;
|
|
use Modules\ProjectManagement\Enums\WeatherCondition;
|
|
use Modules\ProjectManagement\Events\ProjectStatusChanged;
|
|
use Modules\ProjectManagement\Models\Project;
|
|
|
|
class ProjectController extends Controller
|
|
{
|
|
public function index(Request $request)
|
|
{
|
|
$user = auth()->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,
|
|
]);
|
|
}
|
|
}
|
|
|
|
}
|