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:
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user