Estimated Hours
{detailsTask.estimated_hours || 0}h
@@ -628,6 +678,74 @@ export default function Tasks({ project, employees, availableMaterials, delayRea
)}
+
+ {/* Edit Task Dialog */}
+
);
}
diff --git a/Modules/TimelineScheduling/app/Http/Controllers/MilestoneController.php b/Modules/TimelineScheduling/app/Http/Controllers/MilestoneController.php
index 08dbc54..066f2f6 100644
--- a/Modules/TimelineScheduling/app/Http/Controllers/MilestoneController.php
+++ b/Modules/TimelineScheduling/app/Http/Controllers/MilestoneController.php
@@ -19,20 +19,78 @@ class MilestoneController extends Controller
$project = Project::where('ulid', $projectUlid)->firstOrFail();
$project->load([
'tasks' => fn ($q) => $q->select('id', 'project_id')->with('delays'),
- 'milestones',
+ 'milestones' => fn ($q) => $q->with(['tasks.taskMaterials.material']),
]);
}
return \Inertia\Inertia::render('TimelineScheduling::Timeline/Index', [
'project' => $project,
- 'projects' => Project::select('id', 'ulid', 'name')->get(),
- 'milestones' => $project ? $project->milestones->map(fn ($m) => array_merge($m->toArray(), [
- 'is_completed' => $m->is_completed,
- 'is_overdue' => $m->is_overdue,
- 'status' => $m->status,
- 'status_color' => $m->status_color,
- 'days_delayed' => $m->days_delayed,
- ])) : [],
+ 'projects' => Project::with('parentProject:id,ulid,name,code')->get(['id', 'ulid', 'name', 'code', 'project_type', 'parent_project_id']),
+ 'milestones' => $project ? $project->milestones->map(function ($m) {
+ $tasks = $m->tasks;
+
+ // Job done calculation
+ if ($tasks->isEmpty()) {
+ $jobDone = $m->actual_date !== null ? 100.0 : 0.0;
+ } else {
+ $jobDone = round($tasks->avg('completion_percentage') ?? 0, 2);
+ }
+
+ // Materials Spent and Planned calculations
+ $totalPlannedCost = 0.0;
+ $totalActualCost = 0.0;
+ $materialsMap = [];
+
+ foreach ($tasks as $task) {
+ foreach ($task->taskMaterials as $tm) {
+ $plannedCost = (float) $tm->planned_qty * (float) $tm->unit_cost;
+ $actualCost = (float) $tm->actual_qty * (float) $tm->unit_cost;
+
+ $totalPlannedCost += $plannedCost;
+ $totalActualCost += $actualCost;
+
+ $matId = $tm->material_id;
+ $matName = $tm->material->name ?? 'Unknown Material';
+ $matUnit = $tm->material->unit ?? 'pcs';
+
+ if (!isset($materialsMap[$matId])) {
+ $materialsMap[$matId] = [
+ 'material_id' => $matId,
+ 'name' => $matName,
+ 'unit' => $matUnit,
+ 'planned_qty' => 0.0,
+ 'actual_qty' => 0.0,
+ 'planned_cost' => 0.0,
+ 'actual_cost' => 0.0,
+ ];
+ }
+
+ $materialsMap[$matId]['planned_qty'] += (float) $tm->planned_qty;
+ $materialsMap[$matId]['actual_qty'] += (float) $tm->actual_qty;
+ $materialsMap[$matId]['planned_cost'] += $plannedCost;
+ $materialsMap[$matId]['actual_cost'] += $actualCost;
+ }
+ }
+
+ $materialsDetail = array_values($materialsMap);
+ $materialsSpentPercentage = $totalPlannedCost > 0
+ ? round(($totalActualCost / $totalPlannedCost) * 100, 2)
+ : 0.0;
+
+ return array_merge($m->toArray(), [
+ 'is_completed' => $m->is_completed,
+ 'is_overdue' => $m->is_overdue,
+ 'status' => $m->status,
+ 'status_color' => $m->status_color,
+ 'days_delayed' => $m->days_delayed,
+ 'job_done_percentage' => $jobDone,
+ 'total_planned_materials_cost' => $totalPlannedCost,
+ 'total_actual_materials_cost' => $totalActualCost,
+ 'materials_spent_percentage' => $materialsSpentPercentage,
+ 'materials_detail' => $materialsDetail,
+ 'has_tasks_or_materials' => !$tasks->isEmpty() || $totalPlannedCost > 0,
+ ]);
+ }) : [],
'milestoneStats' => $project ? [
'total' => $project->milestones->count(),
'completed' => $project->milestones->where('actual_date', '!=', null)->count(),
diff --git a/Modules/TimelineScheduling/resources/js/Pages/Timeline/Index.tsx b/Modules/TimelineScheduling/resources/js/Pages/Timeline/Index.tsx
index 249848d..f193b88 100644
--- a/Modules/TimelineScheduling/resources/js/Pages/Timeline/Index.tsx
+++ b/Modules/TimelineScheduling/resources/js/Pages/Timeline/Index.tsx
@@ -5,12 +5,109 @@ import { Badge } from '@/Components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/Components/ui/dialog';
import { Input } from '@/Components/ui/input';
import { useForm, router } from '@inertiajs/react';
-import { CheckCircle2, CloudRain, Clock, Milestone as MilestoneIcon, Plus, AlertTriangle, Trash2, Calendar } from 'lucide-react';
-import { useState, useEffect } from 'react';
+import { CheckCircle2, CloudRain, Clock, Milestone as MilestoneIcon, Plus, AlertTriangle, Trash2, Calendar, Package, ChevronDown, Hammer } from 'lucide-react';
+import { useState, useEffect, useMemo } from 'react';
export default function Timeline({ project, milestones, milestoneStats, weatherConditions }: any) {
const [selectedMilestone, setSelectedMilestone] = useState
(null);
const [weatherImpacted, setWeatherImpacted] = useState(false);
+ const [expandedMilestones, setExpandedMilestones] = useState>({});
+ const [viewMode, setViewMode] = useState<'list' | 'chart'>('list');
+
+ const formatCurrency = (v: string | number) => new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
+
+ const toggleExpandMilestone = (ulid: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ setExpandedMilestones(prev => ({
+ ...prev,
+ [ulid]: !prev[ulid]
+ }));
+ };
+
+ const sCurveData = useMemo(() => {
+ if (!project || !milestones || milestones.length === 0) return null;
+
+ const startDate = project.start_date ? new Date(project.start_date) : new Date();
+ const endDate = project.target_end_date ? new Date(project.target_end_date) : new Date();
+
+ const sortedMilestones = [...milestones].sort((a: any, b: any) => {
+ const dateA = a.planned_date ? new Date(a.planned_date).getTime() : 0;
+ const dateB = b.planned_date ? new Date(b.planned_date).getTime() : 0;
+ return dateA - dateB;
+ });
+
+ const plannedPoints: { date: Date; percent: number; label: string }[] = [];
+ plannedPoints.push({ date: startDate, percent: 0, label: 'Project Start' });
+
+ let cumulativePlanned = 0;
+ sortedMilestones.forEach((m: any) => {
+ if (m.planned_date) {
+ cumulativePlanned += Number(m.weight_percentage) || 0;
+ plannedPoints.push({
+ date: new Date(m.planned_date),
+ percent: Math.min(100, cumulativePlanned),
+ label: `Plan: ${m.name}`
+ });
+ }
+ });
+
+ if (plannedPoints.length > 1) {
+ const lastPlanned = plannedPoints[plannedPoints.length - 1];
+ if (lastPlanned.date.getTime() < endDate.getTime()) {
+ plannedPoints.push({
+ date: endDate,
+ percent: 100,
+ label: 'Project Target End'
+ });
+ }
+ }
+
+ const actualPoints: { date: Date; percent: number; label: string }[] = [];
+ actualPoints.push({ date: startDate, percent: 0, label: 'Project Start' });
+
+ const completedMilestones = milestones
+ .filter((m: any) => m.actual_date)
+ .sort((a: any, b: any) => new Date(a.actual_date).getTime() - new Date(b.actual_date).getTime());
+
+ let cumulativeActual = 0;
+ completedMilestones.forEach((m: any) => {
+ cumulativeActual += Number(m.weight_percentage) || 0;
+ actualPoints.push({
+ date: new Date(m.actual_date),
+ percent: Math.min(100, cumulativeActual),
+ label: `Achieved: ${m.name}`
+ });
+ });
+
+ const today = new Date();
+ const currentProgress = milestones.reduce((sum: number, m: any) => {
+ const milestoneProgress = m.actual_date ? 100 : (Number(m.job_done_percentage) || 0);
+ return sum + (milestoneProgress * (Number(m.weight_percentage) || 0)) / 100;
+ }, 0);
+
+ const latestActualDate = actualPoints.length > 1 ? actualPoints[actualPoints.length - 1].date : startDate;
+ if (today.getTime() > latestActualDate.getTime()) {
+ actualPoints.push({
+ date: today,
+ percent: Math.min(100, currentProgress),
+ label: 'Current Status'
+ });
+ }
+
+ const allDates = [...plannedPoints, ...actualPoints].map(p => p.date.getTime());
+ const minTime = Math.min(...allDates, startDate.getTime());
+ const maxTime = Math.max(...allDates, endDate.getTime());
+
+ return {
+ plannedPoints,
+ actualPoints,
+ minTime,
+ maxTime,
+ startDate,
+ endDate,
+ currentProgress
+ };
+ }, [project, milestones]);
// Form for adding a milestone
const addForm = useForm({
@@ -137,19 +234,45 @@ export default function Timeline({ project, milestones, milestoneStats, weatherC
-
+
Milestone Sequence
-
+
+
+
+
+
+
+
@@ -158,19 +281,22 @@ export default function Timeline({ project, milestones, milestoneStats, weatherC
No milestones defined for this project yet.
+ ) : viewMode === 'chart' ? (
+
) : (
- {/* Vertical timeline line */}
-
+ {/* Vertical timeline line (perfectly centered under 32px node dots) */}
+
{milestones?.map((m: any, idx: number) => {
const isSelected = selectedMilestone?.ulid === m.ulid;
+ const isExpanded = !!expandedMilestones[m.ulid];
return (
setSelectedMilestone(m)}
- className={`relative flex items-start gap-4 p-3.5 rounded-xl border cursor-pointer transition-all duration-200 ${
+ className={`relative flex items-start gap-4 p-4 rounded-xl border cursor-pointer transition-all duration-200 ${
isSelected
? 'border-emerald-500 bg-emerald-50/40 shadow-sm'
: 'border-transparent hover:bg-gray-50 hover:border-gray-200'
@@ -193,21 +319,24 @@ export default function Timeline({ project, milestones, milestoneStats, weatherC
{/* Content */}
-
-
- {m.name}
-
-
- {Number(m.weight_percentage).toFixed(0)}%
-
- {m.weather_impacted && (
-
- {m.weather_delay_days}d
+
+
+
+ {m.name}
+
+
+ {Number(m.weight_percentage).toFixed(0)}%
- )}
+ {m.weather_impacted && (
+
+ {m.weather_delay_days}d
+
+ )}
+
+
{m.planned_date && (
Plan: {new Date(m.planned_date).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' })}
@@ -219,6 +348,94 @@ export default function Timeline({ project, milestones, milestoneStats, weatherC
)}
+
+ {/* Progress Bars Section */}
+ {m.has_tasks_or_materials ? (
+
+ {/* Job Done Progress Bar */}
+
+
+
+
+ Job Done
+
+ {Number(m.job_done_percentage).toFixed(0)}%
+
+
+
+
+ {/* Materials Spent Progress Bar */}
+ {m.total_planned_materials_cost > 0 && (
+
+
+
+
+ Materials Spent ({formatCurrency(m.total_actual_materials_cost)} / {formatCurrency(m.total_planned_materials_cost)})
+
+
{Number(m.materials_spent_percentage).toFixed(0)}%
+
+
+
+ )}
+
+ {/* Materials Breakdown Accordion Toggle */}
+ {m.materials_detail && m.materials_detail.length > 0 && (
+
+
+
+
+
+
+ Material Name
+ Spent / Planned
+ Cost Spent
+
+
+ {m.materials_detail.map((mat: any) => (
+
+
+ {mat.name}
+
+
+ {Number(mat.actual_qty).toFixed(1)} / {Number(mat.planned_qty).toFixed(1)} {mat.unit}
+
+
+ {formatCurrency(mat.actual_cost)}
+
+
+ ))}
+
+
+
+
+ )}
+
+ ) : (
+
+
+ No tasks or materials assigned
+
+ )}
);
@@ -458,3 +675,239 @@ export default function Timeline({ project, milestones, milestoneStats, weatherC
);
}
+
+function SCurveChart({ data }: { data: any }) {
+ if (!data) return null;
+ const { plannedPoints, actualPoints, minTime, maxTime, currentProgress } = data;
+
+ const width = 600;
+ const height = 300;
+ const paddingLeft = 60;
+ const paddingRight = 45;
+ const paddingTop = 35;
+ const paddingBottom = 50;
+
+ const chartWidth = width - paddingLeft - paddingRight;
+ const chartHeight = height - paddingTop - paddingBottom;
+
+ const getX = (date: Date) => {
+ const time = date.getTime();
+ const ratio = (time - minTime) / (maxTime - minTime || 1);
+ return paddingLeft + ratio * chartWidth;
+ };
+
+ const getY = (percent: number) => {
+ const ratio = percent / 100;
+ return height - paddingBottom - ratio * chartHeight;
+ };
+
+ // Build planned line path
+ let plannedPath = "";
+ plannedPoints.forEach((p: any, idx: number) => {
+ const x = getX(p.date);
+ const y = getY(p.percent);
+ if (idx === 0) {
+ plannedPath += `M ${x} ${y}`;
+ } else {
+ plannedPath += ` L ${x} ${y}`;
+ }
+ });
+
+ // Build actual line path
+ let actualPath = "";
+ actualPoints.forEach((p: any, idx: number) => {
+ const x = getX(p.date);
+ const y = getY(p.percent);
+ if (idx === 0) {
+ actualPath += `M ${x} ${y}`;
+ } else {
+ actualPath += ` L ${x} ${y}`;
+ }
+ });
+
+ const yGridLines = [0, 25, 50, 75, 100];
+ const xGridLines: { time: number; label: string }[] = [];
+ const step = (maxTime - minTime) / 4;
+ for (let i = 0; i <= 4; i++) {
+ const time = minTime + i * step;
+ const date = new Date(time);
+ xGridLines.push({
+ time,
+ label: date.toLocaleDateString('en-PH', { month: 'short', day: 'numeric' })
+ });
+ }
+
+ const todayX = getX(new Date());
+ const showToday = todayX >= paddingLeft && todayX <= (width - paddingRight);
+
+ const formatCurrency = (v: string | number) => new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v));
+
+ return (
+
+
+
Cumulative Progress S-Curve
+
+
+
+
+
Actual ({currentProgress.toFixed(0)}%)
+
+
+
+
+
+
+ );
+}
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index 0f66560..987f7d0 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -19,6 +19,8 @@ class DatabaseSeeder extends Seeder
SuperAdminSeeder::class,
MainContractorSeeder::class, // Must run after SuperAdminSeeder
\Modules\RolesPermissions\Database\Seeders\RolesPermissionsDatabaseSeeder::class,
+ \Modules\Labors\Database\Seeders\LaborsDatabaseSeeder::class,
+ \Modules\Equipments\Database\Seeders\EquipmentsDatabaseSeeder::class,
]);
$admin = User::firstOrCreate(
diff --git a/docs/PLAN-construction-seeders.md b/docs/PLAN-construction-seeders.md
new file mode 100644
index 0000000..29c2c44
--- /dev/null
+++ b/docs/PLAN-construction-seeders.md
@@ -0,0 +1,36 @@
+# PLAN - Construction Seeders for Single Items and Assemblies & Kits
+
+Create a comprehensive English construction database seeder that populates single items, assemblies, and kits, along with their relational component records.
+
+## Proposed Changes
+
+### Material Logistics Module (Database)
+
+#### [MODIFY] [ConstructionMaterialsSeeder.php](file:///c:/laragon/www/gsb-cons/Modules/MaterialLogistics/database/seeders/ConstructionMaterialsSeeder.php)
+1. Set the `'type' => 'single'` explicitly on all standard materials seeded in the array.
+2. Define a list of compound Assemblies and Kits (e.g. Concrete Slab Pouring Kit, Interior Partition Assembly, Electrical Conduit Kit).
+3. For each Assembly/Kit:
+ - Resolve its child components by name to fetch their database IDs and unit costs.
+ - Calculate the total cost of the assembly/kit as the sum of `unit_cost * quantity` of its child components.
+ - Create the parent `Material` record with `type` set to `'kit'` or `'assembly'` and the computed `unit_cost`.
+ - Create and link the associated component records in the `material_components` table using the `MaterialComponent` model.
+
+---
+
+## Verification Plan
+
+### Automated Tests
+- Run the seeder class to ensure it succeeds without database integrity errors:
+ ```bash
+ C:\laragon\bin\php\php-8.3.30-Win32-vs16-x64\php.exe artisan db:seed --class=\Modules\MaterialLogistics\Database\Seeders\MaterialLogisticsDatabaseSeeder
+ ```
+
+### Manual Verification
+1. Run the seeder and check if the database records are successfully populated:
+ - Check `materials` table to see if `type` values `'single'`, `'kit'`, and `'assembly'` exist.
+ - Check `material_components` table to see if parent-component relationships exist.
+2. Open the browser or UI, go to Materials Catalog, and verify that the Kits and Assemblies display their computed costs and component lists correctly.
+
+## ✅ PHASE X COMPLETE
+- Database Seeder execution: ✅ Pass (Completed successfully in 282 ms)
+- Date: 2026-06-01
diff --git a/docs/PLAN-project-wizard-assemblies-kits.md b/docs/PLAN-project-wizard-assemblies-kits.md
new file mode 100644
index 0000000..617c588
--- /dev/null
+++ b/docs/PLAN-project-wizard-assemblies-kits.md
@@ -0,0 +1,40 @@
+# PLAN - Show Assemblies & Kits in Project Wizard
+
+Pass the database Assemblies & Kits data to the Project Setup Wizard frontend, and populate the materials catalog modal so estimators can use predefined kits.
+
+## Proposed Changes
+
+### Project Management Module (Backend)
+
+#### [MODIFY] [ProjectController.php](file:///c:/laragon/www/gsb-cons/Modules/ProjectManagement/app/Http/Controllers/ProjectController.php)
+- Update the `$materials` query in the `wizard` method to fetch only materials of type `'single'` and active status.
+- Add a `$materialGroups` query to load active `'kit'` and `'assembly'` materials with their component relationships.
+- Pass `'materialGroups' => $materialGroups` to the Inertia render response for the Project Wizard.
+
+### Project Management Module (Frontend)
+
+#### [MODIFY] [Wizard.tsx](file:///c:/laragon/www/gsb-cons/Modules/ProjectManagement/resources/js/Pages/Projects/Wizard.tsx)
+- Update `Props` interface to declare the `materialGroups` property.
+- Update the `Wizard` component parameter signature to receive `materialGroups` (defaulting to `[]`).
+- Replace `materialGroups={[]}` with `materialGroups={materialGroups}` in the `
` element.
+
+---
+
+## Verification Plan
+
+### Automated Tests
+- Run type check to ensure no compilation issues are introduced:
+ ```bash
+ npx tsc --noEmit
+ ```
+
+### Manual Verification
+1. Open the Project Setup Wizard and go to Step 3 (Materials).
+2. Click "Add Materials" to open the catalog modal.
+3. Switch to the "Assemblies & Kits" tab.
+4. Verify that the seeded kits (such as "Concrete Slab Pouring Kit" and others) are displayed with their component list and can be successfully added to the materials estimate table.
+
+## ✅ PHASE X COMPLETE
+- TypeScript Typecheck: ✅ Pass
+- Build check: ✅ Success
+- Date: 2026-06-01
diff --git a/docs/PLAN-project-wizard-dropdown-display.md b/docs/PLAN-project-wizard-dropdown-display.md
new file mode 100644
index 0000000..965e876
--- /dev/null
+++ b/docs/PLAN-project-wizard-dropdown-display.md
@@ -0,0 +1,43 @@
+# PLAN - Fix Project Wizard Dropdown Display Values
+
+Improve the UX of the Project Setup Wizard by ensuring that all select dropdown elements display their human-readable name/label when selected and closed, instead of showing the raw ULID value.
+
+## Proposed Changes
+
+### Project Management Module (Frontend)
+
+#### [MODIFY] [Wizard.tsx](file:///c:/laragon/www/gsb-cons/Modules/ProjectManagement/resources/js/Pages/Projects/Wizard.tsx)
+Pass the corresponding mapped `items` array to all 6 `