feat: implement core ERP modules, multi-tenant architecture, and comprehensive system workflow documentation.

This commit is contained in:
Ajjj
2026-08-05 01:18:31 +08:00
parent 1fcb468dc1
commit c03de704e2
46 changed files with 1902 additions and 387 deletions

View File

@@ -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),
];
}

View File

@@ -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 = <FileText className="h-5 w-5 mr-2" />;
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;

View File

@@ -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<ChainItem>;
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<PageProps>().props;
const isHistory = tab === 'history';
@@ -136,6 +148,46 @@ export default function Index({ approvals, tab }: Props) {
)}
</CardContent>
</Card>
{cashAdvances.length > 0 && (
<Card className="mt-6">
<CardHeader>
<CardTitle>{isHistory ? 'Cash Advance History' : 'Cash Advances Awaiting Approval'}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Requested By</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cashAdvances.map((cashAdvance) => (
<TableRow key={`cash-advance-${cashAdvance.id}`}>
<TableCell className="font-medium">{cashAdvance.project?.name || '-'}</TableCell>
<TableCell>{cashAdvance.requester?.name || '-'}</TableCell>
<TableCell className="max-w-xs truncate">{cashAdvance.reason}</TableCell>
<TableCell className="font-semibold">{Number(cashAdvance.amount).toLocaleString('en-PH', { minimumFractionDigits: 2 })}</TableCell>
<TableCell><Badge variant={cashAdvance.status === 'approved' ? 'default' : 'outline'}>{typeLabel(cashAdvance.status)}</Badge></TableCell>
<TableCell className="text-right">
{!isHistory && cashAdvance.status === 'pending' && (
<Button size="sm" onClick={() => router.patch(route('cash-advances.approve', cashAdvance.ulid))}>
Approve
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
</div>
</AuthenticatedLayout>

View File

@@ -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 = () => {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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.');

View File

@@ -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) {

View File

@@ -0,0 +1,30 @@
<?php
namespace Modules\BiddingManagement\Traits;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
trait AuthorizesBiddingManagement
{
protected function authorizeBiddingManagement(): void
{
abort_unless(
$this->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']));
}
}

View File

@@ -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) {
</p>
</div>
</div>
{auth.user?.user_type !== 'contractor' && (
<div className="flex items-center gap-2">
{canPublish && (
<Link href={route('bids.edit', pkg.ulid)}>
<Button variant="outline" size="sm"><Pencil className="mr-2 h-4 w-4" />Edit</Button>
</Link>
)}
{canPublish && (
<Button size="sm" onClick={() => router.post(route('bids.publish', pkg.ulid))}>
<Send className="mr-2 h-4 w-4" />Publish
</Button>
)}
{canEvaluate && (
<Button size="sm" variant="secondary" onClick={() => router.post(route('bids.evaluate', pkg.ulid))}>
<PlayCircle className="mr-2 h-4 w-4" />Start Evaluation
</Button>
)}
{canCancel && (
<Button size="sm" variant="ghost" onClick={() => { if (confirm('Cancel this bid package?')) router.post(route('bids.cancel', pkg.ulid)); }}>
<Ban className="mr-2 h-4 w-4 text-red-500" />Cancel
</Button>
)}
</div>
)}
</div>
}
>
@@ -220,6 +197,52 @@ export default function Show({ package: pkg, contractors }: Props) {
{flash?.success && <div className="rounded-md bg-green-50 p-4 text-sm text-green-700">{flash.success}</div>}
{flash?.error && <div className="rounded-md bg-red-50 p-4 text-sm text-red-700">{flash.error}</div>}
{isBiddingManager && (
<Card className="overflow-hidden border-slate-200 shadow-sm">
<CardContent className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between sm:p-5">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
<Gavel className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold text-slate-900">Package controls</p>
<p className="mt-0.5 text-xs text-slate-500">
{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()}.`}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
{canPublish && (
<Link href={route('bids.edit', pkg.ulid)}>
<Button variant="outline" size="sm" className="border-slate-300 bg-white">
<Pencil className="mr-2 h-4 w-4" />Edit package
</Button>
</Link>
)}
{canPublish && (
<Button size="sm" className="bg-emerald-600 text-white shadow-sm hover:bg-emerald-700" onClick={() => router.post(route('bids.publish', pkg.ulid))}>
<Send className="mr-2 h-4 w-4" />Publish package
</Button>
)}
{canEvaluate && (
<Button size="sm" variant="secondary" onClick={() => router.post(route('bids.evaluate', pkg.ulid))}>
<PlayCircle className="mr-2 h-4 w-4" />Start evaluation
</Button>
)}
{canCancel && (
<Button size="sm" variant="ghost" className="text-red-600 hover:bg-red-50 hover:text-red-700" onClick={() => { if (confirm('Cancel this bid package?')) router.post(route('bids.cancel', pkg.ulid)); }}>
<Ban className="mr-2 h-4 w-4" />Cancel package
</Button>
)}
</div>
</CardContent>
</Card>
)}
{/* 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) {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Invited Contractors</CardTitle>
{auth.user?.user_type !== 'contractor' && ['draft', 'open'].includes(pkg.status) && (
{isBiddingManager && ['draft', 'open'].includes(pkg.status) && (
<Dialog open={inviteDialog} onOpenChange={setInviteDialog}>
<DialogTrigger render={<Button size="sm" />}><Plus className="mr-2 h-4 w-4" />Invite Contractors</DialogTrigger>
<DialogContent className="max-w-md">
@@ -363,7 +386,7 @@ export default function Show({ package: pkg, contractors }: Props) {
<TableHead>Specialization</TableHead>
<TableHead>Response</TableHead>
<TableHead>Submitted</TableHead>
{auth.user?.user_type !== 'contractor' && <TableHead className="text-right">Actions</TableHead>}
{isBiddingManager && <TableHead className="text-right">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
@@ -381,7 +404,7 @@ export default function Show({ package: pkg, contractors }: Props) {
<TableCell>
{inv.submission ? <CheckCircle2 className="h-4 w-4 text-green-500" /> : <XCircle className="h-4 w-4 text-gray-300" />}
</TableCell>
{auth.user?.user_type !== 'contractor' && (
{isBiddingManager && (
<TableCell className="text-right">
{!inv.submission && ['draft', 'open'].includes(pkg.status) && (
<Button variant="ghost" size="icon-sm" title="Remove invitation"

View File

@@ -93,7 +93,14 @@ class ContractorController extends Controller
'must_change_password' => true,
]);
$role = Role::firstOrCreate(['name' => 'Contractor']);
// Provision the account with the role that owns user-management
// actions. Keep the legacy Contractor role only for existing
// accounts; new contractor admins must use the admin role.
$roleName = $contractor->parent_id
? 'Sub Contractor Admin'
: 'Main Contractor Admin';
$role = Role::firstOrCreate(['name' => $roleName]);
$role->givePermissionTo('users.access');
$user->assignRole($role);
}
});

View File

@@ -12,6 +12,7 @@ use Inertia\Inertia;
use Modules\ContractorManagement\Events\ContractorApproved;
use Modules\ContractorManagement\Events\ContractorOnboarded;
use Modules\ContractorManagement\Models\Contractor;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class ContractorOnboardingController extends Controller
@@ -61,8 +62,13 @@ class ContractorOnboardingController extends Controller
'type' => 'main', // Default to main if self-registered
]);
// Ensure the Contractor role exists
$role = Role::firstOrCreate(['name' => 'Contractor']);
// New contractor accounts are administrators for their own tenant.
$role = Role::firstOrCreate(['name' => 'Main Contractor Admin']);
$usersAccess = Permission::firstOrCreate([
'name' => 'users.access',
'guard_name' => 'web',
]);
$role->givePermissionTo($usersAccess);
// 2. Create the inactive admin user for this contractor
$user = User::create([

View File

@@ -25,7 +25,11 @@ class FinanceController extends Controller
// --- Invoice List ---
public function index(Request $request)
{
$query = FinancialInvoice::with('project:id,name,code');
$query = FinancialInvoice::with([
'project' => fn ($query) => $query
->withoutGlobalScopes()
->select('id', 'ulid', 'name', 'code'),
]);
if ($status = $request->status) {
$query->where('status', $status);
@@ -34,17 +38,22 @@ class FinanceController extends Controller
$query->where('project_id', $projectId);
}
$query->whereIn('project_id', $this->availableProjectIdsQuery());
$invoices = $query->latest()->paginate(15)->withQueryString();
$projects = Project::select('id', 'ulid', 'name', 'code')->get();
$projects = Project::whereIn('projects.id', $this->availableProjectIdsQuery())
->select('id', 'ulid', 'name', 'code')->get();
// Compute summary stats
$allInvoices = FinancialInvoice::query();
$allInvoices = FinancialInvoice::whereIn('project_id', $this->availableProjectIdsQuery());
$summary = [
'total_billed' => (float) $allInvoices->sum('total_amount'),
'total_paid' => (float) $allInvoices->sum('paid_amount'),
'outstanding' => (float) $allInvoices->whereNotIn('status', ['paid'])->sum(\DB::raw('total_amount - paid_amount')),
'total_retention' => (float) RetentionEntry::where('type', 'debit')->sum('amount')
- (float) RetentionEntry::where('type', 'credit')->sum('amount'),
'total_retention' => (float) RetentionEntry::where('type', 'debit')
->whereIn('project_id', $this->availableProjectIdsQuery())->sum('amount')
- (float) RetentionEntry::where('type', 'credit')
->whereIn('project_id', $this->availableProjectIdsQuery())->sum('amount'),
];
return Inertia::render('FinancialManagement::Invoices/Index', [
@@ -58,7 +67,8 @@ class FinanceController extends Controller
// --- Create (Progress Billing) ---
public function create()
{
$projects = Project::select('id', 'ulid', 'name', 'code', 'contract_value', 'last_billed_percentage')
$projects = Project::whereIn('projects.id', $this->availableProjectIdsQuery())
->select('id', 'ulid', 'name', 'code', 'contract_value', 'last_billed_percentage')
->where('current_wizard_step', '>=', 7)
->whereNotIn('status', ['completed', 'closed'])
->get();
@@ -98,6 +108,7 @@ class FinanceController extends Controller
// --- Show Invoice ---
public function show(FinancialInvoice $invoice)
{
abort_unless($this->canAccessProject(auth()->user(), $invoice->project_id), 403);
$invoice->load(['project:id,name,code', 'lineItems', 'retentionEntries']);
return Inertia::render('FinancialManagement::Invoices/Show', [
@@ -108,13 +119,14 @@ class FinanceController extends Controller
// --- State Transitions ---
public function submit(Request $request, FinancialInvoice $invoice)
{
abort_unless($this->canAccessProject($request->user(), $invoice->project_id), 403);
try {
$invoice->transitionTo(InvoiceStatus::Submitted);
// Fetch Admins and Super Admins as approvers
$adminIds = User::where('user_type', 'admin')
->orWhereHas('roles', function ($q) {
$q->whereIn('name', ['Super Admin', 'admin']);
$q->whereIn('name', ['Super Admin', 'admin', 'Project Manager', 'Main Contractor Admin']);
})
->pluck('id')
->toArray();
@@ -138,6 +150,7 @@ class FinanceController extends Controller
public function approve(FinancialInvoice $invoice)
{
$user = auth()->user();
abort_unless($this->canAccessProject($user, $invoice->project_id), 403);
$isApprover = $user->user_type === 'admin' ||
$user->roles()->whereIn('name', ['Super Admin', 'admin'])->exists();
@@ -158,6 +171,7 @@ class FinanceController extends Controller
public function reject(FinancialInvoice $invoice)
{
$user = auth()->user();
abort_unless($this->canAccessProject($user, $invoice->project_id), 403);
$isApprover = $user->user_type === 'admin' ||
$user->roles()->whereIn('name', ['Super Admin', 'admin'])->exists();
@@ -176,6 +190,7 @@ class FinanceController extends Controller
public function send(FinancialInvoice $invoice)
{
abort_unless($this->canAccessProject(auth()->user(), $invoice->project_id), 403);
try {
$invoice->transitionTo(InvoiceStatus::Sent);
} catch (\InvalidArgumentException $e) {
@@ -187,6 +202,7 @@ class FinanceController extends Controller
public function recordPayment(Request $request, FinancialInvoice $invoice)
{
abort_unless($this->canAccessProject($request->user(), $invoice->project_id), 403);
$validated = $request->validate([
'amount' => 'required|numeric|min:0.01',
]);
@@ -203,17 +219,26 @@ class FinanceController extends Controller
// --- Retention Ledger ---
public function retention(Request $request)
{
$query = RetentionEntry::with('project:id,name,code', 'invoice:id,invoice_number');
$query = RetentionEntry::with([
'project' => fn ($query) => $query
->withoutGlobalScopes()
->select('id', 'ulid', 'name', 'code'),
'invoice:id,invoice_number',
]);
if ($projectId = $request->project_id) {
$query->where('project_id', $projectId);
}
$query->whereIn('project_id', $this->availableProjectIdsQuery());
$entries = $query->latest()->paginate(20)->withQueryString();
$projects = Project::select('id', 'ulid', 'name', 'code')->get();
$projects = Project::whereIn('projects.id', $this->availableProjectIdsQuery())
->select('id', 'ulid', 'name', 'code')->get();
// Compute per-project totals
$projectTotals = RetentionEntry::selectRaw('project_id, type, SUM(amount) as total')
$projectTotals = RetentionEntry::whereIn('project_id', $this->availableProjectIdsQuery())
->selectRaw('project_id, type, SUM(amount) as total')
->groupBy('project_id', 'type')
->get()
->groupBy('project_id')
@@ -234,14 +259,22 @@ class FinanceController extends Controller
// Cash Advances
public function cashAdvances(Request $request)
{
$query = \Modules\FinancialManagement\Models\CashAdvance::with(['project:id,name,code', 'requester:id,name,email', 'approver:id,name,email']);
$user = $request->user();
$isApprover = $this->canApproveCashAdvance($user);
$query = $isApprover
? \Modules\FinancialManagement\Models\CashAdvance::withoutGlobalScopes()
: \Modules\FinancialManagement\Models\CashAdvance::query();
$query->with(['project:id,name,code', 'requester:id,name,email', 'approver:id,name,email']);
$query->whereIn('project_id', $this->availableProjectIdsQuery());
if ($projectId = $request->project_id) {
$query->where('project_id', $projectId);
}
$cashAdvances = $query->latest()->paginate(20)->withQueryString();
$projects = Project::select('id', 'ulid', 'name', 'code')->get();
$projects = Project::whereIn('projects.id', $this->availableProjectIdsQuery())
->select('id', 'ulid', 'name', 'code')->get();
return Inertia::render('FinancialManagement::CashAdvances/Index', [
'cashAdvances' => $cashAdvances,
@@ -271,20 +304,28 @@ class FinanceController extends Controller
return back()->with('success', 'Cash advance request submitted successfully.');
}
public function approveCashAdvance(\Modules\FinancialManagement\Models\CashAdvance $cashAdvance)
public function approveCashAdvance(string $cashAdvance)
{
$user = auth()->user();
$cashAdvance = \Modules\FinancialManagement\Models\CashAdvance::withoutGlobalScopes()
->where('ulid', $cashAdvance)
->firstOrFail();
if ($cashAdvance->requested_by === $user->id && $user->user_type !== 'admin' && !$user->hasRole('Super Admin')) {
return back()->with('error', 'You cannot approve your own cash advance request.');
}
$isApprover = $user->user_type === 'admin' ||
$user->roles()->whereIn('name', ['Super Admin', 'admin', 'Project Manager', 'Main Contractor Admin'])->exists();
$isApprover = $this->canApproveCashAdvance($user);
if (! $isApprover) {
return back()->with('error', 'Unauthorized to approve cash advance requests.');
}
if (! $this->isPlatformUser($user)
&& ! $this->availableProjectIdsQuery()->where('projects.id', $cashAdvance->project_id)->exists()) {
return back()->with('error', 'You cannot approve a cash advance for an unrelated project.');
}
$cashAdvance->update([
'status' => 'approved',
'approved_by' => auth()->id(),
@@ -292,4 +333,27 @@ class FinanceController extends Controller
return back()->with('success', 'Cash advance request approved.');
}
private function canApproveCashAdvance(User $user): bool
{
return $user->user_type === 'admin'
|| $user->hasAnyRole(['Super Admin', 'admin', 'Project Manager', 'Main Contractor Admin']);
}
private function availableProjectIdsQuery()
{
return Project::query()->select('projects.id');
}
private function isPlatformUser(User $user): bool
{
return $user->user_type === 'admin' || $user->hasAnyRole(['Super Admin', 'admin']);
}
private function canAccessProject(User $user, ?int $projectId): bool
{
return $projectId !== null
&& ($this->isPlatformUser($user)
|| $this->availableProjectIdsQuery()->where('projects.id', $projectId)->exists());
}
}

View File

@@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\ApprovalWorkflow\Traits\HasApprovable;
use Modules\FinancialManagement\Enums\InvoiceStatus;
use Modules\ProjectManagement\Models\Project;
use Modules\ApprovalWorkflow\Models\ApprovalChain;
use Modules\FinancialManagement\Services\ProgressBillingService;
class FinancialInvoice extends Model
{
@@ -90,4 +92,18 @@ class FinancialInvoice extends Model
&& $this->due_date->isPast()
&& !in_array($this->status, [InvoiceStatus::Paid]);
}
public function onApprovalCompleted(ApprovalChain $chain): void
{
if ($chain->status->value === 'approved') {
$this->update([
'status' => InvoiceStatus::Approved,
'approved_at' => now(),
]);
app(ProgressBillingService::class)->holdRetention($this);
} elseif ($chain->status->value === 'rejected') {
$this->update(['status' => InvoiceStatus::Rejected]);
}
}
}

View File

@@ -62,7 +62,9 @@ class ProgressBillingService
*/
public function holdRetention(FinancialInvoice $invoice): void
{
if ($invoice->retention_amount <= 0) return;
if ($invoice->retention_amount <= 0 || RetentionEntry::where('invoice_id', $invoice->id)->where('type', 'debit')->exists()) {
return;
}
RetentionEntry::create([
'project_id' => $invoice->project_id,

View File

@@ -61,8 +61,8 @@ export default function CashAdvancesIndex({ cashAdvances, projects }: Props) {
});
};
const isApprover = auth.user.user_type === 'admin' ||
auth.roles?.some((r: any) => ['Super Admin', 'admin', 'Project Manager', 'Main Contractor Admin'].includes(r.name));
const isApprover = auth.user.user_type === 'admin' ||
auth.roles?.some((role: string) => ['Super Admin', 'admin', 'Project Manager', 'Main Contractor Admin'].includes(role));
const handleApprove = () => {
if (!selectedItemToApprove) return;

View File

@@ -14,7 +14,7 @@ interface InvoiceItem {
id: number; ulid: string; invoice_number: string; status: string;
subtotal: string; total_amount: string; paid_amount: string; retention_amount: string;
billed_percentage: string; invoice_date: string; due_date?: string;
project: { id: number; ulid: string; name: string; code: string };
project?: { id: number; ulid: string; name: string; code: string } | null;
}
interface Props extends PageProps {
@@ -105,7 +105,7 @@ export default function Index({ invoices, projects, summary, filters }: Props) {
return (
<TableRow key={inv.id} className={isOverdue ? 'bg-red-50' : ''}>
<TableCell><Link href={route('finance.show', inv.ulid)} className="font-medium text-blue-600 hover:underline">{inv.invoice_number}</Link></TableCell>
<TableCell className="text-gray-500">{inv.project.name}</TableCell>
<TableCell className="text-gray-500">{inv.project?.name || 'Project unavailable'}</TableCell>
<TableCell>{Number(inv.billed_percentage).toFixed(1)}%</TableCell>
<TableCell className="text-right">{formatCurrency(inv.subtotal)}</TableCell>
<TableCell className="text-right text-gray-500">{formatCurrency(inv.retention_amount)}</TableCell>

View File

@@ -12,7 +12,7 @@ import { useState, useMemo } from 'react';
interface RetEntry {
id: number; ulid: string; type: string; amount: string; description?: string; created_at: string;
project: { id: number; ulid: string; name: string; code: string };
project?: { id: number; ulid: string; name: string; code: string } | null;
invoice?: { id: number; ulid: string; invoice_number: string };
}
@@ -96,7 +96,7 @@ export default function Index({ entries, projects, projectTotals, filters }: Pro
<TableRow><TableCell colSpan={6} className="text-center text-gray-500 py-8">No retention entries.</TableCell></TableRow>
) : entries.data.map((e) => (
<TableRow key={e.id}>
<TableCell className="font-medium">{e.project.name}</TableCell>
<TableCell className="font-medium">{e.project?.name || 'Project unavailable'}</TableCell>
<TableCell className="text-gray-500">{e.invoice?.invoice_number || '-'}</TableCell>
<TableCell><Badge variant={e.type === 'debit' ? 'destructive' : 'default'}>{e.type}</Badge></TableCell>
<TableCell className="text-right font-medium">{formatCurrency(e.amount)}</TableCell>

View File

@@ -20,12 +20,18 @@ class MaterialRequisitionController extends Controller
public function index(Request $request)
{
$query = MaterialRequisition::with([
'requester:id,ulid,name',
'approver:id,ulid,name',
// The requisition is already tenant/project-scoped. Load the
// display names without User's tenant scope so a valid requester
// from the assigned project is not serialized as null.
'requester' => fn ($userQuery) => $userQuery->withoutGlobalScopes()->select('id', 'ulid', 'name'),
'approver' => fn ($userQuery) => $userQuery->withoutGlobalScopes()->select('id', 'ulid', 'name'),
'project:id,ulid,name,code',
'items:id,material_requisition_id,quantity,unit_cost'
]);
// Requisitions inherit visibility from their projects.
$query->whereIn('project_id', $this->availableProjectsQuery()->select('projects.id'));
if ($request->filled('project_ulid')) {
$project = Project::where('ulid', $request->project_ulid)->first();
if ($project) {
@@ -47,7 +53,8 @@ class MaterialRequisitionController extends Controller
$prefilledItems = [];
if ($request->filled('project_ulid')) {
$selectedProject = Project::where('ulid', $request->project_ulid)
$selectedProject = $this->availableProjectsQuery()
->where('ulid', $request->project_ulid)
->with('materialsEstimates.material:id,ulid,name,unit,unit_cost')
->firstOrFail();
@@ -97,7 +104,12 @@ class MaterialRequisitionController extends Controller
return Inertia::render('MaterialLogistics::Requisitions/Form', [
'materials' => $materials,
'materialGroups' => $materialGroups,
'projects' => Project::active()->with('contractor:id,company_name')->select('id', 'ulid', 'name', 'code', 'contractor_id', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value')->get(),
'projects' => $this->availableProjectsQuery()
->active()
->where('current_wizard_step', '>=', 8)
->with('contractor:id,company_name')
->select('id', 'ulid', 'name', 'code', 'contractor_id', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value')
->get(),
'selectedProject' => $selectedProject,
'prefilledItems' => $prefilledItems,
]);
@@ -114,7 +126,9 @@ class MaterialRequisitionController extends Controller
'items.*.unit_cost' => 'required|numeric|min:0',
]);
$project = Project::where('ulid', $validated['project_ulid'])->firstOrFail();
$project = $this->availableProjectsQuery()
->where('ulid', $validated['project_ulid'])
->firstOrFail();
if ($project->current_wizard_step < 7) {
return redirect()->back()->with('error', 'Cannot create material requisition: Project estimation is not submitted or approved.');
}
@@ -197,7 +211,11 @@ class MaterialRequisitionController extends Controller
'requisition' => $requisition,
'materials' => $materials,
'materialGroups' => $materialGroups,
'projects' => Project::active()->where('current_wizard_step', '>=', 8)->select('id', 'ulid', 'name', 'code', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value')->get(),
'projects' => $this->availableProjectsQuery()
->active()
->where('current_wizard_step', '>=', 8)
->select('id', 'ulid', 'name', 'code', 'client_name', 'location', 'start_date', 'target_end_date', 'status', 'contract_value')
->get(),
]);
}
@@ -216,7 +234,9 @@ class MaterialRequisitionController extends Controller
'items.*.unit_cost' => 'required|numeric|min:0',
]);
$project = Project::where('ulid', $validated['project_ulid'])->firstOrFail();
$project = $this->availableProjectsQuery()
->where('ulid', $validated['project_ulid'])
->firstOrFail();
if ($project->current_wizard_step < 8) {
return redirect()->back()->with('error', 'Cannot update material requisition: Project estimation is not approved.');
}
@@ -279,8 +299,27 @@ class MaterialRequisitionController extends Controller
return back()->with('error', 'Only draft requisitions can be submitted.');
}
// Find personnel with approve_mr permission across the whole system
$approverIds = \App\Models\User::permission('approve_mr')
// Material Requests may be approved by an explicit permission or by
// the system's Project Manager/executive roles. Query without the
// tenant scope so a site user can submit a request for an assigned
// project even when the approver belongs to another tenant context.
$approverIds = \App\Models\User::withoutGlobalScopes()
->where(function ($query) {
$query->whereHas('roles', function ($roleQuery) {
$roleQuery->whereIn('name', [
'Project Manager',
'project_manager',
'Super Admin',
'admin',
'Main Contractor Admin',
]);
})
->orWhere('user_type', 'admin')
->orWhereHas('permissions', function ($permissionQuery) {
$permissionQuery->where('name', 'approve_mr');
});
})
->where('status', 'active')
->pluck('id')
->values()
->toArray();
@@ -341,4 +380,45 @@ class MaterialRequisitionController extends Controller
return $pdf->download("MR-{$requisition->document_number}.pdf");
}
private function availableProjectsQuery()
{
$query = Project::withoutGlobalScope(\App\Scopes\TenantScope::class);
$user = auth()->user();
$isSiteOperationsUser = $user && $user->hasAnyRole([
'Site Technical',
'Construction Supervisor',
'Site Operations',
'Site Engineer',
'Site Supervisor',
]);
if ($user?->contractor_id) {
$contractorIds = DB::table('contractors')
->where('id', $user->contractor_id)
->orWhere('parent_id', $user->contractor_id)
->pluck('id')
->all();
$query->where(function ($projectQuery) use ($contractorIds, $user, $isSiteOperationsUser) {
$projectQuery
->whereIn('projects.contractor_id', $contractorIds)
->orWhereHas('contractors', function ($contractorQuery) use ($contractorIds) {
$contractorQuery->whereIn('contractors.id', $contractorIds);
});
if ($isSiteOperationsUser) {
$projectQuery->orWhereHas('personnel', function ($personnelQuery) use ($user) {
$personnelQuery->where('users.id', $user->id);
});
}
});
} elseif ($isSiteOperationsUser) {
$query->whereHas('personnel', function ($personnelQuery) use ($user) {
$personnelQuery->where('users.id', $user->id);
});
}
return $query;
}
}

View File

@@ -22,13 +22,17 @@ class PurchaseOrderController extends Controller
public function index(Request $request)
{
$query = PurchaseOrder::with([
'requester:id,ulid,name',
'approver:id,ulid,name',
'requester' => fn ($userQuery) => $userQuery->withoutGlobalScopes()->select('id', 'ulid', 'name'),
'approver' => fn ($userQuery) => $userQuery->withoutGlobalScopes()->select('id', 'ulid', 'name'),
'project:id,ulid,name,code',
'requisitions:id,ulid,document_number',
'items:id,purchase_order_id,quantity,unit_cost'
]);
// Purchase Orders inherit visibility from the projects available to
// the authenticated site or contractor user.
$query->whereIn('project_id', $this->availableProjectsQuery()->select('projects.id'));
if ($request->filled('project_ulid')) {
$project = Project::where('ulid', $request->project_ulid)->first();
if ($project) {
@@ -75,8 +79,25 @@ class PurchaseOrderController extends Controller
$approvedRequisitions = $requisitionsQuery->orderByDesc('created_at')->get();
// Find personnel with approve_po permission across the whole system
$approverIds = \App\Models\User::permission('approve_po')
// Purchase Orders may be approved by an explicit permission or by
// the system's Project Manager/executive roles.
$approverIds = \App\Models\User::withoutGlobalScopes()
->where(function ($query) {
$query->whereHas('roles', function ($roleQuery) {
$roleQuery->whereIn('name', [
'Project Manager',
'project_manager',
'Super Admin',
'admin',
'Main Contractor Admin',
]);
})
->orWhere('user_type', 'admin')
->orWhereHas('permissions', function ($permissionQuery) {
$permissionQuery->where('name', 'approve_po');
});
})
->where('status', 'active')
->pluck('id')
->values()
->toArray();
@@ -311,8 +332,25 @@ class PurchaseOrderController extends Controller
return back()->with('error', 'Only draft purchase orders can be submitted.');
}
// Find personnel with approve_po permission across the whole system
$approverIds = \App\Models\User::permission('approve_po')
// Use the same role-based approval rule during submission as on the
// Purchase Order creation form.
$approverIds = \App\Models\User::withoutGlobalScopes()
->where(function ($query) {
$query->whereHas('roles', function ($roleQuery) {
$roleQuery->whereIn('name', [
'Project Manager',
'project_manager',
'Super Admin',
'admin',
'Main Contractor Admin',
]);
})
->orWhere('user_type', 'admin')
->orWhereHas('permissions', function ($permissionQuery) {
$permissionQuery->where('name', 'approve_po');
});
})
->where('status', 'active')
->pluck('id')
->values()
->toArray();
@@ -545,4 +583,43 @@ class PurchaseOrderController extends Controller
$purchaseOrder->receipt_original_name
);
}
private function availableProjectsQuery()
{
$query = Project::withoutGlobalScope(\App\Scopes\TenantScope::class);
$user = auth()->user();
$siteRoles = [
'Site Technical', 'Construction Supervisor', 'Site Operations',
'Site Engineer', 'Site Supervisor',
];
$isSiteOperationsUser = $user && $user->hasAnyRole($siteRoles);
if ($user?->contractor_id) {
$contractorIds = DB::table('contractors')
->where('id', $user->contractor_id)
->orWhere('parent_id', $user->contractor_id)
->pluck('id')
->all();
$query->where(function ($projectQuery) use ($contractorIds, $user, $isSiteOperationsUser) {
$projectQuery
->whereIn('projects.contractor_id', $contractorIds)
->orWhereHas('contractors', function ($contractorQuery) use ($contractorIds) {
$contractorQuery->whereIn('contractors.id', $contractorIds);
});
if ($isSiteOperationsUser) {
$projectQuery->orWhereHas('personnel', function ($personnelQuery) use ($user) {
$personnelQuery->where('users.id', $user->id);
});
}
});
} elseif ($isSiteOperationsUser) {
$query->whereHas('personnel', function ($personnelQuery) use ($user) {
$personnelQuery->where('users.id', $user->id);
});
}
return $query;
}
}

View File

@@ -47,7 +47,7 @@ interface PoRow {
id: number; ulid: string; document_number: string; supplier?: string;
status: string; payment_status: string; notes?: string; created_at: string; approved_at?: string;
receipt_path?: string; receipt_original_name?: string;
requester: Approver; approver?: Approver;
requester?: Approver | null; approver?: Approver | null;
items: PoItem[]; requisitions: MrRef[];
}
@@ -146,7 +146,7 @@ export default function Index({ purchaseOrders, warehouses }: Props) {
<TableCell className="font-mono font-medium text-sm">{po.document_number}</TableCell>
<TableCell className="text-sm">{po.supplier || '—'}</TableCell>
<TableCell className="text-sm">{po.requester.name}</TableCell>
<TableCell className="text-sm">{po.requester?.name ?? 'Unknown user'}</TableCell>
<TableCell className="text-center"><Badge variant="outline">{po.items?.length || 0}</Badge></TableCell>
<TableCell className="text-right text-sm tabular-nums">{fmt(total)}</TableCell>
<TableCell><Badge variant={statusColor(po.status)}>{po.status}</Badge></TableCell>

View File

@@ -68,6 +68,8 @@ export default function Show({ purchaseOrder, warehouses, budgetAnalysis }: Prop
router.patch(route('purchase-orders.deliver', purchaseOrder.ulid));
};
const pendingApprovalChain = purchaseOrder.approvalChains?.find((chain: any) => chain.status === 'pending');
const lineTotal = (item: any) => Number(item.quantity) * Number(item.unit_cost);
const total = purchaseOrder.items.reduce((s: number, i: any) => s + lineTotal(i), 0);
@@ -166,6 +168,12 @@ export default function Show({ purchaseOrder, warehouses, budgetAnalysis }: Prop
</div>
)}
{purchaseOrder.status === 'submitted' && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
This Purchase Order is awaiting Project Manager or executive approval. Mark as Paid and Mark as Delivered will become available after approval.
</div>
)}
<ConfirmModal
open={submitModalOpen}
onOpenChange={setSubmitModalOpen}
@@ -208,7 +216,9 @@ export default function Show({ purchaseOrder, warehouses, budgetAnalysis }: Prop
className="bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={() => {
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));
}
}
}}
>

View File

@@ -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) {
<TableRow className="cursor-pointer hover:bg-gray-50/50" onClick={() => router.visit(route('requisitions.show', mr.ulid))}>
<TableCell className="font-mono font-medium text-sm">{mr.document_number}</TableCell>
<TableCell className="text-sm">{mr.requester.name}</TableCell>
<TableCell className="text-sm">{mr.requester?.name ?? 'Unknown user'}</TableCell>
<TableCell className="text-center"><Badge variant="outline">{mr.items?.length || 0}</Badge></TableCell>
<TableCell className="text-right text-sm tabular-nums">{fmt(total)}</TableCell>
<TableCell><Badge variant={statusColor(mr.status)}>{mr.status}</Badge></TableCell>

View File

@@ -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))

View File

@@ -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');

View File

@@ -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<any>().props;
const { projects, projectOptions } = usePage<any>().props;
const selectableProjects = projectOptions ?? projects;
return (
<AuthenticatedLayout
@@ -80,9 +81,9 @@ export default function ProjectLayout({ project, allowedTransitions, children, c
<p className="mt-2 text-sm text-gray-500">Choose a project to view its {currentTab.replace('-', ' ')} data.</p>
</div>
{projects && projects.length > 0 ? (
{selectableProjects && selectableProjects.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
{projects.map((p: any) => (
{selectableProjects.map((p: any) => (
<div
key={p.ulid}
onClick={() => {

View File

@@ -471,6 +471,19 @@ export default function Wizard({ project, step: currentStep, employees, projects
{/* Step Content Container */}
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs overflow-hidden">
{Object.keys(errors || {}).length > 0 && (
<div className="p-4 bg-rose-50 border-b border-rose-100 text-rose-800 text-xs space-y-1">
<p className="font-bold text-rose-900 flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4 text-rose-600 animate-pulse" />
Please correct the following errors before proceeding:
</p>
<ul className="list-disc pl-5 mt-1 space-y-0.5 font-medium">
{Object.entries(errors).map(([key, val]) => (
<li key={key}>{String(val)}</li>
))}
</ul>
</div>
)}
{/* Step 1: Project Details (Editable Form) */}
{step === 1 && (

View File

@@ -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

View File

@@ -162,51 +162,9 @@ export default function KanbanBoard({ tasks, onReorder, onTaskClick, readOnly =
</div>
</div>
{/* Quick Status Action Buttons */}
<div className="pt-2 border-t border-gray-100 flex items-center justify-between gap-1 mb-2" onClick={(e) => e.stopPropagation()}>
<span className="text-[10px] uppercase tracking-wider font-semibold text-gray-400">Move Status:</span>
<div className="flex items-center gap-1">
{task.status !== 'pending' && (
<button
type="button"
onClick={() => {
const updated = localTasks.map(t => t.ulid === task.ulid ? { ...t, status: 'pending' } : t);
setLocalTasks(updated);
onReorder(updated.map(t => ({ ulid: t.ulid, status: t.status, sort_order: t.sort_order || 0 })));
}}
className="px-2 py-0.5 text-[10px] font-medium rounded bg-slate-100 text-slate-700 hover:bg-slate-200"
>
Pending
</button>
)}
{task.status !== 'in_progress' && (
<button
type="button"
onClick={() => {
const updated = localTasks.map(t => t.ulid === task.ulid ? { ...t, status: 'in_progress' } : t);
setLocalTasks(updated);
onReorder(updated.map(t => ({ ulid: t.ulid, status: t.status, sort_order: t.sort_order || 0 })));
}}
className="px-2 py-0.5 text-[10px] font-medium rounded bg-blue-100 text-blue-700 hover:bg-blue-200"
>
In Progress
</button>
)}
{task.status !== 'completed' && (
<button
type="button"
onClick={() => {
const updated = localTasks.map(t => t.ulid === task.ulid ? { ...t, status: 'completed' } : t);
setLocalTasks(updated);
onReorder(updated.map(t => ({ ulid: t.ulid, status: t.status, sort_order: t.sort_order || 0 })));
}}
className="px-2 py-0.5 text-[10px] font-medium rounded bg-emerald-100 text-emerald-700 hover:bg-emerald-200"
>
Complete
</button>
)}
</div>
</div>
<p className="pt-2 border-t border-gray-100 text-[10px] text-gray-400">
Select the task to view details and move its status.
</p>
<div className="flex items-center justify-between">
<div className="flex -space-x-2">
{task.users?.slice(0, 3).map((user: any) => (

View File

@@ -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<number | null>(null);
const [addMaterialTaskId, setAddMaterialTaskId] = useState<string | null>(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}
/>
) : (
<div className="overflow-x-auto w-full border border-gray-200 rounded-lg">
@@ -310,30 +316,6 @@ export default function Tasks({ project, employees, availableMaterials, delayRea
<TableCell className="text-right text-sm font-medium tabular-nums">{formatCurrency(String(task.total_cost))}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{can('edit', 'tasks') && (
<div className="flex items-center gap-1 shrink-0 border-r pr-2 mr-1">
{task.status === 'pending' && (
<Button variant="outline" size="sm" className="h-7 text-xs bg-blue-50 text-blue-700 hover:bg-blue-100 border-blue-200" onClick={() => handleTaskTransition(task.ulid, 'in_progress')}>
<Play className="h-3 w-3 mr-1" /> Move to In Progress
</Button>
)}
{task.status === 'in_progress' && (
<Button variant="outline" size="sm" className="h-7 text-xs bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border-emerald-200" onClick={() => handleTaskTransition(task.ulid, 'completed')}>
<CheckCircle2 className="h-3 w-3 mr-1" /> Complete
</Button>
)}
{task.status === 'completed' && (
<Button variant="outline" size="sm" className="h-7 text-xs bg-orange-50 text-orange-700 hover:bg-orange-100 border-orange-200" onClick={() => handleTaskTransition(task.ulid, 'closed')}>
<Lock className="h-3 w-3 mr-1" /> Close
</Button>
)}
{task.status === 'blocked' && (
<Button variant="outline" size="sm" className="h-7 text-xs bg-blue-50 text-blue-700 hover:bg-blue-100 border-blue-200" onClick={() => handleTaskTransition(task.ulid, 'in_progress')}>
<Play className="h-3 w-3 mr-1" /> Resume
</Button>
)}
</div>
)}
{can('edit', 'tasks') && (
<Button variant="ghost" size="icon-sm" title="Edit" onClick={() => openEditModal(task)}>
<Edit className="h-4 w-4 text-slate-500" />
@@ -454,7 +436,7 @@ export default function Tasks({ project, employees, availableMaterials, delayRea
<div className="mt-1">{renderStatusBadge(detailsTask.status)}</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{can('edit', 'tasks') && (
{canManageTaskStatus && (
<>
{detailsTask.status === 'pending' && (
<>
@@ -502,6 +484,11 @@ export default function Tasks({ project, employees, availableMaterials, delayRea
</Button>
</>
)}
{detailsTask.status === 'closed' && (
<span className="text-xs font-medium text-orange-700 bg-orange-50 border border-orange-200 rounded-md px-3 py-2">
This task is closed and cannot be moved.
</span>
)}
<Button variant="outline" size="sm" className="text-slate-600 hover:text-slate-700 hover:bg-slate-50 border-slate-200" onClick={() => { setDetailsTaskUlid(null); openEditModal(detailsTask); }}>
<Edit className="h-3.5 w-3.5 mr-1" /> Edit Task
</Button>

View File

@@ -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');

View File

@@ -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,

View File

@@ -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'])
: [],
];
}
}

View File

@@ -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']);
}
}

View File

@@ -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.

View File

@@ -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 users 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.

View File

@@ -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 users 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.

View File

@@ -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 |

View File

@@ -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
};
}

View File

@@ -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 (
<div className="bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 rounded-xl shadow-sm overflow-hidden h-full flex flex-col">
<div className="px-5 py-4 border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50">
<h3 className="font-semibold text-gray-900 dark:text-white">Resource Roll-Call</h3>
</div>
<div className="p-4 flex-1 flex flex-col gap-4">
{/* Labor Section */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 text-gray-900 dark:text-white font-medium">
<HardHat className="w-5 h-5 text-blue-500" />
Labor On-Site
</div>
<div className="text-sm font-bold">
<span className={laborPercentage < 90 ? 'text-amber-600 dark:text-amber-500' : 'text-emerald-600 dark:text-emerald-500'}>
{resources.labor.actual}
</span>
<span className="text-gray-400 dark:text-gray-500 font-medium"> / {resources.labor.expected}</span>
<div className="text-sm font-bold text-emerald-600 dark:text-emerald-500">
{resources.labor.actual}
</div>
</div>
{/* Progress Bar */}
<div className="w-full bg-gray-100 dark:bg-gray-800 rounded-full h-2 mb-3">
<div
className={`h-2 rounded-full ${laborPercentage < 90 ? 'bg-amber-500' : 'bg-emerald-500'}`}
style={{ width: `${Math.min(laborPercentage, 100)}%` }}
></div>
</div>
{/* Trades Breakdown */}
<div className="flex gap-2 flex-wrap mt-2">
{Object.entries(resources.labor.trades).map(([trade, count]) => (
<span key={trade} className="text-[11px] font-medium bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-300 border border-gray-200 dark:border-gray-700 px-2 py-1 rounded">
@@ -61,7 +56,6 @@ export default function ResourceSummary({ resources }: ResourceSummaryProps) {
<hr className="border-gray-100 dark:border-gray-800" />
{/* Equipment Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-gray-900 dark:text-white font-medium">
@@ -69,7 +63,7 @@ export default function ResourceSummary({ resources }: ResourceSummaryProps) {
Heavy Equipment
</div>
</div>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="bg-indigo-50 dark:bg-indigo-900/10 border border-indigo-100 dark:border-indigo-900/50 rounded-lg p-3 text-center">
<div className="text-xl font-bold text-indigo-700 dark:text-indigo-400">{resources.equipment.active}</div>
@@ -85,7 +79,6 @@ export default function ResourceSummary({ resources }: ResourceSummaryProps) {
</div>
</div>
{/* Equipment Status List (Small) */}
<div className="space-y-1.5 mt-2">
{resources.equipment.list.map((item, idx) => (
<div key={idx} className="flex justify-between items-center text-sm">
@@ -101,7 +94,6 @@ export default function ResourceSummary({ resources }: ResourceSummaryProps) {
))}
</div>
</div>
</div>
</div>
);

View File

@@ -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<PageProps>().props;
const isContractorUser = auth.user.contractor_id !== null;
const contractorName = auth.user.contractor?.company_name;
const { flash } = usePage<PageProps>().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<PageProps>().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({
<SidebarProvider>
<AppSidebar />
<SidebarInset>
{/* Contractor Context Banner */}
{isContractorUser && contractorName && (
<div className="flex items-center justify-between border-b border-blue-100 bg-blue-50 px-4 py-1.5">
<div className="flex items-center gap-2">
<Building2 className="h-3.5 w-3.5 shrink-0 text-blue-500" />
<p className="text-xs text-blue-700">
Current Active Contractor: <span className="font-semibold">{contractorName}</span>
{auth.user.roles?.some(r => r.name === 'Super Admin') ? (
<span className="ml-1 text-blue-500">(Platform Super Admin)</span>
) : (
<span className="ml-1 text-blue-600"> Users you create will be automatically attached to this company.</span>
)}
</p>
</div>
</div>
)}
{/* Top header bar */}
<header className="flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4">
<SidebarTrigger className="-ml-1" />
{header && (
@@ -122,30 +90,19 @@ export default function Authenticated({
)}
</header>
{/* Page content */}
<main className="flex-1">
{localFlash && (localFlash.success || localFlash.error) && (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-4">
<div className={`flex items-center justify-between p-4 rounded-xl border shadow-sm transition-all duration-300 ${
localFlash.success
? 'bg-emerald-50/90 border-emerald-200/80 text-emerald-800 dark:bg-emerald-950/30 dark:border-emerald-900/30 dark:text-emerald-400'
localFlash.success
? 'bg-emerald-50/90 border-emerald-200/80 text-emerald-800 dark:bg-emerald-950/30 dark:border-emerald-900/30 dark:text-emerald-400'
: 'bg-rose-50/90 border-rose-200/80 text-rose-800 dark:bg-rose-950/30 dark:border-rose-900/30 dark:text-rose-400'
}`}>
<div className="flex items-center gap-3">
{localFlash.success ? (
<Check className="h-5 w-5 text-emerald-600 shrink-0" />
) : (
<AlertCircle className="h-5 w-5 text-rose-600 shrink-0" />
)}
<p className="text-sm font-medium">
{localFlash.success || localFlash.error}
</p>
{localFlash.success ? <Check className="h-5 w-5 text-emerald-600 shrink-0" /> : <AlertCircle className="h-5 w-5 text-rose-600 shrink-0" />}
<p className="text-sm font-medium">{localFlash.success || localFlash.error}</p>
</div>
<button
type="button"
onClick={dismissFlash}
className="p-1 rounded-lg hover:bg-slate-200/50 dark:hover:bg-slate-800/50 transition-colors"
>
<button type="button" onClick={dismissFlash} className="p-1 rounded-lg hover:bg-slate-200/50 dark:hover:bg-slate-800/50 transition-colors">
<X className="h-4 w-4 text-slate-500 hover:text-slate-700 dark:hover:text-slate-350" />
</button>
</div>
@@ -155,7 +112,6 @@ export default function Authenticated({
</main>
</SidebarInset>
{/* Access Denied Modal */}
<Modal show={showErrorModal} onClose={handleCloseError} maxWidth="md">
<div className="p-6">
<div className="flex items-start gap-4">
@@ -163,20 +119,12 @@ export default function Authenticated({
<ShieldAlert className="h-6 w-6" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Access Denied
</h3>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
{errorMessage}
</p>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Access Denied</h3>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400 leading-relaxed">{errorMessage}</p>
</div>
</div>
<div className="mt-6 flex justify-end">
<Button
variant="destructive"
onClick={handleCloseError}
className="bg-red-600 hover:bg-red-700 text-white font-medium"
>
<Button variant="destructive" onClick={handleCloseError} className="bg-red-600 hover:bg-red-700 text-white font-medium">
Okay
</Button>
</div>

View File

@@ -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 (
<AuthenticatedLayout
header={
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 w-full">
<div>
<h2 className="text-xl font-bold leading-tight text-gray-900 dark:text-white">
{isExecutive ? 'Executive Governance Dashboard' : isPM ? 'Project Operations Dashboard' : isContractor ? 'Contractor Bidding & Financials' : 'Site Execution Dashboard'}
</h2>
<p className="mt-1 text-sm text-gray-500 font-medium">
Daily Operations {new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })}
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<label htmlFor="project-selector" className="text-xs font-semibold text-gray-500 uppercase tracking-wider dark:text-gray-400">
Project:
</label>
<select
id="project-selector"
value={selectedProject ? selectedProject.ulid : ''}
onChange={(e) => handleProjectChange(e.target.value)}
className="block w-64 rounded-lg border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-950 dark:text-white text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-colors py-2 px-3"
>
<option value="">🌍 Global Overview (All Projects)</option>
{projects.map((proj) => (
<option key={proj.id} value={proj.ulid}>
{proj.code} - {proj.name}
</option>
))}
</select>
</div>
<div className="w-full">
<h2 className="text-xl font-bold leading-tight text-gray-900 dark:text-white">
{isExecutive ? 'Executive Governance Dashboard' : isPM ? 'Project Operations Dashboard' : isContractor ? 'Contractor Bidding & Financials' : 'Site Execution Dashboard'}
</h2>
<p className="mt-1 text-sm text-gray-500 font-medium">
Daily Operations {new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })}
</p>
</div>
}
>
@@ -88,13 +55,10 @@ export default function Dashboard({
<div className="py-8">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* Top Row: Weather / critical high-level alerts */}
<div className="mb-6">
<WeatherWidget data={weather} />
</div>
{/* Distinct Dashboard View Per Role */}
{isExecutive ? (
<ExecutiveDashboardView analytics={roleAnalytics} activities={activities} blockers={blockers} />
) : isPM ? (
@@ -104,10 +68,8 @@ export default function Dashboard({
) : (
<SiteDashboardView analytics={roleAnalytics} activities={activities} blockers={blockers} resources={resources} />
)}
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\BiddingManagement\Enums\BidPackageStatus;
use Modules\BiddingManagement\Models\BidPackage;
use Modules\ProjectManagement\Models\Project;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class BiddingPackageWorkflowTest extends TestCase
{
use RefreshDatabase;
public function test_project_management_user_creates_draft_then_publishes_package(): void
{
$permission = Permission::create(['name' => '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']);
}
}

View File

@@ -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

View File

@@ -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));
}
}