feat: complete GSB construction ERP implementation with dual-mode MR, capitalization cap engine, single PM rule, and system sign-off documentation

This commit is contained in:
GSB Construction Engineering
2026-08-25 17:08:03 +08:00
parent 778f27e0a5
commit d4e2b468ff
114 changed files with 3923 additions and 10031 deletions

View File

@@ -26,7 +26,7 @@ enum ProjectStatus: string
public function allowedTransitions(): array
{
return match ($this) {
self::UnderBidding => [self::Planning, self::Closed],
self::UnderBidding => [self::Planning, self::InProgress, self::Closed],
self::Planning => [self::InProgress, self::OnHold, self::Closed],
self::InProgress => [self::OnHold, self::Completed],
self::OnHold => [self::InProgress, self::Closed],

View File

@@ -13,6 +13,7 @@ use Modules\ProjectManagement\Enums\ProjectStatus;
use Modules\ProjectManagement\Enums\WeatherCondition;
use Modules\ProjectManagement\Events\ProjectStatusChanged;
use Modules\ProjectManagement\Models\Project;
use Modules\ProjectManagement\Models\ProjectClassification;
use Modules\ProjectManagement\Models\ProjectMilestone;
use Modules\ProjectManagement\Models\Task;
use Modules\Labors\Models\Labor;
@@ -79,21 +80,23 @@ class ProjectController extends Controller
{
abort_unless($this->canCreateProject(request()->user()), 403);
$employees = User::whereIn('user_type', ['admin', 'employee'])
$employees = User::whereIn('user_type', ['admin', 'employee', 'contractor'])
->where(function ($q) {
$q->whereIn('user_type', ['admin'])
->orWhereHas('roles', function ($rq) {
$rq->whereIn('name', ['Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
$rq->whereIn('name', ['Project Manager', 'Contractor Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
});
})
->with('employeeProfile')
->select('id', 'ulid', 'name', 'email', 'profile_picture')
->with(['employeeProfile', 'roles'])
->select('id', 'ulid', 'name', 'email', 'user_type', 'profile_picture')
->get();
$projects = Project::active()->with('parentProject:id,ulid,name,code')->get(['id', 'ulid', 'name', 'code', 'parent_project_id', 'project_type']);
$classifications = ProjectClassification::orderBy('id')->get(['id', 'code', 'name', 'description']);
return Inertia::render('ProjectManagement::Projects/Create', [
'employees' => $employees,
'projects' => $projects,
'classifications' => $classifications,
]);
}
@@ -127,6 +130,8 @@ class ProjectController extends Controller
'start_date' => 'nullable|date',
'target_end_date' => 'nullable|date|after_or_equal:start_date',
'project_type' => 'required|string|in:standard,special,extension',
'classifications' => 'nullable|array',
'classifications.*' => 'string',
'parent_project_id' => 'nullable|string',
'is_unprofitable' => 'nullable|boolean',
]);
@@ -136,6 +141,7 @@ class ProjectController extends Controller
}
$validated['is_unprofitable'] = $validated['is_unprofitable'] ?? false;
$validated['contract_value'] = $validated['contract_value'] ?? 0;
$validated['classifications'] = $validated['classifications'] ?? [];
$validated['current_wizard_step'] = 2;
$project = Project::create($validated);
@@ -145,6 +151,10 @@ class ProjectController extends Controller
$pmId = User::resolveUlidToId($request->pm_id);
if ($pmId) {
$project->personnel()->attach($pmId, ['role' => 'pm']);
$pmUser = User::find($pmId);
if ($pmUser && $pmUser->contractor_id) {
$project->contractors()->syncWithoutDetaching([$pmUser->contractor_id]);
}
}
}
@@ -200,9 +210,15 @@ class ProjectController extends Controller
{
$project->load(['personnel:id,name,email']);
$employees = User::whereIn('user_type', ['admin', 'employee'])
->with('employeeProfile')
->select('id', 'ulid', 'name', 'email', 'profile_picture')
$employees = User::whereIn('user_type', ['admin', 'employee', 'contractor'])
->where(function ($q) {
$q->whereIn('user_type', ['admin'])
->orWhereHas('roles', function ($rq) {
$rq->whereIn('name', ['Project Manager', 'Contractor Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
});
})
->with(['employeeProfile', 'roles'])
->select('id', 'ulid', 'name', 'email', 'user_type', 'profile_picture')
->get();
return Inertia::render('ProjectManagement::Projects/Team', [
@@ -260,15 +276,15 @@ class ProjectController extends Controller
'materialsEstimates.material',
]);
$employees = User::whereIn('user_type', ['admin', 'employee'])
$employees = User::whereIn('user_type', ['admin', 'employee', 'contractor'])
->where(function ($q) {
$q->whereIn('user_type', ['admin'])
->orWhereHas('roles', function ($rq) {
$rq->whereIn('name', ['Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
$rq->whereIn('name', ['Project Manager', 'Contractor Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
});
})
->with('employeeProfile')
->select('id', 'ulid', 'name', 'email', 'profile_picture')
->with(['employeeProfile', 'roles'])
->select('id', 'ulid', 'name', 'email', 'user_type', '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']);
@@ -304,6 +320,7 @@ class ProjectController extends Controller
'project' => $project,
'employees' => $employees,
'projects' => $projects,
'classifications' => ProjectClassification::orderBy('id')->get(['id', 'code', 'name', 'description']),
'materials' => $materials,
'materialGroups' => $materialGroups,
'labors' => $labors,
@@ -327,6 +344,8 @@ class ProjectController extends Controller
'start_date' => 'nullable|date',
'target_end_date' => 'nullable|date|after_or_equal:start_date',
'project_type' => 'required|string|in:standard,special,extension',
'classifications' => 'nullable|array',
'classifications.*' => 'string',
'parent_project_id' => 'nullable|string',
'is_unprofitable' => 'nullable|boolean',
]);
@@ -337,6 +356,7 @@ class ProjectController extends Controller
$validated['parent_project_id'] = null;
}
$validated['is_unprofitable'] = $validated['is_unprofitable'] ?? false;
$validated['classifications'] = $validated['classifications'] ?? [];
$project->update($validated);
@@ -379,8 +399,21 @@ class ProjectController extends Controller
return back()->with('error', 'User is already assigned to this project.');
}
// If role is PM, ensure single PM by demoting existing PMs to member
if ($request->role === 'pm') {
\DB::table('project_user')
->where('project_id', $project->id)
->where('role', 'pm')
->update(['role' => 'member']);
}
$project->personnel()->attach($userId, ['role' => $request->role]);
$user = User::find($userId);
if ($user && $user->contractor_id) {
$project->contractors()->syncWithoutDetaching([$user->contractor_id]);
}
return back()->with('success', 'Team member added.');
}
@@ -440,11 +473,11 @@ class ProjectController extends Controller
'personnel:id,ulid,name,email',
]);
$employees = User::whereIn('user_type', ['admin', 'employee'])
$employees = User::whereIn('user_type', ['admin', 'employee', 'contractor'])
->where(function ($q) {
$q->whereIn('user_type', ['admin'])
->orWhereHas('roles', function ($rq) {
$rq->whereIn('name', ['Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
$rq->whereIn('name', ['Project Manager', 'Contractor Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
});
})
->with(['employeeProfile', 'roles'])
@@ -488,6 +521,7 @@ class ProjectController extends Controller
'step' => $step,
'employees' => $employees,
'projects' => $parentProjects,
'classifications' => ProjectClassification::orderBy('id')->get(['id', 'code', 'name', 'description']),
'materials' => $materials,
'materialGroups' => $materialGroups,
'labors' => $labors,
@@ -507,12 +541,15 @@ class ProjectController extends Controller
'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',
'project_type' => 'required|string|in:standard,special,extension',
'classifications' => 'nullable|array',
'classifications.*' => 'string',
'parent_project_id' => 'nullable|string',
'is_unprofitable' => 'nullable|boolean',
]);
@@ -523,6 +560,7 @@ class ProjectController extends Controller
$validated['parent_project_id'] = null;
}
$validated['is_unprofitable'] = $validated['is_unprofitable'] ?? false;
$validated['classifications'] = $validated['classifications'] ?? [];
if ($project->current_wizard_step < 2) {
$validated['current_wizard_step'] = 2;
@@ -530,6 +568,25 @@ class ProjectController extends Controller
$project->update($validated);
if ($request->filled('pm_id')) {
$newPmId = User::resolveUlidToId($request->pm_id);
if ($newPmId) {
// Strictly single PM rule: demote any other existing PM
\DB::table('project_user')
->where('project_id', $project->id)
->where('role', 'pm')
->where('user_id', '!=', $newPmId)
->update(['role' => 'member']);
$project->personnel()->syncWithoutDetaching([$newPmId => ['role' => 'pm']]);
$pmUser = User::find($newPmId);
if ($pmUser && $pmUser->contractor_id) {
$project->contractors()->syncWithoutDetaching([$pmUser->contractor_id]);
}
}
}
return redirect()->route('projects.wizard', [$project, 'step' => 2])
->with('success', 'Project details saved.');
}
@@ -747,7 +804,7 @@ class ProjectController extends Controller
}
}
// Keep existing PM if present
// Keep existing single PM if present
$pm = $project->personnel()->wherePivot('role', 'pm')->first();
if ($pm) {
$userIds[$pm->id] = ['role' => 'pm'];
@@ -756,6 +813,17 @@ class ProjectController extends Controller
// Sync the project personnel
if (isset($validated['team_ulids']) || isset($validated['user_ulids'])) {
$project->personnel()->sync($userIds);
// Auto-link all contractors of assigned personnel to project_contractor
$contractorIds = User::whereIn('id', array_keys($userIds))
->whereNotNull('contractor_id')
->pluck('contractor_id')
->unique()
->toArray();
if (!empty($contractorIds)) {
$project->contractors()->syncWithoutDetaching($contractorIds);
}
}
if ($project->current_wizard_step < 5) {

View File

@@ -11,7 +11,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Modules\BiddingManagement\Models\BidPackage;
use Modules\ContractorManagement\Models\Contractor;
use Modules\MasterData\Models\MaterialGroup;
use Modules\MaterialLogistics\Models\ProjectInventory;
@@ -47,6 +46,7 @@ class Project extends Model
'total_capitalization',
'parent_project_id',
'project_type',
'classifications',
'is_unprofitable',
'current_wizard_step',
];
@@ -64,6 +64,7 @@ class Project extends Model
'target_end_date' => 'date',
'actual_end_date' => 'date',
'is_unprofitable' => 'boolean',
'classifications' => 'array',
];
}
@@ -145,11 +146,6 @@ class Project extends Model
->withTimestamps();
}
public function bidPackages(): HasMany
{
return $this->hasMany(BidPackage::class);
}
public function parentProject(): BelongsTo
{
return $this->belongsTo(Project::class, 'parent_project_id');
@@ -165,6 +161,11 @@ class Project extends Model
return $this->hasMany(ProjectMaterialsEstimate::class);
}
public function projectClassifications(): BelongsToMany
{
return $this->belongsToMany(ProjectClassification::class, 'project_classification_pivot');
}
// --- State Machine Helpers ---
public function transitionTo(ProjectStatus $newStatus): void
@@ -233,8 +234,20 @@ class Project extends Model
public function recalculateCapitalization(): void
{
$total = $this->tasks->sum(fn (Task $task) => $task->total_cost);
$this->update(['total_capitalization' => $total]);
$tasksTotal = (float) $this->tasks->sum(fn (Task $task) => $task->total_cost);
$unestimatedMaterialsTotal = 0.0;
if (\Illuminate\Support\Facades\Schema::hasTable('material_requisition_items') && \Illuminate\Support\Facades\Schema::hasColumn('material_requisition_items', 'is_unestimated')) {
$unestimatedMaterialsTotal = (float) \Modules\MaterialLogistics\Models\MaterialRequisitionItem::whereHas('requisition', function ($q) {
$q->where('project_id', $this->id)
->whereNotIn('status', ['rejected', 'cancelled']);
})
->where('is_unestimated', true)
->get()
->sum(fn ($item) => (float) $item->quantity * (float) $item->unit_cost);
}
$this->update(['total_capitalization' => $tasksTotal + $unestimatedMaterialsTotal]);
}
public function getMilestoneCompletionAttribute(): float

