From 42ba980f7a70fb25a12c44eda49a67dc24d0e14e Mon Sep 17 00:00:00 2001 From: Impenggg Date: Sat, 1 Aug 2026 15:38:57 +0800 Subject: [PATCH] feat: implement core material requisition, project management, and daily reporting modules with workflow validation and automated documentation support. --- .../BiddingManagement/app/Models/BidAward.php | 9 +- .../Controllers/DailyReportsController.php | 14 +- .../resources/js/Pages/Create.tsx | 10 +- .../js/Pages/Partials/ReportForm.tsx | 127 +++- .../Http/Controllers/FinanceController.php | 3 +- .../resources/js/Pages/Invoices/Create.tsx | 14 +- Modules/Labors/app/Models/Labor.php | 8 +- .../database/factories/LaborFactory.php | 21 + Modules/MasterData/app/Models/Material.php | 8 +- .../database/factories/MaterialFactory.php | 24 + .../MaterialRequisitionController.php | 6 +- .../js/Pages/PurchaseOrders/Show.tsx | 52 +- .../resources/js/Pages/Requisitions/Form.tsx | 6 + .../resources/js/Pages/Requisitions/Show.tsx | 36 +- .../Http/Controllers/ProjectController.php | 6 +- .../ProjectManagement/app/Models/Project.php | 8 +- Modules/ProjectManagement/app/Models/Task.php | 8 +- .../database/factories/ProjectFactory.php | 25 + .../database/factories/TaskFactory.php | 21 + .../js/Components/EquipmentLookupModal.tsx | 14 +- .../resources/js/Pages/Projects/Create.tsx | 101 ++- .../resources/js/Pages/Projects/Index.tsx | 11 +- .../resources/js/Pages/Projects/Wizard.tsx | 67 +- .../app/Http/Controllers/UserController.php | 33 +- .../resources/js/Pages/Users/Create.tsx | 83 ++- check_user.php | 1 + config/database.php | 2 +- database/seeders/DatabaseSeeder.php | 28 +- package-lock.json | 255 +++++++- package.json | 1 + public/flags/gb.svg | 0 resources/js/Components/ConfirmModal.tsx | 115 ++++ resources/js/Components/ui/dialog.tsx | 2 +- resources/js/Layouts/AuthenticatedLayout.tsx | 20 +- resources/js/app.tsx | 4 + tests/Feature/ProjectWizardFlowTest.php | 595 ++++++++++++++++++ 36 files changed, 1588 insertions(+), 150 deletions(-) create mode 100644 Modules/Labors/database/factories/LaborFactory.php create mode 100644 Modules/MasterData/database/factories/MaterialFactory.php create mode 100644 Modules/ProjectManagement/database/factories/ProjectFactory.php create mode 100644 Modules/ProjectManagement/database/factories/TaskFactory.php create mode 100644 check_user.php create mode 100644 public/flags/gb.svg create mode 100644 resources/js/Components/ConfirmModal.tsx create mode 100644 tests/Feature/ProjectWizardFlowTest.php diff --git a/Modules/BiddingManagement/app/Models/BidAward.php b/Modules/BiddingManagement/app/Models/BidAward.php index 53fe082..e0bcc1d 100644 --- a/Modules/BiddingManagement/app/Models/BidAward.php +++ b/Modules/BiddingManagement/app/Models/BidAward.php @@ -58,9 +58,14 @@ class BidAward extends Model ->get() ->each(fn ($sub) => $sub->update(['status' => BidSubmissionStatus::Rejected])); - // Link contractor to the project + // Link contractor to the project and transition project status from under_bidding -> in_progress $contractor = $winning->contractor; - $package->project()->update(['contractor_id' => $contractor->id]); + if ($package->project) { + $package->project->update([ + 'contractor_id' => $contractor->id, + 'status' => \Modules\ProjectManagement\Enums\ProjectStatus::InProgress, + ]); + } return self::create([ 'bid_package_id' => $package->id, diff --git a/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php b/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php index 58c5c38..f39b1f4 100644 --- a/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php +++ b/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php @@ -40,11 +40,21 @@ class DailyReportsController extends Controller public function create(Request $request) { $projectUlid = $request->query('project'); - $project = $projectUlid ? \Modules\ProjectManagement\Models\Project::where('ulid', $projectUlid)->firstOrFail() : null; + $project = $projectUlid + ? \Modules\ProjectManagement\Models\Project::with([ + 'tasks:id,ulid,project_id,name', + 'materialsEstimates.material:id,ulid,name,unit', + 'tasks.taskLabors.labor:id,ulid,name,category', + 'tasks.taskEquipments.equipment:id,ulid,name', + ])->where('ulid', $projectUlid)->firstOrFail() + : null; return Inertia::render('DailyReports::Create', [ - 'project' => $project, + 'project' => $project, 'projects' => \Modules\ProjectManagement\Models\Project::with('parentProject:id,ulid,name,code')->get(['id', 'ulid', 'name', 'code', 'project_type', 'parent_project_id']), + 'masterLabors' => \Modules\Labors\Models\Labor::select('id', 'ulid', 'name', 'category')->get(), + 'masterEquipments' => \Modules\Equipments\Models\Equipment::select('id', 'ulid', 'name')->get(), + 'masterMaterials' => \Modules\MasterData\Models\Material::select('id', 'ulid', 'name', 'unit')->get(), ]); } diff --git a/Modules/DailyReports/resources/js/Pages/Create.tsx b/Modules/DailyReports/resources/js/Pages/Create.tsx index 7f26077..b34b78a 100644 --- a/Modules/DailyReports/resources/js/Pages/Create.tsx +++ b/Modules/DailyReports/resources/js/Pages/Create.tsx @@ -4,7 +4,7 @@ import ProjectLayout from '../../../../ProjectManagement/resources/js/Layouts/Pr import ReportForm from './Partials/ReportForm'; import { FileText } from 'lucide-react'; -export default function Create({ project }: any) { +export default function Create({ project, masterLabors, masterEquipments, masterMaterials }: any) { return (
@@ -16,7 +16,13 @@ export default function Create({ project }: any) {
- +
); diff --git a/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx b/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx index d00f3df..686c34d 100644 --- a/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx +++ b/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx @@ -7,8 +7,17 @@ import { Textarea } from '@/Components/ui/textarea'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/Components/ui/card'; import { Trash2, Plus, Save } from 'lucide-react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select"; -export default function ReportForm({ project, report = null, isEdit = false }: any) { +export default function ReportForm({ project, report = null, isEdit = false, masterLabors = [], masterEquipments = [], masterMaterials = [] }: any) { + // Project-scoped lookup lists + const projectTasks = project?.tasks || []; + const projectMaterials = project?.materials_estimates?.map((m: any) => m.material).filter(Boolean) || []; + + // Fallbacks to master catalogs if project allocations are empty + const availableMaterials = projectMaterials.length > 0 ? projectMaterials : masterMaterials; + const availableLabors = masterLabors; + const availableEquipments = masterEquipments; const { data, setData, post, put, processing, errors } = useForm({ project_id: project?.id || '', report_number: report?.report_number || '', @@ -131,7 +140,34 @@ export default function ReportForm({ project, report = null, isEdit = false }: a
- updateRow('activities', i, 'task_name', e.target.value)} required /> + + {(!projectTasks.some((t: any) => t.name === row.task_name) || row.task_name === '') && ( + updateRow('activities', i, 'task_name', e.target.value)} + /> + )}
@@ -173,7 +209,34 @@ export default function ReportForm({ project, report = null, isEdit = false }: a
- updateRow('materials', i, 'material_name', e.target.value)} required /> + + {(!availableMaterials.some((m: any) => m.name === row.material_name) || row.material_name === '') && ( + updateRow('materials', i, 'material_name', e.target.value)} + /> + )}
@@ -212,7 +275,34 @@ export default function ReportForm({ project, report = null, isEdit = false }: a
- updateRow('labors', i, 'trade', e.target.value)} required /> + + {(!availableLabors.some((l: any) => l.name === row.trade) || row.trade === '') && ( + updateRow('labors', i, 'trade', e.target.value)} + /> + )}
@@ -254,7 +344,34 @@ export default function ReportForm({ project, report = null, isEdit = false }: a
- updateRow('equipment', i, 'equipment_name', e.target.value)} required /> + + {(!availableEquipments.some((eq: any) => eq.name === row.equipment_name) || row.equipment_name === '') && ( + updateRow('equipment', i, 'equipment_name', e.target.value)} + /> + )}
diff --git a/Modules/FinancialManagement/app/Http/Controllers/FinanceController.php b/Modules/FinancialManagement/app/Http/Controllers/FinanceController.php index 3c91d49..b64a44c 100644 --- a/Modules/FinancialManagement/app/Http/Controllers/FinanceController.php +++ b/Modules/FinancialManagement/app/Http/Controllers/FinanceController.php @@ -55,7 +55,8 @@ class FinanceController extends Controller public function create() { $projects = Project::select('id', 'ulid', 'name', 'code', 'contract_value', 'last_billed_percentage') - ->where('status', '!=', 'completed') + ->where('current_wizard_step', '>=', 7) + ->whereNotIn('status', ['completed', 'closed']) ->get(); return Inertia::render('FinancialManagement::Invoices/Create', [ diff --git a/Modules/FinancialManagement/resources/js/Pages/Invoices/Create.tsx b/Modules/FinancialManagement/resources/js/Pages/Invoices/Create.tsx index 63bec34..abe8351 100644 --- a/Modules/FinancialManagement/resources/js/Pages/Invoices/Create.tsx +++ b/Modules/FinancialManagement/resources/js/Pages/Invoices/Create.tsx @@ -56,8 +56,18 @@ export default function Create({ projects }: Props) {
- { if (v) form.setData('project_id', v); }}> + + + {(() => { + if (!form.data.project_id) return Select project...; + const sel = projects.find(p => p.ulid === form.data.project_id); + return sel ? ( + {sel.name} ({sel.code}) — {formatCurrency(sel.contract_value)} + ) : Select project...; + })()} + + {projects.map(p => ( {p.name} ({p.code}) — {formatCurrency(p.contract_value)} diff --git a/Modules/Labors/app/Models/Labor.php b/Modules/Labors/app/Models/Labor.php index a18f2e5..069209e 100644 --- a/Modules/Labors/app/Models/Labor.php +++ b/Modules/Labors/app/Models/Labor.php @@ -3,11 +3,17 @@ namespace Modules\Labors\Models; use App\Traits\HasPublicIdentifier; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Labor extends Model { - use HasPublicIdentifier; + use HasFactory, HasPublicIdentifier; + + protected static function newFactory() + { + return \Modules\Labors\Database\Factories\LaborFactory::new(); + } protected $table = 'labors'; diff --git a/Modules/Labors/database/factories/LaborFactory.php b/Modules/Labors/database/factories/LaborFactory.php new file mode 100644 index 0000000..e1833b5 --- /dev/null +++ b/Modules/Labors/database/factories/LaborFactory.php @@ -0,0 +1,21 @@ + fake()->jobTitle(), + 'category' => fake()->randomElement(['skilled', 'unskilled', 'technical']), + 'hourly_rate' => fake()->randomFloat(2, 50, 500), + 'status' => 'active', + ]; + } +} \ No newline at end of file diff --git a/Modules/MasterData/app/Models/Material.php b/Modules/MasterData/app/Models/Material.php index 34096ac..496107f 100644 --- a/Modules/MasterData/app/Models/Material.php +++ b/Modules/MasterData/app/Models/Material.php @@ -4,6 +4,7 @@ namespace Modules\MasterData\Models; use App\Traits\BelongsToTenant; use App\Traits\HasPublicIdentifier; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -15,7 +16,12 @@ use Modules\MaterialLogistics\Models\MaterialDeployment; class Material extends Model { - use HasPublicIdentifier, BelongsToTenant; + use BelongsToTenant, HasFactory, HasPublicIdentifier; + + protected static function newFactory() + { + return \Modules\MasterData\Database\Factories\MaterialFactory::new(); + } protected $fillable = [ 'name', 'sku', 'category', 'unit', diff --git a/Modules/MasterData/database/factories/MaterialFactory.php b/Modules/MasterData/database/factories/MaterialFactory.php new file mode 100644 index 0000000..3f269ed --- /dev/null +++ b/Modules/MasterData/database/factories/MaterialFactory.php @@ -0,0 +1,24 @@ + fake()->word() . ' Material', + 'sku' => strtoupper(fake()->bothify('MAT-###??')), + 'category' => fake()->randomElement(['concrete', 'steel', 'wood', 'electrical']), + 'unit' => fake()->randomElement(['pcs', 'kg', 'm', 'bag']), + 'unit_cost' => fake()->randomFloat(2, 10, 5000), + 'status' => 'active', + 'type' => 'single', + ]; + } +} \ No newline at end of file diff --git a/Modules/MaterialLogistics/app/Http/Controllers/MaterialRequisitionController.php b/Modules/MaterialLogistics/app/Http/Controllers/MaterialRequisitionController.php index f61ab57..bac49c6 100644 --- a/Modules/MaterialLogistics/app/Http/Controllers/MaterialRequisitionController.php +++ b/Modules/MaterialLogistics/app/Http/Controllers/MaterialRequisitionController.php @@ -97,7 +97,7 @@ class MaterialRequisitionController extends Controller return Inertia::render('MaterialLogistics::Requisitions/Form', [ 'materials' => $materials, 'materialGroups' => $materialGroups, - 'projects' => Project::active()->where('current_wizard_step', '>=', 8)->select('id', 'ulid', 'name', 'code', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value')->get(), + 'projects' => Project::active()->with('contractor:id,company_name')->select('id', 'ulid', 'name', 'code', 'contractor_id', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value')->get(), 'selectedProject' => $selectedProject, 'prefilledItems' => $prefilledItems, ]); @@ -115,8 +115,8 @@ class MaterialRequisitionController extends Controller ]); $project = Project::where('ulid', $validated['project_ulid'])->firstOrFail(); - if ($project->current_wizard_step < 8) { - return redirect()->back()->with('error', 'Cannot create material requisition: Project estimation is not approved.'); + if ($project->current_wizard_step < 7) { + return redirect()->back()->with('error', 'Cannot create material requisition: Project estimation is not submitted or approved.'); } $workflowService = new ProjectWorkflowService(); diff --git a/Modules/MaterialLogistics/resources/js/Pages/PurchaseOrders/Show.tsx b/Modules/MaterialLogistics/resources/js/Pages/PurchaseOrders/Show.tsx index d4ba3be..3984f06 100644 --- a/Modules/MaterialLogistics/resources/js/Pages/PurchaseOrders/Show.tsx +++ b/Modules/MaterialLogistics/resources/js/Pages/PurchaseOrders/Show.tsx @@ -37,16 +37,18 @@ const fmt = (v: string | number) => new Intl.NumberFormat('en-PH', { style: 'cur const statusColor = (s: string) => ({ draft: 'outline', submitted: 'secondary', approved: 'default', rejected: 'destructive' }[s] ?? 'outline') as 'outline' | 'secondary' | 'default' | 'destructive'; const paymentColor = (s: string) => ({ unpaid: 'destructive', partial: 'secondary', paid: 'default' }[s] ?? 'outline') as 'destructive' | 'secondary' | 'default'; +import { ConfirmModal } from '@/Components/ConfirmModal'; + export default function Show({ purchaseOrder, warehouses, budgetAnalysis }: Props) { const { flash, errors } = usePage().props; const [payDialog, setPayDialog] = useState(false); const [paying, setPaying] = useState(false); + const [submitModalOpen, setSubmitModalOpen] = useState(false); + const [deliverModalOpen, setDeliverModalOpen] = useState(false); const receiptRef = useRef(null); const handleSubmitApproval = () => { - if (confirm('Submit this purchase order for approval?')) { - router.patch(route('purchase-orders.submit', purchaseOrder.ulid)); - } + router.patch(route('purchase-orders.submit', purchaseOrder.ulid)); }; const handlePayment = (e: FormEvent) => { @@ -63,9 +65,7 @@ export default function Show({ purchaseOrder, warehouses, budgetAnalysis }: Prop }; const handleDelivery = () => { - if (confirm(`Confirm delivery? This will officially add the materials to ${purchaseOrder.target_warehouse?.name || 'the target warehouse'}.`)) { - router.patch(route('purchase-orders.deliver', purchaseOrder.ulid)); - } + router.patch(route('purchase-orders.deliver', purchaseOrder.ulid)); }; const lineTotal = (item: any) => Number(item.quantity) * Number(item.unit_cost); @@ -160,16 +160,40 @@ export default function Show({ purchaseOrder, warehouses, budgetAnalysis }: Prop

In Transit

Materials are currently expected at {purchaseOrder.target_warehouse.name}. Mark as delivered once they physically arrive.

-
)} + {}} + /> + + {}} + /> +
{purchaseOrder.status === 'draft' && ( <> - @@ -179,6 +203,18 @@ export default function Show({ purchaseOrder, warehouses, budgetAnalysis }: Prop )} + {purchaseOrder.status === 'submitted' && ( + + )} {purchaseOrder.status === 'approved' && purchaseOrder.payment_status !== 'paid' && ( @@ -64,6 +78,20 @@ export default function Show({ requisition }: Props) { )} + {requisition.status === 'submitted' && ( +
+ +
+ )} - -

- Create Project -

+
+
+

+ Project Wizard: {data.name || 'New Project'} +

+

Guided Project Estimation Setup

+
+ + under bidding +
} > - + -
-
-
- +
+
-
- - - - + {/* Stepper Header */} +
+
+
+ + {stepsList.map((s) => { + const isActive = s.id === 1; + return ( +
+
+ {s.id} +
+ {s.label} +
+ ); + })}
- +
+ + {/* Step 1 Form Container */} +
+ + Step 1: Project Information + Review and update project information parameters. + + +
+ + +
+
+ +
+ +
diff --git a/Modules/ProjectManagement/resources/js/Pages/Projects/Index.tsx b/Modules/ProjectManagement/resources/js/Pages/Projects/Index.tsx index a02cd7e..af03de1 100644 --- a/Modules/ProjectManagement/resources/js/Pages/Projects/Index.tsx +++ b/Modules/ProjectManagement/resources/js/Pages/Projects/Index.tsx @@ -179,7 +179,7 @@ export default function Index({ projects, history, drafts = [], filters, statuse Code - Name + Project & Contractor Client Status Contract Value @@ -199,7 +199,14 @@ export default function Index({ projects, history, drafts = [], filters, statuse projects.data.map((project) => ( {project.code} - {project.name} + +
{project.name}
+ {project.contractor && ( +
+ 🏢 {project.contractor.company_name} +
+ )} +
{project.client_name || '-'} diff --git a/Modules/ProjectManagement/resources/js/Pages/Projects/Wizard.tsx b/Modules/ProjectManagement/resources/js/Pages/Projects/Wizard.tsx index 63ee78d..be3c3c1 100644 --- a/Modules/ProjectManagement/resources/js/Pages/Projects/Wizard.tsx +++ b/Modules/ProjectManagement/resources/js/Pages/Projects/Wizard.tsx @@ -18,6 +18,7 @@ import { PageProps } from '@/types'; import { MaterialCatalogModal, MaterialOption } from '@modules/MaterialLogistics/resources/js/Pages/Requisitions/MaterialCatalogModal'; import LaborLookupModal from '../../Components/LaborLookupModal'; import EquipmentLookupModal from '../../Components/EquipmentLookupModal'; +import { ProjectForm } from '../../Components/ProjectForm'; interface Skill { id: number; @@ -115,6 +116,22 @@ export default function Wizard({ project, step: currentStep, employees, projects setActiveEquipmentRowIdx(null); }; + // Step 1 Local State (Project Details) + const [step1Details, setStep1Details] = useState(() => ({ + name: project.name || '', + project_type: project.project_type || 'standard', + is_unprofitable: project.is_unprofitable || false, + location: project.location || '', + client_name: project.client_name || '', + start_date: (project as any).start_date || '', + target_end_date: (project as any).target_end_date || '', + contract_duration: (project as any).contract_duration || '', + description: (project as any).description || '', + pm_id: (project as any).pm_id ? String((project as any).pm_id) : '', + contract_value: project.contract_value || '', + parent_project_id: project.parent_project_id ? String(project.parent_project_id) : '', + })); + // Step 2 Local States (Tasks & Milestones) const [localMilestones, setLocalMilestones] = useState(() => { return project.milestones.length > 0 ? project.milestones.map(m => ({ @@ -449,24 +466,32 @@ export default function Wizard({ project, step: currentStep, employees, projects {/* Step Content Container */}
- {/* Step 1: Project Details (View Only / Resume Link) */} + {/* Step 1: Project Details (Editable Form) */} {step === 1 && ( -
+
- Step 1: Project Details Registered - Initial project parameters have been saved. You can edit them later on the project settings. + Step 1: Project Information + Review and update project information parameters. -
-
Project Name: {project.name}
-
Client: {project.client_name || 'N/A'}
-
Location: {project.location || 'N/A'}
-
Project Type: {project.project_type}
-
Contract Value: {formatCurrency(project.contract_value)}
-
Unprofitable Status: {project.is_unprofitable ? 'Flagged' : 'No'}
-
-
-
@@ -888,12 +913,16 @@ export default function Wizard({ project, step: currentStep, employees, projects updateEquipmentAllocation(idx, 'task_ulid', val || '')} - items={project.tasks.map(t => ({ value: t.ulid, label: t.name }))} + items={[ + { value: 'general', label: 'General Project / Unassigned' }, + ...project.tasks.map(t => ({ value: t.ulid, label: t.name })) + ]} > + General Project / Unassigned {project.tasks.map(t => ( {t.name} ))} diff --git a/Modules/UserManagement/app/Http/Controllers/UserController.php b/Modules/UserManagement/app/Http/Controllers/UserController.php index cff31f5..c043178 100644 --- a/Modules/UserManagement/app/Http/Controllers/UserController.php +++ b/Modules/UserManagement/app/Http/Controllers/UserController.php @@ -48,10 +48,20 @@ class UserController extends Controller public function create(): Response { - $isPlatformAdmin = is_null(auth()->user()->contractor_id); + $isSuperAdmin = auth()->user()->hasRole('Super Admin'); + + // Auto-generate the next employee code (EMP-XXXX) + $last = EmployeeProfile::where('employee_code', 'like', 'EMP-%') + ->orderByRaw("CAST(SUBSTR(employee_code, 5) AS INTEGER) DESC") + ->value('employee_code'); + + $nextNumber = $last ? ((int) substr($last, 4)) + 1 : 1; + $nextCode = 'EMP-' . str_pad($nextNumber, 4, '0', STR_PAD_LEFT); return Inertia::render('UserManagement::Users/Create', [ - 'contractors' => $isPlatformAdmin + 'isSuperAdmin' => $isSuperAdmin, + 'nextEmployeeCode' => $nextCode, + 'contractors' => $isSuperAdmin ? Contractor::where('status', 'active') ->orderBy('company_name') ->get(['id', 'company_name', 'type']) @@ -59,11 +69,12 @@ class UserController extends Controller ]); } + public function store(Request $request): RedirectResponse { - $isPlatformAdmin = is_null(auth()->user()->contractor_id); + $isSuperAdmin = auth()->user()->hasRole('Super Admin'); - $allowedRoles = $isPlatformAdmin ? ['admin', 'contractor', 'employee', 'customer'] : ['contractor', 'employee', 'customer']; + $allowedRoles = $isSuperAdmin ? ['admin', 'contractor', 'employee', 'customer'] : ['contractor', 'employee', 'customer']; $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], @@ -74,7 +85,7 @@ class UserController extends Controller 'auto_password' => ['nullable', 'boolean'], 'password' => ['nullable', 'string', 'min:8', 'confirmed', 'required_if:auto_password,false'], // Platform admin only: pick the contractor for this user - 'contractor_id' => $isPlatformAdmin ? ['nullable', 'exists:contractors,id'] : ['prohibited'], + 'contractor_id' => $isSuperAdmin ? ['nullable', 'exists:contractors,id'] : ['prohibited'], // Employee fields 'department' => ['nullable', 'string', 'max:255'], 'position' => ['nullable', 'string', 'max:255'], @@ -91,7 +102,7 @@ class UserController extends Controller // Resolve contractor_id: contractor admins always use their own; // platform admins use the one selected in the form (null = platform user) - $contractorId = $isPlatformAdmin + $contractorId = $isSuperAdmin ? ($validated['contractor_id'] ?? null) : auth()->user()->contractor_id; @@ -438,11 +449,19 @@ class UserController extends Controller private function createProfile(User $user, array $validated): void { if (in_array($validated['user_type'], ['admin', 'employee', 'contractor'])) { + // Server-side strict generation & lock for employee_code + $last = EmployeeProfile::where('employee_code', 'like', 'EMP-%') + ->orderByRaw("CAST(SUBSTR(employee_code, 5) AS INTEGER) DESC") + ->value('employee_code'); + + $nextNumber = $last ? ((int) substr($last, 4)) + 1 : 1; + $employeeCode = 'EMP-' . str_pad($nextNumber, 4, '0', STR_PAD_LEFT); + $profile = EmployeeProfile::create([ 'user_id' => $user->id, 'department' => $validated['department'] ?? null, 'position' => $validated['position'] ?? null, - 'employee_code' => $validated['employee_code'] ?? null, + 'employee_code' => $employeeCode, 'hire_date' => $validated['hire_date'] ?? null, 'phone' => $validated['phone'] ?? null, 'address' => $validated['address'] ?? null, diff --git a/Modules/UserManagement/resources/js/Pages/Users/Create.tsx b/Modules/UserManagement/resources/js/Pages/Users/Create.tsx index 30d9b83..796e512 100644 --- a/Modules/UserManagement/resources/js/Pages/Users/Create.tsx +++ b/Modules/UserManagement/resources/js/Pages/Users/Create.tsx @@ -20,11 +20,12 @@ interface AuthUser { interface Props extends PageProps { contractors: Contractor[]; + isSuperAdmin: boolean; + nextEmployeeCode: string; } -export default function Create({ contractors }: Props) { +export default function Create({ contractors, isSuperAdmin, nextEmployeeCode }: Props) { const { auth } = usePage().props; - const isPlatformAdmin = auth.user.contractor_id === null; const { data, setData, post, processing, errors } = useForm({ name: '', @@ -38,7 +39,7 @@ export default function Create({ contractors }: Props) { contractor_id: '' as string | number, department: '', position: '', - employee_code: '', + employee_code: nextEmployeeCode, hire_date: '', phone: '', address: '', @@ -102,40 +103,56 @@ export default function Create({ contractors }: Props) { )} {/* ── Contractor Assignment (Platform Admin only) ── */} - {isPlatformAdmin && contractors.length > 0 && ( - - - - + {isSuperAdmin && contractors.length > 0 && ( + + + + Assign to Contractor -

- Leave blank to create a platform-level user (no contractor affiliation). +

+ Select an active contractor firm or leave blank for platform-level access.

- + - {errors.contractor_id &&

{errors.contractor_id}

} + + {errors.contractor_id &&

{errors.contractor_id}

}
)} @@ -193,10 +210,14 @@ export default function Create({ contractors }: Props) { value={data.spatie_role} onValueChange={(v) => { if (v) { + let targetType = 'employee'; + if (v === 'Super Admin') targetType = 'admin'; + if (v === 'Contractor') targetType = 'contractor'; + setData(d => ({ ...d, spatie_role: v, - user_type: 'employee' + user_type: targetType, })); } }} @@ -205,6 +226,10 @@ export default function Create({ contractors }: Props) { + {isSuperAdmin && ( + Super Admin + )} + Contractor (Contractor Admin) Project Manager Designer Site Technical @@ -310,7 +335,19 @@ export default function Create({ contractors }: Props) {
- setData('employee_code', e.target.value)} placeholder="e.g., EMP-0002" className="mt-1" /> +
+ + + 🔒 + +
+

Auto-assigned — cannot be changed manually.

{errors.employee_code &&

{errors.employee_code}

}
diff --git a/check_user.php b/check_user.php new file mode 100644 index 0000000..d638272 --- /dev/null +++ b/check_user.php @@ -0,0 +1 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); foreach (App\Models\User::with('roles')->get() as \) { echo \->name . ' | ' . \->email . ' | Roles: ' . \->roles->pluck('name')->implode(',') . PHP_EOL; } \ No newline at end of file diff --git a/config/database.php b/config/database.php index 64709ce..49f3483 100644 --- a/config/database.php +++ b/config/database.php @@ -38,7 +38,7 @@ return [ 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, + 'busy_timeout' => 5000, 'journal_mode' => null, 'synchronous' => null, 'transaction_mode' => 'DEFERRED', diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 987f7d0..b2169c2 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,38 +2,30 @@ namespace Database\Seeders; -use App\Models\User; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { - /** - * Seed the application's database. + * Seed the application's core data. */ public function run(): void { $this->call([ + // 1. Roles & Permissions setup ConstructionRolesAndPermissionsSeeder::class, SuperAdminPermissionSeeder::class, - SuperAdminSeeder::class, - MainContractorSeeder::class, // Must run after SuperAdminSeeder \Modules\RolesPermissions\Database\Seeders\RolesPermissionsDatabaseSeeder::class, + + // 2. Super Admin Account & Main Contractor Firm + SuperAdminSeeder::class, + MainContractorSeeder::class, + + // 3. Master Data Catalogs (Standard Items & Rates) \Modules\Labors\Database\Seeders\LaborsDatabaseSeeder::class, \Modules\Equipments\Database\Seeders\EquipmentsDatabaseSeeder::class, + \Modules\MaterialLogistics\Database\Seeders\ConstructionMaterialsSeeder::class, ]); - - $admin = User::firstOrCreate( - ['email' => 'admin@example.com'], - [ - 'name' => 'Super Administrator', - 'password' => bcrypt('password'), - 'user_type' => 'admin', - 'status' => 'active', - 'contractor_id' => null, - ] - ); - - $admin->assignRole('Super Admin'); } } + diff --git a/package-lock.json b/package-lock.json index ab978fb..1b2319c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,9 @@ { - "name": "GSB-Construction", + "name": "gsb", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "GSB-Construction", "dependencies": { "@base-ui/react": "^1.3.0", "@fontsource-variable/geist": "^5.2.8", @@ -13,6 +12,7 @@ "clsx": "^2.1.1", "lucide-react": "^1.0.1", "next-themes": "^0.4.6", + "react-grab": "^0.1.50", "shadcn": "^4.1.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -67,6 +67,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1237,6 +1238,12 @@ "hono": "^4" } }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "license": "ISC" + }, "node_modules/@inertiajs/core": { "version": "2.3.18", "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.18.tgz", @@ -1472,6 +1479,7 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1634,6 +1642,150 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-grab/cli": { + "version": "0.1.50", + "resolved": "https://registry.npmjs.org/@react-grab/cli/-/cli-0.1.50.tgz", + "integrity": "sha512-Px/Hwhhyk2PubCA4ZaRFsfvwxhbxXsetJyvqC6aFFi8WhJhA+oVC33aTzuAeWmM3fhb4/8ce8YsHXI1d6ChcKg==", + "dependencies": { + "agent-install": "^0.0.6", + "commander": "^14.0.3", + "ignore": "^7.0.5", + "ora": "^9.4.0", + "package-manager-detector": "^1.6.0", + "picocolors": "^1.1.1", + "prompts": "^2.4.2", + "tinyexec": "^1.1.2" + }, + "bin": { + "react-grab": "bin/cli.js" + } + }, + "node_modules/@react-grab/cli/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@react-grab/cli/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@react-grab/cli/node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-grab/cli/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@react-grab/cli/node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-grab/cli/node_modules/ora": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", + "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-grab/cli/node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-grab/cli/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-grab/cli/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@react-stately/flags": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", @@ -2452,6 +2604,7 @@ "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -2469,6 +2622,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2570,6 +2724,23 @@ "node": ">= 14" } }, + "node_modules/agent-install": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/agent-install/-/agent-install-0.0.6.tgz", + "integrity": "sha512-7NRMZ/ZDz2vHevQTgJsocBFpakB1/Wx5ip19YSJuj4VOXpraWztTerViNtdSyARKZT9e2yVwUUB5JXXCE7mNrA==", + "license": "MIT", + "dependencies": { + "@iarna/toml": "^2.2.5", + "commander": "^14.0.0", + "jsonc-parser": "^3.3.1", + "picocolors": "^1.1.1", + "prompts": "^2.4.2", + "yaml": "^2.8.3" + }, + "bin": { + "agent-install": "bin/agent-install.mjs" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -2750,6 +2921,15 @@ "node": ">=6.0.0" } }, + "node_modules/bippy": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/bippy/-/bippy-0.6.1.tgz", + "integrity": "sha512-ky4m94Y/KfsddjGkKTsV4uFjZqkJjpOjQ2t5gKPdX6XH1MNxMNX5FrVefsxV4lpjemEmEdwe0e0YbzAMNs3oUQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0.1" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -2830,6 +3010,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4194,6 +4375,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -4592,6 +4774,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -5504,6 +5692,12 @@ "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", "license": "MIT" }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5622,6 +5816,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", @@ -5794,6 +5989,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5806,6 +6002,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5814,6 +6011,27 @@ "react": "^18.3.1" } }, + "node_modules/react-grab": { + "version": "0.1.50", + "resolved": "https://registry.npmjs.org/react-grab/-/react-grab-0.1.50.tgz", + "integrity": "sha512-zRkHKq/8a1msCpEOp8BDROeQZT50m0OH2XPrP6jk5op+JAHrlsm3pj7eAQMOsct87EZDeGNnu4r+sGsJJzyw1Q==", + "license": "MIT", + "dependencies": { + "@react-grab/cli": "0.1.50", + "bippy": "^0.6.1" + }, + "bin": { + "react-grab": "bin/cli.js" + }, + "peerDependencies": { + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", @@ -5867,7 +6085,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/require-directory": { "version": "2.1.1", @@ -6487,7 +6706,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.2", @@ -6509,6 +6729,15 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6703,6 +6932,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6826,6 +7056,7 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6997,6 +7228,21 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -7053,6 +7299,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 20a5c01..adda4cf 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "clsx": "^2.1.1", "lucide-react": "^1.0.1", "next-themes": "^0.4.6", + "react-grab": "^0.1.50", "shadcn": "^4.1.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", diff --git a/public/flags/gb.svg b/public/flags/gb.svg new file mode 100644 index 0000000..e69de29 diff --git a/resources/js/Components/ConfirmModal.tsx b/resources/js/Components/ConfirmModal.tsx new file mode 100644 index 0000000..70c1977 --- /dev/null +++ b/resources/js/Components/ConfirmModal.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/Components/ui/dialog'; +import { Button } from '@/Components/ui/button'; +import { AlertTriangle, Info, CheckCircle2, Send, HelpCircle } from 'lucide-react'; + +interface ConfirmModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title?: string; + message?: string; + confirmText?: string; + cancelText?: string; + variant?: 'default' | 'destructive' | 'warning' | 'info'; + onConfirm: () => void; + onCancel?: () => void; +} + +export function ConfirmModal({ + open, + onOpenChange, + title = 'Are you sure?', + message = 'Please confirm if you wish to proceed with this action.', + confirmText = 'Confirm', + cancelText = 'Cancel', + variant = 'info', + onConfirm, + onCancel, +}: ConfirmModalProps) { + const renderIcon = () => { + switch (variant) { + case 'destructive': + return ; + case 'warning': + return ; + case 'info': + return ; + default: + return ; + } + }; + + const confirmButtonClass = () => { + switch (variant) { + case 'destructive': + return 'bg-rose-600 hover:bg-rose-700 text-white font-medium shadow-sm'; + case 'warning': + return 'bg-amber-600 hover:bg-amber-700 text-white font-medium shadow-sm'; + case 'info': + return 'bg-slate-900 hover:bg-slate-800 text-white font-medium shadow-sm'; + default: + return 'bg-emerald-600 hover:bg-emerald-700 text-white font-medium shadow-sm'; + } + }; + + return ( + + +
+
+ {renderIcon()} +
+
+ + {title} + + + {message} + +
+
+ + + + + +
+
+ ); +} diff --git a/resources/js/Components/ui/dialog.tsx b/resources/js/Components/ui/dialog.tsx index d2ea080..d410535 100644 --- a/resources/js/Components/ui/dialog.tsx +++ b/resources/js/Components/ui/dialog.tsx @@ -100,7 +100,7 @@ function DialogFooter({
- {/* Contractor Context Banner (Option D) */} + {/* Contractor Context Banner */} {isContractorUser && contractorName && ( -
- -

- You are managing {contractorName}. - All users you create will be added to this company. -

+
+
+ +

+ Current Active Contractor: {contractorName} + {auth.user.roles?.some(r => r.name === 'Super Admin') ? ( + (Platform Super Admin) + ) : ( + — Users you create will be automatically attached to this company. + )} +

+
)} diff --git a/resources/js/app.tsx b/resources/js/app.tsx index c6d84a5..aabfc9e 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -7,6 +7,10 @@ import { createRoot } from 'react-dom/client'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; +if (import.meta.env.DEV) { + import('react-grab'); +} + // Glob all page components from both app and modules const appPages = import.meta.glob('./Pages/**/*.tsx'); const modulePages = import.meta.glob('../../Modules/*/resources/js/Pages/**/*.tsx'); diff --git a/tests/Feature/ProjectWizardFlowTest.php b/tests/Feature/ProjectWizardFlowTest.php new file mode 100644 index 0000000..03768b6 --- /dev/null +++ b/tests/Feature/ProjectWizardFlowTest.php @@ -0,0 +1,595 @@ + 'Super Admin', 'guard_name' => 'web']); + + $this->admin = User::factory()->create([ + 'user_type' => 'admin', + 'status' => 'active', + ]); + $this->admin->assignRole('Super Admin'); + + $permission = \Spatie\Permission\Models\Permission::firstOrCreate([ + 'name' => 'projects.access', + 'guard_name' => 'web', + ]); + $this->admin->givePermissionTo($permission); + } + + // ─── STEP 1: Project Create/Store ──────────────────────────────────────── + + /** @test */ + public function guest_cannot_access_project_create(): void + { + $this->get(route('projects.create'))->assertRedirect(route('login')); + } + + /** @test */ + public function admin_can_view_project_create_page(): void + { + $this->actingAs($this->admin) + ->get(route('projects.create')) + ->assertOk() + ->assertInertia(fn ($page) => $page->component('ProjectManagement::Projects/Create', false)); + + } + + /** @test */ + public function project_store_fails_without_name(): void + { + $this->actingAs($this->admin) + ->post(route('projects.store'), ['name' => '', 'project_type' => 'standard']) + ->assertSessionHasErrors('name'); + } + + /** @test */ + public function project_store_fails_without_project_type(): void + { + $this->actingAs($this->admin) + ->post(route('projects.store'), ['name' => 'Test', 'project_type' => '']) + ->assertSessionHasErrors('project_type'); + } + + /** @test */ + public function project_store_fails_with_invalid_project_type(): void + { + $this->actingAs($this->admin) + ->post(route('projects.store'), ['name' => 'Test', 'project_type' => 'invalid']) + ->assertSessionHasErrors('project_type'); + } + + /** @test */ + public function project_store_fails_when_end_date_before_start_date(): void + { + $this->actingAs($this->admin) + ->post(route('projects.store'), [ + 'name' => 'Date Test', + 'project_type' => 'standard', + 'start_date' => '2025-12-31', + 'target_end_date' => '2025-01-01', + ]) + ->assertSessionHasErrors('target_end_date'); + } + + /** @test */ + public function project_store_succeeds_with_minimum_fields(): void + { + $this->actingAs($this->admin) + ->post(route('projects.store'), [ + 'name' => 'Minimal Project', + 'project_type' => 'standard', + ]); + + $project = Project::where('name', 'Minimal Project')->first(); + $this->assertNotNull($project); + $this->assertEquals(2, $project->current_wizard_step); + } + + /** @test */ + public function project_store_auto_generates_code(): void + { + $this->actingAs($this->admin) + ->post(route('projects.store'), [ + 'name' => 'Code Project', + 'project_type' => 'standard', + ]); + + $project = Project::where('name', 'Code Project')->first(); + $this->assertStringStartsWith('PRJ-' . now()->year . '-', $project->code); + } + + /** @test */ + public function project_codes_are_unique_and_sequential(): void + { + $this->actingAs($this->admin)->post(route('projects.store'), ['name' => 'P1', 'project_type' => 'standard']); + $this->actingAs($this->admin)->post(route('projects.store'), ['name' => 'P2', 'project_type' => 'standard']); + + $c1 = Project::where('name', 'P1')->value('code'); + $c2 = Project::where('name', 'P2')->value('code'); + + $this->assertNotEquals($c1, $c2); + $this->assertStringStartsWith('PRJ-', $c1); + } + + /** @test */ + public function project_store_saves_all_optional_fields(): void + { + $this->actingAs($this->admin)->post(route('projects.store'), [ + 'name' => 'Full Project', + 'client_name' => 'Acme Corp', + 'description' => 'Desc', + 'location' => 'Manila', + 'contract_value' => 5000000, + 'contract_duration' => 365, + 'start_date' => '2025-01-01', + 'target_end_date' => '2025-12-31', + 'project_type' => 'standard', + 'is_unprofitable' => false, + ]); + + $project = Project::where('name', 'Full Project')->first(); + $this->assertEquals('Acme Corp', $project->client_name); + $this->assertEquals(5000000, $project->contract_value); + } + + // ─── STEP 2: Milestones & Tasks ────────────────────────────────────────── + + /** @test */ + public function save_tasks_fails_with_empty_milestone_name(): void + { + $project = Project::factory()->create(['current_wizard_step' => 2]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.tasks', $project), [ + 'milestones' => [['name' => '', 'weight_percentage' => 50]], + ]) + ->assertSessionHasErrors('milestones.0.name'); + } + + /** @test */ + public function save_tasks_fails_with_milestone_weight_over_100(): void + { + $project = Project::factory()->create(['current_wizard_step' => 2]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.tasks', $project), [ + 'milestones' => [['name' => 'Phase 1', 'weight_percentage' => 150]], + ]) + ->assertSessionHasErrors('milestones.0.weight_percentage'); + } + + /** @test */ + public function save_tasks_fails_with_empty_task_name(): void + { + $project = Project::factory()->create(['current_wizard_step' => 2]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.tasks', $project), [ + 'tasks' => [['name' => '']], + ]) + ->assertSessionHasErrors('tasks.0.name'); + } + + /** @test */ + public function save_tasks_with_empty_arrays_advances_step(): void + { + $project = Project::factory()->create(['current_wizard_step' => 2]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.tasks', $project), ['milestones' => [], 'tasks' => []]) + ->assertRedirect(route('projects.wizard', [$project, 'step' => 3])); + + $this->assertEquals(3, $project->fresh()->current_wizard_step); + } + + /** @test */ + public function save_tasks_does_not_downgrade_already_advanced_step(): void + { + $project = Project::factory()->create(['current_wizard_step' => 5]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.tasks', $project), ['milestones' => [], 'tasks' => []]); + + $this->assertEquals(5, $project->fresh()->current_wizard_step); + } + + /** @test */ + public function save_tasks_prepopulates_8_default_milestones(): void + { + $project = Project::factory()->create([ + 'current_wizard_step' => 2, + 'start_date' => '2025-01-01', + 'contract_duration' => 365, + ]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.tasks', $project), ['prepopulate_milestones' => true]); + + $this->assertEquals(8, $project->milestones()->count()); + } + + /** @test */ + public function save_tasks_creates_milestones_and_tasks(): void + { + $project = Project::factory()->create(['current_wizard_step' => 2]); + + $this->actingAs($this->admin)->post(route('projects.wizard.tasks', $project), [ + 'milestones' => [['name' => 'Phase 1', 'weight_percentage' => 100]], + 'tasks' => [['name' => 'Task A', 'description' => 'First task']], + ]); + + $this->assertEquals(1, $project->milestones()->count()); + $this->assertEquals(1, $project->tasks()->count()); + } + + // ─── STEP 3: Material Estimates ────────────────────────────────────────── + + /** @test */ + public function save_estimates_fails_without_material_ulid(): void + { + $project = Project::factory()->create(['current_wizard_step' => 3]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.estimates', $project), [ + 'estimates' => [['material_ulid' => '', 'estimated_qty' => 10, 'unit_cost' => 100]], + ]) + ->assertSessionHasErrors('estimates.0.material_ulid'); + } + + /** @test */ + public function save_estimates_fails_with_negative_quantity(): void + { + $project = Project::factory()->create(['current_wizard_step' => 3]); + $material = Material::factory()->create(['type' => 'single', 'status' => 'active']); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.estimates', $project), [ + 'estimates' => [['material_ulid' => $material->ulid, 'estimated_qty' => -5, 'unit_cost' => 100]], + ]) + ->assertSessionHasErrors('estimates.0.estimated_qty'); + } + + /** @test */ + public function save_estimates_with_empty_array_advances_step(): void + { + $project = Project::factory()->create(['current_wizard_step' => 3]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.estimates', $project), ['estimates' => []]) + ->assertRedirect(route('projects.wizard', [$project, 'step' => 4])); + + $this->assertEquals(4, $project->fresh()->current_wizard_step); + } + + /** @test */ + public function save_estimates_creates_material_estimate_record(): void + { + $project = Project::factory()->create(['current_wizard_step' => 3]); + $material = Material::factory()->create(['type' => 'single', 'status' => 'active']); + + $this->actingAs($this->admin)->post(route('projects.wizard.estimates', $project), [ + 'estimates' => [['material_ulid' => $material->ulid, 'estimated_qty' => 10, 'unit_cost' => 250.00]], + ]); + + $this->assertEquals(1, $project->materialsEstimates()->count()); + $est = $project->materialsEstimates()->first(); + $this->assertEquals(10, $est->estimated_qty); + $this->assertEquals(250.00, $est->unit_cost); + } + + // ─── STEP 4: Labor Allocation ──────────────────────────────────────────── + + /** @test */ + public function save_labor_fails_without_task_ulid(): void + { + $project = Project::factory()->create(['current_wizard_step' => 4]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.labor', $project), [ + 'labor' => [['task_ulid' => '', 'labor_ulid' => 'x', 'estimated_hours' => 8]], + ]) + ->assertSessionHasErrors('labor.0.task_ulid'); + } + + /** @test */ + public function save_labor_fails_with_negative_hours(): void + { + $project = Project::factory()->create(['current_wizard_step' => 4]); + $task = Task::factory()->create(['project_id' => $project->id]); + $labor = Labor::factory()->create(['status' => 'active']); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.labor', $project), [ + 'labor' => [['task_ulid' => $task->ulid, 'labor_ulid' => $labor->ulid, 'estimated_hours' => -2]], + ]) + ->assertSessionHasErrors('labor.0.estimated_hours'); + } + + /** @test */ + public function save_labor_with_empty_array_advances_step(): void + { + $project = Project::factory()->create(['current_wizard_step' => 4]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.labor', $project), ['labor' => []]) + ->assertRedirect(route('projects.wizard', [$project, 'step' => 5])); + + $this->assertEquals(5, $project->fresh()->current_wizard_step); + } + + // ─── STEP 5: Equipment Allocation ──────────────────────────────────────── + + /** @test */ + public function save_equipment_fails_without_equipment_ulid(): void + { + $project = Project::factory()->create(['current_wizard_step' => 5]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.equipment', $project), [ + 'equipment' => [['task_ulid' => 'x', 'equipment_ulid' => '', 'estimated_hours' => 4]], + ]) + ->assertSessionHasErrors('equipment.0.equipment_ulid'); + } + + /** @test */ + public function save_equipment_with_empty_array_advances_step(): void + { + $project = Project::factory()->create(['current_wizard_step' => 5]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.equipment', $project), ['equipment' => []]) + ->assertRedirect(route('projects.wizard', [$project, 'step' => 6])); + + $this->assertEquals(6, $project->fresh()->current_wizard_step); + } + + // ─── STEP 6: Submit for Approval ───────────────────────────────────────── + + /** @test */ + public function submit_fails_without_approvers(): void + { + $project = Project::factory()->create(['current_wizard_step' => 6]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.submit', $project), ['approver_ids' => []]) + ->assertSessionHasErrors('approver_ids'); + } + + /** @test */ + public function submit_advances_step_to_7(): void + { + $project = Project::factory()->create(['current_wizard_step' => 6]); + $approver = User::factory()->create(['user_type' => 'admin', 'status' => 'active']); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.submit', $project), ['approver_ids' => [$approver->ulid]]); + + $this->assertEquals(7, $project->fresh()->current_wizard_step); + } + + // ─── Locked Project Guards (step >= 8) ─────────────────────────────────── + + /** @test */ + public function locked_project_blocks_task_save(): void + { + $project = Project::factory()->create(['current_wizard_step' => 8]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.tasks', $project), ['milestones' => [], 'tasks' => []]) + ->assertSessionHas('error'); + } + + /** @test */ + public function locked_project_blocks_estimate_save(): void + { + $project = Project::factory()->create(['current_wizard_step' => 8]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.estimates', $project), ['estimates' => []]) + ->assertSessionHas('error'); + } + + /** @test */ + public function locked_project_blocks_labor_save(): void + { + $project = Project::factory()->create(['current_wizard_step' => 8]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.labor', $project), ['labor' => []]) + ->assertSessionHas('error'); + } + + /** @test */ + public function locked_project_blocks_equipment_save(): void + { + $project = Project::factory()->create(['current_wizard_step' => 8]); + + $this->actingAs($this->admin) + ->post(route('projects.wizard.equipment', $project), ['equipment' => []]) + ->assertSessionHas('error'); + } + + /** @test */ + public function locked_project_blocks_update(): void + { + $project = Project::factory()->create(['current_wizard_step' => 8]); + + $this->actingAs($this->admin) + ->put(route('projects.update', $project), ['name' => 'Changed', 'project_type' => 'standard']) + ->assertSessionHas('error'); + + $this->assertNotEquals('Changed', $project->fresh()->name); + } + + // ─── Project Index Visibility ───────────────────────────────────────────── + + /** @test */ + public function index_only_shows_completed_wizard_projects(): void + { + $active = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']); + Project::factory()->create(['current_wizard_step' => 3]); + + $this->actingAs($this->admin) + ->get(route('projects.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has('projects.data', 1) + ->where('projects.data.0.id', $active->id) + ); + } + + /** @test */ + public function drafts_shows_incomplete_wizard_projects(): void + { + $draft = Project::factory()->create(['current_wizard_step' => 3]); + + $this->actingAs($this->admin) + ->get(route('projects.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has('drafts', 1) + ->where('drafts.0.id', $draft->id) + ); + } + + // ─── Status Transitions ────────────────────────────────────────────────── + + /** @test */ + public function cannot_start_project_without_pm(): void + { + $project = Project::factory()->create(['current_wizard_step' => 8, 'status' => 'planning']); + + $this->actingAs($this->admin) + ->patch(route('projects.transition', $project), ['status' => 'in_progress']) + ->assertSessionHas('error'); + } + + /** @test */ + public function can_start_project_with_pm_assigned(): void + { + $project = Project::factory()->create(['current_wizard_step' => 8, 'status' => 'planning']); + $pm = User::factory()->create(['user_type' => 'admin']); + $project->personnel()->attach($pm->id, ['role' => 'pm']); + + $this->actingAs($this->admin) + ->patch(route('projects.transition', $project), ['status' => 'in_progress']); + + $this->assertEquals('in_progress', $project->fresh()->status->value); + } + + /** @test */ + public function cannot_complete_parent_with_active_children(): void + { + $parent = Project::factory()->create(['current_wizard_step' => 8, 'status' => 'in_progress']); + $pm = User::factory()->create(); + $parent->personnel()->attach($pm->id, ['role' => 'pm']); + + Project::factory()->create([ + 'parent_project_id' => $parent->id, + 'current_wizard_step' => 8, + 'status' => 'in_progress', + ]); + + $this->actingAs($this->admin) + ->patch(route('projects.transition', $parent), ['status' => 'completed']) + ->assertSessionHas('error'); + + $this->assertEquals('in_progress', $parent->fresh()->status->value); + } + + // ─── Update Project ─────────────────────────────────────────────────────── + + /** @test */ + public function update_succeeds_on_unlocked_project(): void + { + $project = Project::factory()->create(['current_wizard_step' => 5, 'status' => 'planning']); + + $this->actingAs($this->admin) + ->put(route('projects.update', $project), ['name' => 'New Name', 'project_type' => 'standard']); + + $this->assertEquals('New Name', $project->fresh()->name); + } + + // ─── Personnel Management ───────────────────────────────────────────────── + + /** @test */ + public function add_personnel_fails_with_nonexistent_user(): void + { + $project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']); + + $this->actingAs($this->admin) + ->post(route('projects.personnel.add', $project), ['user_id' => 'bad-ulid', 'role' => 'pm']) + ->assertSessionHas('error'); + } + + /** @test */ + public function add_personnel_fails_with_invalid_role(): void + { + $project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']); + $user = User::factory()->create(); + + $this->actingAs($this->admin) + ->post(route('projects.personnel.add', $project), ['user_id' => $user->ulid, 'role' => 'bad_role']) + ->assertSessionHasErrors('role'); + } + + /** @test */ + public function add_personnel_fails_on_duplicate(): void + { + $project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']); + $user = User::factory()->create(); + $project->personnel()->attach($user->id, ['role' => 'member']); + + $this->actingAs($this->admin) + ->post(route('projects.personnel.add', $project), ['user_id' => $user->ulid, 'role' => 'member']) + ->assertSessionHas('error'); + } + + /** @test */ + public function can_remove_personnel(): void + { + $project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']); + $user = User::factory()->create(); + $project->personnel()->attach($user->id, ['role' => 'member']); + + $this->actingAs($this->admin) + ->delete(route('projects.personnel.remove', [$project, $user])); + + $this->assertFalse($project->personnel()->where('user_id', $user->id)->exists()); + } + + // ─── Wizard Step Clamping ───────────────────────────────────────────────── + + /** @test */ + public function cannot_skip_ahead_wizard_steps(): void + { + $project = Project::factory()->create(['current_wizard_step' => 2]); + + $this->actingAs($this->admin) + ->get(route('projects.wizard', [$project, 'step' => 5])) + ->assertOk() + ->assertInertia(fn ($page) => $page->where('step', 2)); + } +}