-
+
'Under Bidding',
+ self::UnderBidding => 'Planning',
self::Planning => 'Planning',
self::InProgress => 'In Progress',
self::OnHold => 'On Hold',
diff --git a/Modules/ProjectManagement/app/Http/Controllers/ProjectController.php b/Modules/ProjectManagement/app/Http/Controllers/ProjectController.php
index 404a1cc..17feb95 100644
--- a/Modules/ProjectManagement/app/Http/Controllers/ProjectController.php
+++ b/Modules/ProjectManagement/app/Http/Controllers/ProjectController.php
@@ -7,6 +7,7 @@ use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Modules\MasterData\Models\Material;
+use Modules\MasterData\Models\MaterialGroup;
use Modules\MaterialLogistics\Models\ProjectInventory;
use Modules\ProjectManagement\Enums\DelayReason;
use Modules\ProjectManagement\Enums\ProjectStatus;
@@ -17,6 +18,7 @@ use Modules\ProjectManagement\Models\ProjectClassification;
use Modules\ProjectManagement\Models\ProjectMilestone;
use Modules\ProjectManagement\Models\Task;
use Modules\Labors\Models\Labor;
+use Modules\Equipments\Models\Equipment;
use Modules\MasterData\Models\Team;
use Modules\MasterData\Models\EquipmentRate;
use Barryvdh\DomPDF\Facade\Pdf;
@@ -122,18 +124,19 @@ class ProjectController extends Controller
$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',
+ 'client_name' => 'required|string|max:255',
+ 'description' => 'required|string|min:3',
+ 'location' => 'required|string|max:255',
+ 'contract_value' => 'required|numeric|min:0.01',
+ 'contract_duration' => 'required|integer|min:1',
+ 'start_date' => 'required|date',
+ 'target_end_date' => 'required|date|after_or_equal:start_date',
'project_type' => 'required|string|in:standard,special,extension',
- 'classifications' => 'nullable|array',
+ 'classifications' => 'required|array|min:1',
'classifications.*' => 'string',
'parent_project_id' => 'nullable|string',
'is_unprofitable' => 'nullable|boolean',
+ 'pm_id' => 'required',
]);
if (!empty($validated['parent_project_id'])) {
@@ -336,15 +339,15 @@ class ProjectController extends Controller
$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',
+ 'client_name' => 'required|string|max:255',
+ 'description' => 'required|string|min:3',
+ 'location' => 'required|string|max:255',
+ 'contract_value' => 'required|numeric|min:0.01',
+ 'contract_duration' => 'required|integer|min:1',
+ 'start_date' => 'required|date',
+ 'target_end_date' => 'required|date|after_or_equal:start_date',
'project_type' => 'required|string|in:standard,special,extension',
- 'classifications' => 'nullable|array',
+ 'classifications' => 'required|array|min:1',
'classifications.*' => 'string',
'parent_project_id' => 'nullable|string',
'is_unprofitable' => 'nullable|boolean',
@@ -483,38 +486,22 @@ class ProjectController extends Controller
->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')
+ $parentProjects = Project::where('id', '!=', $project->id)->active()->with('parentProject:id,ulid,name,code')->get(['id', 'ulid', 'name', 'code', 'parent_project_id', 'project_type']);
+ $materials = Material::where('type', 'single')->where('status', 'active')->orderBy('name')->get();
+ $materialGroups = 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()
- ];
- });
+ ->get();
+ $labors = Labor::active()->with('skills')->orderBy('name')->get();
+ $equipments = Equipment::active()->orderBy('name')->get();
+ $teams = Team::with('users:id,ulid,name')->get();
- $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);
+ $budget = [
+ 'total_materials' => $project->materialsEstimates->sum(fn ($e) => $e->estimated_qty * $e->unit_cost),
+ 'total_labor' => $project->tasks->flatMap->taskLabors->sum(fn ($tl) => $tl->estimated_hours * ($tl->labor->hourly_rate ?? 0)),
+ 'total_equipment' => $project->tasks->flatMap->taskEquipments->sum(fn ($te) => $te->estimated_hours * ($te->equipment->hourly_rate ?? 0)),
+ ];
return Inertia::render('ProjectManagement::Projects/Wizard', [
'project' => $project,
@@ -539,16 +526,16 @@ class ProjectController extends Controller
$validated = $request->validate([
'name' => 'required|string|max:255',
- 'client_name' => 'nullable|string|max:255',
- 'description' => 'nullable|string',
- 'pm_id' => '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',
+ 'client_name' => 'required|string|max:255',
+ 'description' => 'required|string|min:3',
+ 'pm_id' => 'required',
+ 'location' => 'required|string|max:255',
+ 'contract_value' => 'required|numeric|min:0.01',
+ 'contract_duration' => 'required|integer|min:1',
+ 'start_date' => 'required|date',
+ 'target_end_date' => 'required|date|after_or_equal:start_date',
'project_type' => 'required|string|in:standard,special,extension',
- 'classifications' => 'nullable|array',
+ 'classifications' => 'required|array|min:1',
'classifications.*' => 'string',
'parent_project_id' => 'nullable|string',
'is_unprofitable' => 'nullable|boolean',
@@ -602,11 +589,12 @@ class ProjectController extends Controller
'milestones.*.ulid' => 'nullable|string',
'milestones.*.name' => 'required|string|max:255',
'milestones.*.weight_percentage' => 'required|numeric|min:0|max:100',
+ 'milestones.*.target_date' => 'required|date',
'tasks' => 'nullable|array',
'tasks.*.ulid' => 'nullable|string',
'tasks.*.name' => 'required|string|max:255',
- 'tasks.*.description' => 'nullable|string',
- 'tasks.*.milestone_ulid' => 'nullable|string',
+ 'tasks.*.description' => 'required|string|min:3',
+ 'tasks.*.milestone_ulid' => 'required|string',
'tasks.*.start_date' => 'nullable|date',
'tasks.*.end_date' => 'nullable|date|after_or_equal:tasks.*.start_date',
]);
@@ -624,16 +612,19 @@ class ProjectController extends Controller
if (!empty($m['ulid'])) {
$milestone = $project->milestones()->where('ulid', $m['ulid'])->first();
}
+ $plannedDate = !empty($m['target_date']) ? $m['target_date'] : ($m['planned_date'] ?? null);
if (!$milestone) {
$milestone = $project->milestones()->create([
'name' => $m['name'],
'weight_percentage' => $m['weight_percentage'],
+ 'planned_date' => $plannedDate,
'sort_order' => $index,
]);
} else {
$milestone->update([
'name' => $m['name'],
'weight_percentage' => $m['weight_percentage'],
+ 'planned_date' => $plannedDate,
'sort_order' => $index,
]);
}
diff --git a/Modules/ProjectManagement/app/Models/Project.php b/Modules/ProjectManagement/app/Models/Project.php
index eb67b2c..152fd62 100644
--- a/Modules/ProjectManagement/app/Models/Project.php
+++ b/Modules/ProjectManagement/app/Models/Project.php
@@ -166,6 +166,66 @@ class Project extends Model
return $this->belongsToMany(ProjectClassification::class, 'project_classification_pivot');
}
+ public const CANONICAL_CLASSIFICATIONS = [
+ 'road highway, pavement, railways, airport horizontal structures and bridges' => 'Road, Highway, Pavement, Railways, Airport Horizontal Structures and Bridges',
+ 'road, highway, pavement, railways, airport horizontal structures and bridges' => 'Road, Highway, Pavement, Railways, Airport Horizontal Structures and Bridges',
+ 'irrigation and flood control' => 'Irrigation and Flood Control',
+ 'dam, reservoir, and tunneling' => 'Dam, Reservoir, and Tunneling',
+ 'dam, reservoir and tunneling' => 'Dam, Reservoir, and Tunneling',
+ 'water supply' => 'Water Supply',
+ 'port, harbor and offshore engineering' => 'Port, Harbor and Offshore Engineering',
+ 'building and industrial plant' => 'Building and Industrial Plant',
+ 'sewerage treatment/disposal plant' => 'Sewerage Treatment / Disposal Plant',
+ 'sewerage treatment / disposal plant' => 'Sewerage Treatment / Disposal Plant',
+ 'water treatment plant and system' => 'Water Treatment Plant and System',
+ 'park, playground and recreational work' => 'Park, Playground and Recreational Work',
+ 'electrical work' => 'Electrical Work',
+ ];
+
+ /**
+ * Ensure classifications always return properly formatted Title Case strings.
+ */
+ public function getClassificationsAttribute($value): array
+ {
+ if (is_null($value)) {
+ return [];
+ }
+ $decoded = is_string($value) ? json_decode($value, true) : $value;
+ if (!is_array($decoded)) {
+ return [];
+ }
+
+ return array_values(array_map(function ($name) {
+ $trimmed = trim((string) $name);
+ $lower = strtolower($trimmed);
+ return self::CANONICAL_CLASSIFICATIONS[$lower] ?? (self::CANONICAL_CLASSIFICATIONS[$trimmed] ?? $trimmed);
+ }, $decoded));
+ }
+
+ /**
+ * Mutate classifications on set to canonical Title Case strings.
+ */
+ public function setClassificationsAttribute($value): void
+ {
+ if (is_null($value)) {
+ $this->attributes['classifications'] = json_encode([]);
+ return;
+ }
+
+ $array = is_string($value) ? json_decode($value, true) : $value;
+ if (!is_array($array)) {
+ $array = [];
+ }
+
+ $canonical = array_values(array_map(function ($name) {
+ $trimmed = trim((string) $name);
+ $lower = strtolower($trimmed);
+ return self::CANONICAL_CLASSIFICATIONS[$lower] ?? (self::CANONICAL_CLASSIFICATIONS[$trimmed] ?? $trimmed);
+ }, $array));
+
+ $this->attributes['classifications'] = json_encode($canonical);
+ }
+
// --- State Machine Helpers ---
public function transitionTo(ProjectStatus $newStatus): void
diff --git a/Modules/ProjectManagement/app/Services/ProjectWorkflowService.php b/Modules/ProjectManagement/app/Services/ProjectWorkflowService.php
index e0f25fd..f6cdbf7 100644
--- a/Modules/ProjectManagement/app/Services/ProjectWorkflowService.php
+++ b/Modules/ProjectManagement/app/Services/ProjectWorkflowService.php
@@ -101,4 +101,116 @@ class ProjectWorkflowService
'is_over_budget' => $poTotal > $totalEstimated || $project->rollup_capitalization > $project->rollup_contract_value,
];
}
+
+ /**
+ * Compute remaining estimated materials for a project taking into account:
+ * - Project baseline estimated quantities
+ * - Quantities requested on active (non-rejected/cancelled) estimated MR items
+ * - Missing/shortage quantities released back from delivered POs
+ * - Optional exclusion of a requisition ID (for editing an existing draft MR)
+ */
+ public function getRemainingMaterialEstimates(Project $project, ?int $excludeRequisitionId = null): array
+ {
+ $project->loadMissing('materialsEstimates.material');
+
+ // Sum requested quantities in non-rejected, non-cancelled MRs
+ $alreadyReqMap = \Modules\MaterialLogistics\Models\MaterialRequisitionItem::whereHas('requisition', function ($q) use ($project, $excludeRequisitionId) {
+ $q->where('project_id', $project->id)
+ ->whereNotIn('status', ['rejected', 'cancelled']);
+ if ($excludeRequisitionId) {
+ $q->where('id', '!=', $excludeRequisitionId);
+ }
+ })
+ ->where('is_unestimated', false)
+ ->select('material_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity) as total_qty'))
+ ->groupBy('material_id')
+ ->pluck('total_qty', 'material_id')
+ ->toArray();
+
+ // Release shortage/missing quantities from delivered POs back into the available pool
+ $missingPoQtyMap = \Modules\MaterialLogistics\Models\PurchaseOrderItem::whereHas('purchaseOrder', function ($q) use ($project) {
+ $q->where('project_id', $project->id)
+ ->where('status', 'delivered');
+ })
+ ->where('missing_quantity', '>', 0)
+ ->select('material_id', \Illuminate\Support\Facades\DB::raw('SUM(missing_quantity) as total_missing'))
+ ->groupBy('material_id')
+ ->pluck('total_missing', 'material_id')
+ ->toArray();
+
+ $estimates = [];
+ foreach ($project->materialsEstimates as $estimate) {
+ if (!$estimate->material) {
+ continue;
+ }
+
+ $alreadyReq = (float) ($alreadyReqMap[$estimate->material_id] ?? 0);
+ $missingFromPo = (float) ($missingPoQtyMap[$estimate->material_id] ?? 0);
+ $netRequested = max(0, $alreadyReq - $missingFromPo);
+ $remaining = max(0, (float) $estimate->estimated_qty - $netRequested);
+
+ if ($remaining > 0) {
+ $estimates[] = [
+ 'material_id' => $estimate->material_id,
+ 'material_ulid' => $estimate->material->ulid,
+ 'material_name' => $estimate->material->name,
+ 'unit' => $estimate->material->unit,
+ 'quantity' => $remaining,
+ 'unit_cost' => (float) $estimate->unit_cost,
+ 'estimated_qty' => (float) $estimate->estimated_qty,
+ 'remaining_qty' => $remaining,
+ ];
+ }
+ }
+
+ return $estimates;
+ }
+
+ /**
+ * Validate that estimated items do not exceed the remaining estimated quantities.
+ * Returns null if valid, or an error message string if any item exceeds limits.
+ */
+ public function validateEstimatedQuantities(Project $project, array $items, ?int $excludeRequisitionId = null): ?string
+ {
+ $remainingEstimates = $this->getRemainingMaterialEstimates($project, $excludeRequisitionId);
+ $remainingByUlid = collect($remainingEstimates)->keyBy('material_ulid');
+ $remainingById = collect($remainingEstimates)->keyBy('material_id');
+
+ // Track cumulative requested quantities in this single submission to prevent duplicates bypassing limit
+ $cumulativeRequested = [];
+
+ foreach ($items as $item) {
+ $isUnestimated = isset($item['is_unestimated']) ? (bool) $item['is_unestimated'] : false;
+ if ($isUnestimated) {
+ continue; // Unestimated/supplemental items are not restricted by estimated budget caps
+ }
+
+ $materialUlid = $item['material_ulid'] ?? null;
+ $materialId = $item['material_id'] ?? null;
+ $qty = (float) ($item['quantity'] ?? 0);
+
+ $estimate = $materialUlid ? ($remainingByUlid[$materialUlid] ?? null) : ($remainingById[$materialId] ?? null);
+
+ if (!$estimate) {
+ $materialName = $item['material_name'] ?? 'Material';
+ if ($materialUlid) {
+ $mat = \Modules\MasterData\Models\Material::where('ulid', $materialUlid)->first();
+ if ($mat) {
+ $materialName = $mat->name;
+ }
+ }
+ return "The material '{$materialName}' has no remaining estimated quantity on this project. Please select Unestimated Materials for supplemental requests.";
+ }
+
+ $key = $estimate['material_ulid'];
+ $cumulativeRequested[$key] = ($cumulativeRequested[$key] ?? 0) + $qty;
+ $maxAllowed = (float) $estimate['remaining_qty'];
+
+ if ($cumulativeRequested[$key] > $maxAllowed + 0.0001) {
+ return "The requested quantity (" . number_format($cumulativeRequested[$key], 2) . ") for '{$estimate['material_name']}' exceeds the project's remaining estimate of " . number_format($maxAllowed, 2) . " {$estimate['unit']}. Please reduce the quantity or choose Unestimated Materials for additional items.";
+ }
+ }
+
+ return null;
+ }
}
diff --git a/Modules/ProjectManagement/resources/js/Components/ProjectForm.tsx b/Modules/ProjectManagement/resources/js/Components/ProjectForm.tsx
index 861a5a2..6dae81a 100644
--- a/Modules/ProjectManagement/resources/js/Components/ProjectForm.tsx
+++ b/Modules/ProjectManagement/resources/js/Components/ProjectForm.tsx
@@ -308,16 +308,16 @@ export interface ProjectClassificationItem {
}
export const DEFAULT_PROJECT_CLASSIFICATIONS: ProjectClassificationItem[] = [
- { id: 1, code: 'ROAD-HWY', name: 'road highway, pavement, railways, airport horizontal structures and bridges' },
- { id: 2, code: 'IRR-FLD', name: 'irrigation and flood control' },
- { id: 3, code: 'DAM-RES', name: 'dam, reservoir, and tunneling' },
- { id: 4, code: 'WAT-SUP', name: 'water supply' },
- { id: 5, code: 'PORT-ENG', name: 'port, harbor and offshore engineering' },
- { id: 6, code: 'BLD-PLANT', name: 'building and industrial plant' },
- { id: 7, code: 'SEW-TREAT', name: 'sewerage treatment/disposal plant' },
- { id: 8, code: 'WTR-TREAT', name: 'water treatment plant and system' },
- { id: 9, code: 'REC-PARK', name: 'park, playground and recreational work' },
- { id: 10, code: 'ELEC-WRK', name: 'electrical work' },
+ { id: 1, code: 'ROAD-HWY', name: 'Road, Highway, Pavement, Railways, Airport Horizontal Structures and Bridges' },
+ { id: 2, code: 'IRR-FLD', name: 'Irrigation and Flood Control' },
+ { id: 3, code: 'DAM-RES', name: 'Dam, Reservoir, and Tunneling' },
+ { id: 4, code: 'WAT-SUP', name: 'Water Supply' },
+ { id: 5, code: 'PORT-ENG', name: 'Port, Harbor and Offshore Engineering' },
+ { id: 6, code: 'BLD-PLANT', name: 'Building and Industrial Plant' },
+ { id: 7, code: 'SEW-TREAT', name: 'Sewerage Treatment / Disposal Plant' },
+ { id: 8, code: 'WTR-TREAT', name: 'Water Treatment Plant and System' },
+ { id: 9, code: 'REC-PARK', name: 'Park, Playground and Recreational Work' },
+ { id: 10, code: 'ELEC-WRK', name: 'Electrical Work' },
];
export interface ProjectFormData {
@@ -365,9 +365,11 @@ export function ProjectForm({ data, setData, errors, employees, projects = [], c
const start = new Date(data.start_date);
const end = new Date(data.target_end_date);
const diffMs = end.getTime() - start.getTime();
- if (diffMs >= 0) {
- const days = Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1; // inclusive of start & end
+ if (!isNaN(diffMs) && diffMs >= 0) {
+ const days = Math.max(1, Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1); // inclusive of start & end
setData('contract_duration', String(days));
+ } else if (diffMs < 0) {
+ setData('contract_duration', '0');
}
}
}, [data.start_date, data.target_end_date]);
@@ -409,7 +411,7 @@ export function ProjectForm({ data, setData, errors, employees, projects = [], c
@@ -452,7 +454,7 @@ export function ProjectForm({ data, setData, errors, employees, projects = [], c
- Select one or more construction classification tags applicable to this project:
+ Select one or more construction classification tags applicable to this project (minimum 1 required):