View File

@@ -0,0 +1,23 @@
<?php
namespace Modules\ProjectManagement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class ProjectClassification extends Model
{
use HasFactory;
protected $fillable = [
'name',
'code',
'description',
];
public function projects(): BelongsToMany
{
return $this->belongsToMany(Project::class, 'project_classification_pivot');
}
}

View File

@@ -55,13 +55,10 @@ class ProjectWorkflowService
*/
public function canCreatePurchaseOrder(Project $project): bool
{
// Requisition must exist and be approved
$hasApprovedMr = \Modules\MaterialLogistics\Models\MaterialRequisition::whereHas('items.material', function ($q) use ($project) {
// Check if the material is associated with this project's estimates
$q->whereIn('materials.id', $project->materialsEstimates()->pluck('material_id'));
})
->where('status', 'approved')
->exists();
// Requisition must exist and be approved for this project
$hasApprovedMr = \Modules\MaterialLogistics\Models\MaterialRequisition::where('project_id', $project->id)
->where('status', 'approved')
->exists();
return $hasApprovedMr || $project->status === ProjectStatus::InProgress;
}

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('project_classifications')) {
Schema::create('project_classifications', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('code')->nullable();
$table->text('description')->nullable();
$table->timestamps();
});
}
if (!Schema::hasTable('project_classification_pivot')) {
Schema::create('project_classification_pivot', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained('projects')->cascadeOnDelete();
$table->foreignId('project_classification_id')->constrained('project_classifications')->cascadeOnDelete();
$table->timestamps();
});
}
Schema::table('projects', function (Blueprint $table) {
if (!Schema::hasColumn('projects', 'classifications')) {
$table->json('classifications')->nullable()->after('project_type');
}
});
}
public function down(): void
{
Schema::table('projects', function (Blueprint $table) {
if (Schema::hasColumn('projects', 'classifications')) {
$table->dropColumn('classifications');
}
});
Schema::dropIfExists('project_classification_pivot');
Schema::dropIfExists('project_classifications');
}
};

