chore: update document approval workflow and bug fixes

This commit is contained in:
2026-05-25 13:05:20 +08:00
parent d573c02893
commit 39a8e1d4cd
910 changed files with 49994 additions and 1010 deletions

View File

@@ -0,0 +1,290 @@
<?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/owners only see projects they are assigned to
if ($user && !$user->hasRole('Super Admin') && !$user->hasRole('Owner')) {
$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(),
];
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,
]);
}
}
}