Files
GSB-Construction/Modules/ProjectManagement/app/Http/Controllers/ProjectController.php
Ajjj 936ae1c6b2
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
feat: implement comprehensive dashboard controller and modular administrative and project management interfaces
2026-08-03 18:28:52 +08:00

1218 lines
50 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;
use Modules\ProjectManagement\Models\ProjectMilestone;
use Modules\ProjectManagement\Models\Task;
use Modules\Labors\Models\Labor;
use Modules\MasterData\Models\Team;
use Modules\MasterData\Models\EquipmentRate;
use Barryvdh\DomPDF\Facade\Pdf;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
class ProjectController extends Controller
{
public function index(Request $request)
{
$user = auth()->user();
$query = Project::query()
->where('current_wizard_step', '>=', 7)
->whereNotIn('status', ['completed', 'closed'])
->with(['contractor:id,company_name', 'personnel:id,name']);
$historyQuery = Project::query()
->where('current_wizard_step', '>=', 7)
->whereIn('status', ['completed', 'closed'])
->with(['contractor:id,company_name', 'personnel:id,name']);
$draftsQuery = Project::query()
->where('current_wizard_step', '<', 7)
->with(['contractor:id,company_name', 'personnel:id,name']);
// Filter non-admin users to assigned projects or company projects
if ($user && $user->user_type !== 'admin' && ! $user->hasRole('Super Admin')) {
$query->where(function ($q) use ($user) {
$q->whereHas('personnel', function ($pq) use ($user) {
$pq->where('users.id', $user->id);
});
if ($user->contractor_id) {
$q->orWhere('contractor_id', $user->contractor_id);
} else {
$q->orWhereNull('contractor_id');
}
});
$historyQuery->where(function ($q) use ($user) {
$q->whereHas('personnel', function ($pq) use ($user) {
$pq->where('users.id', $user->id);
});
if ($user->contractor_id) {
$q->orWhere('contractor_id', $user->contractor_id);
} else {
$q->orWhereNull('contractor_id');
}
});
$draftsQuery->where(function ($q) use ($user) {
$q->whereHas('personnel', function ($pq) use ($user) {
$pq->where('users.id', $user->id);
});
if ($user->contractor_id) {
$q->orWhere('contractor_id', $user->contractor_id);
} else {
$q->orWhereNull('contractor_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, ['*'], 'page')
->withQueryString()
->through(fn ($p) => $p->append(['capitalization_percentage', 'is_over_budget', 'milestone_completion']));
$history = $historyQuery
->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, ['*'], 'history_page')
->withQueryString()
->through(fn ($p) => $p->append(['capitalization_percentage', 'is_over_budget', 'milestone_completion']));
$drafts = $draftsQuery->latest()->get();
return Inertia::render('ProjectManagement::Projects/Index', [
'projects' => $projects,
'history' => $history,
'drafts' => $drafts,
'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'])
->where(function ($q) {
$q->whereIn('user_type', ['admin'])
->orWhereHas('roles', function ($rq) {
$rq->whereIn('name', ['Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Contractor']);
});
})
->with('employeeProfile')
->select('id', 'ulid', 'name', 'email', 'profile_picture')
->get();
$projects = Project::active()->with('parentProject:id,ulid,name,code')->get(['id', 'ulid', 'name', 'code', 'parent_project_id', 'project_type']);
return Inertia::render('ProjectManagement::Projects/Create', [
'employees' => $employees,
'projects' => $projects,
]);
}
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_type' => 'required|string|in:standard,special,extension',
'parent_project_id' => 'nullable|string',
'is_unprofitable' => 'nullable|boolean',
]);
if (!empty($validated['parent_project_id'])) {
$validated['parent_project_id'] = Project::resolveUlidToId($validated['parent_project_id']);
}
$validated['is_unprofitable'] = $validated['is_unprofitable'] ?? false;
$validated['contract_value'] = $validated['contract_value'] ?? 0;
$validated['current_wizard_step'] = 2;
$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']);
}
}
return redirect()->route('projects.wizard', [$project, 'step' => 2])
->with('success', "Project \"{$project->name}\" details saved. Please add tasks.");
}
public function show(Project $project)
{
$project->load([
'contractor:id,company_name',
'tasks' => fn ($q) => $q->with(['taskLabors.labor', 'taskEquipments.equipment'])->orderBy('sort_order'),
'parentProject:id,ulid,name,code',
'extensionProjects:id,parent_project_id,ulid,name,code,status,contract_value,total_capitalization,start_date,target_end_date',
'milestones' => fn ($q) => $q->orderBy('sort_order'),
'materialsEstimates.material',
]);
$project->append([
'capitalization_percentage',
'is_over_budget',
'rollup_capitalization',
'rollup_estimated_value',
'rollup_contract_value',
]);
$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(),
];
$estimationTotals = $this->getWizardTotals($project);
return Inertia::render('ProjectManagement::Projects/Overview', [
'project' => $project,
'taskStats' => $taskStats,
'allowedTransitions' => collect($project->status->allowedTransitions())->map(fn ($s) => [
'value' => $s->value,
'label' => $s->label(),
]),
'estimationTotals' => $estimationTotals,
'tab' => request()->query('tab', 'overview'),
]);
}
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', 'profile_picture')
->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)
{
$project->load([
'personnel:id,name',
'parentProject:id,ulid',
'milestones' => fn ($q) => $q->orderBy('sort_order'),
'tasks' => fn ($q) => $q->with(['taskLabors.labor.skills', 'taskEquipments', 'taskIssues'])->orderBy('sort_order'),
'materialsEstimates.material',
]);
$employees = User::whereIn('user_type', ['admin', 'employee'])
->where(function ($q) {
$q->whereIn('user_type', ['admin'])
->orWhereHas('roles', function ($rq) {
$rq->whereIn('name', ['Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Contractor']);
});
})
->with('employeeProfile')
->select('id', 'ulid', 'name', 'email', 'profile_picture')
->get();
$projects = Project::active()->with('parentProject:id,ulid,name,code')->where('id', '!=', $project->id)->get(['id', 'ulid', 'name', 'code', 'parent_project_id', 'project_type']);
$materials = \Modules\MasterData\Models\Material::where('type', 'single')
->where('status', 'active')
->select('id', 'ulid', 'name', 'unit', 'unit_cost')
->get();
$materialGroups = \Modules\MasterData\Models\Material::with('components.component:id,ulid,name,sku,category,unit,unit_cost')
->whereIn('type', ['kit', 'assembly'])
->where('status', 'active')
->orderBy('name')
->get()
->map(function ($kit) {
return [
'id' => $kit->id,
'ulid' => $kit->ulid,
'name' => $kit->name,
'description' => $kit->description ?? '',
'status' => $kit->status,
'materials' => $kit->components->map(function ($comp) {
return array_merge($comp->component->toArray(), [
'pivot' => ['quantity' => $comp->quantity]
]);
})->toArray()
];
});
$labors = \Modules\Labors\Models\Labor::with('skills')->active()->get();
$equipments = \Modules\Equipments\Models\Equipment::with('specifications')->active()->get();
return Inertia::render('ProjectManagement::Projects/Edit', [
'project' => $project,
'employees' => $employees,
'projects' => $projects,
'materials' => $materials,
'materialGroups' => $materialGroups,
'labors' => $labors,
'equipments' => $equipments,
]);
}
public function update(Request $request, Project $project)
{
if ($project->current_wizard_step >= 8) {
return redirect()->back()->with('error', 'Cannot update details: This project has been approved and locked.');
}
$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_type' => 'required|string|in:standard,special,extension',
'parent_project_id' => 'nullable|string',
'is_unprofitable' => 'nullable|boolean',
]);
if (!empty($validated['parent_project_id'])) {
$validated['parent_project_id'] = Project::resolveUlidToId($validated['parent_project_id']);
} else {
$validated['parent_project_id'] = null;
}
$validated['is_unprofitable'] = $validated['is_unprofitable'] ?? false;
$project->update($validated);
return redirect()->route('projects.show', $project)
->with('success', "Project \"{$project->name}\" updated.");
}
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,
]);
}
}
public function wizard(Project $project, Request $request)
{
$step = (int) $request->query('step', $project->current_wizard_step);
if ($step > $project->current_wizard_step && $project->status !== ProjectStatus::InProgress) {
$step = $project->current_wizard_step;
}
$project->load([
'milestones' => fn ($q) => $q->orderBy('sort_order'),
'tasks' => fn ($q) => $q->with(['taskLabors.labor.skills', 'taskEquipments', 'taskIssues'])->orderBy('sort_order'),
'materialsEstimates.material',
'personnel:id,ulid,name,email',
]);
$employees = User::whereIn('user_type', ['admin', 'employee'])
->where(function ($q) {
$q->whereIn('user_type', ['admin'])
->orWhereHas('roles', function ($rq) {
$rq->whereIn('name', ['Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Contractor']);
});
})
->with(['employeeProfile', 'roles'])
->select('id', 'ulid', 'name', 'email', 'user_type', 'profile_picture')
->get();
$parentProjects = Project::active()->with('parentProject:id,ulid,name,code')->where('id', '!=', $project->id)->get(['id', 'ulid', 'name', 'code', 'parent_project_id', 'project_type']);
$materials = \Modules\MasterData\Models\Material::where('type', 'single')
->where('status', 'active')
->select('id', 'ulid', 'name', 'unit', 'unit_cost')
->get();
$materialGroups = \Modules\MasterData\Models\Material::with('components.component:id,ulid,name,sku,category,unit,unit_cost')
->whereIn('type', ['kit', 'assembly'])
->where('status', 'active')
->orderBy('name')
->get()
->map(function ($kit) {
return [
'id' => $kit->id,
'ulid' => $kit->ulid,
'name' => $kit->name,
'description' => $kit->description ?? '',
'status' => $kit->status,
'materials' => $kit->components->map(function ($comp) {
return array_merge($comp->component->toArray(), [
'pivot' => ['quantity' => $comp->quantity]
]);
})->toArray()
];
});
$labors = \Modules\Labors\Models\Labor::with('skills')->active()->get();
$equipments = \Modules\Equipments\Models\Equipment::with('specifications')->active()->get();
$teams = Team::with('users:id,ulid,name,email')->active()->get();
$workflowService = app(\Modules\ProjectManagement\Services\ProjectWorkflowService::class);
$budget = $workflowService->getBudgetAnalysis($project);
return Inertia::render('ProjectManagement::Projects/Wizard', [
'project' => $project,
'step' => $step,
'employees' => $employees,
'projects' => $parentProjects,
'materials' => $materials,
'materialGroups' => $materialGroups,
'labors' => $labors,
'equipments' => $equipments,
'budget' => $budget,
'teams' => $teams,
]);
}
public function saveWizardTasks(Project $project, Request $request)
{
if ($project->current_wizard_step >= 8) {
return redirect()->back()->with('error', 'Cannot modify tasks: This project has been approved and locked.');
}
$validated = $request->validate([
'prepopulate_milestones' => 'nullable|boolean',
'milestones' => 'nullable|array',
'milestones.*.ulid' => 'nullable|string',
'milestones.*.name' => 'required|string|max:255',
'milestones.*.weight_percentage' => 'required|numeric|min:0|max:100',
'tasks' => 'nullable|array',
'tasks.*.ulid' => 'nullable|string',
'tasks.*.name' => 'required|string|max:255',
'tasks.*.description' => 'nullable|string',
'tasks.*.milestone_ulid' => 'nullable|string',
'tasks.*.start_date' => 'nullable|date',
'tasks.*.end_date' => 'nullable|date|after_or_equal:tasks.*.start_date',
]);
\DB::transaction(function () use ($project, $validated) {
if (!empty($validated['prepopulate_milestones']) && !$project->milestones()->exists()) {
$this->seedDefaultMilestones($project);
}
if (isset($validated['milestones'])) {
$existingMilestoneIds = [];
foreach ($validated['milestones'] as $index => $m) {
$milestone = null;
if (!empty($m['ulid'])) {
$milestone = $project->milestones()->where('ulid', $m['ulid'])->first();
}
if (!$milestone) {
$milestone = $project->milestones()->create([
'name' => $m['name'],
'weight_percentage' => $m['weight_percentage'],
'sort_order' => $index,
]);
} else {
$milestone->update([
'name' => $m['name'],
'weight_percentage' => $m['weight_percentage'],
'sort_order' => $index,
]);
}
$existingMilestoneIds[] = $milestone->id;
}
$project->milestones()->whereNotIn('id', $existingMilestoneIds)->delete();
}
if (isset($validated['tasks'])) {
$existingTaskIds = [];
foreach ($validated['tasks'] as $index => $t) {
$milestoneId = null;
if (!empty($t['milestone_ulid'])) {
$milestoneId = \Modules\ProjectManagement\Models\ProjectMilestone::resolveUlidToId($t['milestone_ulid']);
}
$task = null;
if (!empty($t['ulid'])) {
$task = $project->tasks()->where('ulid', $t['ulid'])->first();
}
$taskData = [
'project_id' => $project->id,
'name' => $t['name'],
'description' => $t['description'] ?? null,
'milestone_id' => $milestoneId,
'start_date' => $t['start_date'] ?? null,
'end_date' => $t['end_date'] ?? null,
'sort_order' => $index,
];
if (!$task) {
$task = \Modules\ProjectManagement\Models\Task::create($taskData);
} else {
$task->update($taskData);
}
$existingTaskIds[] = $task->id;
}
$project->tasks()->whereNotIn('id', $existingTaskIds)->delete();
}
if ($project->current_wizard_step < 3) {
$project->update(['current_wizard_step' => 3]);
}
});
return redirect()->route('projects.wizard', [$project, 'step' => 3])
->with('success', 'Tasks saved successfully.');
}
public function saveWizardEstimates(Project $project, Request $request)
{
if ($project->current_wizard_step >= 8) {
return redirect()->back()->with('error', 'Cannot modify estimates: This project has been approved and locked.');
}
$validated = $request->validate([
'estimates' => 'nullable|array',
'estimates.*.material_ulid' => 'required|string',
'estimates.*.estimated_qty' => 'required|numeric|min:0',
'estimates.*.unit_cost' => 'required|numeric|min:0',
]);
\DB::transaction(function () use ($project, $validated) {
$existingEstimateIds = [];
if (isset($validated['estimates'])) {
foreach ($validated['estimates'] as $est) {
$material = \Modules\MasterData\Models\Material::where('ulid', $est['material_ulid'])->firstOrFail();
$estimate = $project->materialsEstimates()->updateOrCreate(
['material_id' => $material->id],
[
'estimated_qty' => $est['estimated_qty'],
'unit_cost' => $est['unit_cost'],
]
);
$existingEstimateIds[] = $estimate->id;
}
}
$project->materialsEstimates()->whereNotIn('id', $existingEstimateIds)->delete();
if ($project->current_wizard_step < 4) {
$project->update(['current_wizard_step' => 4]);
}
});
return redirect()->route('projects.wizard', [$project, 'step' => 4])
->with('success', 'Materials estimates saved.');
}
public function saveWizardLabor(Project $project, Request $request)
{
if ($project->current_wizard_step >= 8) {
return redirect()->back()->with('error', 'Cannot modify labor allocations: This project has been approved and locked.');
}
$validated = $request->validate([
'labor' => 'nullable|array',
'labor.*.task_ulid' => 'required|string',
'labor.*.labor_ulid' => 'required|string',
'labor.*.estimated_hours' => 'required|numeric|min:0',
'team_ulids' => 'nullable|array',
'team_ulids.*' => 'string|exists:teams,ulid',
'user_ulids' => 'nullable|array',
'user_ulids.*' => 'string|exists:users,ulid',
]);
\DB::transaction(function () use ($project, $validated) {
$existingLaborIds = [];
if (isset($validated['labor'])) {
foreach ($validated['labor'] as $lb) {
$task = $project->tasks()->where('ulid', $lb['task_ulid'])->firstOrFail();
$laborRecord = \Modules\Labors\Models\Labor::where('ulid', $lb['labor_ulid'])->firstOrFail();
$taskLabor = $task->taskLabors()->updateOrCreate(
['labor_id' => $laborRecord->id],
['estimated_hours' => $lb['estimated_hours']]
);
$existingLaborIds[] = $taskLabor->id;
}
}
$taskIds = $project->tasks()->pluck('id');
\Modules\ProjectManagement\Models\TaskLabor::whereIn('task_id', $taskIds)
->whereNotIn('id', $existingLaborIds)
->delete();
// Synchronize project personnel pool
$userIds = [];
// Add users from selected teams
if (!empty($validated['team_ulids'])) {
$teams = Team::whereIn('ulid', $validated['team_ulids'])->with('users')->get();
foreach ($teams as $team) {
foreach ($team->users as $user) {
$userIds[$user->id] = ['role' => 'member'];
}
}
}
// Add individual users
if (!empty($validated['user_ulids'])) {
$users = User::whereIn('ulid', $validated['user_ulids'])->get();
foreach ($users as $user) {
$userIds[$user->id] = ['role' => 'member'];
}
}
// Keep existing PM if present
$pm = $project->personnel()->wherePivot('role', 'pm')->first();
if ($pm) {
$userIds[$pm->id] = ['role' => 'pm'];
}
// Sync the project personnel
if (isset($validated['team_ulids']) || isset($validated['user_ulids'])) {
$project->personnel()->sync($userIds);
}
if ($project->current_wizard_step < 5) {
$project->update(['current_wizard_step' => 5]);
}
});
return redirect()->route('projects.wizard', [$project, 'step' => 5])
->with('success', 'Manpower allocations saved.');
}
public function saveWizardEquipment(Project $project, Request $request)
{
if ($project->current_wizard_step >= 8) {
return redirect()->back()->with('error', 'Cannot modify equipment allocations: This project has been approved and locked.');
}
$validated = $request->validate([
'equipment' => 'nullable|array',
'equipment.*.task_ulid' => 'required|string',
'equipment.*.equipment_ulid' => 'required|string',
'equipment.*.estimated_hours' => 'required|numeric|min:0',
]);
\DB::transaction(function () use ($project, $validated) {
$existingEquipmentIds = [];
if (isset($validated['equipment'])) {
foreach ($validated['equipment'] as $eq) {
$task = $project->tasks()->where('ulid', $eq['task_ulid'])->firstOrFail();
$equipmentRecord = \Modules\Equipments\Models\Equipment::where('ulid', $eq['equipment_ulid'])->firstOrFail();
$taskEquipment = $task->taskEquipments()->updateOrCreate(
['equipment_id' => $equipmentRecord->id],
['estimated_hours' => $eq['estimated_hours']]
);
$existingEquipmentIds[] = $taskEquipment->id;
}
}
$taskIds = $project->tasks()->pluck('id');
\Modules\ProjectManagement\Models\TaskEquipment::whereIn('task_id', $taskIds)
->whereNotIn('id', $existingEquipmentIds)
->delete();
if ($project->current_wizard_step < 6) {
$project->update(['current_wizard_step' => 6]);
}
});
return redirect()->route('projects.wizard', [$project, 'step' => 6])
->with('success', 'Equipment allocations saved.');
}
public function submitWizardForApproval(Project $project, Request $request)
{
$validated = $request->validate([
'approver_ids' => 'required|array',
'approver_ids.*' => 'string',
'notes' => 'nullable|string',
]);
\DB::transaction(function () use ($project, $validated) {
$approverIds = array_map(function ($ulid) {
return User::resolveUlidToId($ulid);
}, $validated['approver_ids']);
$approvalService = app(\Modules\ApprovalWorkflow\Services\ApprovalService::class);
$approvalService->createChain(
$project,
$approverIds,
'project_estimation',
auth()->id(),
$validated['notes'] ?? 'Project Setup and Estimation Approval Request'
);
$project->update([
'current_wizard_step' => 7,
]);
});
return redirect()->route('projects.show', $project)
->with('success', 'Project estimation has been successfully submitted for approval.');
}
private function getWizardTotals(Project $project): array
{
$materialsCost = $project->materialsEstimates->reduce(function ($sum, $est) {
return $sum + ($est->estimated_qty * $est->unit_cost);
}, 0);
$laborCost = 0;
foreach ($project->tasks as $task) {
foreach ($task->taskLabors as $tl) {
$laborCost += ($tl->estimated_hours * $tl->labor->hourly_rate);
}
}
$equipmentCost = 0;
foreach ($project->tasks as $task) {
foreach ($task->taskEquipments as $te) {
$equipmentCost += ($te->estimated_hours * $te->equipment->hourly_rate);
}
}
$totalEstimatedCost = $materialsCost + $laborCost + $equipmentCost;
$contractValue = (float) $project->contract_value;
$margin = $contractValue - $totalEstimatedCost;
$marginPct = $contractValue > 0 ? ($margin / $contractValue) * 100 : 0;
return [
'materials' => (float) $materialsCost,
'labor' => (float) $laborCost,
'equipment' => (float) $equipmentCost,
'sum' => (float) $totalEstimatedCost,
'margin' => (float) $margin,
'margin_pct' => (float) $marginPct,
];
}
public function exportWizardPdf(Project $project)
{
$project->load([
'materialsEstimates.material',
'tasks.taskLabors.labor',
'tasks.taskEquipments.equipment',
]);
$totals = $this->getWizardTotals($project);
$pdf = Pdf::loadView('projectmanagement::pdf.estimation', [
'project' => $project,
'totals' => $totals,
]);
return $pdf->download("ProjectEstimation_{$project->code}.pdf");
}
public function exportWizardExcel(Project $project)
{
$project->load([
'materialsEstimates.material',
'tasks.taskLabors.labor',
'tasks.taskEquipments.equipment',
]);
$totals = $this->getWizardTotals($project);
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Estimation Report');
$sheet->setShowGridlines(true);
// Styling helpers
$titleStyle = [
'font' => [
'name' => 'Arial',
'size' => 16,
'bold' => true,
'color' => ['rgb' => 'FFFFFF'],
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => '334155'], // Slate 700
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
],
];
$metaHeaderStyle = [
'font' => [
'name' => 'Arial',
'size' => 11,
'bold' => true,
'color' => ['rgb' => '334155'],
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'F1F5F9'], // Slate 100
],
'borders' => [
'bottom' => [
'borderStyle' => Border::BORDER_MEDIUM,
'color' => ['rgb' => 'CBD5E1'],
],
],
];
$sectionHeaderStyle = [
'font' => [
'name' => 'Arial',
'size' => 12,
'bold' => true,
'color' => ['rgb' => 'FFFFFF'],
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => '475569'], // Slate 600
],
'alignment' => [
'vertical' => Alignment::VERTICAL_CENTER,
],
];
$boldLabelStyle = [
'font' => [
'name' => 'Arial',
'size' => 10,
'bold' => true,
'color' => ['rgb' => '1E293B'],
],
];
$tableHeaderStyle = [
'font' => [
'name' => 'Arial',
'size' => 10,
'bold' => true,
'color' => ['rgb' => '475569'],
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'F8FAFC'],
],
'borders' => [
'bottom' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => 'E2E8F0'],
],
],
];
$thinBorderBottom = [
'borders' => [
'bottom' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => 'F1F5F9'],
],
],
];
$totalRowStyle = [
'font' => [
'bold' => true,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'F8FAFC'],
],
'borders' => [
'top' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => 'CBD5E1'],
],
'bottom' => [
'borderStyle' => Border::BORDER_DOUBLE,
'color' => ['rgb' => '1E293B'],
],
],
];
// 1. Branding Header
$sheet->mergeCells('A1:G1');
$sheet->setCellValue('A1', 'GREAT SWISS BUILDER CONSTRUCTION');
$sheet->getStyle('A1:G1')->applyFromArray($titleStyle);
$sheet->getRowDimension(1)->setRowHeight(40);
// 2. Metadata Block
$sheet->mergeCells('A3:G3');
$sheet->setCellValue('A3', ' ESTIMATION PROJECT DETAILS');
$sheet->getStyle('A3:G3')->applyFromArray($metaHeaderStyle);
$sheet->getRowDimension(3)->setRowHeight(25);
// Row 4
$sheet->setCellValue('A4', 'Project Code:');
$sheet->setCellValue('B4', $project->code);
$sheet->setCellValue('D4', 'Project Type:');
$sheet->setCellValue('E4', ucfirst($project->project_type));
$sheet->setCellValue('F4', 'Date Generated:');
$sheet->setCellValue('G4', now()->format('Y-m-d'));
// Row 5
$sheet->setCellValue('A5', 'Project Name:');
$sheet->mergeCells('B5:C5');
$sheet->setCellValue('B5', $project->name);
$sheet->setCellValue('D5', 'Client Name:');
$sheet->mergeCells('E5:G5');
$sheet->setCellValue('E5', $project->client_name ?: 'N/A');
// Row 6
$sheet->setCellValue('A6', 'Location:');
$sheet->mergeCells('B6:C6');
$sheet->setCellValue('B6', $project->location ?: 'N/A');
$sheet->setCellValue('D6', 'Contract Value:');
$sheet->setCellValue('E6', $project->contract_value);
$sheet->getStyle('E6')->getNumberFormat()->setFormatCode('"₱"#,##0.00');
foreach (['A4', 'D4', 'F4', 'A5', 'D5', 'A6', 'D6'] as $cell) {
$sheet->getStyle($cell)->applyFromArray($boldLabelStyle);
}
$rowIdx = 8;
// 3. Materials Cost Center
$sheet->mergeCells("A{$rowIdx}:G{$rowIdx}");
$sheet->setCellValue("A{$rowIdx}", ' 1. MATERIALS COST');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($sectionHeaderStyle);
$sheet->getRowDimension($rowIdx)->setRowHeight(25);
$rowIdx++;
// Table Headers
$sheet->setCellValue("A{$rowIdx}", '#');
$sheet->mergeCells("B{$rowIdx}:C{$rowIdx}");
$sheet->setCellValue("B{$rowIdx}", 'Material Name');
$sheet->setCellValue("D{$rowIdx}", 'Unit');
$sheet->setCellValue("E{$rowIdx}", 'Est. Qty');
$sheet->setCellValue("F{$rowIdx}", 'Unit Cost');
$sheet->setCellValue("G{$rowIdx}", 'Total Cost');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($tableHeaderStyle);
$rowIdx++;
$matIndex = 1;
foreach ($project->materialsEstimates as $est) {
$sheet->setCellValue("A{$rowIdx}", $matIndex++);
$sheet->mergeCells("B{$rowIdx}:C{$rowIdx}");
$sheet->setCellValue("B{$rowIdx}", $est->material->name);
$sheet->setCellValue("D{$rowIdx}", $est->material->unit);
$sheet->setCellValue("E{$rowIdx}", $est->estimated_qty);
$sheet->getStyle("E{$rowIdx}")->getNumberFormat()->setFormatCode('#,##0.00');
$sheet->setCellValue("F{$rowIdx}", $est->unit_cost);
$sheet->getStyle("F{$rowIdx}")->getNumberFormat()->setFormatCode('"₱"#,##0.00');
// Formula for total cost: E * F
$sheet->setCellValue("G{$rowIdx}", "=E{$rowIdx}*F{$rowIdx}");
$sheet->getStyle("G{$rowIdx}")->getNumberFormat()->setFormatCode('"₱"#,##0.00');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($thinBorderBottom);
$rowIdx++;
}
// Subtotal row for Materials
$sheet->mergeCells("A{$rowIdx}:F{$rowIdx}");
$sheet->setCellValue("A{$rowIdx}", 'Subtotal Materials Cost Center');
$sheet->setCellValue("G{$rowIdx}", $totals['materials']);
$sheet->getStyle("G{$rowIdx}")->getNumberFormat()->setFormatCode('"₱"#,##0.00');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($totalRowStyle);
$sheet->getStyle("A{$rowIdx}")->applyFromArray($boldLabelStyle);
$rowIdx += 2;
// 4. Labor Cost Center
$sheet->mergeCells("A{$rowIdx}:G{$rowIdx}");
$sheet->setCellValue("A{$rowIdx}", ' 2. LABOR MANPOWER COST');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($sectionHeaderStyle);
$sheet->getRowDimension($rowIdx)->setRowHeight(25);
$rowIdx++;
// Table Headers
$sheet->setCellValue("A{$rowIdx}", '#');
$sheet->setCellValue("B{$rowIdx}", 'Target Task');
$sheet->setCellValue("C{$rowIdx}", 'Labor Record');
$sheet->setCellValue("D{$rowIdx}", 'Category');
$sheet->setCellValue("E{$rowIdx}", 'Hourly Rate');
$sheet->setCellValue("F{$rowIdx}", 'Est. Hours');
$sheet->setCellValue("G{$rowIdx}", 'Total Cost');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($tableHeaderStyle);
$rowIdx++;
$labIndex = 1;
foreach ($project->tasks as $task) {
foreach ($task->taskLabors as $tl) {
$sheet->setCellValue("A{$rowIdx}", $labIndex++);
$sheet->setCellValue("B{$rowIdx}", $task->name);
$sheet->setCellValue("C{$rowIdx}", $tl->labor->name);
$sheet->setCellValue("D{$rowIdx}", ucfirst($tl->labor->category));
$sheet->setCellValue("E{$rowIdx}", $tl->labor->hourly_rate);
$sheet->getStyle("E{$rowIdx}")->getNumberFormat()->setFormatCode('"₱"#,##0.00');
$sheet->setCellValue("F{$rowIdx}", $tl->estimated_hours);
$sheet->getStyle("F{$rowIdx}")->getNumberFormat()->setFormatCode('#,##0.0');
// Formula: E * F
$sheet->setCellValue("G{$rowIdx}", "=E{$rowIdx}*F{$rowIdx}");
$sheet->getStyle("G{$rowIdx}")->getNumberFormat()->setFormatCode('"₱"#,##0.00');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($thinBorderBottom);
$rowIdx++;
}
}
// Subtotal row for Labor
$sheet->mergeCells("A{$rowIdx}:F{$rowIdx}");
$sheet->setCellValue("A{$rowIdx}", 'Subtotal Labor Manpower Cost Center');
$sheet->setCellValue("G{$rowIdx}", $totals['labor']);
$sheet->getStyle("G{$rowIdx}")->getNumberFormat()->setFormatCode('"₱"#,##0.00');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($totalRowStyle);
$sheet->getStyle("A{$rowIdx}")->applyFromArray($boldLabelStyle);
$rowIdx += 2;
// 5. Equipment Cost Center
$sheet->mergeCells("A{$rowIdx}:G{$rowIdx}");
$sheet->setCellValue("A{$rowIdx}", ' 3. EQUIPMENT MACHINERY COST');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($sectionHeaderStyle);
$sheet->getRowDimension($rowIdx)->setRowHeight(25);
$rowIdx++;
// Table Headers
$sheet->setCellValue("A{$rowIdx}", '#');
$sheet->setCellValue("B{$rowIdx}", 'Target Task');
$sheet->setCellValue("C{$rowIdx}", 'Equipment / Tool');
$sheet->setCellValue("D{$rowIdx}", 'Owner');
$sheet->setCellValue("E{$rowIdx}", 'Hourly Rate');
$sheet->setCellValue("F{$rowIdx}", 'Est. Hours');
$sheet->setCellValue("G{$rowIdx}", 'Total Cost');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($tableHeaderStyle);
$rowIdx++;
$eqIndex = 1;
foreach ($project->tasks as $task) {
foreach ($task->taskEquipments as $te) {
$sheet->setCellValue("A{$rowIdx}", $eqIndex++);
$sheet->setCellValue("B{$rowIdx}", $task->name);
$sheet->setCellValue("C{$rowIdx}", $te->equipment->name);
$sheet->setCellValue("D{$rowIdx}", $te->equipment->owner_name ?: 'Unspecified');
$sheet->setCellValue("E{$rowIdx}", $te->equipment->hourly_rate);
$sheet->getStyle("E{$rowIdx}")->getNumberFormat()->setFormatCode('"₱"#,##0.00');
$sheet->setCellValue("F{$rowIdx}", $te->estimated_hours);
$sheet->getStyle("F{$rowIdx}")->getNumberFormat()->setFormatCode('#,##0.0');
// Formula: E * F
$sheet->setCellValue("G{$rowIdx}", "=E{$rowIdx}*F{$rowIdx}");
$sheet->getStyle("G{$rowIdx}")->getNumberFormat()->setFormatCode('"₱"#,##0.00');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($thinBorderBottom);
$rowIdx++;
}
}
// Subtotal row for Equipment
$sheet->mergeCells("A{$rowIdx}:F{$rowIdx}");
$sheet->setCellValue("A{$rowIdx}", 'Subtotal Equipment Machinery Cost Center');
$sheet->setCellValue("G{$rowIdx}", $totals['equipment']);
$sheet->getStyle("G{$rowIdx}")->getNumberFormat()->setFormatCode('"₱"#,##0.00');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($totalRowStyle);
$sheet->getStyle("A{$rowIdx}")->applyFromArray($boldLabelStyle);
$rowIdx += 2;
// 6. Summary Block
$sheet->mergeCells("A{$rowIdx}:G{$rowIdx}");
$sheet->setCellValue("A{$rowIdx}", ' 4. FINANCIAL SUMMARY');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($sectionHeaderStyle);
$sheet->getRowDimension($rowIdx)->setRowHeight(25);
$rowIdx++;
$sheet->mergeCells("A{$rowIdx}:F{$rowIdx}");
$sheet->setCellValue("A{$rowIdx}", 'Total Summed Budget Estimation (Materials + Labor + Equipment):');
$sheet->setCellValue("G{$rowIdx}", $totals['sum']);
$sheet->getStyle("G{$rowIdx}")->getNumberFormat()->setFormatCode('"₱"#,##0.00');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($thinBorderBottom);
$sheet->getStyle("A{$rowIdx}")->applyFromArray($boldLabelStyle);
$rowIdx++;
$sheet->mergeCells("A{$rowIdx}:F{$rowIdx}");
$sheet->setCellValue("A{$rowIdx}", 'Registered Project Contract Value:');
$sheet->setCellValue("G{$rowIdx}", $project->contract_value);
$sheet->getStyle("G{$rowIdx}")->getNumberFormat()->setFormatCode('"₱"#,##0.00');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($thinBorderBottom);
$sheet->getStyle("A{$rowIdx}")->applyFromArray($boldLabelStyle);
$rowIdx++;
$sheet->mergeCells("A{$rowIdx}:F{$rowIdx}");
$sheet->setCellValue("A{$rowIdx}", 'Projected Gross Margin:');
$sheet->setCellValue("G{$rowIdx}", $totals['margin']);
$sheet->getStyle("G{$rowIdx}")->getNumberFormat()->setFormatCode('"₱"#,##0.00');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($thinBorderBottom);
$sheet->getStyle("A{$rowIdx}")->applyFromArray($boldLabelStyle);
$rowIdx++;
$sheet->mergeCells("A{$rowIdx}:F{$rowIdx}");
$sheet->setCellValue("A{$rowIdx}", 'Projected Profit Margin Percentage:');
$sheet->setCellValue("G{$rowIdx}", $totals['margin_pct'] / 100);
$sheet->getStyle("G{$rowIdx}")->getNumberFormat()->setFormatCode('0.00%');
$sheet->getStyle("A{$rowIdx}:G{$rowIdx}")->applyFromArray($totalRowStyle);
$sheet->getStyle("A{$rowIdx}")->applyFromArray($boldLabelStyle);
// Auto-fit column widths
foreach (range('A', 'G') as $col) {
$sheet->getColumnDimension($col)->setAutoSize(true);
}
// Export response headers
$filename = "EstimationReport_{$project->code}_" . now()->format('Ymd') . ".xlsx";
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment;filename="' . $filename . '"');
header('Cache-Control: max-age=0');
$writer = new Xlsx($spreadsheet);
$writer->save('php://output');
exit;
}
}