diff --git a/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalController.php b/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalController.php index 1f8217b..f412e71 100644 --- a/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalController.php +++ b/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalController.php @@ -8,6 +8,8 @@ use Illuminate\Http\Request; use Inertia\Inertia; use Modules\ApprovalWorkflow\Models\ApprovalChain; use Modules\ApprovalWorkflow\Services\ApprovalService; +use Modules\FinancialManagement\Models\CashAdvance; +use Modules\ProjectManagement\Models\Project; class ApprovalController extends Controller { @@ -51,9 +53,28 @@ class ApprovalController extends Controller ->paginate(15) ->withQueryString(); + // Cash advances intentionally use their own direct pending -> approved + // workflow. They must still appear in the approvals workspace without + // creating an ApprovalChain record. + $cashAdvanceQuery = CashAdvance::withoutGlobalScopes() + ->with(['project:id,name,code', 'requester:id,name,email']); + + if ($tab === 'history') { + $cashAdvanceQuery->whereIn('status', ['approved', 'rejected']); + } else { + $cashAdvanceQuery->where('status', 'pending'); + } + + $cashAdvanceQuery->whereIn('project_id', Project::query()->select('projects.id')); + + if (! $isAdmin) { + $cashAdvanceQuery->where('requested_by', '!=', $user->id); + } + return Inertia::render('ApprovalWorkflow::Approvals/Index', [ 'approvals' => $chains, 'tab' => $tab, + 'cashAdvances' => $cashAdvanceQuery->latest()->get(), ]); } @@ -104,9 +125,16 @@ class ApprovalController extends Controller $breakdownData = [ 'document_number' => $approvalChain->approvable->document_number ?? $approvalChain->approvable->po_number + ?? ($approvalChain->approvable instanceof \Modules\FinancialManagement\Models\FinancialInvoice ? $approvalChain->approvable->invoice_number : null) ?? ($approvalChain->approvable instanceof \Modules\ProjectManagement\Models\Project ? $approvalChain->approvable->code : null), - 'total_cost' => $totalCost, + 'total_cost' => $approvalChain->approvable instanceof \Modules\FinancialManagement\Models\FinancialInvoice + ? $approvalChain->approvable->total_amount + : $totalCost, + 'retention_amount' => $approvalChain->approvable instanceof \Modules\FinancialManagement\Models\FinancialInvoice + ? $approvalChain->approvable->retention_amount + : null, 'notes' => $approvalChain->approvable->notes + ?? ($approvalChain->approvable instanceof \Modules\FinancialManagement\Models\FinancialInvoice ? $approvalChain->approvable->notes : null) ?? ($approvalChain->approvable instanceof \Modules\ProjectManagement\Models\Project ? $approvalChain->approvable->description : null), ]; } diff --git a/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx b/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx index babdfaf..1cf50ed 100644 --- a/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx +++ b/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx @@ -47,6 +47,17 @@ export default function ApprovableBreakdown({ chain, breakdownData }: Props) { { label: 'Description', value: breakdownData.notes || 'None' }, ]; break; + case 'Modules\\FinancialManagement\\Models\\FinancialInvoice': + title = 'Invoice & Retention Details'; + icon = ; + linkUrl = route('finance.show', approvable.ulid || approvable.id); + details = [ + { label: 'Invoice Number', value: breakdownData.document_number || approvable.invoice_number || 'N/A' }, + { label: 'Invoice Total', value: breakdownData.total_cost != null ? `₱${Number(breakdownData.total_cost).toLocaleString(undefined, { minimumFractionDigits: 2 })}` : 'N/A' }, + { label: 'Retention Held', value: breakdownData.retention_amount != null ? `₱${Number(breakdownData.retention_amount).toLocaleString(undefined, { minimumFractionDigits: 2 })}` : '₱0.00' }, + { label: 'Notes', value: breakdownData.notes || 'Progress billing invoice' }, + ]; + break; default: title = 'Generic Document'; break; diff --git a/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Index.tsx b/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Index.tsx index 2e6c386..fa72bad 100644 --- a/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Index.tsx +++ b/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Index.tsx @@ -1,5 +1,5 @@ import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; -import { Head, Link, usePage } from '@inertiajs/react'; +import { Head, Link, router, usePage } from '@inertiajs/react'; import { Badge } from '@/Components/ui/badge'; import { Button } from '@/Components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card'; @@ -26,6 +26,18 @@ interface ChainItem { interface Props extends PageProps { approvals: PaginatedData; tab: string; + cashAdvances?: CashAdvanceItem[]; +} + +interface CashAdvanceItem { + id: number; + ulid: string; + amount: string; + reason: string; + status: string; + created_at: string; + project?: { name: string; code: string }; + requester?: { name: string }; } const typeLabel = (type: string) => type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); @@ -38,7 +50,7 @@ const statusVariant = (s: string) => { } }; -export default function Index({ approvals, tab }: Props) { +export default function Index({ approvals, tab, cashAdvances = [] }: Props) { const { flash } = usePage().props; const isHistory = tab === 'history'; @@ -136,6 +148,46 @@ export default function Index({ approvals, tab }: Props) { )} + + {cashAdvances.length > 0 && ( + + + {isHistory ? 'Cash Advance History' : 'Cash Advances Awaiting Approval'} + + + + + + Project + Requested By + Reason + Amount + Status + Actions + + + + {cashAdvances.map((cashAdvance) => ( + + {cashAdvance.project?.name || '-'} + {cashAdvance.requester?.name || '-'} + {cashAdvance.reason} + ₱{Number(cashAdvance.amount).toLocaleString('en-PH', { minimumFractionDigits: 2 })} + {typeLabel(cashAdvance.status)} + + {!isHistory && cashAdvance.status === 'pending' && ( + + )} + + + ))} + +
+
+
+ )} diff --git a/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Show.tsx b/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Show.tsx index 2227f2d..20b4900 100644 --- a/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Show.tsx +++ b/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Show.tsx @@ -40,8 +40,8 @@ export default function Show({ chain, breakdownData }: Props) { const [rejectNotes, setRejectNotes] = useState(''); const currentStep = chain.steps.find(s => s.status === 'pending'); - const isApprovingRole = auth.user.user_type === 'admin' - || auth.user.roles?.some((r: any) => ['Super Admin', 'admin', 'Main Contractor Admin', 'Project Manager', 'project_manager'].includes(r.name)); + const isApprovingRole = auth.user.user_type === 'admin' + || auth.roles?.some((role: string) => ['Super Admin', 'admin', 'Main Contractor Admin', 'Project Manager', 'project_manager'].includes(role)); const isCurrentApprover = currentStep && (currentStep.approver.id === auth.user.id || isApprovingRole); const handleApprove = () => { diff --git a/Modules/BiddingManagement/app/Http/Controllers/BidAwardController.php b/Modules/BiddingManagement/app/Http/Controllers/BidAwardController.php index c4047d8..a688d48 100644 --- a/Modules/BiddingManagement/app/Http/Controllers/BidAwardController.php +++ b/Modules/BiddingManagement/app/Http/Controllers/BidAwardController.php @@ -10,11 +10,16 @@ use Modules\BiddingManagement\Events\BidAwarded; use Modules\BiddingManagement\Models\BidAward; use Modules\BiddingManagement\Models\BidPackage; use Modules\BiddingManagement\Models\BidSubmission; +use Modules\BiddingManagement\Traits\AuthorizesBiddingManagement; class BidAwardController extends Controller { + use AuthorizesBiddingManagement; + public function store(Request $request, BidPackage $bid) { + $this->authorizeBiddingManagement(); + abort_unless( in_array($bid->status, [BidPackageStatus::Open, BidPackageStatus::Evaluating]), 403, diff --git a/Modules/BiddingManagement/app/Http/Controllers/BidInvitationController.php b/Modules/BiddingManagement/app/Http/Controllers/BidInvitationController.php index ba77e46..78a92fd 100644 --- a/Modules/BiddingManagement/app/Http/Controllers/BidInvitationController.php +++ b/Modules/BiddingManagement/app/Http/Controllers/BidInvitationController.php @@ -7,12 +7,17 @@ use Illuminate\Http\Request; use Modules\BiddingManagement\Enums\BidPackageStatus; use Modules\BiddingManagement\Models\BidInvitation; use Modules\BiddingManagement\Models\BidPackage; +use Modules\BiddingManagement\Traits\AuthorizesBiddingManagement; use Modules\ContractorManagement\Models\Contractor; class BidInvitationController extends Controller { + use AuthorizesBiddingManagement; + public function store(Request $request, BidPackage $bid) { + $this->authorizeBiddingManagement(); + abort_unless( in_array($bid->status, [BidPackageStatus::Draft, BidPackageStatus::Open]), 403, @@ -50,6 +55,8 @@ class BidInvitationController extends Controller public function destroy(BidInvitation $invitation) { + $this->authorizeBiddingManagement(); + abort_unless( in_array($invitation->package->status, [BidPackageStatus::Draft, BidPackageStatus::Open]), 403, diff --git a/Modules/BiddingManagement/app/Http/Controllers/BidPackageController.php b/Modules/BiddingManagement/app/Http/Controllers/BidPackageController.php index 6f4b34a..d277b12 100644 --- a/Modules/BiddingManagement/app/Http/Controllers/BidPackageController.php +++ b/Modules/BiddingManagement/app/Http/Controllers/BidPackageController.php @@ -9,13 +9,23 @@ use Inertia\Inertia; use Modules\BiddingManagement\Enums\BidPackageStatus; use Modules\BiddingManagement\Events\BidPackageOpened; use Modules\BiddingManagement\Models\BidPackage; +use Modules\BiddingManagement\Traits\AuthorizesBiddingManagement; use Modules\ContractorManagement\Models\Contractor; use Modules\ProjectManagement\Models\Project; class BidPackageController extends Controller { + use AuthorizesBiddingManagement; + public function index(Request $request) { + $user = Auth::user(); + abort_unless( + $this->isBiddingManager($user) || $this->isContractorUser($user), + 403, + 'Only Project Manager, executive, or invited contractor roles can access bidding.' + ); + $query = BidPackage::with(['project:id,name,code', 'creator:id,name', 'invitations:id,bid_package_id,ulid,contractor_id,status']) ->withCount(['invitations', 'submissions']); @@ -37,8 +47,7 @@ class BidPackageController extends Controller } // Contractor Admin users should ONLY see published (non-draft) bid packages where their company is invited - $user = Auth::user(); - if ($user && $user->contractor_id) { + if (! $this->isBiddingManager($user) && $this->isContractorUser($user)) { $query->where('status', '!=', BidPackageStatus::Draft->value) ->whereHas('invitations', function ($q) use ($user) { $q->where('contractor_id', $user->contractor_id); @@ -56,13 +65,17 @@ class BidPackageController extends Controller public function create() { + $this->authorizePackageManagement(); + return Inertia::render('BiddingManagement::Bids/Create', [ - 'projects' => Project::select('id', 'ulid', 'name', 'code')->get(), + 'projects' => Project::where('current_wizard_step', '>=', 7)->select('id', 'ulid', 'name', 'code')->get(), ]); } public function store(Request $request) { + $this->authorizePackageManagement(); + $validated = $request->validate([ 'project_id' => 'required|string', 'title' => 'required|string|max:255', @@ -78,6 +91,19 @@ class BidPackageController extends Controller ]); $validated['project_id'] = Project::resolveUlidToId($validated['project_id']); + $project = Project::find($validated['project_id']); + abort_unless($project, 422, 'The selected project could not be found.'); + abort_unless( + $project->current_wizard_step >= 7, + 422, + 'The project must finish initialization before a bid package can be created.' + ); + abort_unless( + $this->isExecutive(Auth::user()) + || $project->personnel()->where('users.id', Auth::id())->wherePivot('role', 'pm')->exists(), + 403, + 'Only the assigned Project Manager or an executive can create this bid package.' + ); $validated['created_by'] = Auth::id(); $criteria = $validated['criteria'] ?? []; @@ -107,7 +133,12 @@ class BidPackageController extends Controller public function show(BidPackage $bid) { $user = Auth::user(); - if ($user && $user->contractor_id) { + abort_unless( + $this->isBiddingManager($user) || $this->isContractorUser($user), + 403, + 'Only Project Manager, executive, or invited contractor roles can access bidding.' + ); + if (! $this->isBiddingManager($user) && $this->isContractorUser($user)) { abort_if($bid->status === BidPackageStatus::Draft, 403, 'This bid package is currently in draft state.'); abort_unless($bid->invitations()->where('contractor_id', $user->contractor_id)->exists(), 403, 'Your company has not been invited to this bid package.'); } @@ -136,18 +167,22 @@ class BidPackageController extends Controller public function edit(BidPackage $bid) { + $this->authorizePackageManagement(); + abort_unless($bid->status === BidPackageStatus::Draft, 403, 'Only draft packages can be edited.'); $bid->load('criteria'); return Inertia::render('BiddingManagement::Bids/Edit', [ 'package' => $bid, - 'projects' => Project::select('id', 'ulid', 'name', 'code')->get(), + 'projects' => Project::where('current_wizard_step', '>=', 7)->select('id', 'ulid', 'name', 'code')->get(), ]); } public function update(Request $request, BidPackage $bid) { + $this->authorizePackageManagement(); + abort_unless($bid->status === BidPackageStatus::Draft, 403, 'Only draft packages can be edited.'); $validated = $request->validate([ @@ -184,6 +219,8 @@ class BidPackageController extends Controller public function destroy(BidPackage $bid) { + $this->authorizePackageManagement(); + abort_unless($bid->status === BidPackageStatus::Draft, 403, 'Only draft packages can be deleted.'); $bid->delete(); @@ -193,6 +230,8 @@ class BidPackageController extends Controller public function publish(BidPackage $bid) { + $this->authorizePackageManagement(); + try { $bid->transitionTo(BidPackageStatus::Open); BidPackageOpened::dispatch($bid); @@ -205,6 +244,8 @@ class BidPackageController extends Controller public function startEvaluation(BidPackage $bid) { + $this->authorizePackageManagement(); + try { $bid->transitionTo(BidPackageStatus::Evaluating); } catch (\InvalidArgumentException $e) { @@ -216,6 +257,8 @@ class BidPackageController extends Controller public function cancel(BidPackage $bid) { + $this->authorizePackageManagement(); + try { $bid->transitionTo(BidPackageStatus::Cancelled); } catch (\InvalidArgumentException $e) { @@ -224,4 +267,16 @@ class BidPackageController extends Controller return back()->with('success', 'Bid package cancelled.'); } + + private function authorizePackageManagement(): void + { + $this->authorizeBiddingManagement(); + } + + private function isContractorUser($user): bool + { + return $user !== null + && ! $this->isBiddingManager($user) + && ($user->user_type === 'contractor' || $user->contractor_id !== null); + } } diff --git a/Modules/BiddingManagement/app/Http/Controllers/BidScoreController.php b/Modules/BiddingManagement/app/Http/Controllers/BidScoreController.php index b125de5..a7a23d1 100644 --- a/Modules/BiddingManagement/app/Http/Controllers/BidScoreController.php +++ b/Modules/BiddingManagement/app/Http/Controllers/BidScoreController.php @@ -8,11 +8,16 @@ use Illuminate\Support\Facades\Auth; use Modules\BiddingManagement\Enums\BidPackageStatus; use Modules\BiddingManagement\Models\BidScore; use Modules\BiddingManagement\Models\BidSubmission; +use Modules\BiddingManagement\Traits\AuthorizesBiddingManagement; class BidScoreController extends Controller { + use AuthorizesBiddingManagement; + public function store(Request $request, BidSubmission $submission) { + $this->authorizeBiddingManagement(); + $package = $submission->invitation->package; abort_unless($package->status === BidPackageStatus::Evaluating, 403, 'Package is not in evaluation phase.'); diff --git a/Modules/BiddingManagement/app/Http/Controllers/BidSubmissionController.php b/Modules/BiddingManagement/app/Http/Controllers/BidSubmissionController.php index 2b2bdb9..3a59c50 100644 --- a/Modules/BiddingManagement/app/Http/Controllers/BidSubmissionController.php +++ b/Modules/BiddingManagement/app/Http/Controllers/BidSubmissionController.php @@ -12,9 +12,12 @@ use Modules\BiddingManagement\Enums\BidSubmissionStatus; use Modules\BiddingManagement\Events\BidSubmitted; use Modules\BiddingManagement\Models\BidInvitation; use Modules\BiddingManagement\Models\BidSubmission; +use Modules\BiddingManagement\Traits\AuthorizesBiddingManagement; class BidSubmissionController extends Controller { + use AuthorizesBiddingManagement; + public function create(BidInvitation $invitation) { abort_unless($invitation->package->status === BidPackageStatus::Open, 403, 'Bid package is not open.'); @@ -96,6 +99,8 @@ class BidSubmissionController extends Controller public function shortlist(BidSubmission $submission) { + $this->authorizeBiddingManagement(); + try { $submission->transitionTo(BidSubmissionStatus::Shortlisted); } catch (\InvalidArgumentException $e) { @@ -107,6 +112,8 @@ class BidSubmissionController extends Controller public function reject(BidSubmission $submission) { + $this->authorizeBiddingManagement(); + try { $submission->transitionTo(BidSubmissionStatus::Rejected); } catch (\InvalidArgumentException $e) { diff --git a/Modules/BiddingManagement/app/Traits/AuthorizesBiddingManagement.php b/Modules/BiddingManagement/app/Traits/AuthorizesBiddingManagement.php new file mode 100644 index 0000000..c8cdf8d --- /dev/null +++ b/Modules/BiddingManagement/app/Traits/AuthorizesBiddingManagement.php @@ -0,0 +1,30 @@ +isBiddingManager(Auth::user()), + 403, + 'Only Project Manager and executive roles can perform bidding actions.' + ); + } + + protected function isBiddingManager(?User $user): bool + { + return $user !== null + && $user->hasAnyRole(['Project Manager', 'admin', 'Super Admin']); + } + + protected function isExecutive(?User $user): bool + { + return $user !== null + && ($user->user_type === 'admin' || $user->hasAnyRole(['admin', 'Super Admin'])); + } +} diff --git a/Modules/BiddingManagement/resources/js/Pages/Bids/Show.tsx b/Modules/BiddingManagement/resources/js/Pages/Bids/Show.tsx index d4b34dc..eb2272b 100644 --- a/Modules/BiddingManagement/resources/js/Pages/Bids/Show.tsx +++ b/Modules/BiddingManagement/resources/js/Pages/Bids/Show.tsx @@ -148,6 +148,7 @@ export default function Show({ package: pkg, contractors }: Props) { const canAward = ['open', 'evaluating'].includes(pkg.status); const canCancel = !['awarded', 'cancelled'].includes(pkg.status); const isScored = pkg.evaluation_mode === 'scored'; + const isBiddingManager = auth.roles?.some((role: string) => ['Project Manager', 'admin', 'Super Admin'].includes(role)) || auth.user?.user_type === 'admin'; const submissionsWithScore = useMemo(() => { return pkg.invitations @@ -186,30 +187,6 @@ export default function Show({ package: pkg, contractors }: Props) {

- {auth.user?.user_type !== 'contractor' && ( -
- {canPublish && ( - - - - )} - {canPublish && ( - - )} - {canEvaluate && ( - - )} - {canCancel && ( - - )} -
- )} } > @@ -220,6 +197,52 @@ export default function Show({ package: pkg, contractors }: Props) { {flash?.success &&
{flash.success}
} {flash?.error &&
{flash.error}
} + {isBiddingManager && ( + + +
+
+ +
+
+

Package controls

+

+ {pkg.status === 'draft' + ? 'Review the package, then publish it to invite contractors.' + : pkg.status === 'open' + ? 'The package is accepting submissions.' + : `This package is ${statusLabel(pkg.status).toLowerCase()}.`} +

+
+
+
+ {canPublish && ( + + + + )} + {canPublish && ( + + )} + {canEvaluate && ( + + )} + {canCancel && ( + + )} +
+
+
+ )} + {/* Contractor Action Banner (For Subcontractor Admin users) */} {auth.user?.user_type === 'contractor' && auth.user?.contractor_id !== null && ( (() => { @@ -324,7 +347,7 @@ export default function Show({ package: pkg, contractors }: Props) {
Invited Contractors - {auth.user?.user_type !== 'contractor' && ['draft', 'open'].includes(pkg.status) && ( + {isBiddingManager && ['draft', 'open'].includes(pkg.status) && ( }>Invite Contractors @@ -363,7 +386,7 @@ export default function Show({ package: pkg, contractors }: Props) { Specialization Response Submitted - {auth.user?.user_type !== 'contractor' && Actions} + {isBiddingManager && Actions} @@ -381,7 +404,7 @@ export default function Show({ package: pkg, contractors }: Props) { {inv.submission ? : } - {auth.user?.user_type !== 'contractor' && ( + {isBiddingManager && ( {!inv.submission && ['draft', 'open'].includes(pkg.status) && (
)} + {purchaseOrder.status === 'submitted' && ( +
+ This Purchase Order is awaiting Project Manager or executive approval. Mark as Paid and Mark as Delivered will become available after approval. +
+ )} + { if (confirm('Approve this purchase order?')) { - router.post(route('approvals.approve', purchaseOrder.approvalChains?.[0]?.steps?.find((s: any) => s.status === 'pending')?.id || 0)); + if (pendingApprovalChain?.ulid) { + router.patch(route('approvals.approve', pendingApprovalChain.ulid)); + } } }} > diff --git a/Modules/MaterialLogistics/resources/js/Pages/Requisitions/Index.tsx b/Modules/MaterialLogistics/resources/js/Pages/Requisitions/Index.tsx index d7ac4af..7fc7b6f 100644 --- a/Modules/MaterialLogistics/resources/js/Pages/Requisitions/Index.tsx +++ b/Modules/MaterialLogistics/resources/js/Pages/Requisitions/Index.tsx @@ -30,7 +30,7 @@ interface MrRow { id: number; ulid: string; document_number: string; status: string; notes?: string; created_at: string; approved_at?: string; - requester: Approver; + requester?: Approver | null; approver?: Approver; items: MrItem[]; } @@ -104,7 +104,7 @@ export default function Index({ requisitions }: Props) { router.visit(route('requisitions.show', mr.ulid))}> {mr.document_number} - {mr.requester.name} + {mr.requester?.name ?? 'Unknown user'} {mr.items?.length || 0} {fmt(total)} {mr.status} diff --git a/Modules/ProjectManagement/app/Http/Controllers/ProjectController.php b/Modules/ProjectManagement/app/Http/Controllers/ProjectController.php index fcfe14b..7d0333f 100644 --- a/Modules/ProjectManagement/app/Http/Controllers/ProjectController.php +++ b/Modules/ProjectManagement/app/Http/Controllers/ProjectController.php @@ -45,40 +45,6 @@ class ProjectController extends Controller ->where('current_wizard_step', '<', 7) ->with(['contractor:id,company_name', 'personnel:id,name']); - // Filter non-admin users to assigned projects or company projects - if ($user && $user->user_type !== 'admin' && ! $user->hasRole('Super Admin')) { - $query->where(function ($q) use ($user) { - $q->whereHas('personnel', function ($pq) use ($user) { - $pq->where('users.id', $user->id); - }); - if ($user->contractor_id) { - $q->orWhere('contractor_id', $user->contractor_id); - } else { - $q->orWhereNull('contractor_id'); - } - }); - $historyQuery->where(function ($q) use ($user) { - $q->whereHas('personnel', function ($pq) use ($user) { - $pq->where('users.id', $user->id); - }); - if ($user->contractor_id) { - $q->orWhere('contractor_id', $user->contractor_id); - } else { - $q->orWhereNull('contractor_id'); - } - }); - $draftsQuery->where(function ($q) use ($user) { - $q->whereHas('personnel', function ($pq) use ($user) { - $pq->where('users.id', $user->id); - }); - if ($user->contractor_id) { - $q->orWhere('contractor_id', $user->contractor_id); - } else { - $q->orWhereNull('contractor_id'); - } - }); - } - $projects = $query ->when($request->search, fn ($q, $s) => $q->where('name', 'like', "%{$s}%")->orWhere('code', 'like', "%{$s}%")) ->when($request->status, fn ($q, $s) => $q->where('status', $s)) diff --git a/Modules/ProjectManagement/app/Models/Project.php b/Modules/ProjectManagement/app/Models/Project.php index a072397..237f049 100644 --- a/Modules/ProjectManagement/app/Models/Project.php +++ b/Modules/ProjectManagement/app/Models/Project.php @@ -110,6 +110,13 @@ class Project extends Model return $this->belongsTo(Contractor::class); } + public function contractors(): BelongsToMany + { + return $this->belongsToMany(Contractor::class, 'project_contractor') + ->withPivot('role', 'contract_amount') + ->withTimestamps(); + } + public function tasks(): HasMany { return $this->hasMany(Task::class)->orderBy('sort_order'); diff --git a/Modules/ProjectManagement/resources/js/Layouts/ProjectLayout.tsx b/Modules/ProjectManagement/resources/js/Layouts/ProjectLayout.tsx index c990b46..2a664de 100644 --- a/Modules/ProjectManagement/resources/js/Layouts/ProjectLayout.tsx +++ b/Modules/ProjectManagement/resources/js/Layouts/ProjectLayout.tsx @@ -32,7 +32,8 @@ export default function ProjectLayout({ project, allowedTransitions, children, c // Kept for backward compatibility if needed, but tabs are removed }; - const { projects } = usePage().props; + const { projects, projectOptions } = usePage().props; + const selectableProjects = projectOptions ?? projects; return ( Choose a project to view its {currentTab.replace('-', ' ')} data.

- {projects && projects.length > 0 ? ( + {selectableProjects && selectableProjects.length > 0 ? (
- {projects.map((p: any) => ( + {selectableProjects.map((p: any) => (
{ diff --git a/Modules/ProjectManagement/resources/js/Pages/Projects/Wizard.tsx b/Modules/ProjectManagement/resources/js/Pages/Projects/Wizard.tsx index c32fb27..ee109a7 100644 --- a/Modules/ProjectManagement/resources/js/Pages/Projects/Wizard.tsx +++ b/Modules/ProjectManagement/resources/js/Pages/Projects/Wizard.tsx @@ -471,6 +471,19 @@ export default function Wizard({ project, step: currentStep, employees, projects {/* Step Content Container */}
+ {Object.keys(errors || {}).length > 0 && ( +
+

+ + Please correct the following errors before proceeding: +

+
    + {Object.entries(errors).map(([key, val]) => ( +
  • {String(val)}
  • + ))} +
+
+ )} {/* Step 1: Project Details (Editable Form) */} {step === 1 && ( diff --git a/Modules/RolesPermissions/database/seeders/RolesPermissionsDatabaseSeeder.php b/Modules/RolesPermissions/database/seeders/RolesPermissionsDatabaseSeeder.php index 9cbabf9..3e093ea 100644 --- a/Modules/RolesPermissions/database/seeders/RolesPermissionsDatabaseSeeder.php +++ b/Modules/RolesPermissions/database/seeders/RolesPermissionsDatabaseSeeder.php @@ -23,6 +23,9 @@ class RolesPermissionsDatabaseSeeder extends Seeder 'roles.access', 'labors.access', 'equipments.access', + 'create tasks', + 'edit tasks', + 'delete tasks', ]; foreach ($permissions as $permission) { @@ -33,6 +36,10 @@ class RolesPermissionsDatabaseSeeder extends Seeder $validRoles = [ 'Super Admin', 'Contractor', + 'Main Contractor Admin', + 'Main Contractor User', + 'Sub Contractor Admin', + 'Sub Contractor User', 'Project Manager', 'Designer', 'Site Technical', @@ -48,6 +55,29 @@ class RolesPermissionsDatabaseSeeder extends Seeder $siteTechnical = \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Site Technical']); $constructionSupervisor = \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Construction Supervisor']); + $mainContractorAdmin = \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Main Contractor Admin']); + $mainContractorAdmin->syncPermissions([ + 'dashboard.access', + 'projects.access', + 'contractors.access', + 'bidding.access', + 'users.access', + 'materials-catalog.access', + 'inventory.access', + 'finance.access', + 'documents.access', + 'approvals.access', + ]); + + $subContractorAdmin = \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Sub Contractor Admin']); + $subContractorAdmin->syncPermissions([ + 'dashboard.access', + 'projects.access', + 'users.access', + 'finance.access', + 'documents.access', + ]); + $projectManager->syncPermissions([ 'dashboard.access', 'projects.access', @@ -60,6 +90,9 @@ class RolesPermissionsDatabaseSeeder extends Seeder 'approvals.access', 'labors.access', 'equipments.access', + 'create tasks', + 'edit tasks', + 'delete tasks', ]); $contractor->syncPermissions([ @@ -68,6 +101,7 @@ class RolesPermissionsDatabaseSeeder extends Seeder 'bidding.access', 'finance.access', 'documents.access', + 'edit tasks', ]); $designer->syncPermissions([ @@ -81,6 +115,7 @@ class RolesPermissionsDatabaseSeeder extends Seeder 'projects.access', 'inventory.access', 'documents.access', + 'edit tasks', ]); $constructionSupervisor->syncPermissions([ @@ -89,6 +124,7 @@ class RolesPermissionsDatabaseSeeder extends Seeder 'inventory.access', 'materials-catalog.access', 'documents.access', + 'edit tasks', ]); // Assign super-admin to the first user if it exists diff --git a/Modules/TaskManagement/resources/js/Components/KanbanBoard.tsx b/Modules/TaskManagement/resources/js/Components/KanbanBoard.tsx index 749ee41..9575346 100644 --- a/Modules/TaskManagement/resources/js/Components/KanbanBoard.tsx +++ b/Modules/TaskManagement/resources/js/Components/KanbanBoard.tsx @@ -162,51 +162,9 @@ export default function KanbanBoard({ tasks, onReorder, onTaskClick, readOnly =
- {/* Quick Status Action Buttons */} -
e.stopPropagation()}> - Move Status: -
- {task.status !== 'pending' && ( - - )} - {task.status !== 'in_progress' && ( - - )} - {task.status !== 'completed' && ( - - )} -
-
+

+ Select the task to view details and move its status. +

{task.users?.slice(0, 3).map((user: any) => ( diff --git a/Modules/TaskManagement/resources/js/Pages/Tasks/Index.tsx b/Modules/TaskManagement/resources/js/Pages/Tasks/Index.tsx index 22eb1e2..7ca1e61 100644 --- a/Modules/TaskManagement/resources/js/Pages/Tasks/Index.tsx +++ b/Modules/TaskManagement/resources/js/Pages/Tasks/Index.tsx @@ -17,7 +17,13 @@ import { LayoutList, Kanban, Activity as ActivityIcon } from 'lucide-react'; import { Textarea } from '@/Components/ui/textarea'; export default function Tasks({ project, employees, availableMaterials, delayReasons, weatherConditions }: any) { - const { can } = usePermission(); + const { can, hasRole } = usePermission(); + const canManageTaskStatus = can('edit', 'tasks') || [ + 'Project Manager', + 'Site Technical', + 'Construction Supervisor', + 'Contractor', + ].some((role) => hasRole(role)); const [addTaskOpen, setAddTaskOpen] = useState(false); const [expandedTask, setExpandedTask] = useState(null); const [addMaterialTaskId, setAddMaterialTaskId] = useState(null); @@ -262,7 +268,7 @@ export default function Tasks({ project, employees, availableMaterials, delayRea tasks={project?.tasks || []} onReorder={handleReorder} onTaskClick={(ulid: string) => setDetailsTaskUlid(ulid)} - readOnly={!can('edit', 'tasks')} + readOnly={!canManageTaskStatus} /> ) : (
@@ -310,30 +316,6 @@ export default function Tasks({ project, employees, availableMaterials, delayRea {formatCurrency(String(task.total_cost))}
- {can('edit', 'tasks') && ( -
- {task.status === 'pending' && ( - - )} - {task.status === 'in_progress' && ( - - )} - {task.status === 'completed' && ( - - )} - {task.status === 'blocked' && ( - - )} -
- )} {can('edit', 'tasks') && (
- {can('edit', 'tasks') && ( + {canManageTaskStatus && ( <> {detailsTask.status === 'pending' && ( <> @@ -502,6 +484,11 @@ export default function Tasks({ project, employees, availableMaterials, delayRea )} + {detailsTask.status === 'closed' && ( + + This task is closed and cannot be moved. + + )} diff --git a/Modules/UserManagement/routes/web.php b/Modules/UserManagement/routes/web.php index 7430377..9c41e98 100644 --- a/Modules/UserManagement/routes/web.php +++ b/Modules/UserManagement/routes/web.php @@ -3,7 +3,9 @@ use Illuminate\Support\Facades\Route; use Modules\UserManagement\Http\Controllers\UserController; -Route::middleware(['auth', 'verified', 'permission:users.access'])->group(function () { +// Platform users may use the module-level permission, while contractor +// administrators use the domain permission assigned to their role. +Route::middleware(['auth', 'verified', 'permission:users.access|manage users'])->group(function () { Route::resource('users', UserController::class); Route::patch('users/{user}/toggle-status', [UserController::class, 'toggleStatus'])->name('users.toggle-status'); Route::patch('users/{user}/link-contractor', [UserController::class, 'linkContractor'])->name('users.link-contractor'); diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index d6f4534..024de08 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -6,10 +6,10 @@ use Inertia\Inertia; use Illuminate\Http\Request; use Modules\ProjectManagement\Models\Project; use Modules\ProjectManagement\Models\Task; +use Modules\DailyReports\Models\DailyReportLabor; use Modules\ProjectManagement\Models\TaskActivity; use Modules\ProjectManagement\Models\TaskDelay; use Modules\DailyReports\Models\DailyReport; -use Modules\DailyReports\Models\DailyReportLabor; use Modules\DailyReports\Models\DailyReportEquipment; use Modules\DailyReports\Models\DailyReportIssue; use App\Services\WeatherService; @@ -274,7 +274,7 @@ class DashboardController extends Controller } /** - * Compile resource logs (Labor & Equipment) from latest Daily Reports. + * Compile deployed labor and current equipment from active project data. */ private function getResourcesData(?Project $project): array { @@ -286,31 +286,35 @@ class DashboardController extends Controller $equipmentIdle = 0; $equipmentList = []; - // Resolve which projects we care about - $projectIds = $project ? [$project->id] : Project::pluck('id')->toArray(); + // Global resource roll-ups should represent active execution only. + // A selected project intentionally narrows the dashboard to that project. + $projectIds = $project + ? [$project->id] + : Project::whereIn('status', ['active', 'planning', 'in_progress']) + ->pluck('id') + ->toArray(); - // Fetch latest reports for the selected project or across all active projects - $latestReports = DailyReport::whereIn('project_id', $projectIds) - ->whereIn('id', function ($query) use ($projectIds) { - $query->selectRaw('MAX(id)') - ->from('daily_reports') - ->whereIn('project_id', $projectIds) - ->groupBy('project_id'); - }) + // Labor totals come from every Daily Report labor entry for the active + // project set. This intentionally includes all stored report records. + $laborLogs = DailyReportLabor::whereHas('dailyReport', function ($query) use ($projectIds) { + $query->whereIn('project_id', $projectIds); + })->get(); + + foreach ($laborLogs as $log) { + $laborActual += (int) $log->workers_count; + $tradeName = $log->trade ?? 'General Labor'; + $trades[$tradeName] = ($trades[$tradeName] ?? 0) + (int) $log->workers_count; + } + + // Read equipment entries from every daily report in the project set. + // ResourceSummary must represent all stored daily-report records, not + // only the latest report for each project. + $reports = DailyReport::whereIn('project_id', $projectIds) + ->with('equipment') ->get(); - foreach ($latestReports as $report) { - // Compile Labor - $laborLogs = DailyReportLabor::where('daily_report_id', $report->id)->get(); - foreach ($laborLogs as $log) { - $laborActual += (int) $log->workers_count; - $tradeName = $log->trade ?? 'General Labor'; - $trades[$tradeName] = ($trades[$tradeName] ?? 0) + (int) $log->workers_count; - } - - // Compile Equipment - $equipLogs = DailyReportEquipment::where('daily_report_id', $report->id)->get(); - foreach ($equipLogs as $log) { + foreach ($reports as $report) { + foreach ($report->equipment as $log) { $status = strtolower($log->status ?? 'active'); if (str_contains($status, 'active') || str_contains($status, 'use') || str_contains($status, 'operat')) { $equipmentActive++; @@ -330,9 +334,8 @@ class DashboardController extends Controller } } - // Expected labor calculation: calculate expected labor from project allocation if present, otherwise dynamically from actual logs if ($laborActual > 0) { - $laborExpected = max($laborActual, (int)ceil($laborActual * 1.15)); + $laborExpected = max($laborActual, (int) ceil($laborActual * 1.15)); } return [ @@ -345,7 +348,7 @@ class DashboardController extends Controller 'active' => $equipmentActive, 'maintenance' => $equipmentMaintenance, 'idle' => $equipmentIdle, - 'list' => array_slice($equipmentList, 0, 5) // Cap list at 5 + 'list' => $equipmentList ] ]; } @@ -374,8 +377,14 @@ class DashboardController extends Controller $totalRetention = (float) $invoicesQuery->sum('retention_amount'); $pendingApprovalsCount = \Modules\ApprovalWorkflow\Models\ApprovalChain::where('status', 'in_review')->count(); - $totalProjectsCount = Project::count(); - $activeProjectsCount = Project::where('status', 'active')->count(); + $projectsAnalyticsQuery = Project::query(); + if ($project) { + $projectsAnalyticsQuery->whereKey($project->id); + } + $totalProjectsCount = (clone $projectsAnalyticsQuery)->count(); + $activeProjectsCount = (clone $projectsAnalyticsQuery) + ->whereIn('status', ['planning', 'in_progress']) + ->count(); // 2. PM Milestone & Progress Analytics $milestonesQuery = \Modules\TimelineScheduling\Models\Milestone::query(); @@ -402,6 +411,7 @@ class DashboardController extends Controller return [ 'role' => $roleName, + 'roles' => $user?->getRoleNames()->values()->all() ?? [], 'user_type' => $userType, 'financials' => [ 'total_billed' => $totalBilled, diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index e7b7b0b..e5924d5 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -8,6 +8,7 @@ use Modules\ApprovalWorkflow\Models\ApprovalChain; use Modules\ContractorManagement\Models\Contractor; use Modules\MaterialLogistics\Models\MaterialRequisition; use Modules\MaterialLogistics\Models\PurchaseOrder; +use Modules\ProjectManagement\Models\Project; class HandleInertiaRequests extends Middleware { @@ -44,12 +45,46 @@ class HandleInertiaRequests extends Middleware 'success' => $request->session()->get('success'), 'error' => $request->session()->get('error'), ], - 'sidebarBadges' => fn () => $request->user() ? [ - 'pending_requisitions' => MaterialRequisition::where('status', 'draft')->count(), - 'pending_purchase_orders' => PurchaseOrder::where('status', 'draft')->count(), - 'pending_approvals' => ApprovalChain::where('status', 'pending')->count(), - ] : null, + 'sidebarBadges' => fn () => $request->user() ? (function () use ($request) { + $user = $request->user(); + + // Drafts belong to their creator. Submitted requests are + // relevant only when the current user is an assigned approver. + $ownDraftRequisitions = MaterialRequisition::where('status', 'draft') + ->where('requested_by', $user->id) + ->count(); + + $assignedRequisitionApprovals = ApprovalChain::where('status', 'pending') + ->where('type', 'material_requisition') + ->whereHas('steps', function ($query) use ($user) { + $query->where('approver_id', $user->id) + ->where('status', 'pending'); + }) + ->count(); + + $ownDraftPurchaseOrders = PurchaseOrder::where('status', 'draft') + ->where('requested_by', $user->id) + ->count(); + + $assignedApprovals = ApprovalChain::where('status', 'pending') + ->whereHas('steps', function ($query) use ($user) { + $query->where('approver_id', $user->id) + ->where('status', 'pending'); + }) + ->count(); + + return [ + 'pending_requisitions' => $ownDraftRequisitions + $assignedRequisitionApprovals, + 'pending_purchase_orders' => $ownDraftPurchaseOrders, + 'pending_approvals' => $assignedApprovals, + ]; + })() : null, + 'projectOptions' => fn () => $request->user() + ? Project::where('current_wizard_step', '>=', 7) + ->whereHas('bidPackages') + ->with('parentProject:id,ulid,name,code') + ->get(['id', 'ulid', 'name', 'code', 'project_type', 'parent_project_id', 'status']) + : [], ]; } } - diff --git a/app/Scopes/TenantScope.php b/app/Scopes/TenantScope.php index 085dd6a..ad4bff8 100644 --- a/app/Scopes/TenantScope.php +++ b/app/Scopes/TenantScope.php @@ -37,6 +37,35 @@ class TenantScope implements Scope return; } + if ($model instanceof \Modules\ProjectManagement\Models\Project && $this->isSiteOperationsUser($user)) { + if ($user->contractor_id) { + $allowedIds = $this->resolveAllowedContractorIds($user->contractor_id); + + $builder->where(function ($query) use ($user, $allowedIds, $model) { + $query->whereIn($model->getTable() . '.contractor_id', $allowedIds) + ->orWhereHas('contractors', function ($contractorQuery) use ($allowedIds) { + $contractorQuery->whereIn('contractors.id', $allowedIds); + }) + ->orWhereHas('personnel', function ($personnelQuery) use ($user) { + $personnelQuery->where('users.id', $user->id); + }); + }); + } else { + $builder->whereHas('personnel', function ($query) use ($user) { + $query->where('users.id', $user->id); + }); + } + + return; + } + + // Platform roles always have global visibility. Some existing + // Super Admin/admin accounts were created with a contractor_id; + // the role must take precedence over that stale tenant link. + if ($this->isPlatformUser($user)) { + return; + } + // Platform owners/admins have no contractor_id — full access if (is_null($user->contractor_id)) { return; @@ -59,7 +88,16 @@ class TenantScope implements Scope $builder->whereIn($model->getTable() . '.contractor_id', $allowedIds); } } else { - $builder->whereIn($model->getTable() . '.contractor_id', $allowedIds); + if ($model instanceof \Modules\ProjectManagement\Models\Project) { + $builder->where(function ($query) use ($model, $allowedIds) { + $query->whereIn($model->getTable() . '.contractor_id', $allowedIds) + ->orWhereHas('contractors', function ($contractorQuery) use ($allowedIds) { + $contractorQuery->whereIn('contractors.id', $allowedIds); + }); + }); + } else { + $builder->whereIn($model->getTable() . '.contractor_id', $allowedIds); + } } } finally { self::$resolvingAuth = false; @@ -83,4 +121,20 @@ class TenantScope implements Scope return array_merge([$contractorId], $childIds); } + + private function isSiteOperationsUser($user): bool + { + return $user->hasAnyRole([ + 'Site Technical', + 'Construction Supervisor', + 'Site Operations', + 'Site Engineer', + 'Site Supervisor', + ]); + } + + private function isPlatformUser($user): bool + { + return $user->hasAnyRole(['Super Admin', 'admin']); + } } diff --git a/contractor-onboarding-fix.md b/contractor-onboarding-fix.md new file mode 100644 index 0000000..291b275 --- /dev/null +++ b/contractor-onboarding-fix.md @@ -0,0 +1,54 @@ +# Contractor Onboarding Registration Fix Plan + +## Goal + +Make guest contractor registration succeed on a fresh database, preserve the intended `Main Contractor Admin` role, and prevent regressions in contractor onboarding. + +## Confirmed Failure + +`tests/Feature/ContractorOnboardingTest.php::test_guest_can_register_as_contractor` currently receives HTTP 500 because `ContractorOnboardingController::register()` calls: + +```php +$role->givePermissionTo('users.access'); +``` + +before the `users.access` permission exists. + +The same test also expects the legacy `Contractor` role, while the current controller intentionally assigns `Main Contractor Admin`. + +## Tasks + +- [ ] Establish the permission contract: ensure `users.access` is available before onboarding role assignment. Prefer a centralized role/permission provisioning path; if registration must work independently of seed execution, make the onboarding service safely create or resolve the permission before assigning it. + - Verify: registration on a fresh test database no longer throws `PermissionDoesNotExist`. + +- [ ] Align the onboarding role contract with the current role policy. + - Expected role: `Main Contractor Admin` for a self-registered main contractor. + - Verify: the created inactive user has `Main Contractor Admin`, `users.access`, the correct `contractor_id`, and no unintended legacy role. + +- [ ] Update the contractor onboarding test fixture so it explicitly provisions the permission/role prerequisites instead of relying on unrelated seeders. + - Verify: the test is isolated and passes with `RefreshDatabase`. + +- [ ] Add regression coverage for a clean-database registration where no permissions have been pre-created. + - Verify: the transaction creates the pending contractor and inactive admin, or rolls back cleanly with a controlled error; it never returns HTTP 500 from a missing permission. + +- [ ] Re-run the complete onboarding test file. + - Verify: all contractor onboarding tests pass, including inactive-login blocking, platform approval, rejection, and non-platform access denial. + +- [ ] Re-run the feature suite and then the whole test suite. + - Verify: no new failures; record remaining failures separately rather than changing unrelated workflows. + +- [ ] Run frontend and backend validation after implementation. + - Commands: `php -l` on changed PHP files, `npx tsc --noEmit`, `npm run build`, and `php artisan test`. + +## Done When + +- Contractor registration returns the login redirect and success message. +- Contractor and inactive admin records are committed together. +- The new admin is assigned `Main Contractor Admin` and `users.access`. +- Failed provisioning rolls back the transaction. +- Onboarding tests pass on a clean database. +- The full-suite result is recorded with no unclassified failures. + +## Scope Guard + +Do not change dashboard role routing, tenant visibility, bidding rules, approval rules, or unrelated UI while fixing this defect. Those are separate workflows and should be addressed only if a regression test identifies them. diff --git a/docs/SYSTEM_WORKFLOW_TEST_CASES.md b/docs/SYSTEM_WORKFLOW_TEST_CASES.md new file mode 100644 index 0000000..24e9789 --- /dev/null +++ b/docs/SYSTEM_WORKFLOW_TEST_CASES.md @@ -0,0 +1,308 @@ +# GSB Construction ERP — End-to-End System Workflow Test Cases + +## 1. Purpose + +This document defines the complete business-workflow test plan for the GSB Construction ERP. It covers authentication, contractor tenancy, roles, projects, bidding, project execution, materials, finance, approvals, dashboards, and cross-role visibility. + +This is a test specification. Discovered defects must be recorded in the defect log and must not be fixed during the test run. + +## 2. Test Rules + +- Use a disposable or staging database only. +- Use the same test records across a workflow so each state transition can be verified. +- Follow Arrange → Act → Assert for every case. +- Record the user, role, contractor, project, route, request payload, response, database state, and screenshot for failures. +- A workflow passes only when the UI result, server response, authorization rule, and database state agree. +- Do not mark a case passed because a page loads; verify the action and resulting data. + +## 3. Test Environment + +| Item | Requirement | +|---|---| +| Backend | Laravel application with migrations loaded | +| Frontend | Vite development build or compiled production assets | +| Database | Disposable database with seed data plus test fixtures | +| Browser | Chromium/Chrome, desktop width, responsive width | +| Browser checks | Console free of uncaught exceptions; network requests return expected status | +| Build checks | `npx tsc --noEmit`, `npm run build`, `php artisan test` | +| Date/time | Confirm expected timezone and date formatting before testing reports and deadlines | + +## 4. Required Test Accounts and Data + +Create these accounts before execution. Replace the placeholder emails with unique test emails. + +| ID | Account | Role | Contractor | +|---|---|---|---| +| U-01 | Platform owner | Super Admin | None | +| U-02 | Platform administrator | admin | None | +| U-03 | Project manager | Project Manager | None | +| U-04 | Main contractor administrator | Main Contractor Admin | C-01 | +| U-05 | Main contractor user | Main Contractor User | C-01 | +| U-06 | Site technical user | Site Technical | C-01 | +| U-07 | Construction supervisor | Construction Supervisor | C-01 | +| U-08 | Site operations user | Site Operations | C-01 | +| U-09 | Subcontractor administrator | Sub Contractor Admin | C-02, child of C-01 | +| U-10 | Unrelated contractor user | Contractor User | C-03 | + +Create at least: + +| ID | Fixture | Required state | +|---|---|---| +| C-01 | Great SwissMetal Builders Corporation | Active main contractor | +| C-02 | C-01 subcontractor | Active child contractor | +| C-03 | Unrelated contractor | Active separate tenant | +| P-01 | Main construction project | Draft, then initialized at wizard step 7 | +| P-02 | C-01 active project | `planning` or `in_progress` | +| P-03 | C-03 project | Must never appear to C-01 users | +| M-01 | Material catalog item | Active, with unit and cost | +| L-01 | Labor record | Active labor/trade | +| E-01 | Equipment record | Active equipment | + +## 5. Role and Tenant Access Matrix + +Run the matrix against every protected module. “Own tenant” means the user’s contractor and permitted contractor hierarchy. “Related project” means a project directly owned by, linked to, or assigned to that contractor according to the application rule. + +| Area | Super Admin/admin | Project Manager | Contractor Admin | Site Technical/Supervisor/Operations | Unrelated contractor | +|---|---:|---:|---:|---:|---:| +| Dashboard | Global | Assigned/allowed scope | Contractor dashboard | Related project scope | Own scope only | +| Users | Global administration | According to permission | Own contractor users | No administration unless explicitly granted | Own contractor only | +| Projects | Global | Project scope | Own contractor scope | Related project scope | Own contractor scope | +| Bid package management | Yes | Yes | According to configured role rule | No | No | +| Bid submissions | Yes | Yes | Yes for invited contractor | No | Only invited packages | +| Material requisitions | Global | Create/approve as assigned | Own contractor/project | Create on related project | Own scope | +| Purchase orders | Global | Create/approve as assigned | Own contractor/project | Operate related project | Own scope | +| Cash advances | Global approver | Approve other users’ requests | Own contractor rules | Create, not self-approve | Own scope | +| Invoice/retention approvals | Global | As configured | As configured | No approval unless configured | Own scope | +| Documents/daily reports/tasks | Global | Assigned/managed projects | Own contractor projects | Related projects only | Own scope | + +Any deviation must be logged as a defect, including both unexpected access and missing access. + +## 6. End-to-End Workflow Cases + +### WF-01 — Authentication and account lifecycle + +| Field | Test | +|---|---| +| Preconditions | U-01 through U-10 exist; active and inactive accounts are available | +| Steps | Log in with valid credentials; log out; attempt invalid password; attempt inactive account; reset password; complete forced password change | +| Expected | Valid users reach the correct dashboard; invalid users receive a safe error; inactive users are blocked; password reset and forced password change complete successfully | +| Security | No password or sensitive token appears in the UI, URL, response, or logs | + +### WF-02 — Contractor onboarding and tenant assignment + +| Field | Test | +|---|---| +| Actor | U-01 or U-02 | +| Steps | Create C-01 with administrator; verify administrator is linked to C-01; create C-02 as child contractor; create U-05/U-06/U-07 under C-01; create U-09 under C-02 | +| Expected | Contractor records, user records, roles, profile records, and contractor IDs are consistent; users created by a contractor admin inherit that contractor | +| Negative | Attempt to assign a contractor user to C-03 from a C-01 admin account; request is rejected and no cross-tenant change is saved | + +### WF-03 — User management and role enforcement + +| Field | Test | +|---|---| +| Actor | U-01, U-04, U-09 | +| Steps | List users; create user; edit user; assign each supported role; deactivate user; attempt to view/edit a user from another contractor | +| Expected | Platform users see global users; contractor admins see only their permitted contractor tree; role names and permissions persist; deactivated users cannot log in | +| Negative | Site technical and supervisor accounts cannot access user-management actions unless explicitly permitted | + +### WF-04 — Project creation and initialization wizard + +| Field | Test | +|---|---| +| Actor | U-02, U-03, U-04 | +| Steps | Create P-01; enter project information; add milestones; add tasks; add materials; add labor; add equipment; review financial submission; submit final initialization | +| Expected | Each step saves; back/next navigation preserves data; validation rejects incomplete required data; step number advances correctly; initialized project is visible to permitted users | +| Database assertions | `projects.current_wizard_step`, project status, contractor ID, milestones, tasks, estimates, labor, equipment, and personnel links are correct | +| Negative | A draft project is not treated as an initialized execution project; an unrelated contractor cannot open P-01 | + +### WF-05 — Project visibility and cross-role reflection + +| Field | Test | +|---|---| +| Actor | U-04 creates/updates; U-05/U-06/U-07/U-08 verify | +| Steps | U-04 initializes or updates P-01; sign in as every C-01 role; inspect Projects, project details, tasks, daily reports, documents, inventory, finance, and dashboard | +| Expected | All permitted C-01 roles see the same contractor-related project state; restricted actions remain hidden or forbidden; C-03 users never see P-01 | +| Important | Verify both a fresh login and navigation from an already-open session. Record whether updates require manual refresh according to the intended product behavior | + +### WF-06 — Bid package lifecycle + +| Field | Test | +|---|---| +| Actor | U-03 or authorized contractor project-management role | +| Steps | Create package for initialized P-01; verify initial draft; add scope, criteria, dates, and documents; invite C-01/C-02/C-03 as appropriate; publish; edit while allowed; close/cancel; award | +| Expected | Package remains draft until published; draft cannot accept submissions; published package is visible only to invited contractors; status transitions follow allowed rules; awarded package records winner | +| Negative | Contractor User, Site Operations, Site Technical, and Supervisor cannot create/manage packages unless policy explicitly grants it; a draft package must not be displayed as an open bid | + +### WF-07 — Contractor bid submission and evaluation + +| Field | Test | +|---|---| +| Actor | Invited contractor user/admin and U-03 | +| Steps | Open invitation; submit proposal and price; edit before closing; attempt duplicate submission; evaluate using simple mode; evaluate using scored criteria; award/reject | +| Expected | Only invited contractor can submit; required fields validate; duplicate and late submissions are handled correctly; scores and totals are persisted; award updates package and submission status | +| Database assertions | Invitations, submissions, evaluation criteria, scores, total score, award fields, and timestamps match the UI | + +### WF-08 — Tasks, milestones, and progress + +| Field | Test | +|---|---| +| Actor | U-03, U-06, U-07 | +| Steps | Create task under milestone; move Pending → In Progress → Completed; test Blocked and Closed; open information modal; add materials, labor, and equipment; assign user; record activity | +| Expected | Only valid transitions are available; transition buttons appear in the information modal; blocked/closed rules work; costs and progress recalculate; activity log records actor and time | +| Negative | Invalid transitions and unauthorized updates return a controlled error and preserve the previous state | + +### WF-09 — Daily reports and resource roll-up + +| Field | Test | +|---|---| +| Actor | U-06/U-07 | +| Steps | Create multiple daily reports for P-01 on different dates; add multiple labor rows, trades, equipment rows, materials, activities, and issues; edit one report; inspect dashboard ResourceSummary | +| Expected | Dashboard labor totals use all intended daily-report labor records; trade totals aggregate correctly; equipment counts follow the documented aggregation rule; blockers and activity feed show permitted reports | +| Negative | Reports from P-03 or unrelated contractors never affect C-01 dashboard totals | + +### WF-10 — Material requisition approval workflow + +| Field | Test | +|---|---| +| Actor | Site role creates; Project Manager/executive role approves | +| Steps | Create draft requisition; add materials; save; submit; inspect approver availability; approve; reject a second request; inspect sidebar badge for each role | +| Expected | Draft is visible to its creator; submitted request is visible to the correct approvers; Project Manager and executive approver rules work; approval changes status and audit history; unrelated roles do not receive irrelevant alerts | +| Negative | No-approver condition is reported clearly and does not create a falsely submitted request | + +### WF-11 — Purchase order workflow + +| Field | Test | +|---|---| +| Actor | Authorized logistics user; Project Manager/executive approver | +| Steps | Create PO from requisition or manually; add supplier/items; submit; approve/reject; mark delivered; upload receipt; mark paid; inspect list/detail pages | +| Expected | PO status transitions are valid; approver list follows configured roles; delivery and payment buttons appear only when applicable; receipt and payment data persist; tenant restrictions hold | +| Negative | Site roles cannot approve or update finance-sensitive states without permission; null project/supplier relations do not crash the page | + +### WF-12 — Inventory, warehouse, movements, and transfers + +| Field | Test | +|---|---| +| Steps | Receive delivered PO; verify warehouse stock; create movement; transfer between warehouses; inspect project inventory and available quantities; attempt insufficient-stock transfer | +| Expected | Quantities, on-hand, allocated, received, and transferred values remain consistent; invalid quantities are rejected; users see only permitted warehouses and projects | + +### WF-13 — Cash advance direct approval + +| Field | Test | +|---|---| +| Actor | Site/contractor user creates; Project Manager or executive approves | +| Steps | Submit cash advance; verify pending status; inspect Approvals page; attempt self-approval; approve as authorized PM/executive; reject another request | +| Expected | Cash advances appear directly in the approvals workspace without an approval-chain record if that is the configured rule; self-approval is blocked; approval/rejection records approver and timestamp | + +### WF-14 — Invoice and retention approval + +| Field | Test | +|---|---| +| Steps | Create/submit invoice with retention; inspect approval item; open breakdown; approve; verify invoice status and retention hold; reject another invoice; inspect retention page | +| Expected | Invoice and retention information appears in approvals; approved invoice updates status and retention ledger exactly once; rejected invoice remains rejected; null project relations render safely | + +### WF-15 — Documents, drawings, and technical records + +| Field | Test | +|---|---| +| Steps | Upload document to P-01; categorize; view/download; update status; attempt access from C-03; inspect project Documents and sidebar navigation | +| Expected | File metadata and project/contractor relation persist; permitted roles can view/download; unrelated tenant receives 403/404 according to policy; missing files produce controlled errors | + +### WF-16 — Dashboard role routing and data accuracy + +| Field | Test | +|---|---| +| Steps | Log in as U-01 through U-10; inspect dashboard heading and cards; compare every displayed count with database queries for the same scope; inspect Site, PM, Contractor, and Executive layouts | +| Expected | Super Admin/admin → Executive; Project Manager → PM; Contractor Admin/Main/Sub Contractor roles → Contractor; site roles → Site; contractor admin with `user_type=admin` remains Contractor when its assigned role is contractor-based | +| Data checks | Project counts use valid project statuses; labor uses intended daily-report aggregation; finance, approvals, documents, inventory, and bidding metrics are tenant/project scoped | + +### WF-17 — Sidebar navigation and role visibility + +| Field | Test | +|---|---| +| Steps | Capture sidebar for every role; open every visible parent and child item; directly request hidden routes; inspect badges after creating drafts/submissions/approvals | +| Expected | Navigation matches permissions and role policy; Bidding is absent from site operations roles where required; badges are relevant to the current role; direct URL access is denied even if a link is hidden | + +### WF-18 — Security and tenant isolation regression + +| Field | Test | +|---|---| +| Steps | From U-04/U-05/U-06/U-07/U-08/U-09, request P-03, its tasks, reports, inventory, invoices, requisitions, POs, documents, bids, and users by URL/ULID; repeat with guessed numeric IDs | +| Expected | No cross-tenant record is returned, modified, or deleted; response is 403/404 as designed; no sensitive data leaks through Inertia props, JSON, exports, or error messages | + +### WF-19 — Validation, error handling, and null relationships + +| Field | Test | +|---|---| +| Steps | Submit empty forms; invalid dates; negative quantities; missing project; deleted/null related user, contractor, supplier, or project; duplicate codes/numbers; expired invitation | +| Expected | Server validation is returned to the form; no uncaught React error occurs; pages show a safe fallback such as “Unavailable”; no partial transaction remains in the database | + +### WF-20 — Responsive and browser workflow + +| Field | Test | +|---|---| +| Steps | Repeat critical pages at desktop, tablet, and mobile widths; open sidebar; open modals; scroll tables; use keyboard navigation; test dark mode if enabled | +| Expected | Grids collapse correctly; buttons remain reachable; modals fit viewport; tables remain usable; focus states and labels are available; no horizontal overflow blocks actions | + +## 7. Execution Record + +Use one row per case execution. + +| Case ID | Date | Build/commit | Actor | Result | Evidence | Defect ID | +|---|---|---|---|---|---|---| +| WF-01 | | | | NOT RUN | | | +| WF-02 | | | | NOT RUN | | | +| WF-03 | | | | NOT RUN | | | +| WF-04 | | | | NOT RUN | | | +| WF-05 | | | | NOT RUN | | | +| WF-06 | | | | NOT RUN | | | +| WF-07 | | | | NOT RUN | | | +| WF-08 | | | | NOT RUN | | | +| WF-09 | | | | NOT RUN | | | +| WF-10 | | | | NOT RUN | | | +| WF-11 | | | | NOT RUN | | | +| WF-12 | | | | NOT RUN | | | +| WF-13 | | | | NOT RUN | | | +| WF-14 | | | | NOT RUN | | | +| WF-15 | | | | NOT RUN | | | +| WF-16 | | | | NOT RUN | | | +| WF-17 | | | | NOT RUN | | | +| WF-18 | | | | NOT RUN | | | +| WF-19 | | | | NOT RUN | | | +| WF-20 | | | | NOT RUN | | | + +## 8. Defect Log + +Record defects without changing code during this test pass. + +| Defect ID | Case | Severity | Preconditions | Steps to reproduce | Expected | Actual | Evidence | Status | +|---|---|---|---|---|---|---|---|---| +| D-001 | WF-02 | Major | Fresh test database; guest contractor registration | Submit the contractor registration form with valid data | Registration redirects successfully, creates pending contractor/admin records, and assigns the configured role | HTTP 500: `Spatie\\Permission\\Exceptions\\PermissionDoesNotExist` because `users.access` is missing when `ContractorOnboardingController.php:66` calls `givePermissionTo('users.access')` | `tests/Feature/ContractorOnboardingTest.php::test_guest_can_register_as_contractor` | Resolved | +| D-002 | WF-13 | Major | Comprehensive E2E fixture; supervisor and initialized project | Submit a cash advance and query the created request | A pending cash advance exists for the submitted amount | `CashAdvance::where('amount', 750.50)->firstOrFail()` finds no record at `ComprehensiveSystemE2ETest.php:156` | `tests/Feature/ComprehensiveSystemE2ETest.php::test_e2e_cash_advance_full_lifecycle_and_security_rules` | Open | +| D-003 | WF-04 | Major | Project at wizard step 6; no approver IDs | Submit final project initialization with `approver_ids: []` | Validation error is returned for `approver_ids` | Response does not contain the expected session validation error | `tests/Feature/ProjectWizardFlowTest.php::submit_fails_without_approvers` | Open | + +Severity guide: Blocker = workflow cannot continue or data/security risk; Critical = major business flow or tenant isolation failure; Major = important function incorrect; Minor = non-blocking UI or copy issue. + +## 9. Automated Baseline + +At the time this document was created: + +| Check | Result | Notes | +|---|---|---| +| `npx tsc --noEmit` | PASS | TypeScript completed successfully during the current verification run | +| `npm run build` | PASS | Vite production build completed successfully during the current verification run | +| `php artisan test --testsuite=Feature --stop-on-failure --debug` | FAIL | 23 passed, 2 failed, 85 pending; first confirmed failure is D-001 | +| `php artisan test tests/Feature/ContractorOnboardingTest.php --debug` | FAIL | 4 passed, 1 failed; D-001 reproduced in 3.90s | +| Existing `ComprehensiveSystemE2ETest` | PARTIAL COVERAGE | Covers page traversal, cash advance, invoice/retention, and part of project wizard; it does not cover all workflows in this document | + +## 10. Exit Criteria + +The whole-system workflow is ready for sign-off only when: + +- All WF-01 through WF-20 have a recorded result. +- No Blocker or Critical defects remain open. +- Tenant isolation cases pass for main contractors, subcontractors, site roles, and unrelated contractors. +- Project wizard, bidding, requisition, PO, inventory, cash advance, invoice/retention, and dashboard flows pass end-to-end. +- Browser console has no uncaught errors on tested pages. +- Database assertions and UI states agree for every completed workflow. +- Failed automated tests are either rerun successfully or documented with an approved exception. diff --git a/docs/SYSTEM_WORKFLOW_TEST_CASES.txt b/docs/SYSTEM_WORKFLOW_TEST_CASES.txt new file mode 100644 index 0000000..24e9789 --- /dev/null +++ b/docs/SYSTEM_WORKFLOW_TEST_CASES.txt @@ -0,0 +1,308 @@ +# GSB Construction ERP — End-to-End System Workflow Test Cases + +## 1. Purpose + +This document defines the complete business-workflow test plan for the GSB Construction ERP. It covers authentication, contractor tenancy, roles, projects, bidding, project execution, materials, finance, approvals, dashboards, and cross-role visibility. + +This is a test specification. Discovered defects must be recorded in the defect log and must not be fixed during the test run. + +## 2. Test Rules + +- Use a disposable or staging database only. +- Use the same test records across a workflow so each state transition can be verified. +- Follow Arrange → Act → Assert for every case. +- Record the user, role, contractor, project, route, request payload, response, database state, and screenshot for failures. +- A workflow passes only when the UI result, server response, authorization rule, and database state agree. +- Do not mark a case passed because a page loads; verify the action and resulting data. + +## 3. Test Environment + +| Item | Requirement | +|---|---| +| Backend | Laravel application with migrations loaded | +| Frontend | Vite development build or compiled production assets | +| Database | Disposable database with seed data plus test fixtures | +| Browser | Chromium/Chrome, desktop width, responsive width | +| Browser checks | Console free of uncaught exceptions; network requests return expected status | +| Build checks | `npx tsc --noEmit`, `npm run build`, `php artisan test` | +| Date/time | Confirm expected timezone and date formatting before testing reports and deadlines | + +## 4. Required Test Accounts and Data + +Create these accounts before execution. Replace the placeholder emails with unique test emails. + +| ID | Account | Role | Contractor | +|---|---|---|---| +| U-01 | Platform owner | Super Admin | None | +| U-02 | Platform administrator | admin | None | +| U-03 | Project manager | Project Manager | None | +| U-04 | Main contractor administrator | Main Contractor Admin | C-01 | +| U-05 | Main contractor user | Main Contractor User | C-01 | +| U-06 | Site technical user | Site Technical | C-01 | +| U-07 | Construction supervisor | Construction Supervisor | C-01 | +| U-08 | Site operations user | Site Operations | C-01 | +| U-09 | Subcontractor administrator | Sub Contractor Admin | C-02, child of C-01 | +| U-10 | Unrelated contractor user | Contractor User | C-03 | + +Create at least: + +| ID | Fixture | Required state | +|---|---|---| +| C-01 | Great SwissMetal Builders Corporation | Active main contractor | +| C-02 | C-01 subcontractor | Active child contractor | +| C-03 | Unrelated contractor | Active separate tenant | +| P-01 | Main construction project | Draft, then initialized at wizard step 7 | +| P-02 | C-01 active project | `planning` or `in_progress` | +| P-03 | C-03 project | Must never appear to C-01 users | +| M-01 | Material catalog item | Active, with unit and cost | +| L-01 | Labor record | Active labor/trade | +| E-01 | Equipment record | Active equipment | + +## 5. Role and Tenant Access Matrix + +Run the matrix against every protected module. “Own tenant” means the user’s contractor and permitted contractor hierarchy. “Related project” means a project directly owned by, linked to, or assigned to that contractor according to the application rule. + +| Area | Super Admin/admin | Project Manager | Contractor Admin | Site Technical/Supervisor/Operations | Unrelated contractor | +|---|---:|---:|---:|---:|---:| +| Dashboard | Global | Assigned/allowed scope | Contractor dashboard | Related project scope | Own scope only | +| Users | Global administration | According to permission | Own contractor users | No administration unless explicitly granted | Own contractor only | +| Projects | Global | Project scope | Own contractor scope | Related project scope | Own contractor scope | +| Bid package management | Yes | Yes | According to configured role rule | No | No | +| Bid submissions | Yes | Yes | Yes for invited contractor | No | Only invited packages | +| Material requisitions | Global | Create/approve as assigned | Own contractor/project | Create on related project | Own scope | +| Purchase orders | Global | Create/approve as assigned | Own contractor/project | Operate related project | Own scope | +| Cash advances | Global approver | Approve other users’ requests | Own contractor rules | Create, not self-approve | Own scope | +| Invoice/retention approvals | Global | As configured | As configured | No approval unless configured | Own scope | +| Documents/daily reports/tasks | Global | Assigned/managed projects | Own contractor projects | Related projects only | Own scope | + +Any deviation must be logged as a defect, including both unexpected access and missing access. + +## 6. End-to-End Workflow Cases + +### WF-01 — Authentication and account lifecycle + +| Field | Test | +|---|---| +| Preconditions | U-01 through U-10 exist; active and inactive accounts are available | +| Steps | Log in with valid credentials; log out; attempt invalid password; attempt inactive account; reset password; complete forced password change | +| Expected | Valid users reach the correct dashboard; invalid users receive a safe error; inactive users are blocked; password reset and forced password change complete successfully | +| Security | No password or sensitive token appears in the UI, URL, response, or logs | + +### WF-02 — Contractor onboarding and tenant assignment + +| Field | Test | +|---|---| +| Actor | U-01 or U-02 | +| Steps | Create C-01 with administrator; verify administrator is linked to C-01; create C-02 as child contractor; create U-05/U-06/U-07 under C-01; create U-09 under C-02 | +| Expected | Contractor records, user records, roles, profile records, and contractor IDs are consistent; users created by a contractor admin inherit that contractor | +| Negative | Attempt to assign a contractor user to C-03 from a C-01 admin account; request is rejected and no cross-tenant change is saved | + +### WF-03 — User management and role enforcement + +| Field | Test | +|---|---| +| Actor | U-01, U-04, U-09 | +| Steps | List users; create user; edit user; assign each supported role; deactivate user; attempt to view/edit a user from another contractor | +| Expected | Platform users see global users; contractor admins see only their permitted contractor tree; role names and permissions persist; deactivated users cannot log in | +| Negative | Site technical and supervisor accounts cannot access user-management actions unless explicitly permitted | + +### WF-04 — Project creation and initialization wizard + +| Field | Test | +|---|---| +| Actor | U-02, U-03, U-04 | +| Steps | Create P-01; enter project information; add milestones; add tasks; add materials; add labor; add equipment; review financial submission; submit final initialization | +| Expected | Each step saves; back/next navigation preserves data; validation rejects incomplete required data; step number advances correctly; initialized project is visible to permitted users | +| Database assertions | `projects.current_wizard_step`, project status, contractor ID, milestones, tasks, estimates, labor, equipment, and personnel links are correct | +| Negative | A draft project is not treated as an initialized execution project; an unrelated contractor cannot open P-01 | + +### WF-05 — Project visibility and cross-role reflection + +| Field | Test | +|---|---| +| Actor | U-04 creates/updates; U-05/U-06/U-07/U-08 verify | +| Steps | U-04 initializes or updates P-01; sign in as every C-01 role; inspect Projects, project details, tasks, daily reports, documents, inventory, finance, and dashboard | +| Expected | All permitted C-01 roles see the same contractor-related project state; restricted actions remain hidden or forbidden; C-03 users never see P-01 | +| Important | Verify both a fresh login and navigation from an already-open session. Record whether updates require manual refresh according to the intended product behavior | + +### WF-06 — Bid package lifecycle + +| Field | Test | +|---|---| +| Actor | U-03 or authorized contractor project-management role | +| Steps | Create package for initialized P-01; verify initial draft; add scope, criteria, dates, and documents; invite C-01/C-02/C-03 as appropriate; publish; edit while allowed; close/cancel; award | +| Expected | Package remains draft until published; draft cannot accept submissions; published package is visible only to invited contractors; status transitions follow allowed rules; awarded package records winner | +| Negative | Contractor User, Site Operations, Site Technical, and Supervisor cannot create/manage packages unless policy explicitly grants it; a draft package must not be displayed as an open bid | + +### WF-07 — Contractor bid submission and evaluation + +| Field | Test | +|---|---| +| Actor | Invited contractor user/admin and U-03 | +| Steps | Open invitation; submit proposal and price; edit before closing; attempt duplicate submission; evaluate using simple mode; evaluate using scored criteria; award/reject | +| Expected | Only invited contractor can submit; required fields validate; duplicate and late submissions are handled correctly; scores and totals are persisted; award updates package and submission status | +| Database assertions | Invitations, submissions, evaluation criteria, scores, total score, award fields, and timestamps match the UI | + +### WF-08 — Tasks, milestones, and progress + +| Field | Test | +|---|---| +| Actor | U-03, U-06, U-07 | +| Steps | Create task under milestone; move Pending → In Progress → Completed; test Blocked and Closed; open information modal; add materials, labor, and equipment; assign user; record activity | +| Expected | Only valid transitions are available; transition buttons appear in the information modal; blocked/closed rules work; costs and progress recalculate; activity log records actor and time | +| Negative | Invalid transitions and unauthorized updates return a controlled error and preserve the previous state | + +### WF-09 — Daily reports and resource roll-up + +| Field | Test | +|---|---| +| Actor | U-06/U-07 | +| Steps | Create multiple daily reports for P-01 on different dates; add multiple labor rows, trades, equipment rows, materials, activities, and issues; edit one report; inspect dashboard ResourceSummary | +| Expected | Dashboard labor totals use all intended daily-report labor records; trade totals aggregate correctly; equipment counts follow the documented aggregation rule; blockers and activity feed show permitted reports | +| Negative | Reports from P-03 or unrelated contractors never affect C-01 dashboard totals | + +### WF-10 — Material requisition approval workflow + +| Field | Test | +|---|---| +| Actor | Site role creates; Project Manager/executive role approves | +| Steps | Create draft requisition; add materials; save; submit; inspect approver availability; approve; reject a second request; inspect sidebar badge for each role | +| Expected | Draft is visible to its creator; submitted request is visible to the correct approvers; Project Manager and executive approver rules work; approval changes status and audit history; unrelated roles do not receive irrelevant alerts | +| Negative | No-approver condition is reported clearly and does not create a falsely submitted request | + +### WF-11 — Purchase order workflow + +| Field | Test | +|---|---| +| Actor | Authorized logistics user; Project Manager/executive approver | +| Steps | Create PO from requisition or manually; add supplier/items; submit; approve/reject; mark delivered; upload receipt; mark paid; inspect list/detail pages | +| Expected | PO status transitions are valid; approver list follows configured roles; delivery and payment buttons appear only when applicable; receipt and payment data persist; tenant restrictions hold | +| Negative | Site roles cannot approve or update finance-sensitive states without permission; null project/supplier relations do not crash the page | + +### WF-12 — Inventory, warehouse, movements, and transfers + +| Field | Test | +|---|---| +| Steps | Receive delivered PO; verify warehouse stock; create movement; transfer between warehouses; inspect project inventory and available quantities; attempt insufficient-stock transfer | +| Expected | Quantities, on-hand, allocated, received, and transferred values remain consistent; invalid quantities are rejected; users see only permitted warehouses and projects | + +### WF-13 — Cash advance direct approval + +| Field | Test | +|---|---| +| Actor | Site/contractor user creates; Project Manager or executive approves | +| Steps | Submit cash advance; verify pending status; inspect Approvals page; attempt self-approval; approve as authorized PM/executive; reject another request | +| Expected | Cash advances appear directly in the approvals workspace without an approval-chain record if that is the configured rule; self-approval is blocked; approval/rejection records approver and timestamp | + +### WF-14 — Invoice and retention approval + +| Field | Test | +|---|---| +| Steps | Create/submit invoice with retention; inspect approval item; open breakdown; approve; verify invoice status and retention hold; reject another invoice; inspect retention page | +| Expected | Invoice and retention information appears in approvals; approved invoice updates status and retention ledger exactly once; rejected invoice remains rejected; null project relations render safely | + +### WF-15 — Documents, drawings, and technical records + +| Field | Test | +|---|---| +| Steps | Upload document to P-01; categorize; view/download; update status; attempt access from C-03; inspect project Documents and sidebar navigation | +| Expected | File metadata and project/contractor relation persist; permitted roles can view/download; unrelated tenant receives 403/404 according to policy; missing files produce controlled errors | + +### WF-16 — Dashboard role routing and data accuracy + +| Field | Test | +|---|---| +| Steps | Log in as U-01 through U-10; inspect dashboard heading and cards; compare every displayed count with database queries for the same scope; inspect Site, PM, Contractor, and Executive layouts | +| Expected | Super Admin/admin → Executive; Project Manager → PM; Contractor Admin/Main/Sub Contractor roles → Contractor; site roles → Site; contractor admin with `user_type=admin` remains Contractor when its assigned role is contractor-based | +| Data checks | Project counts use valid project statuses; labor uses intended daily-report aggregation; finance, approvals, documents, inventory, and bidding metrics are tenant/project scoped | + +### WF-17 — Sidebar navigation and role visibility + +| Field | Test | +|---|---| +| Steps | Capture sidebar for every role; open every visible parent and child item; directly request hidden routes; inspect badges after creating drafts/submissions/approvals | +| Expected | Navigation matches permissions and role policy; Bidding is absent from site operations roles where required; badges are relevant to the current role; direct URL access is denied even if a link is hidden | + +### WF-18 — Security and tenant isolation regression + +| Field | Test | +|---|---| +| Steps | From U-04/U-05/U-06/U-07/U-08/U-09, request P-03, its tasks, reports, inventory, invoices, requisitions, POs, documents, bids, and users by URL/ULID; repeat with guessed numeric IDs | +| Expected | No cross-tenant record is returned, modified, or deleted; response is 403/404 as designed; no sensitive data leaks through Inertia props, JSON, exports, or error messages | + +### WF-19 — Validation, error handling, and null relationships + +| Field | Test | +|---|---| +| Steps | Submit empty forms; invalid dates; negative quantities; missing project; deleted/null related user, contractor, supplier, or project; duplicate codes/numbers; expired invitation | +| Expected | Server validation is returned to the form; no uncaught React error occurs; pages show a safe fallback such as “Unavailable”; no partial transaction remains in the database | + +### WF-20 — Responsive and browser workflow + +| Field | Test | +|---|---| +| Steps | Repeat critical pages at desktop, tablet, and mobile widths; open sidebar; open modals; scroll tables; use keyboard navigation; test dark mode if enabled | +| Expected | Grids collapse correctly; buttons remain reachable; modals fit viewport; tables remain usable; focus states and labels are available; no horizontal overflow blocks actions | + +## 7. Execution Record + +Use one row per case execution. + +| Case ID | Date | Build/commit | Actor | Result | Evidence | Defect ID | +|---|---|---|---|---|---|---| +| WF-01 | | | | NOT RUN | | | +| WF-02 | | | | NOT RUN | | | +| WF-03 | | | | NOT RUN | | | +| WF-04 | | | | NOT RUN | | | +| WF-05 | | | | NOT RUN | | | +| WF-06 | | | | NOT RUN | | | +| WF-07 | | | | NOT RUN | | | +| WF-08 | | | | NOT RUN | | | +| WF-09 | | | | NOT RUN | | | +| WF-10 | | | | NOT RUN | | | +| WF-11 | | | | NOT RUN | | | +| WF-12 | | | | NOT RUN | | | +| WF-13 | | | | NOT RUN | | | +| WF-14 | | | | NOT RUN | | | +| WF-15 | | | | NOT RUN | | | +| WF-16 | | | | NOT RUN | | | +| WF-17 | | | | NOT RUN | | | +| WF-18 | | | | NOT RUN | | | +| WF-19 | | | | NOT RUN | | | +| WF-20 | | | | NOT RUN | | | + +## 8. Defect Log + +Record defects without changing code during this test pass. + +| Defect ID | Case | Severity | Preconditions | Steps to reproduce | Expected | Actual | Evidence | Status | +|---|---|---|---|---|---|---|---|---| +| D-001 | WF-02 | Major | Fresh test database; guest contractor registration | Submit the contractor registration form with valid data | Registration redirects successfully, creates pending contractor/admin records, and assigns the configured role | HTTP 500: `Spatie\\Permission\\Exceptions\\PermissionDoesNotExist` because `users.access` is missing when `ContractorOnboardingController.php:66` calls `givePermissionTo('users.access')` | `tests/Feature/ContractorOnboardingTest.php::test_guest_can_register_as_contractor` | Resolved | +| D-002 | WF-13 | Major | Comprehensive E2E fixture; supervisor and initialized project | Submit a cash advance and query the created request | A pending cash advance exists for the submitted amount | `CashAdvance::where('amount', 750.50)->firstOrFail()` finds no record at `ComprehensiveSystemE2ETest.php:156` | `tests/Feature/ComprehensiveSystemE2ETest.php::test_e2e_cash_advance_full_lifecycle_and_security_rules` | Open | +| D-003 | WF-04 | Major | Project at wizard step 6; no approver IDs | Submit final project initialization with `approver_ids: []` | Validation error is returned for `approver_ids` | Response does not contain the expected session validation error | `tests/Feature/ProjectWizardFlowTest.php::submit_fails_without_approvers` | Open | + +Severity guide: Blocker = workflow cannot continue or data/security risk; Critical = major business flow or tenant isolation failure; Major = important function incorrect; Minor = non-blocking UI or copy issue. + +## 9. Automated Baseline + +At the time this document was created: + +| Check | Result | Notes | +|---|---|---| +| `npx tsc --noEmit` | PASS | TypeScript completed successfully during the current verification run | +| `npm run build` | PASS | Vite production build completed successfully during the current verification run | +| `php artisan test --testsuite=Feature --stop-on-failure --debug` | FAIL | 23 passed, 2 failed, 85 pending; first confirmed failure is D-001 | +| `php artisan test tests/Feature/ContractorOnboardingTest.php --debug` | FAIL | 4 passed, 1 failed; D-001 reproduced in 3.90s | +| Existing `ComprehensiveSystemE2ETest` | PARTIAL COVERAGE | Covers page traversal, cash advance, invoice/retention, and part of project wizard; it does not cover all workflows in this document | + +## 10. Exit Criteria + +The whole-system workflow is ready for sign-off only when: + +- All WF-01 through WF-20 have a recorded result. +- No Blocker or Critical defects remain open. +- Tenant isolation cases pass for main contractors, subcontractors, site roles, and unrelated contractors. +- Project wizard, bidding, requisition, PO, inventory, cash advance, invoice/retention, and dashboard flows pass end-to-end. +- Browser console has no uncaught errors on tested pages. +- Database assertions and UI states agree for every completed workflow. +- Failed automated tests are either rerun successfully or documented with an approved exception. diff --git a/official_system_operations_manual.md b/official_system_operations_manual.md new file mode 100644 index 0000000..f10755f --- /dev/null +++ b/official_system_operations_manual.md @@ -0,0 +1,132 @@ +# GSB Construction ERP — Official System Operations Manual +## Wizard-First End-to-End Operational Lifecycle & Module Documentation + +This manual documents the official end-to-end operational flow of the GSB Construction ERP platform, starting from **Project Wizard Initialization** through **Subcontractor Bidding**, **Won Bid Award**, **Logistics & Inventory Setup**, **Site Execution**, and **Financial Progress Claims**. + +--- + +## 🗺️ System Master Lifecycle Flow Diagram + +```mermaid +flowchart TD + subgraph STAGE1 ["STAGE 1: PROJECT WIZARD INITIALIZATION"] + A1["Step 1: Core Parameters & PM"] --> A2["Step 2: WBS Tasks & Milestones"] + A2 --> A3["Step 3: Material BOQ Catalog Items"] + A3 --> A4["Step 4: Manpower Rate Allocation"] + A4 --> A5["Step 5: Equipment & Machinery Allocation"] + A5 --> A6["Step 6: Financial Estimation Summary"] + A6 --> A7["Step 7: Automated Executive Submission"] + end + + subgraph STAGE2 ["STAGE 2: BIDDING & TENDER (Under Bidding)"] + A7 --> B1["Project Status: Under Bidding"] + B1 --> B2["Publish Tender Packages & RFQs"] + B2 --> B3["Collect Subcontractor Bids & Pricing"] + B3 --> B4{"Won Bid / Awarded to Client?"} + B4 -- Lost / Void --> B5["Status: Closed"] + end + + subgraph STAGE3 ["STAGE 3: LOGISTICS & SUPPLY CHAIN SETUP"] + B4 -- Won Bid --> C1["Status Transitions to 'In Progress'"] + C1 --> C2["Generate Material Requisitions (MR) from Wizard BOQ"] + C2 --> C3["Issue Purchase Orders (PO) to Vendors"] + C3 --> C4["Goods Received Note (GRN) & Site Laydown Stocking"] + end + + subgraph STAGE4 ["STAGE 4: SITE SCHEDULING, TASKS & DAILY OPERATIONS"] + C4 --> D1["Load Wizard Tasks into Interactive Gantt Chart"] + D1 --> D2["Log Daily Site Reports, Weather & Manpower"] + D2 --> D3["Record Site Blockers & Supervisor Attestation"] + D3 --> D4["Auto-deduct Material Consumption from On-Hand Inventory"] + end + + subgraph STAGE5 ["STAGE 5: FINANCIAL PROGRESS BILLING & HANDOVER"] + D4 --> E1["File Progress Billing Invoices from Work Completion %"] + E1 --> E2["Deduct 10% Retention Withholding into Project Ledger"] + E2 --> E3["Milestone Accomplishment Sign-off & Final Retention Release"] + E3 --> E4["Status Transitions to 'Completed' then 'Closed'"] + end +``` + +--- + +## 📘 Detailed Stage-by-Stage Operations Guide + +### Stage 1: Project Initialization via 7-Step Wizard (`/projects/wizard`) + +1. **Trigger**: User with `projects.access` or `Project Manager` / `Admin` role clicks **"New Project"**. +2. **Step 1 — General Information**: + - Fill in Project Name, Classification (`Standard`, `Special`, `Extension`), Client Name, Location, Target Start/End Dates, PM Assignment, and Contract Value. +3. **Step 2 — Tasks & Milestones (WBS)**: + - Define project milestones with weight percentages (must total 100%). + - Add scheduled tasks mapped to milestones with target completion dates. +4. **Step 3 — Material Estimation (BOQ)**: + - Select items from Master Materials Catalog. + - Enter estimated quantities and unit costs; total materials cost auto-calculates. +5. **Step 4 — Manpower Rate Allocation**: + - Assign labor trade categories (e.g. `Rebar Steelman`, `Formwork Carpenter`, `General Helper`). + - Input estimated work hours and link to specific tasks. +6. **Step 5 — Equipment Allocation**: + - Select machinery from Equipment Fleet (e.g. `Caterpillar 320 Excavator`, `10-Wheeler Dump Truck`). + - Enter estimated operating hours per task. +7. **Step 6 — Financial Estimation Summary**: + - System aggregates Total Est. Cost (Materials + Labor + Equipment) vs. Contract Value to project Profit Margin %. +8. **Step 7 — Automated Submission**: + - Click **"Submit for Approval"** $\rightarrow$ Request automatically routes to Executive Management (`Admin` / `Super Admin`) for single sign-off. + +--- + +### Stage 2: Bidding & Tender Management (`Under Bidding`) + +1. **Status**: Project is created under **`Under Bidding`**. +2. **Tender Package Creation**: Cost estimators publish trade packages to invited subcontractors. +3. **Proposal Evaluation**: Subcontractor quotes are logged and compared against the baseline Wizard BOQ estimates. +4. **Won Bid Gate**: Upon client award, project status transitions to **`In Progress`**. + +--- + +### Stage 3: Logistics & Inventory Setup (`/inventory` & `/procurement`) + +1. **Material Requisitions (MR)**: Site Engineers generate MRs pre-populated with quantities from **Step 3 of the Wizard**. +2. **Purchase Orders (PO)**: Procurement issues POs to approved material suppliers. +3. **Goods Received Notes (GRN)**: Site receivers inspect incoming deliveries against PO line items. +4. **Inventory Stocking**: Delivered materials are added to the project's on-hand site laydown inventory (`ProjectInventory`). + +--- + +### Stage 4: Site Scheduling, Tasks & Daily Operations (`/daily-reports`) + +1. **Gantt Scheduling**: Tasks defined in **Step 2 of the Wizard** load into the interactive Gantt chart for scheduling and assignment. +2. **Daily Site Logging**: + - Site Supervisors log shift hours, weather conditions, and manpower headcount per trade. + - Task completion percentages (`0%` $\rightarrow$ `100%`) updated directly on daily reports. +3. **Site Issue & Blocker Compliance**: + - Encountered blockers are logged with severity levels. + - Resolved blockers remain visible on Executive Dashboards with a **Soft Green Resolved Badge** and supervisor attestation. +4. **Material Consumption**: Daily material usage auto-deducts from site on-hand inventory. + +--- + +### Stage 5: Financial Management & Claims (`/finance`) + +1. **Progress Billing Invoices**: Project Manager generates progress invoices based on validated task completion percentages. +2. **10% Retention Ledger**: + - System automatically holds **10% Retention** on every invoice claim. + - Debit entries logged in `RetentionEntry` per project. +3. **Executive Approval & Payment**: Admin / Super Admin approves progress invoices for payment. +4. **Project Completion & Retention Release**: + - Upon 100% work completion, status transitions to **`Completed`**. + - Following defect liability period, held retention funds are released to contractor and status transitions to **`Closed`**. + +--- + +## 🔒 Security & Approval Hierarchy Matrix + +| Request Type | Initiated By | Required Approver | Enforcement Rules | +| :--- | :--- | :--- | :--- | +| **Site Blockers & Daily Logs** | Site Supervisor / Technical | Project Manager or Executive | Green resolved badge with supervisor sign-off | +| **Cash Advance Requests** | Site Supervisor | Project Manager or Executive | Self-approval blocked | +| **Material Requisitions (MR)** | Site Technical | Project Manager | BOQ quantity limit checks | +| **Project Wizard & Estimations** | Project Manager | Executive (Admin / Super Admin) | Single sign-off completes chain | +| **Progress Invoices** | Project Manager | Executive (Admin / Super Admin) | 10% Retention auto-withheld | +| **Admin Submissions** | Admin User | Super Admin | Final executive sign-off | diff --git a/resources/js/Components/AppSidebar.tsx b/resources/js/Components/AppSidebar.tsx index 9edefa3..3c7b9ec 100644 --- a/resources/js/Components/AppSidebar.tsx +++ b/resources/js/Components/AppSidebar.tsx @@ -161,7 +161,22 @@ export default function AppSidebar() { auth.roles.includes('Project Manager') || auth.roles.includes('Main Contractor Admin'); - const hasParentAccess = isAdminUser || + const isBiddingManager = auth.user.user_type === 'admin' || + auth.roles.includes('Super Admin') || + auth.roles.includes('admin') || + auth.roles.includes('Project Manager'); + const isSiteOperationsUser = auth.roles.some(role => [ + 'Site Technical', + 'Construction Supervisor', + 'Site Operations', + 'Site Engineer', + 'Site Supervisor', + ].includes(role)); + const isContractorPortalUser = auth.user.user_type === 'contractor' || auth.user.contractor_id !== null; + + const hasParentAccess = item.title === 'Bidding' + ? !isSiteOperationsUser && (isBiddingManager || isContractorPortalUser) + : isAdminUser || !item.permissions || item.permissions.length === 0 || item.permissions.some(p => auth.permissions.includes(p)); @@ -179,7 +194,9 @@ export default function AppSidebar() { return { ...item, - children: filteredChildren + children: item.title === 'Bidding' && !isBiddingManager + ? filteredChildren.filter(child => ['Bid Packages', 'My Bids'].includes(child.title)) + : filteredChildren }; } diff --git a/resources/js/Components/Dashboard/ResourceSummary.tsx b/resources/js/Components/Dashboard/ResourceSummary.tsx index add9bbc..36d44fa 100644 --- a/resources/js/Components/Dashboard/ResourceSummary.tsx +++ b/resources/js/Components/Dashboard/ResourceSummary.tsx @@ -1,4 +1,6 @@ +import { router } from '@inertiajs/react'; import { HardHat, Truck } from 'lucide-react'; +import { useEffect } from 'react'; interface ResourceSummaryProps { resources: { @@ -17,39 +19,32 @@ interface ResourceSummaryProps { } export default function ResourceSummary({ resources }: ResourceSummaryProps) { - const laborPercentage = Math.round((resources.labor.actual / resources.labor.expected) * 100); - + useEffect(() => { + const refreshTimer = window.setInterval(() => { + router.reload({ only: ['resources'] }); + }, 30000); + + return () => window.clearInterval(refreshTimer); + }, []); + return (

Resource Roll-Call

- +
- {/* Labor Section */}
Labor On-Site
-
- - {resources.labor.actual} - - / {resources.labor.expected} +
+ {resources.labor.actual}
- - {/* Progress Bar */} -
-
-
- - {/* Trades Breakdown */} +
{Object.entries(resources.labor.trades).map(([trade, count]) => ( @@ -61,7 +56,6 @@ export default function ResourceSummary({ resources }: ResourceSummaryProps) {
- {/* Equipment Section */}
@@ -69,7 +63,7 @@ export default function ResourceSummary({ resources }: ResourceSummaryProps) { Heavy Equipment
- +
{resources.equipment.active}
@@ -85,7 +79,6 @@ export default function ResourceSummary({ resources }: ResourceSummaryProps) {
- {/* Equipment Status List (Small) */}
{resources.equipment.list.map((item, idx) => (
@@ -101,7 +94,6 @@ export default function ResourceSummary({ resources }: ResourceSummaryProps) { ))}
-
); diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index 9187e3f..1e04b38 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -2,9 +2,9 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/Components/ui/s import { Separator } from '@/Components/ui/separator'; import AppSidebar from '@/Components/AppSidebar'; import { usePage, router } from '@inertiajs/react'; -import { PropsWithChildren, ReactNode, useState, useEffect } from 'react'; +import { PropsWithChildren, ReactNode, useEffect, useState } from 'react'; import { PageProps } from '@/types'; -import { Building2, ShieldAlert, Check, AlertCircle, X } from 'lucide-react'; +import { ShieldAlert, Check, AlertCircle, X } from 'lucide-react'; import Modal from '@/Components/Modal'; import { Button } from '@/Components/ui/button'; @@ -12,23 +12,17 @@ export default function Authenticated({ header, children, }: PropsWithChildren<{ header?: ReactNode }>) { - const { auth, flash } = usePage().props; - const isContractorUser = auth.user.contractor_id !== null; - const contractorName = auth.user.contractor?.company_name; - + const { flash } = usePage().props; const [showErrorModal, setShowErrorModal] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [localFlash, setLocalFlash] = useState<{ success?: string; error?: string } | null>(null); useEffect(() => { if (flash?.error) { - const isPermissionError = - flash.error.toLowerCase().includes('permission') || - flash.error.toLowerCase().includes('unauthorized') || - flash.error.toLowerCase().includes('access denied') || - flash.error.toLowerCase().includes('not have access') || - flash.error.toLowerCase().includes('only platform admin'); - + const error = flash.error.toLowerCase(); + const isPermissionError = ['permission', 'unauthorized', 'access denied', 'not have access', 'only platform admin'] + .some(message => error.includes(message)); + if (isPermissionError) { setErrorMessage(flash.error); setShowErrorModal(true); @@ -45,22 +39,16 @@ export default function Authenticated({ useEffect(() => { const unregisterException = router.on('exception', (event) => { console.error('Inertia Exception:', event.detail.exception); - setLocalFlash({ - error: 'A server error occurred. Please try again later.', - }); + setLocalFlash({ error: 'A server error occurred. Please try again later.' }); }); const unregisterInvalid = router.on('invalid', (event) => { console.error('Inertia Invalid Response:', event.detail.response); - setLocalFlash({ - error: `Server returned an error (${event.detail.response.status}). Please try again.`, - }); + setLocalFlash({ error: `Server returned an error (${event.detail.response.status}). Please try again.` }); }); const handleOffline = () => { - setLocalFlash({ - error: 'You are offline. Please check your network connection.', - }); + setLocalFlash({ error: 'You are offline. Please check your network connection.' }); }; window.addEventListener('offline', handleOffline); @@ -74,9 +62,7 @@ export default function Authenticated({ const handleCloseError = () => { setShowErrorModal(false); const props = usePage().props; - if (props.flash) { - props.flash.error = undefined; - } + if (props.flash) props.flash.error = undefined; }; const dismissFlash = () => { @@ -92,24 +78,6 @@ export default function Authenticated({ - {/* Contractor Context Banner */} - {isContractorUser && contractorName && ( -
-
- -

- 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. - )} -

-
-
- )} - - {/* Top header bar */}
{header && ( @@ -122,30 +90,19 @@ export default function Authenticated({ )}
- {/* Page content */}
{localFlash && (localFlash.success || localFlash.error) && (
- {localFlash.success ? ( - - ) : ( - - )} -

- {localFlash.success || localFlash.error} -

+ {localFlash.success ? : } +

{localFlash.success || localFlash.error}

-
@@ -155,7 +112,6 @@ export default function Authenticated({
- {/* Access Denied Modal */}
@@ -163,20 +119,12 @@ export default function Authenticated({
-

- Access Denied -

-

- {errorMessage} -

+

Access Denied

+

{errorMessage}

-
diff --git a/resources/js/Pages/Dashboard.tsx b/resources/js/Pages/Dashboard.tsx index 6f1975a..c75209a 100644 --- a/resources/js/Pages/Dashboard.tsx +++ b/resources/js/Pages/Dashboard.tsx @@ -1,23 +1,14 @@ import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; -import { Head, router } from '@inertiajs/react'; +import { Head } from '@inertiajs/react'; import WeatherWidget from '@/Components/Dashboard/WeatherWidget'; -import RoleAnalyticsBanner from '@/Components/Dashboard/RoleAnalyticsBanner'; import ExecutiveDashboardView from '@/Components/Dashboard/ExecutiveDashboardView'; import PMDashboardView from '@/Components/Dashboard/PMDashboardView'; import ContractorDashboardView from '@/Components/Dashboard/ContractorDashboardView'; import SiteDashboardView from '@/Components/Dashboard/SiteDashboardView'; -interface Project { - id: number; - ulid: string; - name: string; - code: string; - status: string; -} - interface DashboardProps { - projects: Project[]; - selectedProject: Project | null; + projects: unknown[]; + selectedProject: unknown | null; weather: any; activities: any; blockers: any; @@ -26,61 +17,37 @@ interface DashboardProps { } export default function Dashboard({ - projects = [], - selectedProject = null, weather, activities, blockers, resources, - roleAnalytics + roleAnalytics, }: DashboardProps) { - const handleProjectChange = (projectIdOrUlid: string) => { - if (!projectIdOrUlid) { - router.get('/dashboard'); - } else { - router.get('/dashboard', { project: projectIdOrUlid }); - } - }; - - const roleName = (roleAnalytics?.role || '').toLowerCase(); + const roleNames = (roleAnalytics?.roles || [roleAnalytics?.role || '']) + .map((role: string) => role.toLowerCase()); const userType = (roleAnalytics?.user_type || '').toLowerCase(); - - // Executive dashboard is ONLY for system admins (user_type === 'admin' or role includes 'super' / 'admin' AND NOT contractor) - const isContractor = userType === 'contractor' || roleName.includes('contractor'); - const isExecutive = !isContractor && (userType === 'admin' || roleName.includes('super admin') || roleName === 'admin'); - const isPM = !isContractor && !isExecutive && roleName.includes('project manager'); + + const hasPlatformRole = roleNames.some((role: string) => ['super admin', 'admin'].includes(role)); + const hasContractorRole = roleNames.some((role: string) => role.includes('contractor')); + const hasPMRole = roleNames.some((role: string) => + ['project manager', 'project_manager'].includes(role) + ); + const isExecutive = hasPlatformRole || (userType === 'admin' && !hasContractorRole && !hasPMRole); + const isPM = !isExecutive && hasPMRole; + const isContractor = !isExecutive && !isPM && ( + userType === 'contractor' || hasContractorRole + ); return ( -
-

- {isExecutive ? 'Executive Governance Dashboard' : isPM ? 'Project Operations Dashboard' : isContractor ? 'Contractor Bidding & Financials' : 'Site Execution Dashboard'} -

-

- Daily Operations • {new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })} -

-
- -
- - -
+
+

+ {isExecutive ? 'Executive Governance Dashboard' : isPM ? 'Project Operations Dashboard' : isContractor ? 'Contractor Bidding & Financials' : 'Site Execution Dashboard'} +

+

+ Daily Operations • {new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })} +

} > @@ -88,13 +55,10 @@ export default function Dashboard({
- - {/* Top Row: Weather / critical high-level alerts */}
- {/* Distinct Dashboard View Per Role */} {isExecutive ? ( ) : isPM ? ( @@ -104,10 +68,8 @@ export default function Dashboard({ ) : ( )} -
); } - diff --git a/tests/Feature/BiddingPackageWorkflowTest.php b/tests/Feature/BiddingPackageWorkflowTest.php new file mode 100644 index 0000000..d16c4c7 --- /dev/null +++ b/tests/Feature/BiddingPackageWorkflowTest.php @@ -0,0 +1,68 @@ + 'bidding.access']); + $role = Role::create(['name' => 'Project Manager']); + $role->givePermissionTo($permission); + + // The role is authoritative for package management, even if legacy user data + // still carries a contractor user_type. + $user = User::factory()->create(['user_type' => 'contractor']); + $user->assignRole($role); + $project = Project::factory()->create(['current_wizard_step' => 7]); + $project->personnel()->attach($user->id, ['role' => 'pm']); + + $response = $this->actingAs($user)->post(route('bids.store'), [ + 'project_id' => $project->ulid, + 'title' => 'Structural Works Tender', + 'evaluation_mode' => 'simple', + ]); + + $response->assertRedirect(); + $package = BidPackage::where('title', 'Structural Works Tender')->firstOrFail(); + $this->assertSame(BidPackageStatus::Draft, $package->status); + + $this->actingAs($user) + ->post(route('bids.publish', $package->ulid)) + ->assertSessionHas('success', 'Bid package is now open for submissions.'); + + $this->assertSame(BidPackageStatus::Open, $package->fresh()->status); + } + + public function test_contractor_cannot_create_or_publish_bid_packages(): void + { + $permission = Permission::create(['name' => 'bidding.access']); + $role = Role::create(['name' => 'Contractor']); + $role->givePermissionTo($permission); + + $user = User::factory()->create(['user_type' => 'contractor']); + $user->assignRole($role); + $project = Project::factory()->create(); + + $this->actingAs($user) + ->post(route('bids.store'), [ + 'project_id' => $project->ulid, + 'title' => 'Unauthorized Tender', + 'evaluation_mode' => 'simple', + ]) + ->assertForbidden(); + + $this->assertDatabaseMissing('bid_packages', ['title' => 'Unauthorized Tender']); + } +} diff --git a/tests/Feature/ContractorOnboardingTest.php b/tests/Feature/ContractorOnboardingTest.php index 82a9a92..129034c 100644 --- a/tests/Feature/ContractorOnboardingTest.php +++ b/tests/Feature/ContractorOnboardingTest.php @@ -6,6 +6,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; use Modules\ContractorManagement\Models\Contractor; +use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; use Tests\TestCase; @@ -20,6 +21,8 @@ class ContractorOnboardingTest extends TestCase // Create required roles for tests Role::firstOrCreate(['name' => 'admin']); Role::firstOrCreate(['name' => 'Contractor']); + Role::firstOrCreate(['name' => 'Main Contractor Admin']); + Permission::firstOrCreate(['name' => 'users.access', 'guard_name' => 'web']); } public function test_guest_can_register_as_contractor(): void @@ -52,7 +55,8 @@ class ContractorOnboardingTest extends TestCase $this->assertNotNull($user); $this->assertEquals('inactive', $user->status); $this->assertEquals($contractor->id, $user->contractor_id); - $this->assertTrue($user->hasRole('Contractor')); + $this->assertTrue($user->hasRole('Main Contractor Admin')); + $this->assertTrue($user->can('users.access')); } public function test_inactive_contractor_admin_cannot_login(): void diff --git a/tests/Feature/TenantScopeTest.php b/tests/Feature/TenantScopeTest.php index 6871559..a00afca 100644 --- a/tests/Feature/TenantScopeTest.php +++ b/tests/Feature/TenantScopeTest.php @@ -21,6 +21,8 @@ class TenantScopeTest extends TestCase // Create required roles for tests Role::firstOrCreate(['name' => 'admin']); Role::firstOrCreate(['name' => 'contractor-admin']); + Role::firstOrCreate(['name' => 'Super Admin']); + Role::firstOrCreate(['name' => 'Site Technical']); } public function test_tenant_isolation_is_enforced_between_independent_contractors(): void @@ -71,6 +73,37 @@ class TenantScopeTest extends TestCase $this->assertFalse($visibleProjects->contains($projectA)); } + public function test_users_page_data_is_visible_to_platform_admin_but_isolated_for_contractor_admins(): void + { + $contractorA = Contractor::create([ + 'company_name' => 'Users Contractor A', + 'contact_person' => 'Person A', + 'email' => 'users-a@contractor.com', + 'status' => 'active', + 'payment_terms' => 'net_30', + ]); + $contractorB = Contractor::create([ + 'company_name' => 'Users Contractor B', + 'contact_person' => 'Person B', + 'email' => 'users-b@contractor.com', + 'status' => 'active', + 'payment_terms' => 'net_30', + ]); + + $userA = User::factory()->create(['contractor_id' => $contractorA->id]); + $userB = User::factory()->create(['contractor_id' => $contractorB->id]); + $platformAdmin = User::factory()->create(['contractor_id' => null, 'user_type' => 'admin']); + + $this->actingAs($platformAdmin); + $this->assertTrue(User::all()->contains($userA)); + $this->assertTrue(User::all()->contains($userB)); + + $this->actingAs($userA); + $visibleUsers = User::all(); + $this->assertTrue($visibleUsers->contains($userA)); + $this->assertFalse($visibleUsers->contains($userB)); + } + public function test_platform_owners_bypass_tenant_filtering(): void { // 1. Create independent contractors and projects @@ -118,6 +151,38 @@ class TenantScopeTest extends TestCase $this->assertTrue($visibleProjects->contains($projectB)); } + public function test_platform_super_admin_with_legacy_contractor_link_sees_all_users(): void + { + $contractorA = Contractor::create([ + 'company_name' => 'Legacy Link Contractor A', + 'contact_person' => 'Person A', + 'email' => 'legacy-a@contractor.com', + 'status' => 'active', + 'payment_terms' => 'net_30', + ]); + $contractorB = Contractor::create([ + 'company_name' => 'Legacy Link Contractor B', + 'contact_person' => 'Person B', + 'email' => 'legacy-b@contractor.com', + 'status' => 'active', + 'payment_terms' => 'net_30', + ]); + + $userA = User::factory()->create(['contractor_id' => $contractorA->id]); + $userB = User::factory()->create(['contractor_id' => $contractorB->id]); + $platformAdmin = User::factory()->create([ + 'contractor_id' => $contractorA->id, + 'user_type' => 'admin', + ]); + $platformAdmin->assignRole('Super Admin'); + + $this->actingAs($platformAdmin); + $visibleUsers = User::all(); + + $this->assertTrue($visibleUsers->contains($userA)); + $this->assertTrue($visibleUsers->contains($userB)); + } + public function test_parent_contractor_can_traverse_subcontractor_hierarchy(): void { // 1. Create Parent Contractor A @@ -322,4 +387,107 @@ class TenantScopeTest extends TestCase // and avoiding duplicate key violation PRJ-{year}-001 $this->assertEquals(sprintf('PRJ-%d-002', $year), $projectB->code); } + + public function test_contractor_admin_sees_projects_assigned_through_project_contractor(): void + { + $contractor = Contractor::create([ + 'company_name' => 'Assigned Contractor', + 'contact_person' => 'Person A', + 'email' => 'assigned@contractor.com', + 'status' => 'active', + 'payment_terms' => 'net_30', + ]); + + $assignedProject = Project::create([ + 'name' => 'Assigned Project', + 'code' => 'PRJ-2026-101', + 'contractor_id' => null, + ]); + $unrelatedProject = Project::create([ + 'name' => 'Unrelated Project', + 'code' => 'PRJ-2026-102', + 'contractor_id' => null, + ]); + $contractor->projects()->attach($assignedProject->id, ['role' => 'subcontractor']); + + $user = User::factory()->create([ + 'contractor_id' => $contractor->id, + 'user_type' => 'admin', + 'status' => 'active', + ]); + $user->assignRole('contractor-admin'); + + $this->actingAs($user); + $visibleProjects = Project::all(); + + $this->assertTrue($visibleProjects->contains($assignedProject)); + $this->assertFalse($visibleProjects->contains($unrelatedProject)); + } + + public function test_site_operations_user_sees_only_projects_assigned_to_their_personnel_record(): void + { + $siteUser = User::factory()->create([ + 'user_type' => 'employee', + 'status' => 'active', + ]); + $siteUser->assignRole('Site Technical'); + + $assignedProject = Project::create([ + 'name' => 'Site Assigned Project', + 'code' => 'PRJ-2026-111', + ]); + $unrelatedProject = Project::create([ + 'name' => 'Site Unrelated Project', + 'code' => 'PRJ-2026-112', + ]); + $assignedProject->personnel()->attach($siteUser->id, ['role' => 'site_technical']); + + $this->actingAs($siteUser); + $visibleProjects = Project::all(); + + $this->assertTrue($visibleProjects->contains($assignedProject)); + $this->assertFalse($visibleProjects->contains($unrelatedProject)); + } + + public function test_site_operations_user_with_contractor_link_sees_contractor_projects(): void + { + $contractor = Contractor::create([ + 'company_name' => 'Site Operations Contractor', + 'contact_person' => 'Person S', + 'email' => 'site-ops@contractor.com', + 'status' => 'active', + 'payment_terms' => 'net_30', + ]); + $otherContractor = Contractor::create([ + 'company_name' => 'Other Contractor', + 'contact_person' => 'Person O', + 'email' => 'other@contractor.com', + 'status' => 'active', + 'payment_terms' => 'net_30', + ]); + + $contractorProject = Project::create([ + 'name' => 'Contractor Site Project', + 'code' => 'PRJ-2026-121', + 'contractor_id' => $contractor->id, + ]); + $otherProject = Project::create([ + 'name' => 'Other Site Project', + 'code' => 'PRJ-2026-122', + 'contractor_id' => $otherContractor->id, + ]); + + $siteUser = User::factory()->create([ + 'contractor_id' => $contractor->id, + 'user_type' => 'employee', + 'status' => 'active', + ]); + $siteUser->assignRole('Site Technical'); + + $this->actingAs($siteUser); + $visibleProjects = Project::all(); + + $this->assertTrue($visibleProjects->contains($contractorProject)); + $this->assertFalse($visibleProjects->contains($otherProject)); + } }