View File

@@ -6,7 +6,7 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/Components/ui/select';
import { Button } from '@/Components/ui/button';
import { ChevronDown, ChevronUp, Building2, Search, CheckCircle2 } from 'lucide-react';
import { ChevronDown, ChevronUp, Building2, Search, CheckCircle2, Tags } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/Components/ui/dialog';
import { Avatar, AvatarFallback, AvatarImage } from '@/Components/ui/avatar';
import { Badge } from '@/Components/ui/badge';
@@ -20,6 +20,7 @@ interface Employee {
name: string;
email?: string;
profile_picture?: string | null;
roles?: { id: number; name: string }[];
employee_profile?: {
department?: string;
position?: string;
@@ -139,10 +140,10 @@ function ProjectManagerLookup({ employees, selectedId, onSelect, disabled }: { e
<p className="text-xs text-muted-foreground truncate" title={emp.email}>
{emp.email || 'No email provided'}
</p>
{emp.employee_profile?.position && (
{(emp.employee_profile?.position || (emp.roles && emp.roles.length > 0)) && (
<div className="mt-1">
<span className="inline-block rounded-md bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground truncate max-w-full">
{emp.employee_profile.position}
{emp.employee_profile?.position || (emp.roles?.[0]?.name === 'Main Contractor Admin' ? 'Contractor Admin' : emp.roles?.[0]?.name)}
</span>
</div>
)}
@@ -299,6 +300,26 @@ function ParentProjectLookup({ projects, selectedId, onSelect, disabled }: { pro
);
}
export interface ProjectClassificationItem {
id: number;
code?: string;
name: string;
description?: string;
}
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' },
];
export interface ProjectFormData {
name: string;
client_name: string;
@@ -311,6 +332,7 @@ export interface ProjectFormData {
pm_id: string;
contract_value: string;
project_type: string;
classifications?: string[];
parent_project_id: string;
is_unprofitable: boolean;
}
@@ -321,12 +343,17 @@ interface Props {
errors: Partial<Record<keyof ProjectFormData, string>>;
employees: Employee[];
projects?: { id: number; ulid: string; name: string; code: string }[];
classifications?: ProjectClassificationItem[];
isLocked?: boolean;
}
export function ProjectForm({ data, setData, errors, employees, projects = [], isLocked = false }: Props) {
export function ProjectForm({ data, setData, errors, employees, projects = [], classifications = [], isLocked = false }: Props) {
const [showAdvanced, setShowAdvanced] = useState(true);
const activeClassificationOptions = useMemo(() => {
return classifications && classifications.length > 0 ? classifications : DEFAULT_PROJECT_CLASSIFICATIONS;
}, [classifications]);
const employeeSelectItems = useMemo(
() => employees.map(e => ({ value: e.ulid, label: e.name })),
[employees],
@@ -358,6 +385,18 @@ export function ProjectForm({ data, setData, errors, employees, projects = [], i
}
};
const toggleClassification = (name: string) => {
if (isLocked) return;
const current = Array.isArray(data.classifications) ? [...data.classifications] : [];
const index = current.indexOf(name);
if (index > -1) {
current.splice(index, 1);
} else {
current.push(name);
}
setData('classifications', current);
};
return (
<div className="space-y-6">
{/* Primary Fields — 1.1 to 1.7 */}
@@ -365,7 +404,7 @@ export function ProjectForm({ data, setData, errors, employees, projects = [], i
<CardHeader className="bg-slate-50/50 border-b border-slate-100 py-3 px-5">
<CardTitle className="text-sm font-bold text-slate-800">Project Core Parameters</CardTitle>
</CardHeader>
<CardContent className="space-y-4 p-5">
<CardContent className="space-y-5 p-5">
{/* 1.1 Project Name */}
<div>
<Label htmlFor="name" className="text-xs font-bold text-slate-700 mb-1.5 block">
@@ -383,10 +422,84 @@ export function ProjectForm({ data, setData, errors, employees, projects = [], i
{errors.name && <p className="mt-1 text-xs font-medium text-red-500">{errors.name}</p>}
</div>
{/* Project Type Selection */}
{/* Project Classification Multi-Tag Checkbox Group */}
<div className="space-y-3 p-4 bg-slate-50/60 border border-slate-200/80 rounded-xl">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-1 pb-2 border-b border-slate-200/60">
<div className="flex items-center gap-2">
<Tags className="h-4 w-4 text-emerald-600" />
<Label className="text-xs font-bold text-slate-800">
Project Classification Tagging <span className="text-emerald-700 font-bold">({(data.classifications || []).length} selected)</span>
</Label>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setData('classifications', activeClassificationOptions.map(c => c.name))}
disabled={isLocked}
className="text-[11px] font-semibold text-emerald-600 hover:text-emerald-700 disabled:opacity-50 cursor-pointer"
>
Select All
</button>
<span className="text-slate-300 text-xs">|</span>
<button
type="button"
onClick={() => setData('classifications', [])}
disabled={isLocked}
className="text-[11px] font-semibold text-slate-500 hover:text-slate-700 disabled:opacity-50 cursor-pointer"
>
Clear All
</button>
</div>
</div>
<p className="text-[11px] text-slate-500 font-medium">
Select one or more construction classification tags applicable to this project:
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
{activeClassificationOptions.map((item, idx) => {
const isChecked = Array.isArray(data.classifications) && data.classifications.includes(item.name);
return (
<div
key={item.id || idx}
onClick={() => toggleClassification(item.name)}
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-all select-none cursor-pointer ${
isChecked
? 'bg-emerald-50/90 border-emerald-300 ring-1 ring-emerald-400/40 text-emerald-950 shadow-2xs'
: 'bg-white hover:bg-slate-50 border-slate-200/80 text-slate-700'
} ${isLocked ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<div className="pt-0.5 shrink-0">
<input
type="checkbox"
checked={isChecked}
onChange={() => {}} // Handled by parent container click
disabled={isLocked}
className="h-4 w-4 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start gap-1.5">
<span className={`inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-bold rounded-full shrink-0 mt-0.5 ${
isChecked ? 'bg-emerald-200 text-emerald-800' : 'bg-slate-100 text-slate-600 border border-slate-200'
}`}>
{idx + 1}
</span>
<span className="text-xs font-semibold leading-snug">
{item.name}
</span>
</div>
</div>
</div>
);
})}
</div>
{errors.classifications && <p className="mt-1 text-xs font-medium text-red-500">{errors.classifications}</p>}
</div>
{/* Project Type & Scope Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="project_type" className="text-xs font-bold text-slate-700 mb-1.5 block">Project Classification</Label>
<Label htmlFor="project_type" className="text-xs font-bold text-slate-700 mb-1.5 block">Project Type / Scope</Label>
<Select
value={data.project_type || 'standard'}
onValueChange={handleProjectTypeChange}

View File

@@ -6,7 +6,7 @@ import { CardHeader, CardTitle, CardDescription } from '@/Components/ui/card';
import { Sparkles, ChevronRight, Check } from 'lucide-react';
import { PageProps } from '@/types';
import { FormEvent } from 'react';
import { ProjectForm, ProjectFormData } from '../../Components/ProjectForm';
import { ProjectForm, ProjectFormData, ProjectClassificationItem } from '../../Components/ProjectForm';
interface Employee { id: number; ulid: string; name: string }
interface ParentProject { id: number; ulid: string; name: string; code: string }
@@ -14,9 +14,10 @@ interface ParentProject { id: number; ulid: string; name: string; code: string }
interface Props extends PageProps {
employees: Employee[];
projects: ParentProject[];
classifications?: ProjectClassificationItem[];
}
export default function Create({ employees, projects }: Props) {
export default function Create({ employees, projects, classifications = [] }: Props) {
const { data, setData, post, processing, errors } = useForm<ProjectFormData>({
name: '',
client_name: '',
@@ -28,6 +29,7 @@ export default function Create({ employees, projects }: Props) {
pm_id: '',
contract_value: '',
project_type: 'standard',
classifications: [],
parent_project_id: '',
is_unprofitable: false,
});
@@ -107,6 +109,7 @@ export default function Create({ employees, projects }: Props) {
errors={errors}
employees={employees}
projects={projects}
classifications={classifications}
/>
<div className="flex justify-between pt-6 border-t border-slate-100">

View File

@@ -35,6 +35,7 @@ interface ProjectData {
start_date?: string; target_end_date?: string;
personnel?: { id: number; ulid: string; name: string; pivot?: { role: string } }[];
project_type: string;
classifications?: string[];
parent_project?: { id: number; ulid: string } | null;
is_unprofitable: boolean;
current_wizard_step: number;
@@ -48,13 +49,14 @@ interface Props extends PageProps {
project: ProjectData;
employees: Employee[];
projects: ParentProject[];
classifications?: any[];
materials: any[];
materialGroups: any[];
labors: Labor[];
equipments: Equipment[];
}
export default function Edit({ project, employees, projects, labors, equipments, materials = [], materialGroups = [] }: Props) {
export default function Edit({ project, employees, projects, classifications = [], labors, equipments, materials = [], materialGroups = [] }: Props) {
const isLocked = project.current_wizard_step >= 8;
const [activeTab, setActiveTab] = useState<'details' | 'tasks' | 'materials' | 'manpower' | 'equipment' | 'estimation'>('details');
@@ -79,6 +81,7 @@ export default function Edit({ project, employees, projects, labors, equipments,
pm_id: pm?.ulid || '',
contract_value: project.contract_value || '',
project_type: project.project_type || 'standard',
classifications: project.classifications || [],
parent_project_id: project.parent_project?.ulid || '',
is_unprofitable: !!project.is_unprofitable,
});
@@ -409,6 +412,7 @@ export default function Edit({ project, employees, projects, labors, equipments,
errors={errors}
employees={employees}
projects={projects}
classifications={classifications}
isLocked={isLocked}
/>
{!isLocked && (

View File

@@ -2,7 +2,9 @@ import ProjectLayout from '../../Layouts/ProjectLayout';
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
import { Badge } from '@/Components/ui/badge';
import { Package } from 'lucide-react';
import { Button } from '@/Components/ui/button';
import { Link } from '@inertiajs/react';
import { Package, Plus } from 'lucide-react';
export default function Materials({ project, availableMaterials }: any) {
return (
@@ -13,6 +15,11 @@ export default function Materials({ project, availableMaterials }: any) {
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5 text-gray-500" /> Project Materials
</CardTitle>
<Link href={route('requisitions.create', { project_ulid: project.ulid })}>
<Button size="sm" className="gap-1.5 bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm">
<Plus className="h-4 w-4" /> Request Materials (MR)
</Button>
</Link>
</div>
</CardHeader>
<CardContent>

View File

@@ -1,7 +1,7 @@
import ProjectLayout from '../../Layouts/ProjectLayout';
import EvmSCurveChart from '../../Components/EvmSCurveChart';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/Components/ui/card';
import { MapPin, Users, TrendingUp, DollarSign, Calendar, AlertTriangle, ArrowRight, Activity, ShieldCheck, FileText, ClipboardList, Hammer, Truck, Layers, Milestone as MilestoneIcon } from 'lucide-react';
import { MapPin, Users, TrendingUp, DollarSign, Calendar, AlertTriangle, ArrowRight, Activity, ShieldCheck, FileText, ClipboardList, Hammer, Truck, Layers, Milestone as MilestoneIcon, Tags } from 'lucide-react';
import { PageProps } from '@/types';
import { Badge } from '@/Components/ui/badge';
import { Link } from '@inertiajs/react';
@@ -32,6 +32,7 @@ interface ProjectData {
created_at: string;
is_unprofitable: boolean;
project_type: string;
classifications?: string[];
extension_projects?: ChildProject[];
rollup_contract_value?: string | number;
rollup_capitalization?: string | number;
@@ -231,6 +232,25 @@ export default function Overview({ project, taskStats, allowedTransitions, estim
{project.client_name && <div className="flex items-center gap-2 text-sm"><Users className="h-4 w-4 text-gray-400" />Client: {project.client_name}</div>}
</div>
{project.classifications && project.classifications.length > 0 && (
<div>
<p className="text-xs text-gray-500 mb-1.5 flex items-center gap-1.5 font-medium">
<Tags className="h-3.5 w-3.5 text-emerald-600" /> Project Classifications
</p>
<div className="flex flex-wrap gap-1.5">
{project.classifications.map((tag: string, idx: number) => (
<Badge
key={idx}
variant="outline"
className="bg-emerald-50/80 text-emerald-800 border-emerald-200 text-xs py-0.5 px-2 font-medium"
>
{tag}
</Badge>
))}
</div>
</div>
)}
{/* Capitalization Section */}
<div className="mt-4 space-y-3">
<div>

View File

@@ -126,7 +126,7 @@ export default function TimelineTab({
<form onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
router.post(route('projects.milestones.store', project.ulid), {
router.post(route('projects.milestones.store'), {
project_id: project.id,
name: fd.get('name'),
planned_date: fd.get('planned_date'),
@@ -215,7 +215,7 @@ export default function TimelineTab({
<form onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
router.put(route('projects.milestones.update', [project.ulid, m.ulid]), {
router.put(route('projects.milestones.update', m.ulid), {
name: fd.get('name'),
planned_date: fd.get('planned_date') || null,
actual_date: fd.get('actual_date') || null,
@@ -272,7 +272,7 @@ export default function TimelineTab({
<div className="flex justify-between">
<Button type="button" variant="ghost" size="sm" className="text-red-500" onClick={() => {
if (confirm('Delete this milestone?')) {
router.delete(route('projects.milestones.destroy', [project.ulid, m.ulid]), { preserveScroll: true });
router.delete(route('projects.milestones.destroy', m.ulid), { preserveScroll: true });
setEditMilestoneId(null);
}
}}>

View File

@@ -20,6 +20,7 @@ export default function Wizard({
step: currentStep,
employees,
projects = [],
classifications = [],
materials = [],
materialGroups = [],
labors = [],
@@ -40,6 +41,7 @@ export default function Wizard({
const [step1Details, setStep1Details] = useState<any>(() => ({
name: project.name || '',
project_type: project.project_type || 'standard',
classifications: project.classifications || [],
is_unprofitable: project.is_unprofitable || false,
location: project.location || '',
client_name: project.client_name || '',
@@ -591,6 +593,7 @@ export default function Wizard({
errors={errors}
employees={employees}
projects={projects}
classifications={classifications}
onNext={() => setStep(2)}
/>
)}

View File

@@ -12,6 +12,7 @@ interface Step1DetailsProps {
errors: any;
employees: Employee[];
projects: any[];
classifications?: any[];
onNext: () => void;
}
@@ -22,6 +23,7 @@ export default function Step1Details({
errors,
employees,
projects,
classifications = [],
onNext
}: Step1DetailsProps) {
return (
@@ -39,6 +41,7 @@ export default function Step1Details({
errors={errors || {}}
employees={employees}
projects={projects}
classifications={classifications}
/>
<div className="flex justify-between pt-5 border-t border-slate-100">

View File

@@ -150,7 +150,15 @@ export default function Step4Manpower({
}}
className="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500 h-4 w-4"
/>
<span className="text-xs text-slate-700">{emp.name} <span className="text-[10px] text-slate-450">({emp.email})</span></span>
<span className="text-xs text-slate-700 flex items-center gap-1.5 flex-wrap">
<span className="font-medium">{emp.name}</span>
{emp.roles && emp.roles.length > 0 && (
<span className="text-[10px] bg-slate-100 text-slate-600 px-1.5 py-0.2 rounded font-normal">
{emp.roles[0].name === 'Main Contractor Admin' ? 'Contractor Admin' : emp.roles[0].name}
</span>
)}
<span className="text-[10px] text-slate-400">({emp.email})</span>
</span>
</label>
);
})}

View File

@@ -30,6 +30,13 @@ export interface Equipment {
specifications: EquipmentSpecification[];
}
export interface ProjectClassificationOption {
id: number;
code?: string;
name: string;
description?: string;
}
export interface ProjectWizardData {
id: number;
ulid: string;
@@ -39,6 +46,7 @@ export interface ProjectWizardData {
client_name?: string;
location?: string;
project_type: string;
classifications?: string[];
parent_project_id?: number;
is_unprofitable: boolean;
contract_value: string;
@@ -67,6 +75,7 @@ export interface ProjectWizardProps extends PageProps {
step: number;
employees: Employee[];
projects: any[];
classifications?: ProjectClassificationOption[];
materials: any[];
materialGroups: any[];
labors: Labor[];