feat: implement core ERP modules, multi-tenant architecture, and comprehensive system workflow documentation.
This commit is contained in:
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
: [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
54
contractor-onboarding-fix.md
Normal file
54
contractor-onboarding-fix.md
Normal 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.
|
||||
308
docs/SYSTEM_WORKFLOW_TEST_CASES.md
Normal file
308
docs/SYSTEM_WORKFLOW_TEST_CASES.md
Normal 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 user’s contractor and permitted contractor hierarchy. “Related project” means a project directly owned by, linked to, or assigned to that contractor according to the application rule.
|
||||
|
||||
| Area | Super Admin/admin | Project Manager | Contractor Admin | Site Technical/Supervisor/Operations | Unrelated contractor |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| Dashboard | Global | Assigned/allowed scope | Contractor dashboard | Related project scope | Own scope only |
|
||||
| Users | Global administration | According to permission | Own contractor users | No administration unless explicitly granted | Own contractor only |
|
||||
| Projects | Global | Project scope | Own contractor scope | Related project scope | Own contractor scope |
|
||||
| Bid package management | Yes | Yes | According to configured role rule | No | No |
|
||||
| Bid submissions | Yes | Yes | Yes for invited contractor | No | Only invited packages |
|
||||
| Material requisitions | Global | Create/approve as assigned | Own contractor/project | Create on related project | Own scope |
|
||||
| Purchase orders | Global | Create/approve as assigned | Own contractor/project | Operate related project | Own scope |
|
||||
| Cash advances | Global approver | Approve other users’ requests | Own contractor rules | Create, not self-approve | Own scope |
|
||||
| Invoice/retention approvals | Global | As configured | As configured | No approval unless configured | Own scope |
|
||||
| Documents/daily reports/tasks | Global | Assigned/managed projects | Own contractor projects | Related projects only | Own scope |
|
||||
|
||||
Any deviation must be logged as a defect, including both unexpected access and missing access.
|
||||
|
||||
## 6. End-to-End Workflow Cases
|
||||
|
||||
### WF-01 — Authentication and account lifecycle
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Preconditions | U-01 through U-10 exist; active and inactive accounts are available |
|
||||
| Steps | Log in with valid credentials; log out; attempt invalid password; attempt inactive account; reset password; complete forced password change |
|
||||
| Expected | Valid users reach the correct dashboard; invalid users receive a safe error; inactive users are blocked; password reset and forced password change complete successfully |
|
||||
| Security | No password or sensitive token appears in the UI, URL, response, or logs |
|
||||
|
||||
### WF-02 — Contractor onboarding and tenant assignment
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-01 or U-02 |
|
||||
| Steps | Create C-01 with administrator; verify administrator is linked to C-01; create C-02 as child contractor; create U-05/U-06/U-07 under C-01; create U-09 under C-02 |
|
||||
| Expected | Contractor records, user records, roles, profile records, and contractor IDs are consistent; users created by a contractor admin inherit that contractor |
|
||||
| Negative | Attempt to assign a contractor user to C-03 from a C-01 admin account; request is rejected and no cross-tenant change is saved |
|
||||
|
||||
### WF-03 — User management and role enforcement
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-01, U-04, U-09 |
|
||||
| Steps | List users; create user; edit user; assign each supported role; deactivate user; attempt to view/edit a user from another contractor |
|
||||
| Expected | Platform users see global users; contractor admins see only their permitted contractor tree; role names and permissions persist; deactivated users cannot log in |
|
||||
| Negative | Site technical and supervisor accounts cannot access user-management actions unless explicitly permitted |
|
||||
|
||||
### WF-04 — Project creation and initialization wizard
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-02, U-03, U-04 |
|
||||
| Steps | Create P-01; enter project information; add milestones; add tasks; add materials; add labor; add equipment; review financial submission; submit final initialization |
|
||||
| Expected | Each step saves; back/next navigation preserves data; validation rejects incomplete required data; step number advances correctly; initialized project is visible to permitted users |
|
||||
| Database assertions | `projects.current_wizard_step`, project status, contractor ID, milestones, tasks, estimates, labor, equipment, and personnel links are correct |
|
||||
| Negative | A draft project is not treated as an initialized execution project; an unrelated contractor cannot open P-01 |
|
||||
|
||||
### WF-05 — Project visibility and cross-role reflection
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-04 creates/updates; U-05/U-06/U-07/U-08 verify |
|
||||
| Steps | U-04 initializes or updates P-01; sign in as every C-01 role; inspect Projects, project details, tasks, daily reports, documents, inventory, finance, and dashboard |
|
||||
| Expected | All permitted C-01 roles see the same contractor-related project state; restricted actions remain hidden or forbidden; C-03 users never see P-01 |
|
||||
| Important | Verify both a fresh login and navigation from an already-open session. Record whether updates require manual refresh according to the intended product behavior |
|
||||
|
||||
### WF-06 — Bid package lifecycle
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-03 or authorized contractor project-management role |
|
||||
| Steps | Create package for initialized P-01; verify initial draft; add scope, criteria, dates, and documents; invite C-01/C-02/C-03 as appropriate; publish; edit while allowed; close/cancel; award |
|
||||
| Expected | Package remains draft until published; draft cannot accept submissions; published package is visible only to invited contractors; status transitions follow allowed rules; awarded package records winner |
|
||||
| Negative | Contractor User, Site Operations, Site Technical, and Supervisor cannot create/manage packages unless policy explicitly grants it; a draft package must not be displayed as an open bid |
|
||||
|
||||
### WF-07 — Contractor bid submission and evaluation
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | Invited contractor user/admin and U-03 |
|
||||
| Steps | Open invitation; submit proposal and price; edit before closing; attempt duplicate submission; evaluate using simple mode; evaluate using scored criteria; award/reject |
|
||||
| Expected | Only invited contractor can submit; required fields validate; duplicate and late submissions are handled correctly; scores and totals are persisted; award updates package and submission status |
|
||||
| Database assertions | Invitations, submissions, evaluation criteria, scores, total score, award fields, and timestamps match the UI |
|
||||
|
||||
### WF-08 — Tasks, milestones, and progress
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-03, U-06, U-07 |
|
||||
| Steps | Create task under milestone; move Pending → In Progress → Completed; test Blocked and Closed; open information modal; add materials, labor, and equipment; assign user; record activity |
|
||||
| Expected | Only valid transitions are available; transition buttons appear in the information modal; blocked/closed rules work; costs and progress recalculate; activity log records actor and time |
|
||||
| Negative | Invalid transitions and unauthorized updates return a controlled error and preserve the previous state |
|
||||
|
||||
### WF-09 — Daily reports and resource roll-up
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-06/U-07 |
|
||||
| Steps | Create multiple daily reports for P-01 on different dates; add multiple labor rows, trades, equipment rows, materials, activities, and issues; edit one report; inspect dashboard ResourceSummary |
|
||||
| Expected | Dashboard labor totals use all intended daily-report labor records; trade totals aggregate correctly; equipment counts follow the documented aggregation rule; blockers and activity feed show permitted reports |
|
||||
| Negative | Reports from P-03 or unrelated contractors never affect C-01 dashboard totals |
|
||||
|
||||
### WF-10 — Material requisition approval workflow
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | Site role creates; Project Manager/executive role approves |
|
||||
| Steps | Create draft requisition; add materials; save; submit; inspect approver availability; approve; reject a second request; inspect sidebar badge for each role |
|
||||
| Expected | Draft is visible to its creator; submitted request is visible to the correct approvers; Project Manager and executive approver rules work; approval changes status and audit history; unrelated roles do not receive irrelevant alerts |
|
||||
| Negative | No-approver condition is reported clearly and does not create a falsely submitted request |
|
||||
|
||||
### WF-11 — Purchase order workflow
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | Authorized logistics user; Project Manager/executive approver |
|
||||
| Steps | Create PO from requisition or manually; add supplier/items; submit; approve/reject; mark delivered; upload receipt; mark paid; inspect list/detail pages |
|
||||
| Expected | PO status transitions are valid; approver list follows configured roles; delivery and payment buttons appear only when applicable; receipt and payment data persist; tenant restrictions hold |
|
||||
| Negative | Site roles cannot approve or update finance-sensitive states without permission; null project/supplier relations do not crash the page |
|
||||
|
||||
### WF-12 — Inventory, warehouse, movements, and transfers
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Receive delivered PO; verify warehouse stock; create movement; transfer between warehouses; inspect project inventory and available quantities; attempt insufficient-stock transfer |
|
||||
| Expected | Quantities, on-hand, allocated, received, and transferred values remain consistent; invalid quantities are rejected; users see only permitted warehouses and projects |
|
||||
|
||||
### WF-13 — Cash advance direct approval
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | Site/contractor user creates; Project Manager or executive approves |
|
||||
| Steps | Submit cash advance; verify pending status; inspect Approvals page; attempt self-approval; approve as authorized PM/executive; reject another request |
|
||||
| Expected | Cash advances appear directly in the approvals workspace without an approval-chain record if that is the configured rule; self-approval is blocked; approval/rejection records approver and timestamp |
|
||||
|
||||
### WF-14 — Invoice and retention approval
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Create/submit invoice with retention; inspect approval item; open breakdown; approve; verify invoice status and retention hold; reject another invoice; inspect retention page |
|
||||
| Expected | Invoice and retention information appears in approvals; approved invoice updates status and retention ledger exactly once; rejected invoice remains rejected; null project relations render safely |
|
||||
|
||||
### WF-15 — Documents, drawings, and technical records
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Upload document to P-01; categorize; view/download; update status; attempt access from C-03; inspect project Documents and sidebar navigation |
|
||||
| Expected | File metadata and project/contractor relation persist; permitted roles can view/download; unrelated tenant receives 403/404 according to policy; missing files produce controlled errors |
|
||||
|
||||
### WF-16 — Dashboard role routing and data accuracy
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Log in as U-01 through U-10; inspect dashboard heading and cards; compare every displayed count with database queries for the same scope; inspect Site, PM, Contractor, and Executive layouts |
|
||||
| Expected | Super Admin/admin → Executive; Project Manager → PM; Contractor Admin/Main/Sub Contractor roles → Contractor; site roles → Site; contractor admin with `user_type=admin` remains Contractor when its assigned role is contractor-based |
|
||||
| Data checks | Project counts use valid project statuses; labor uses intended daily-report aggregation; finance, approvals, documents, inventory, and bidding metrics are tenant/project scoped |
|
||||
|
||||
### WF-17 — Sidebar navigation and role visibility
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Capture sidebar for every role; open every visible parent and child item; directly request hidden routes; inspect badges after creating drafts/submissions/approvals |
|
||||
| Expected | Navigation matches permissions and role policy; Bidding is absent from site operations roles where required; badges are relevant to the current role; direct URL access is denied even if a link is hidden |
|
||||
|
||||
### WF-18 — Security and tenant isolation regression
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | From U-04/U-05/U-06/U-07/U-08/U-09, request P-03, its tasks, reports, inventory, invoices, requisitions, POs, documents, bids, and users by URL/ULID; repeat with guessed numeric IDs |
|
||||
| Expected | No cross-tenant record is returned, modified, or deleted; response is 403/404 as designed; no sensitive data leaks through Inertia props, JSON, exports, or error messages |
|
||||
|
||||
### WF-19 — Validation, error handling, and null relationships
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Submit empty forms; invalid dates; negative quantities; missing project; deleted/null related user, contractor, supplier, or project; duplicate codes/numbers; expired invitation |
|
||||
| Expected | Server validation is returned to the form; no uncaught React error occurs; pages show a safe fallback such as “Unavailable”; no partial transaction remains in the database |
|
||||
|
||||
### WF-20 — Responsive and browser workflow
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Repeat critical pages at desktop, tablet, and mobile widths; open sidebar; open modals; scroll tables; use keyboard navigation; test dark mode if enabled |
|
||||
| Expected | Grids collapse correctly; buttons remain reachable; modals fit viewport; tables remain usable; focus states and labels are available; no horizontal overflow blocks actions |
|
||||
|
||||
## 7. Execution Record
|
||||
|
||||
Use one row per case execution.
|
||||
|
||||
| Case ID | Date | Build/commit | Actor | Result | Evidence | Defect ID |
|
||||
|---|---|---|---|---|---|---|
|
||||
| WF-01 | | | | NOT RUN | | |
|
||||
| WF-02 | | | | NOT RUN | | |
|
||||
| WF-03 | | | | NOT RUN | | |
|
||||
| WF-04 | | | | NOT RUN | | |
|
||||
| WF-05 | | | | NOT RUN | | |
|
||||
| WF-06 | | | | NOT RUN | | |
|
||||
| WF-07 | | | | NOT RUN | | |
|
||||
| WF-08 | | | | NOT RUN | | |
|
||||
| WF-09 | | | | NOT RUN | | |
|
||||
| WF-10 | | | | NOT RUN | | |
|
||||
| WF-11 | | | | NOT RUN | | |
|
||||
| WF-12 | | | | NOT RUN | | |
|
||||
| WF-13 | | | | NOT RUN | | |
|
||||
| WF-14 | | | | NOT RUN | | |
|
||||
| WF-15 | | | | NOT RUN | | |
|
||||
| WF-16 | | | | NOT RUN | | |
|
||||
| WF-17 | | | | NOT RUN | | |
|
||||
| WF-18 | | | | NOT RUN | | |
|
||||
| WF-19 | | | | NOT RUN | | |
|
||||
| WF-20 | | | | NOT RUN | | |
|
||||
|
||||
## 8. Defect Log
|
||||
|
||||
Record defects without changing code during this test pass.
|
||||
|
||||
| Defect ID | Case | Severity | Preconditions | Steps to reproduce | Expected | Actual | Evidence | Status |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| D-001 | WF-02 | Major | Fresh test database; guest contractor registration | Submit the contractor registration form with valid data | Registration redirects successfully, creates pending contractor/admin records, and assigns the configured role | HTTP 500: `Spatie\\Permission\\Exceptions\\PermissionDoesNotExist` because `users.access` is missing when `ContractorOnboardingController.php:66` calls `givePermissionTo('users.access')` | `tests/Feature/ContractorOnboardingTest.php::test_guest_can_register_as_contractor` | Resolved |
|
||||
| D-002 | WF-13 | Major | Comprehensive E2E fixture; supervisor and initialized project | Submit a cash advance and query the created request | A pending cash advance exists for the submitted amount | `CashAdvance::where('amount', 750.50)->firstOrFail()` finds no record at `ComprehensiveSystemE2ETest.php:156` | `tests/Feature/ComprehensiveSystemE2ETest.php::test_e2e_cash_advance_full_lifecycle_and_security_rules` | Open |
|
||||
| D-003 | WF-04 | Major | Project at wizard step 6; no approver IDs | Submit final project initialization with `approver_ids: []` | Validation error is returned for `approver_ids` | Response does not contain the expected session validation error | `tests/Feature/ProjectWizardFlowTest.php::submit_fails_without_approvers` | Open |
|
||||
|
||||
Severity guide: Blocker = workflow cannot continue or data/security risk; Critical = major business flow or tenant isolation failure; Major = important function incorrect; Minor = non-blocking UI or copy issue.
|
||||
|
||||
## 9. Automated Baseline
|
||||
|
||||
At the time this document was created:
|
||||
|
||||
| Check | Result | Notes |
|
||||
|---|---|---|
|
||||
| `npx tsc --noEmit` | PASS | TypeScript completed successfully during the current verification run |
|
||||
| `npm run build` | PASS | Vite production build completed successfully during the current verification run |
|
||||
| `php artisan test --testsuite=Feature --stop-on-failure --debug` | FAIL | 23 passed, 2 failed, 85 pending; first confirmed failure is D-001 |
|
||||
| `php artisan test tests/Feature/ContractorOnboardingTest.php --debug` | FAIL | 4 passed, 1 failed; D-001 reproduced in 3.90s |
|
||||
| Existing `ComprehensiveSystemE2ETest` | PARTIAL COVERAGE | Covers page traversal, cash advance, invoice/retention, and part of project wizard; it does not cover all workflows in this document |
|
||||
|
||||
## 10. Exit Criteria
|
||||
|
||||
The whole-system workflow is ready for sign-off only when:
|
||||
|
||||
- All WF-01 through WF-20 have a recorded result.
|
||||
- No Blocker or Critical defects remain open.
|
||||
- Tenant isolation cases pass for main contractors, subcontractors, site roles, and unrelated contractors.
|
||||
- Project wizard, bidding, requisition, PO, inventory, cash advance, invoice/retention, and dashboard flows pass end-to-end.
|
||||
- Browser console has no uncaught errors on tested pages.
|
||||
- Database assertions and UI states agree for every completed workflow.
|
||||
- Failed automated tests are either rerun successfully or documented with an approved exception.
|
||||
308
docs/SYSTEM_WORKFLOW_TEST_CASES.txt
Normal file
308
docs/SYSTEM_WORKFLOW_TEST_CASES.txt
Normal 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 user’s contractor and permitted contractor hierarchy. “Related project” means a project directly owned by, linked to, or assigned to that contractor according to the application rule.
|
||||
|
||||
| Area | Super Admin/admin | Project Manager | Contractor Admin | Site Technical/Supervisor/Operations | Unrelated contractor |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| Dashboard | Global | Assigned/allowed scope | Contractor dashboard | Related project scope | Own scope only |
|
||||
| Users | Global administration | According to permission | Own contractor users | No administration unless explicitly granted | Own contractor only |
|
||||
| Projects | Global | Project scope | Own contractor scope | Related project scope | Own contractor scope |
|
||||
| Bid package management | Yes | Yes | According to configured role rule | No | No |
|
||||
| Bid submissions | Yes | Yes | Yes for invited contractor | No | Only invited packages |
|
||||
| Material requisitions | Global | Create/approve as assigned | Own contractor/project | Create on related project | Own scope |
|
||||
| Purchase orders | Global | Create/approve as assigned | Own contractor/project | Operate related project | Own scope |
|
||||
| Cash advances | Global approver | Approve other users’ requests | Own contractor rules | Create, not self-approve | Own scope |
|
||||
| Invoice/retention approvals | Global | As configured | As configured | No approval unless configured | Own scope |
|
||||
| Documents/daily reports/tasks | Global | Assigned/managed projects | Own contractor projects | Related projects only | Own scope |
|
||||
|
||||
Any deviation must be logged as a defect, including both unexpected access and missing access.
|
||||
|
||||
## 6. End-to-End Workflow Cases
|
||||
|
||||
### WF-01 — Authentication and account lifecycle
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Preconditions | U-01 through U-10 exist; active and inactive accounts are available |
|
||||
| Steps | Log in with valid credentials; log out; attempt invalid password; attempt inactive account; reset password; complete forced password change |
|
||||
| Expected | Valid users reach the correct dashboard; invalid users receive a safe error; inactive users are blocked; password reset and forced password change complete successfully |
|
||||
| Security | No password or sensitive token appears in the UI, URL, response, or logs |
|
||||
|
||||
### WF-02 — Contractor onboarding and tenant assignment
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-01 or U-02 |
|
||||
| Steps | Create C-01 with administrator; verify administrator is linked to C-01; create C-02 as child contractor; create U-05/U-06/U-07 under C-01; create U-09 under C-02 |
|
||||
| Expected | Contractor records, user records, roles, profile records, and contractor IDs are consistent; users created by a contractor admin inherit that contractor |
|
||||
| Negative | Attempt to assign a contractor user to C-03 from a C-01 admin account; request is rejected and no cross-tenant change is saved |
|
||||
|
||||
### WF-03 — User management and role enforcement
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-01, U-04, U-09 |
|
||||
| Steps | List users; create user; edit user; assign each supported role; deactivate user; attempt to view/edit a user from another contractor |
|
||||
| Expected | Platform users see global users; contractor admins see only their permitted contractor tree; role names and permissions persist; deactivated users cannot log in |
|
||||
| Negative | Site technical and supervisor accounts cannot access user-management actions unless explicitly permitted |
|
||||
|
||||
### WF-04 — Project creation and initialization wizard
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-02, U-03, U-04 |
|
||||
| Steps | Create P-01; enter project information; add milestones; add tasks; add materials; add labor; add equipment; review financial submission; submit final initialization |
|
||||
| Expected | Each step saves; back/next navigation preserves data; validation rejects incomplete required data; step number advances correctly; initialized project is visible to permitted users |
|
||||
| Database assertions | `projects.current_wizard_step`, project status, contractor ID, milestones, tasks, estimates, labor, equipment, and personnel links are correct |
|
||||
| Negative | A draft project is not treated as an initialized execution project; an unrelated contractor cannot open P-01 |
|
||||
|
||||
### WF-05 — Project visibility and cross-role reflection
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-04 creates/updates; U-05/U-06/U-07/U-08 verify |
|
||||
| Steps | U-04 initializes or updates P-01; sign in as every C-01 role; inspect Projects, project details, tasks, daily reports, documents, inventory, finance, and dashboard |
|
||||
| Expected | All permitted C-01 roles see the same contractor-related project state; restricted actions remain hidden or forbidden; C-03 users never see P-01 |
|
||||
| Important | Verify both a fresh login and navigation from an already-open session. Record whether updates require manual refresh according to the intended product behavior |
|
||||
|
||||
### WF-06 — Bid package lifecycle
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-03 or authorized contractor project-management role |
|
||||
| Steps | Create package for initialized P-01; verify initial draft; add scope, criteria, dates, and documents; invite C-01/C-02/C-03 as appropriate; publish; edit while allowed; close/cancel; award |
|
||||
| Expected | Package remains draft until published; draft cannot accept submissions; published package is visible only to invited contractors; status transitions follow allowed rules; awarded package records winner |
|
||||
| Negative | Contractor User, Site Operations, Site Technical, and Supervisor cannot create/manage packages unless policy explicitly grants it; a draft package must not be displayed as an open bid |
|
||||
|
||||
### WF-07 — Contractor bid submission and evaluation
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | Invited contractor user/admin and U-03 |
|
||||
| Steps | Open invitation; submit proposal and price; edit before closing; attempt duplicate submission; evaluate using simple mode; evaluate using scored criteria; award/reject |
|
||||
| Expected | Only invited contractor can submit; required fields validate; duplicate and late submissions are handled correctly; scores and totals are persisted; award updates package and submission status |
|
||||
| Database assertions | Invitations, submissions, evaluation criteria, scores, total score, award fields, and timestamps match the UI |
|
||||
|
||||
### WF-08 — Tasks, milestones, and progress
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-03, U-06, U-07 |
|
||||
| Steps | Create task under milestone; move Pending → In Progress → Completed; test Blocked and Closed; open information modal; add materials, labor, and equipment; assign user; record activity |
|
||||
| Expected | Only valid transitions are available; transition buttons appear in the information modal; blocked/closed rules work; costs and progress recalculate; activity log records actor and time |
|
||||
| Negative | Invalid transitions and unauthorized updates return a controlled error and preserve the previous state |
|
||||
|
||||
### WF-09 — Daily reports and resource roll-up
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | U-06/U-07 |
|
||||
| Steps | Create multiple daily reports for P-01 on different dates; add multiple labor rows, trades, equipment rows, materials, activities, and issues; edit one report; inspect dashboard ResourceSummary |
|
||||
| Expected | Dashboard labor totals use all intended daily-report labor records; trade totals aggregate correctly; equipment counts follow the documented aggregation rule; blockers and activity feed show permitted reports |
|
||||
| Negative | Reports from P-03 or unrelated contractors never affect C-01 dashboard totals |
|
||||
|
||||
### WF-10 — Material requisition approval workflow
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | Site role creates; Project Manager/executive role approves |
|
||||
| Steps | Create draft requisition; add materials; save; submit; inspect approver availability; approve; reject a second request; inspect sidebar badge for each role |
|
||||
| Expected | Draft is visible to its creator; submitted request is visible to the correct approvers; Project Manager and executive approver rules work; approval changes status and audit history; unrelated roles do not receive irrelevant alerts |
|
||||
| Negative | No-approver condition is reported clearly and does not create a falsely submitted request |
|
||||
|
||||
### WF-11 — Purchase order workflow
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | Authorized logistics user; Project Manager/executive approver |
|
||||
| Steps | Create PO from requisition or manually; add supplier/items; submit; approve/reject; mark delivered; upload receipt; mark paid; inspect list/detail pages |
|
||||
| Expected | PO status transitions are valid; approver list follows configured roles; delivery and payment buttons appear only when applicable; receipt and payment data persist; tenant restrictions hold |
|
||||
| Negative | Site roles cannot approve or update finance-sensitive states without permission; null project/supplier relations do not crash the page |
|
||||
|
||||
### WF-12 — Inventory, warehouse, movements, and transfers
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Receive delivered PO; verify warehouse stock; create movement; transfer between warehouses; inspect project inventory and available quantities; attempt insufficient-stock transfer |
|
||||
| Expected | Quantities, on-hand, allocated, received, and transferred values remain consistent; invalid quantities are rejected; users see only permitted warehouses and projects |
|
||||
|
||||
### WF-13 — Cash advance direct approval
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Actor | Site/contractor user creates; Project Manager or executive approves |
|
||||
| Steps | Submit cash advance; verify pending status; inspect Approvals page; attempt self-approval; approve as authorized PM/executive; reject another request |
|
||||
| Expected | Cash advances appear directly in the approvals workspace without an approval-chain record if that is the configured rule; self-approval is blocked; approval/rejection records approver and timestamp |
|
||||
|
||||
### WF-14 — Invoice and retention approval
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Create/submit invoice with retention; inspect approval item; open breakdown; approve; verify invoice status and retention hold; reject another invoice; inspect retention page |
|
||||
| Expected | Invoice and retention information appears in approvals; approved invoice updates status and retention ledger exactly once; rejected invoice remains rejected; null project relations render safely |
|
||||
|
||||
### WF-15 — Documents, drawings, and technical records
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Upload document to P-01; categorize; view/download; update status; attempt access from C-03; inspect project Documents and sidebar navigation |
|
||||
| Expected | File metadata and project/contractor relation persist; permitted roles can view/download; unrelated tenant receives 403/404 according to policy; missing files produce controlled errors |
|
||||
|
||||
### WF-16 — Dashboard role routing and data accuracy
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Log in as U-01 through U-10; inspect dashboard heading and cards; compare every displayed count with database queries for the same scope; inspect Site, PM, Contractor, and Executive layouts |
|
||||
| Expected | Super Admin/admin → Executive; Project Manager → PM; Contractor Admin/Main/Sub Contractor roles → Contractor; site roles → Site; contractor admin with `user_type=admin` remains Contractor when its assigned role is contractor-based |
|
||||
| Data checks | Project counts use valid project statuses; labor uses intended daily-report aggregation; finance, approvals, documents, inventory, and bidding metrics are tenant/project scoped |
|
||||
|
||||
### WF-17 — Sidebar navigation and role visibility
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Capture sidebar for every role; open every visible parent and child item; directly request hidden routes; inspect badges after creating drafts/submissions/approvals |
|
||||
| Expected | Navigation matches permissions and role policy; Bidding is absent from site operations roles where required; badges are relevant to the current role; direct URL access is denied even if a link is hidden |
|
||||
|
||||
### WF-18 — Security and tenant isolation regression
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | From U-04/U-05/U-06/U-07/U-08/U-09, request P-03, its tasks, reports, inventory, invoices, requisitions, POs, documents, bids, and users by URL/ULID; repeat with guessed numeric IDs |
|
||||
| Expected | No cross-tenant record is returned, modified, or deleted; response is 403/404 as designed; no sensitive data leaks through Inertia props, JSON, exports, or error messages |
|
||||
|
||||
### WF-19 — Validation, error handling, and null relationships
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Submit empty forms; invalid dates; negative quantities; missing project; deleted/null related user, contractor, supplier, or project; duplicate codes/numbers; expired invitation |
|
||||
| Expected | Server validation is returned to the form; no uncaught React error occurs; pages show a safe fallback such as “Unavailable”; no partial transaction remains in the database |
|
||||
|
||||
### WF-20 — Responsive and browser workflow
|
||||
|
||||
| Field | Test |
|
||||
|---|---|
|
||||
| Steps | Repeat critical pages at desktop, tablet, and mobile widths; open sidebar; open modals; scroll tables; use keyboard navigation; test dark mode if enabled |
|
||||
| Expected | Grids collapse correctly; buttons remain reachable; modals fit viewport; tables remain usable; focus states and labels are available; no horizontal overflow blocks actions |
|
||||
|
||||
## 7. Execution Record
|
||||
|
||||
Use one row per case execution.
|
||||
|
||||
| Case ID | Date | Build/commit | Actor | Result | Evidence | Defect ID |
|
||||
|---|---|---|---|---|---|---|
|
||||
| WF-01 | | | | NOT RUN | | |
|
||||
| WF-02 | | | | NOT RUN | | |
|
||||
| WF-03 | | | | NOT RUN | | |
|
||||
| WF-04 | | | | NOT RUN | | |
|
||||
| WF-05 | | | | NOT RUN | | |
|
||||
| WF-06 | | | | NOT RUN | | |
|
||||
| WF-07 | | | | NOT RUN | | |
|
||||
| WF-08 | | | | NOT RUN | | |
|
||||
| WF-09 | | | | NOT RUN | | |
|
||||
| WF-10 | | | | NOT RUN | | |
|
||||
| WF-11 | | | | NOT RUN | | |
|
||||
| WF-12 | | | | NOT RUN | | |
|
||||
| WF-13 | | | | NOT RUN | | |
|
||||
| WF-14 | | | | NOT RUN | | |
|
||||
| WF-15 | | | | NOT RUN | | |
|
||||
| WF-16 | | | | NOT RUN | | |
|
||||
| WF-17 | | | | NOT RUN | | |
|
||||
| WF-18 | | | | NOT RUN | | |
|
||||
| WF-19 | | | | NOT RUN | | |
|
||||
| WF-20 | | | | NOT RUN | | |
|
||||
|
||||
## 8. Defect Log
|
||||
|
||||
Record defects without changing code during this test pass.
|
||||
|
||||
| Defect ID | Case | Severity | Preconditions | Steps to reproduce | Expected | Actual | Evidence | Status |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| D-001 | WF-02 | Major | Fresh test database; guest contractor registration | Submit the contractor registration form with valid data | Registration redirects successfully, creates pending contractor/admin records, and assigns the configured role | HTTP 500: `Spatie\\Permission\\Exceptions\\PermissionDoesNotExist` because `users.access` is missing when `ContractorOnboardingController.php:66` calls `givePermissionTo('users.access')` | `tests/Feature/ContractorOnboardingTest.php::test_guest_can_register_as_contractor` | Resolved |
|
||||
| D-002 | WF-13 | Major | Comprehensive E2E fixture; supervisor and initialized project | Submit a cash advance and query the created request | A pending cash advance exists for the submitted amount | `CashAdvance::where('amount', 750.50)->firstOrFail()` finds no record at `ComprehensiveSystemE2ETest.php:156` | `tests/Feature/ComprehensiveSystemE2ETest.php::test_e2e_cash_advance_full_lifecycle_and_security_rules` | Open |
|
||||
| D-003 | WF-04 | Major | Project at wizard step 6; no approver IDs | Submit final project initialization with `approver_ids: []` | Validation error is returned for `approver_ids` | Response does not contain the expected session validation error | `tests/Feature/ProjectWizardFlowTest.php::submit_fails_without_approvers` | Open |
|
||||
|
||||
Severity guide: Blocker = workflow cannot continue or data/security risk; Critical = major business flow or tenant isolation failure; Major = important function incorrect; Minor = non-blocking UI or copy issue.
|
||||
|
||||
## 9. Automated Baseline
|
||||
|
||||
At the time this document was created:
|
||||
|
||||
| Check | Result | Notes |
|
||||
|---|---|---|
|
||||
| `npx tsc --noEmit` | PASS | TypeScript completed successfully during the current verification run |
|
||||
| `npm run build` | PASS | Vite production build completed successfully during the current verification run |
|
||||
| `php artisan test --testsuite=Feature --stop-on-failure --debug` | FAIL | 23 passed, 2 failed, 85 pending; first confirmed failure is D-001 |
|
||||
| `php artisan test tests/Feature/ContractorOnboardingTest.php --debug` | FAIL | 4 passed, 1 failed; D-001 reproduced in 3.90s |
|
||||
| Existing `ComprehensiveSystemE2ETest` | PARTIAL COVERAGE | Covers page traversal, cash advance, invoice/retention, and part of project wizard; it does not cover all workflows in this document |
|
||||
|
||||
## 10. Exit Criteria
|
||||
|
||||
The whole-system workflow is ready for sign-off only when:
|
||||
|
||||
- All WF-01 through WF-20 have a recorded result.
|
||||
- No Blocker or Critical defects remain open.
|
||||
- Tenant isolation cases pass for main contractors, subcontractors, site roles, and unrelated contractors.
|
||||
- Project wizard, bidding, requisition, PO, inventory, cash advance, invoice/retention, and dashboard flows pass end-to-end.
|
||||
- Browser console has no uncaught errors on tested pages.
|
||||
- Database assertions and UI states agree for every completed workflow.
|
||||
- Failed automated tests are either rerun successfully or documented with an approved exception.
|
||||
132
official_system_operations_manual.md
Normal file
132
official_system_operations_manual.md
Normal 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 |
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
68
tests/Feature/BiddingPackageWorkflowTest.php
Normal file
68
tests/Feature/BiddingPackageWorkflowTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user