diff --git a/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalController.php b/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalController.php index 3366d95..7a8838c 100644 --- a/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalController.php +++ b/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalController.php @@ -122,9 +122,21 @@ class ApprovalController extends Controller $totalCost = $materialsCost + $laborCost + $equipmentCost; } + $chainNotes = $approvalChain->notes + ?? $approvalChain->steps->whereNotNull('notes')->filter(fn ($s) => trim($s->notes) !== '')->last()?->notes + ?? $approvalChain->approvable?->notes + ?? $approvalChain->approvable?->description + ?? $approvalChain->approvable?->remarks + ?? $approvalChain->approvable?->reason + ?? $approvalChain->approvable?->justification; + + if (empty($approvalChain->notes) && $chainNotes) { + $approvalChain->notes = $chainNotes; + } + $breakdownData = [ - 'document_number' => $approvalChain->approvable->document_number - ?? $approvalChain->approvable->po_number + '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' => $approvalChain->approvable instanceof \Modules\FinancialManagement\Models\FinancialInvoice @@ -133,9 +145,7 @@ class ApprovalController extends Controller '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), + 'notes' => $chainNotes ?? 'None', ]; } diff --git a/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx b/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx index 1cf50ed..4d28e27 100644 --- a/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx +++ b/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx @@ -24,7 +24,7 @@ export default function ApprovableBreakdown({ chain, breakdownData }: Props) { details = [ { label: 'Document Number', value: breakdownData.document_number || `REQ-${approvable.id}` }, { label: 'Total Cost', value: breakdownData.total_cost != null ? `₱${parseFloat(breakdownData.total_cost).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : 'N/A' }, - { label: 'Notes', value: breakdownData.notes || 'None' }, + { label: 'Notes', value: breakdownData.notes || chain.notes || 'None' }, ]; break; case 'Modules\\MaterialLogistics\\Models\\PurchaseOrder': @@ -34,7 +34,7 @@ export default function ApprovableBreakdown({ chain, breakdownData }: Props) { details = [ { label: 'PO Number', value: breakdownData.document_number || `PO-${approvable.id}` }, { label: 'Total Amount', value: breakdownData.total_cost != null ? `₱${parseFloat(breakdownData.total_cost).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : 'N/A' }, - { label: 'Notes', value: breakdownData.notes || 'None' }, + { label: 'Notes', value: breakdownData.notes || chain.notes || 'None' }, ]; break; case 'Modules\\ProjectManagement\\Models\\Project': @@ -44,7 +44,7 @@ export default function ApprovableBreakdown({ chain, breakdownData }: Props) { details = [ { label: 'Project Code', value: breakdownData.document_number || approvable.code || 'N/A' }, { label: 'Total Estimated Cost', value: breakdownData.total_cost != null ? `₱${parseFloat(breakdownData.total_cost).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : 'N/A' }, - { label: 'Description', value: breakdownData.notes || 'None' }, + { label: 'Notes / Description', value: breakdownData.notes || chain.notes || 'None' }, ]; break; case 'Modules\\FinancialManagement\\Models\\FinancialInvoice': @@ -55,7 +55,7 @@ export default function ApprovableBreakdown({ chain, breakdownData }: Props) { { 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' }, + { label: 'Notes', value: breakdownData.notes || chain.notes || 'Progress billing invoice' }, ]; break; default: diff --git a/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Show.tsx b/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Show.tsx index 20b4900..f8ea6a9 100644 --- a/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Show.tsx +++ b/Modules/ApprovalWorkflow/resources/js/Pages/Approvals/Show.tsx @@ -103,12 +103,12 @@ export default function Show({ chain, breakdownData }: Props) { )} - {chain.notes && ( -
-

Notes

-

{chain.notes}

-
- )} +
+

Notes / Justification

+

+ {chain.notes || breakdownData?.notes || 'None'} +

+
diff --git a/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php b/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php index ca6dab0..2f3e2fa 100644 --- a/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php +++ b/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php @@ -78,7 +78,20 @@ class DailyReportsController extends Controller $report->equipment()->createMany($request->equipment); } if ($request->has('activities')) { - $report->activities()->createMany($request->activities); + foreach ($request->activities as $index => $actData) { + $activity = $report->activities()->create([ + 'task_name' => $actData['task_name'] ?? '', + 'quantity_completed' => $actData['quantity_completed'] ?? 0, + 'percentage_completed' => $actData['percentage_completed'] ?? 0, + 'zone_area' => $actData['zone_area'] ?? null, + ]); + + if ($request->hasFile("activities.{$index}.evidence")) { + foreach ($request->file("activities.{$index}.evidence") as $file) { + $activity->addMedia($file)->toMediaCollection('evidence'); + } + } + } } if ($request->has('materials')) { $report->materials()->createMany($request->materials); @@ -108,7 +121,7 @@ class DailyReportsController extends Controller */ public function show(DailyReport $daily_report) { - $daily_report->load(['project', 'user', 'labors', 'equipment', 'activities', 'materials', 'issues', 'media']); + $daily_report->load(['project', 'user', 'labors', 'equipment', 'activities.media', 'materials', 'issues', 'media']); return Inertia::render('DailyReports::Show', [ 'report' => $daily_report, @@ -120,7 +133,7 @@ class DailyReportsController extends Controller */ public function edit(DailyReport $daily_report) { - $daily_report->load(['project', 'labors', 'equipment', 'activities', 'materials', 'issues', 'media']); + $daily_report->load(['project', 'labors', 'equipment', 'activities.media', 'materials', 'issues', 'media']); return Inertia::render('DailyReports::Edit', [ 'report' => $daily_report, @@ -149,7 +162,20 @@ class DailyReportsController extends Controller } if ($request->has('activities')) { $daily_report->activities()->delete(); - $daily_report->activities()->createMany($request->activities); + foreach ($request->activities as $index => $actData) { + $activity = $daily_report->activities()->create([ + 'task_name' => $actData['task_name'] ?? '', + 'quantity_completed' => $actData['quantity_completed'] ?? 0, + 'percentage_completed' => $actData['percentage_completed'] ?? 0, + 'zone_area' => $actData['zone_area'] ?? null, + ]); + + if ($request->hasFile("activities.{$index}.evidence")) { + foreach ($request->file("activities.{$index}.evidence") as $file) { + $activity->addMedia($file)->toMediaCollection('evidence'); + } + } + } } if ($request->has('materials')) { $daily_report->materials()->delete(); diff --git a/Modules/DailyReports/app/Models/DailyReportActivity.php b/Modules/DailyReports/app/Models/DailyReportActivity.php index 3b8a096..56f8c42 100644 --- a/Modules/DailyReports/app/Models/DailyReportActivity.php +++ b/Modules/DailyReports/app/Models/DailyReportActivity.php @@ -4,9 +4,15 @@ namespace Modules\DailyReports\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; -class DailyReportActivity extends Model +class DailyReportActivity extends Model implements HasMedia { + use InteractsWithMedia; + + protected $appends = ['evidence_urls']; + protected $fillable = [ 'daily_report_id', 'task_name', @@ -15,6 +21,20 @@ class DailyReportActivity extends Model 'zone_area', ]; + public function getEvidenceUrlsAttribute(): array + { + return $this->getMedia('evidence')->map(function ($media) { + return [ + 'id' => $media->id, + 'name' => $media->name, + 'file_name' => $media->file_name, + 'original_url' => $media->original_url, + 'mime_type' => $media->mime_type, + 'size' => $media->size, + ]; + })->toArray(); + } + public function dailyReport(): BelongsTo { return $this->belongsTo(DailyReport::class); diff --git a/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx b/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx index 686c34d..3d20f36 100644 --- a/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx +++ b/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx @@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/Com import { Trash2, Plus, Save } from 'lucide-react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select"; +import TaskEvidenceUploader from '@/Components/TaskEvidenceUploader'; export default function ReportForm({ project, report = null, isEdit = false, masterLabors = [], masterEquipments = [], masterMaterials = [] }: any) { // Project-scoped lookup lists @@ -136,55 +137,64 @@ export default function ReportForm({ project, report = null, isEdit = false, mas {data.activities.map((row: any, i: number) => ( -
-
-
- - - {(!projectTasks.some((t: any) => t.name === row.task_name) || row.task_name === '') && ( - updateRow('activities', i, 'task_name', e.target.value)} - /> - )} -
-
- - updateRow('activities', i, 'quantity_completed', e.target.value)} placeholder="e.g. 450 m2" /> -
-
- - updateRow('activities', i, 'percentage_completed', e.target.value)} /> -
-
- - updateRow('activities', i, 'zone_area', e.target.value)} /> +
+
+
+
+ + + {(!projectTasks.some((t: any) => t.name === row.task_name) || row.task_name === '') && ( + updateRow('activities', i, 'task_name', e.target.value)} + /> + )} +
+
+ + updateRow('activities', i, 'quantity_completed', e.target.value)} placeholder="e.g. 450 m2" /> +
+
+ + updateRow('activities', i, 'percentage_completed', e.target.value)} /> +
+
+ + updateRow('activities', i, 'zone_area', e.target.value)} /> +
+ +
+
+ updateRow('activities', i, 'evidence', files)} + />
-
))} {data.activities.length === 0 &&

No activities added yet.

} diff --git a/Modules/DailyReports/resources/js/Pages/Show.tsx b/Modules/DailyReports/resources/js/Pages/Show.tsx index cfda0f3..298d7b4 100644 --- a/Modules/DailyReports/resources/js/Pages/Show.tsx +++ b/Modules/DailyReports/resources/js/Pages/Show.tsx @@ -16,6 +16,7 @@ import { } from 'lucide-react'; import { Badge } from '@/Components/ui/badge'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table"; +import TaskEvidenceUploader from '@/Components/TaskEvidenceUploader'; export default function Show({ report }: any) { const project = report.project; @@ -271,8 +272,20 @@ export default function Show({ report }: any) { {report.activities.map((a: any, i: number) => ( - - {a.task_name} + + +
{a.task_name}
+ {a.evidence_urls && a.evidence_urls.length > 0 && ( +
+ +
+ )} +
{a.zone_area || '-'} {a.quantity_completed || '-'} {a.percentage_completed ? `${a.percentage_completed}%` : '-'} diff --git a/Modules/DocumentManagement/app/Http/Controllers/DocumentController.php b/Modules/DocumentManagement/app/Http/Controllers/DocumentController.php index 4776d89..8dc61ae 100644 --- a/Modules/DocumentManagement/app/Http/Controllers/DocumentController.php +++ b/Modules/DocumentManagement/app/Http/Controllers/DocumentController.php @@ -14,7 +14,7 @@ class DocumentController extends Controller { public function index(Request $request) { - $query = Document::with(['uploader:id,name', 'category', 'project', 'approvals.approver']); + $query = Document::with(['uploader:id,name,contractor_id', 'category', 'project', 'approvals.approver']); if ($search = $request->search) { $query->where('title', 'like', "%{$search}%"); @@ -259,6 +259,22 @@ class DocumentController extends Controller public function destroy(Document $document) { + $user = auth()->user(); + + // Platform / Executive roles can delete any document + $isExecutive = is_null($user?->contractor_id) + || in_array($user?->user_type, ['admin', 'super_admin']) + || $user?->hasAnyRole(['Super Admin', 'Admin', 'Project Manager', 'Executive']); + + if (!$isExecutive) { + // Contractors can delete documents uploaded by anyone in their contractor company + $uploaderContractorId = $document->uploader?->contractor_id; + $isOwnCompany = ((int)$document->uploaded_by === (int)$user->id) + || ($user->contractor_id && $uploaderContractorId !== null && (int)$uploaderContractorId === (int)$user->contractor_id); + + abort_unless($isOwnCompany, 403, 'Contractors can only delete documents uploaded by their company.'); + } + // Delete all version files foreach ($document->versions as $version) { $disk = $this->resolveDisk($version->file_path); diff --git a/Modules/DocumentManagement/app/Models/Document.php b/Modules/DocumentManagement/app/Models/Document.php index 14024fd..842e70e 100644 --- a/Modules/DocumentManagement/app/Models/Document.php +++ b/Modules/DocumentManagement/app/Models/Document.php @@ -27,7 +27,7 @@ class Document extends Model public function uploader(): BelongsTo { - return $this->belongsTo(User::class, 'uploaded_by'); + return $this->belongsTo(User::class, 'uploaded_by')->withoutGlobalScope(\App\Scopes\TenantScope::class); } public function versions(): HasMany diff --git a/Modules/DocumentManagement/app/Models/DocumentApproval.php b/Modules/DocumentManagement/app/Models/DocumentApproval.php index 2c746df..e8e39e7 100644 --- a/Modules/DocumentManagement/app/Models/DocumentApproval.php +++ b/Modules/DocumentManagement/app/Models/DocumentApproval.php @@ -32,6 +32,6 @@ class DocumentApproval extends Model public function approver() { - return $this->belongsTo(\App\Models\User::class, 'approved_by'); + return $this->belongsTo(\App\Models\User::class, 'approved_by')->withoutGlobalScope(\App\Scopes\TenantScope::class); } } diff --git a/Modules/DocumentManagement/app/Models/DocumentVersion.php b/Modules/DocumentManagement/app/Models/DocumentVersion.php index 12a5a3d..faca108 100644 --- a/Modules/DocumentManagement/app/Models/DocumentVersion.php +++ b/Modules/DocumentManagement/app/Models/DocumentVersion.php @@ -23,6 +23,6 @@ class DocumentVersion extends Model public function uploader(): BelongsTo { - return $this->belongsTo(User::class, 'uploaded_by'); + return $this->belongsTo(User::class, 'uploaded_by')->withoutGlobalScope(\App\Scopes\TenantScope::class); } } diff --git a/Modules/DocumentManagement/resources/js/Pages/Documents/Index.tsx b/Modules/DocumentManagement/resources/js/Pages/Documents/Index.tsx index d0c443e..9c6d8be 100644 --- a/Modules/DocumentManagement/resources/js/Pages/Documents/Index.tsx +++ b/Modules/DocumentManagement/resources/js/Pages/Documents/Index.tsx @@ -12,6 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { PaginatedData, PageProps } from '@/types'; import { FileText, Upload, Download, History, Trash2, Image, File, CheckCircle, XCircle, LayoutGrid, List, Eye } from 'lucide-react'; import { FormEvent, useState, useEffect, useRef } from 'react'; +import { ConfirmModal } from '@/Components/ConfirmModal'; interface Category { id: number; @@ -25,9 +26,10 @@ interface Project { interface DocItem { id: number; ulid: string; title: string; category_id: number; project_id?: number; description?: string; + uploaded_by?: number; current_file_name?: string; mime_type?: string; file_size: number; version_count: number; created_at: string; status: string; - uploader?: { id: number; ulid: string; name: string }; + uploader?: { id: number; ulid?: string; name: string; contractor_id?: number | null }; category?: Category; project?: Project; } @@ -375,8 +377,39 @@ export default function Index({ documents, categories, projects, filters }: Prop const [dialog, setDialog] = useState(false); const [viewMode, setViewMode] = useState<'gallery' | 'list'>('gallery'); const [previewDoc, setPreviewDoc] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = () => { + if (!deleteTarget || isDeleting) return; + setIsDeleting(true); + router.delete(route('documents.destroy', deleteTarget.ulid), { + preserveScroll: true, + onSuccess: () => { + setDeleteTarget(null); + setIsDeleting(false); + }, + onError: () => { + setIsDeleting(false); + }, + onFinish: () => { + setIsDeleting(false); + } + }); + }; const isContractorAdmin = auth.roles?.includes('Main Contractor Admin'); + const isExecutive = (auth.user?.user_type as string) === 'admin' + || (auth.user?.user_type as string) === 'super_admin' + || auth.user?.contractor_id === null + || auth.roles?.some((r: any) => ['Super Admin', 'Admin', 'Project Manager', 'Executive'].includes(typeof r === 'string' ? r : r.name)); + + const canDeleteDocument = (doc: DocItem) => { + if (isExecutive) return true; + if (doc.uploaded_by && Number(doc.uploaded_by) === Number(auth.user?.id)) return true; + if (auth.user?.contractor_id && doc.uploader?.contractor_id && Number(doc.uploader.contractor_id) === Number(auth.user?.contractor_id)) return true; + return false; + }; const form = useForm<{ title: string; category_id: string; project_id: string; description: string; file: File | null }>({ title: '', category_id: '', project_id: '', description: '', file: null, @@ -576,7 +609,9 @@ export default function Index({ documents, categories, projects, filters }: Prop )} - + {canDeleteDocument(doc) && ( + + )}
@@ -643,15 +678,17 @@ export default function Index({ documents, categories, projects, filters }: Prop
- + {canDeleteDocument(doc) && ( + + )}
@@ -673,6 +710,20 @@ export default function Index({ documents, categories, projects, filters }: Prop {previewDoc && setPreviewDoc(null)} />} + + { + if (!open && !isDeleting) setDeleteTarget(null); + }} + title="Delete Document" + message={`Are you sure you want to delete "${deleteTarget?.title}" and all its uploaded versions? This action cannot be undone.`} + confirmText={isDeleting ? 'Deleting...' : 'Delete Document'} + cancelText="Keep Document" + variant="destructive" + loading={isDeleting} + onConfirm={handleDelete} + /> ); } diff --git a/Modules/FinancialManagement/app/Enums/InvoiceStatus.php b/Modules/FinancialManagement/app/Enums/InvoiceStatus.php index 1e1d4fc..c7e3830 100644 --- a/Modules/FinancialManagement/app/Enums/InvoiceStatus.php +++ b/Modules/FinancialManagement/app/Enums/InvoiceStatus.php @@ -9,6 +9,7 @@ enum InvoiceStatus: string case Approved = 'approved'; case Rejected = 'rejected'; case Sent = 'sent'; + case PaymentSent = 'payment_sent'; case PartiallyPaid = 'partially_paid'; case Paid = 'paid'; case Overdue = 'overdue'; @@ -21,6 +22,7 @@ enum InvoiceStatus: string self::Approved => 'Approved', self::Rejected => 'Rejected', self::Sent => 'Sent', + self::PaymentSent => 'Payment Sent (Pending Confirmation)', self::PartiallyPaid => 'Partially Paid', self::Paid => 'Paid', self::Overdue => 'Overdue', @@ -32,12 +34,13 @@ enum InvoiceStatus: string return match ($this) { self::Draft => [self::Submitted], self::Submitted => [self::Approved, self::Rejected], - self::Approved => [self::Sent], + self::Approved => [self::Sent, self::PaymentSent, self::Paid], self::Rejected => [self::Draft], - self::Sent => [self::PartiallyPaid, self::Paid, self::Overdue], - self::PartiallyPaid => [self::Paid, self::Overdue], + self::Sent => [self::PaymentSent, self::PartiallyPaid, self::Paid, self::Overdue], + self::PaymentSent => [self::Paid, self::PartiallyPaid, self::Overdue], + self::PartiallyPaid => [self::PaymentSent, self::Paid, self::Overdue], self::Paid => [], - self::Overdue => [self::PartiallyPaid, self::Paid], + self::Overdue => [self::PaymentSent, self::PartiallyPaid, self::Paid], }; } @@ -49,6 +52,7 @@ enum InvoiceStatus: string self::Approved => 'green', self::Rejected => 'red', self::Sent => 'indigo', + self::PaymentSent => 'purple', self::PartiallyPaid => 'amber', self::Paid => 'emerald', self::Overdue => 'red', diff --git a/Modules/FinancialManagement/app/Http/Controllers/FinanceController.php b/Modules/FinancialManagement/app/Http/Controllers/FinanceController.php index fb2bd5f..7367b31 100644 --- a/Modules/FinancialManagement/app/Http/Controllers/FinanceController.php +++ b/Modules/FinancialManagement/app/Http/Controllers/FinanceController.php @@ -48,13 +48,15 @@ class FinanceController extends Controller $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') - ->whereIn('project_id', $this->availableProjectIdsQuery())->sum('amount') + 'total_paid' => (float) (clone $allInvoices)->where('status', 'paid')->sum('paid_amount'), + 'outstanding' => (float) (clone $allInvoices)->where('status', '!=', 'paid')->sum('total_amount'), + 'total_retention' => abs( + (float) RetentionEntry::where('type', 'debit') + ->whereIn('project_id', $this->availableProjectIdsQuery())->sum('amount') - (float) RetentionEntry::where('type', 'credit') - ->whereIn('status', ['posted', 'paid']) - ->whereIn('project_id', $this->availableProjectIdsQuery())->sum('amount'), + ->whereIn('status', ['posted', 'paid']) + ->whereIn('project_id', $this->availableProjectIdsQuery())->sum('amount') + ), ]; return Inertia::render('FinancialManagement::Invoices/Index', [ @@ -209,12 +211,37 @@ class FinanceController extends Controller ]); try { - $this->billingService->recordPayment($invoice, $validated['amount']); + $user = $request->user(); + $isExecutive = is_null($user?->contractor_id) + || in_array($user?->user_type, ['admin', 'super_admin']) + || $user?->hasAnyRole(['Super Admin', 'Admin', 'Project Manager', 'Executive']); + + if ($isExecutive) { + $this->billingService->recordExecutivePayment($invoice, (float) $validated['amount']); + $msg = 'Payment recorded and sent for contractor receipt confirmation.'; + } else { + $this->billingService->confirmContractorPayment($invoice); + $msg = 'Payment receipt confirmed.'; + } } catch (\InvalidArgumentException $e) { return back()->with('error', $e->getMessage()); } - return back()->with('success', 'Payment recorded.'); + return back()->with('success', $msg); + } + + public function confirmPayment(Request $request, FinancialInvoice $invoice) + { + abort_unless($this->canAccessProject($request->user(), $invoice->project_id), 403); + abort_unless($this->isContractorAdmin($request->user()), 403, 'Only Contractor Admin can confirm payment receipts.'); + + try { + $this->billingService->confirmContractorPayment($invoice); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return back()->with('success', 'Payment receipt confirmed successfully.'); } // --- Retention Ledger --- @@ -239,8 +266,8 @@ class FinanceController extends Controller $pendingReleases = RetentionEntry::whereIn('project_id', $this->availableProjectIdsQuery()) ->where('type', 'credit') - ->where('status', 'submitted') - ->get(['id', 'ulid', 'project_id', 'amount', 'media_path', 'media_original_name']) + ->where('status', '!=', 'paid') + ->get(['id', 'ulid', 'project_id', 'amount', 'status', 'media_path', 'media_original_name']) ->keyBy('project_id'); // Compute per-project totals @@ -259,6 +286,7 @@ class FinanceController extends Controller 'balance' => (float) $debits - (float) $credits, 'pending_release_id' => $pending?->id, 'pending_release_ulid' => $pending?->ulid, + 'pending_release_status' => $pending?->status, 'pending_release_amount' => $pending ? (float) $pending->amount : null, 'pending_release_media_path' => $pending?->media_path, 'pending_release_media_name' => $pending?->media_original_name, @@ -331,7 +359,41 @@ class FinanceController extends Controller return back()->with('error', $e->getMessage()); } - return back()->with('success', 'Retention marked as paid with proof attached.'); + return back()->with('success', 'Retention payment recorded and sent for contractor receipt confirmation.'); + } + + public function confirmRetentionPayment(Request $request, RetentionEntry $retentionEntry) + { + abort_unless($this->canAccessProject($request->user(), $retentionEntry->project_id), 403); + abort_unless($this->isContractorAdmin($request->user()), 403, 'Only Contractor Admin can confirm payment receipts.'); + + try { + $this->billingService->confirmRetentionPayment($retentionEntry); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + + return back()->with('success', 'Retention payment receipt confirmed successfully.'); + } + + private function isContractorAdmin(?User $user): bool + { + if (! $user) return false; + + $userType = strtolower($user->user_type ?? ''); + $roles = strtolower($user->getRoleNames()->implode(',')); + + $isExecutive = in_array($userType, ['admin', 'super_admin', 'project_manager']) + || $user->hasAnyRole(['Super Admin', 'admin', 'Admin', 'Project Manager', 'Executive']); + + if ($isExecutive) { + return false; + } + + return !is_null($user->contractor_id) + || $userType === 'contractor' + || $user->hasAnyRole(['Main Contractor Admin', 'Contractor Admin', 'Contractor', 'Subcontractor Admin']) + || \Illuminate\Support\Str::contains($roles, 'contractor'); } public function viewRetentionMedia(Request $request, RetentionEntry $retentionEntry) diff --git a/Modules/FinancialManagement/app/Services/ProgressBillingService.php b/Modules/FinancialManagement/app/Services/ProgressBillingService.php index a72e756..6c2b0b8 100644 --- a/Modules/FinancialManagement/app/Services/ProgressBillingService.php +++ b/Modules/FinancialManagement/app/Services/ProgressBillingService.php @@ -75,6 +75,41 @@ class ProgressBillingService ]); } + /** + * On invoice payment, update project's last billed percentage. + */ + /** + * Executive releases/records payment (transitions invoice to PaymentSent pending contractor confirmation). + */ + public function recordExecutivePayment(FinancialInvoice $invoice, float $amount): void + { + $newPaid = (float) $invoice->paid_amount + $amount; + + $updates = [ + 'paid_amount' => $newPaid, + ]; + + $invoice->transitionTo(InvoiceStatus::PaymentSent); + $invoice->update($updates); + } + + /** + * Contractor confirms receipt of payment (transitions invoice to Paid & updates project progress). + */ + public function confirmContractorPayment(FinancialInvoice $invoice): void + { + $totalAmount = (float) $invoice->total_amount; + + $invoice->status = InvoiceStatus::Paid; + $invoice->paid_amount = $totalAmount; + $invoice->paid_at = now(); + $invoice->save(); + + if (\Schema::hasColumn('projects', 'completion_percentage')) { + $invoice->project()->update(['completion_percentage' => $invoice->billed_percentage]); + } + } + /** * On invoice payment, update project's last billed percentage. */ @@ -83,12 +118,14 @@ class ProgressBillingService $newPaid = (float) $invoice->paid_amount + $amount; $totalAmount = (float) $invoice->total_amount; - $updates = ['paid_amount' => $newPaid]; + $updates = ['paid_amount' => $newPaid, 'paid_at' => now()]; if ($newPaid >= $totalAmount) { $invoice->transitionTo(InvoiceStatus::Paid); - // Update project's last billed percentage - $invoice->project()->update(['last_billed_percentage' => $invoice->billed_percentage]); + // Update project's completion percentage + if (\Schema::hasColumn('projects', 'completion_percentage')) { + $invoice->project()->update(['completion_percentage' => $invoice->billed_percentage]); + } } else { $invoice->transitionTo(InvoiceStatus::PartiallyPaid); } @@ -162,7 +199,7 @@ class ProgressBillingService public function markRetentionAsPaid(RetentionEntry $entry, ?string $mediaPath = null, ?string $mediaOriginalName = null): void { $data = [ - 'status' => 'paid', + 'status' => 'payment_sent', 'paid_by' => auth()->id(), 'paid_at' => now(), ]; @@ -174,16 +211,43 @@ class ProgressBillingService if ($entry->type === 'debit') { $data['type'] = 'credit'; - $data['description'] = 'Retention released and paid'; + $data['description'] = 'Retention released (Payment Sent - Pending Contractor Confirmation)'; $entry->update($data); return; } - if ($entry->type !== 'credit' || !in_array($entry->status, ['submitted', 'posted', 'pending', null], true)) { + if ($entry->type !== 'credit' || !in_array($entry->status, ['submitted', 'posted', 'pending', 'payment_sent', null], true)) { throw new \InvalidArgumentException('Retention entry cannot be marked as paid.'); } - $data['description'] = 'Retention released and paid to contractor'; + $data['description'] = 'Retention released (Payment Sent - Pending Contractor Confirmation)'; $entry->update($data); } + + public function confirmRetentionPayment(RetentionEntry $entry): void + { + $entry->status = 'paid'; + $entry->description = 'Retention released and paid to contractor'; + $entry->paid_at = now(); + $entry->save(); + + if ($entry->invoice) { + $entry->invoice->status = InvoiceStatus::Paid; + $entry->invoice->paid_amount = $entry->invoice->total_amount; + $entry->invoice->paid_at = now(); + $entry->invoice->save(); + + if (\Schema::hasColumn('projects', 'completion_percentage')) { + $entry->invoice->project()->update(['completion_percentage' => $entry->invoice->billed_percentage]); + } + } elseif ($entry->project_id) { + $invoice = FinancialInvoice::where('project_id', $entry->project_id)->latest()->first(); + if ($invoice && $invoice->status !== InvoiceStatus::Paid) { + $invoice->status = InvoiceStatus::Paid; + $invoice->paid_amount = $invoice->total_amount; + $invoice->paid_at = now(); + $invoice->save(); + } + } + } } diff --git a/Modules/FinancialManagement/resources/js/Pages/Invoices/Index.tsx b/Modules/FinancialManagement/resources/js/Pages/Invoices/Index.tsx index 53c183c..add8ec9 100644 --- a/Modules/FinancialManagement/resources/js/Pages/Invoices/Index.tsx +++ b/Modules/FinancialManagement/resources/js/Pages/Invoices/Index.tsx @@ -32,13 +32,14 @@ const statusConfig: Record().props; + const { flash, auth } = usePage().props; const [statusFilter, setStatusFilter] = useState(filters.status || 'all'); // Items arrays for Select label lookup @@ -94,14 +95,27 @@ export default function Index({ invoices, projects, summary, filters }: Props) { Invoice #ProjectProgress SubtotalRetention - TotalStatusDue + TotalStatusDueAction {invoices.data.length === 0 ? ( - No invoices. + No invoices. ) : invoices.data.map((inv) => { const cfg = statusConfig[inv.status] || statusConfig.draft; const isOverdue = inv.due_date && new Date(inv.due_date) < new Date() && inv.status !== 'paid'; + const roles: string[] = ((auth?.roles || []) as any[]).map(r => (typeof r === 'string' ? r : r.name || '').toLowerCase()); + const userType = (auth?.user?.user_type || '').toLowerCase(); + + const isExecutive = userType === 'admin' + || userType === 'super_admin' + || userType === 'project_manager' + || roles.some(r => ['super admin', 'admin', 'project manager', 'executive'].includes(r)); + + const isContractorAdmin = !isExecutive && ( + userType === 'contractor' + || auth?.user?.contractor_id !== null + || roles.some(r => r.includes('contractor')) + ); return ( {inv.invoice_number} @@ -112,6 +126,21 @@ export default function Index({ invoices, projects, summary, filters }: Props) { {formatCurrency(inv.total_amount)} {cfg.label}{isOverdue && inv.status !== 'overdue' && } {inv.due_date ? new Date(inv.due_date).toLocaleDateString() : '-'} + + {inv.status === 'payment_sent' && isContractorAdmin ? ( + + ) : ( + + + + )} + ); })} diff --git a/Modules/FinancialManagement/resources/js/Pages/Invoices/Show.tsx b/Modules/FinancialManagement/resources/js/Pages/Invoices/Show.tsx index 0b96c5e..10570ca 100644 --- a/Modules/FinancialManagement/resources/js/Pages/Invoices/Show.tsx +++ b/Modules/FinancialManagement/resources/js/Pages/Invoices/Show.tsx @@ -31,7 +31,9 @@ const formatCurrency = (v: string | number) => new Intl.NumberFormat('en-PH', { const statusConfig: Record = { draft: { variant: 'outline', label: 'Draft' }, submitted: { variant: 'secondary', label: 'Submitted' }, approved: { variant: 'default', label: 'Approved' }, rejected: { variant: 'destructive', label: 'Rejected' }, - sent: { variant: 'secondary', label: 'Sent' }, partially_paid: { variant: 'outline', label: 'Partially Paid' }, + sent: { variant: 'secondary', label: 'Sent' }, + payment_sent: { variant: 'secondary', label: 'Payment Sent (Pending Confirmation)' }, + partially_paid: { variant: 'outline', label: 'Partially Paid' }, paid: { variant: 'default', label: 'Paid' }, overdue: { variant: 'destructive', label: 'Overdue' }, }; @@ -42,8 +44,16 @@ export default function Show({ invoice }: Props) { const cfg = statusConfig[invoice.status] || statusConfig.draft; const balanceDue = Number(invoice.total_amount) - Number(invoice.paid_amount); - const isHigherUp = auth?.user?.user_type === 'admin' || - auth?.user?.roles?.some(r => ['Super Admin', 'admin'].includes(r.name)); + const roles: string[] = ((auth?.roles || []) as any[]).map(r => (typeof r === 'string' ? r : r.name || '').toLowerCase()); + const userType = (auth?.user?.user_type || '').toLowerCase(); + + const isHigherUp = userType === 'admin' || userType === 'super_admin' || userType === 'project_manager' || roles.some(r => ['super admin', 'admin', 'project manager', 'executive'].includes(r)); + + const isContractorAdmin = !isHigherUp && ( + userType === 'contractor' + || auth?.user?.contractor_id !== null + || roles.some(r => r.includes('contractor')) + ); const submitPayment = (e: FormEvent) => { e.preventDefault(); @@ -128,6 +138,52 @@ export default function Show({ invoice }: Props) { )} + {/* Executive Payment Release Banner */} + {isHigherUp && (invoice.status === 'approved' || invoice.status === 'sent' || invoice.status === 'partially_paid') && ( + + +
+
+ +
+
+

Release Invoice Payment

+

Record payment sent to contractor. Contractor must confirm receipt to finalize payment analytics.

+
+
+ +
+
+ )} + + {/* Contractor Payment Confirmation Banner */} + {invoice.status === 'payment_sent' && isContractorAdmin && ( + + +
+
+ +
+
+

Payment Sent by Executive

+

Please verify receipt of {formatCurrency(invoice.total_amount)} and confirm payment receipt to update dashboard totals.

+
+
+ +
+
+ )} + {/* Summary */}

Progress

{Number(invoice.billed_percentage).toFixed(1)}%

diff --git a/Modules/FinancialManagement/resources/js/Pages/Retention/Index.tsx b/Modules/FinancialManagement/resources/js/Pages/Retention/Index.tsx index c7817a7..7e6b53c 100644 --- a/Modules/FinancialManagement/resources/js/Pages/Retention/Index.tsx +++ b/Modules/FinancialManagement/resources/js/Pages/Retention/Index.tsx @@ -7,7 +7,7 @@ import { DataTableToolbar } from '@/Components/DataTableToolbar'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table'; import { PaginatedData, PageProps } from '@/types'; -import { CreditCard, Eye, Upload, Wallet } from 'lucide-react'; +import { CheckCircle2, CreditCard, Eye, Upload, Wallet } from 'lucide-react'; import { FormEvent, useMemo, useRef, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/Components/ui/dialog'; import { Input } from '@/Components/ui/input'; @@ -22,7 +22,7 @@ interface RetEntry { interface Props extends PageProps { entries: PaginatedData; projects: { id: number; ulid: string; name: string; code: string; status?: string }[]; - projectTotals: Record; + projectTotals: Record; filters: { project_id?: string }; } @@ -39,8 +39,23 @@ export default function Index({ entries, projects, projectTotals, filters }: Pro const mediaRef = useRef(null); const markPaidMediaRef = useRef(null); - const isApprover = auth?.user?.user_type === 'admin' || - (auth?.user as any)?.roles?.some((r: any) => ['Super Admin', 'admin', 'Admin', 'Project Manager', 'project_manager'].includes(r.name)); + const roles: string[] = ((auth?.roles || []) as any[]).map(r => (typeof r === 'string' ? r : r.name || '').toLowerCase()); + const userType = (auth?.user?.user_type || '').toLowerCase(); + + const isExecutive = userType === 'admin' + || userType === 'super_admin' + || userType === 'project_manager' + || roles.some(r => ['super admin', 'admin', 'project manager', 'executive'].includes(r)); + + const isApprover = isExecutive; + + const isContractorAdmin = !isExecutive && ( + userType === 'contractor' + || auth?.user?.contractor_id !== null + || roles.some(r => r.includes('contractor')) + ); + + const isContractor = isContractorAdmin; // Items arrays for Select label lookup const projectFilterItems = useMemo(() => [{ value: 'all', label: 'All Projects' }, ...projects.map(p => ({ value: p.ulid, label: p.name }))], [projects]); @@ -124,11 +139,16 @@ export default function Index({ entries, projects, projectTotals, filters }: Pro View Proof )} - {isApprover && ( - )} + {isContractorAdmin && t.pending_release_status && t.pending_release_status !== 'paid' && ( + + )} )}
@@ -177,15 +197,24 @@ export default function Index({ entries, projects, projectTotals, filters }: Pro {formatCurrency(e.amount)} {e.description || '-'} {new Date(e.created_at).toLocaleDateString()} - e.stopPropagation()}> - {e.media_path ? ( - - ) : ( - - )} - + evt.stopPropagation()}> +
+ {e.media_path && ( + + )} + {isContractorAdmin && e.type === 'credit' && e.status !== 'paid' && ( + + )} +
+
))}
@@ -245,7 +274,7 @@ export default function Index({ entries, projects, projectTotals, filters }: Pro )}
- {isApprover && (selectedEntry.type === 'debit' || (selectedEntry.type === 'credit' && selectedEntry.status === 'submitted')) && ( + {isApprover && (selectedEntry.type === 'debit' || (selectedEntry.type === 'credit' && ['submitted', 'posted'].includes(selectedEntry.status || ''))) && ( )} + {isContractorAdmin && selectedEntry.type === 'credit' && selectedEntry.status !== 'paid' && ( + + )} diff --git a/Modules/FinancialManagement/routes/web.php b/Modules/FinancialManagement/routes/web.php index ccd4bc5..0cd7013 100644 --- a/Modules/FinancialManagement/routes/web.php +++ b/Modules/FinancialManagement/routes/web.php @@ -16,11 +16,13 @@ Route::middleware(['web', 'auth', 'permission:finance.access'])->group(function Route::patch('finance/{invoice}/reject', [FinanceController::class, 'reject'])->name('finance.reject'); Route::patch('finance/{invoice}/send', [FinanceController::class, 'send'])->name('finance.send'); Route::post('finance/{invoice}/payment', [FinanceController::class, 'recordPayment'])->name('finance.payment'); + Route::patch('finance/{invoice}/confirm-payment', [FinanceController::class, 'confirmPayment'])->name('finance.confirm-payment'); // Retention Route::get('retention', [FinanceController::class, 'retention'])->name('retention.index'); Route::post('retention/projects/{project}/submit', [FinanceController::class, 'submitRetentionRelease'])->name('retention.submit'); Route::patch('retention/{retentionEntry}/paid', [FinanceController::class, 'markRetentionAsPaid'])->name('retention.paid'); + Route::patch('retention/{retentionEntry}/confirm', [FinanceController::class, 'confirmRetentionPayment'])->name('retention.confirm'); Route::get('retention/{retentionEntry}/media/view', [FinanceController::class, 'viewRetentionMedia'])->name('retention.media.view'); }); diff --git a/Modules/Labors/resources/js/Pages/Index.tsx b/Modules/Labors/resources/js/Pages/Index.tsx index 5311740..7f9ab0b 100644 --- a/Modules/Labors/resources/js/Pages/Index.tsx +++ b/Modules/Labors/resources/js/Pages/Index.tsx @@ -16,8 +16,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/Components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/Components/ui/tabs'; -import { Users, Plus, Pencil, Trash2, ShieldAlert, Sparkles, BookOpen, Check, BadgeAlert } from 'lucide-react'; -import { useState } from 'react'; +import { Users, Plus, Pencil, Trash2, ShieldAlert, Sparkles, BookOpen, Check, Layers, HardHat, Wrench, Zap, Hammer } from 'lucide-react'; +import { useState, useEffect } from 'react'; interface Skill { id: number; @@ -36,6 +36,124 @@ interface Labor { skills: Skill[]; } +export interface LaborBundleItem { + labor_name: string; + category: 'skilled' | 'unskilled'; + default_hours?: number; + estimated_hourly_rate?: number; + rate_per_sqm: number; +} + +export interface JobBundle { + id: string; + name: string; + job_type: string; + category: string; + description: string; + badge_color?: string; + items: LaborBundleItem[]; +} + +const INITIAL_PREDEFINED_BUNDLES: JobBundle[] = [ + { + id: 'excavation-crew', + name: 'Excavation & Earthworks Crew Package', + job_type: 'Excavation & Earthworks', + category: 'Earthworks & Foundation', + badge_color: 'bg-amber-100 text-amber-800 border-amber-200', + description: 'Complete manpower package for site excavation, trenching, soil hauling, and ground leveling.', + items: [ + { labor_name: 'Excavator Operator', category: 'skilled', rate_per_sqm: 80 }, + { labor_name: 'Heavy Equipment Hand', category: 'skilled', rate_per_sqm: 40 }, + { labor_name: 'Earthwork Laborer', category: 'unskilled', rate_per_sqm: 30 }, + { labor_name: 'Flagman & Site Safety', category: 'unskilled', rate_per_sqm: 15 }, + ] + }, + { + id: 'steelworks-crew', + name: 'Steelworks & Rebar Crew Package', + job_type: 'Steelworks & Structural Rebar', + category: 'Structural & Reinforcement', + badge_color: 'bg-slate-100 text-slate-800 border-slate-200', + description: 'Specialized crew for rebar cutting, bending, column/beam tying, and structural steel welding.', + items: [ + { labor_name: 'Master Welder', category: 'skilled', rate_per_sqm: 110 }, + { labor_name: 'Steel Fixer', category: 'skilled', rate_per_sqm: 95 }, + { labor_name: 'Rebar Craftsman', category: 'skilled', rate_per_sqm: 75 }, + { labor_name: 'Rigging Helper', category: 'unskilled', rate_per_sqm: 40 }, + ] + }, + { + id: 'electricity-crew', + name: 'Electricity & MEP Rough-In Package', + job_type: 'Electrical & MEP', + category: 'Electrical & Utility Systems', + badge_color: 'bg-yellow-100 text-yellow-800 border-yellow-200', + description: 'Crew for main feeder line installation, conduit laying, breaker panel wiring, and rough-in.', + items: [ + { labor_name: 'Master Electrician', category: 'skilled', rate_per_sqm: 90 }, + { labor_name: 'Journeyman Electrician', category: 'skilled', rate_per_sqm: 75 }, + { labor_name: 'Cable Puller', category: 'unskilled', rate_per_sqm: 45 }, + { labor_name: 'Conduit Installer', category: 'skilled', rate_per_sqm: 40 }, + ] + }, + { + id: 'masonry-crew', + name: 'Masonry & Concrete Pouring Package', + job_type: 'Masonry & Civil Works', + category: 'Masonry & Concrete', + badge_color: 'bg-emerald-100 text-emerald-800 border-emerald-200', + description: 'Full manpower setup for CHB block laying, wall plastering, slab pouring, and concrete finishing.', + items: [ + { labor_name: 'Master Mason', category: 'skilled', rate_per_sqm: 110 }, + { labor_name: 'Concrete Finisher', category: 'skilled', rate_per_sqm: 85 }, + { labor_name: 'Masonry Helper', category: 'unskilled', rate_per_sqm: 55 }, + { labor_name: 'Mixer Attendant', category: 'unskilled', rate_per_sqm: 30 }, + ] + }, + { + id: 'plumbing-crew', + name: 'Plumbing & Piping Package', + job_type: 'Plumbing & Sanitary', + category: 'Plumbing & Piping', + badge_color: 'bg-blue-100 text-blue-800 border-blue-200', + description: 'Specialized crew for potable water supply distribution, DWV sanitary piping, and fixture rough-in.', + items: [ + { labor_name: 'Master Plumber', category: 'skilled', rate_per_sqm: 95 }, + { labor_name: 'Pipefitter', category: 'skilled', rate_per_sqm: 75 }, + { labor_name: 'Drainage Installer', category: 'skilled', rate_per_sqm: 50 }, + { labor_name: 'Plumbing Helper', category: 'unskilled', rate_per_sqm: 30 }, + ] + }, + { + id: 'carpentry-crew', + name: 'Carpentry & Formwork Package', + job_type: 'Carpentry & Formwork', + category: 'Formwork & Framing', + badge_color: 'bg-orange-100 text-orange-800 border-orange-200', + description: 'Team for column/beam formwork fabrication, scaffolding erection, and temporary shoring.', + items: [ + { labor_name: 'Master Carpenter', category: 'skilled', rate_per_sqm: 100 }, + { labor_name: 'Formwork Installer', category: 'skilled', rate_per_sqm: 85 }, + { labor_name: 'Scaffold Erector', category: 'skilled', rate_per_sqm: 55 }, + { labor_name: 'Carpentry Helper', category: 'unskilled', rate_per_sqm: 35 }, + ] + }, + { + id: 'painting-crew', + name: 'Painting & Architectural Finishing Package', + job_type: 'Painting & Finishing', + category: 'Architectural & Finishing', + badge_color: 'bg-purple-100 text-purple-800 border-purple-200', + description: 'Crew for surface preparation, skimcoating, primer application, and topcoat architectural painting.', + items: [ + { labor_name: 'Lead Painter', category: 'skilled', rate_per_sqm: 85 }, + { labor_name: 'Surface Applicator', category: 'skilled', rate_per_sqm: 65 }, + { labor_name: 'Sanding & Skimcoat Helper', category: 'unskilled', rate_per_sqm: 40 }, + ] + } +]; + interface Props { labors: Labor[]; skills: Skill[]; @@ -43,7 +161,13 @@ interface Props { export default function Index({ labors = [], skills = [] }: Props) { // Tab State - const [activeTab, setActiveTab] = useState('labor'); + const [activeTab, setActiveTab] = useState(() => { + if (typeof window !== 'undefined') { + const urlParams = new URLSearchParams(window.location.search); + return urlParams.get('tab') || 'labor'; + } + return 'labor'; + }); // Dialog state for Labor const [laborModalOpen, setLaborModalOpen] = useState(false); @@ -53,13 +177,50 @@ export default function Index({ labors = [], skills = [] }: Props) { const [skillModalOpen, setSkillModalOpen] = useState(false); const [editingSkill, setEditingSkill] = useState(null); + // Job Bundles State + const [bundles, setBundles] = useState(() => { + try { + const saved = localStorage.getItem('gsb_custom_labor_bundles'); + if (saved) return JSON.parse(saved); + } catch (e) { + console.error(e); + } + return INITIAL_PREDEFINED_BUNDLES; + }); + + const [bundleModalOpen, setBundleModalOpen] = useState(false); + const [editingBundle, setEditingBundle] = useState(null); + const [bundleForm, setBundleForm] = useState<{ + name: string; + job_type: string; + category: string; + description: string; + items: LaborBundleItem[]; + }>({ + name: '', + job_type: '', + category: 'General Construction', + description: '', + items: [ + { labor_name: '', category: 'skilled', rate_per_sqm: 80 } + ] + }); + + useEffect(() => { + try { + localStorage.setItem('gsb_custom_labor_bundles', JSON.stringify(bundles)); + } catch (e) { + console.error(e); + } + }, [bundles]); + // Inertia forms const laborForm = useForm({ name: '', category: 'skilled', hourly_rate: '', status: 'active', - skills: [] as number[], // selected skill IDs + skills: [] as number[], }); const skillForm = useForm({ @@ -67,7 +228,6 @@ export default function Index({ labors = [], skills = [] }: Props) { description: '', }); - // Formatting currency const formatCurrency = (v: string | number) => { return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v)); }; @@ -170,7 +330,6 @@ export default function Index({ labors = [], skills = [] }: Props) { } }; - // Helper to toggle skill in form state const toggleSkillSelection = (skillId: number) => { const current = [...laborForm.data.skills]; const index = current.indexOf(skillId); @@ -182,61 +341,127 @@ export default function Index({ labors = [], skills = [] }: Props) { laborForm.setData('skills', current); }; + // Job Bundles CRUD Actions + const openAddBundle = () => { + setEditingBundle(null); + setBundleForm({ + name: '', + job_type: '', + category: 'Civil & Construction', + description: '', + items: [ + { labor_name: 'Lead Craftsman', category: 'skilled', rate_per_sqm: 120 }, + { labor_name: 'General Assistant', category: 'unskilled', rate_per_sqm: 60 } + ] + }); + setBundleModalOpen(true); + }; + + const openEditBundle = (bundle: JobBundle) => { + setEditingBundle(bundle); + setBundleForm({ + name: bundle.name, + job_type: bundle.job_type, + category: bundle.category, + description: bundle.description, + items: bundle.items.map(item => ({ ...item })) + }); + setBundleModalOpen(true); + }; + + const handleBundleSave = (e: React.FormEvent) => { + e.preventDefault(); + if (!bundleForm.name || !bundleForm.job_type) return; + + if (editingBundle) { + setBundles(bundles.map(b => b.id === editingBundle.id ? { + ...editingBundle, + name: bundleForm.name, + job_type: bundleForm.job_type, + category: bundleForm.category, + description: bundleForm.description, + items: bundleForm.items + } : b)); + } else { + const newBundle: JobBundle = { + id: 'bundle-' + Date.now(), + name: bundleForm.name, + job_type: bundleForm.job_type, + category: bundleForm.category, + description: bundleForm.description, + badge_color: 'bg-indigo-100 text-indigo-800 border-indigo-200', + items: bundleForm.items + }; + setBundles([...bundles, newBundle]); + } + setBundleModalOpen(false); + }; + + const handleBundleDelete = (id: string) => { + if (confirm('Are you sure you want to delete this job bundle from the catalog?')) { + setBundles(bundles.filter(b => b.id !== id)); + } + }; + + const addBundleItemRow = () => { + setBundleForm({ + ...bundleForm, + items: [...bundleForm.items, { labor_name: '', category: 'skilled', rate_per_sqm: 80 }] + }); + }; + + const removeBundleItemRow = (idx: number) => { + if (bundleForm.items.length <= 1) return; + setBundleForm({ + ...bundleForm, + items: bundleForm.items.filter((_, i) => i !== idx) + }); + }; + + const updateBundleItemRow = (idx: number, field: string, value: any) => { + const updated = bundleForm.items.map((item, i) => i === idx ? { ...item, [field]: value } : item); + setBundleForm({ ...bundleForm, items: updated }); + }; + return (
-

- Labor Capability & Skill Registry +

+ Labor Catalog & Job Crew Bundles

-

Manage labor records, categories, capability skillsets, and hourly rates

+

Manage single labor trade profiles, skill dictionary, and predefined activity crew bundles

} > - +
-
- - - Labor Profiles - - - Skill Dictionary - - - {activeTab === 'labor' && ( - - )} - - {activeTab === 'skill' && ( - - )} -
{/* Labor Profiles Tab */} - - Active Labor Profiles - - Standards for labor grades, category classification, and active capability skillsets. - + +
+ Single Labor Profiles + + Individual labor records, classification grades, standard hourly rates, and skills. + +
+
- Labor Name + Labor Trade Name Category Standard Hourly Rate Grouped Skillsets @@ -254,7 +479,9 @@ export default function Index({ labors = [], skills = [] }: Props) { ) : ( labors.map((labor) => ( - {labor.name} + + {labor.name} + + {/* JOB BUNDLES & CREW PACKAGES TAB */} + + + +
+ Job Bundles & Crew Packages + + Manage your predefined composite labor crew packages and activity bundles. + +
+ +
+ +
+ + + Bundle Name + Job Activity Type + Category + Est. Crew Rate (₱/m²) + BOM / Trade Components + Actions + + + + {bundles.length === 0 ? ( + + + No job bundles defined yet. + + + ) : ( + bundles.map((bundle) => { + const estRatePerSqm = bundle.items.reduce((sum, item) => sum + (item.rate_per_sqm || (item.default_hours ? item.default_hours * (item.estimated_hourly_rate || 0) : 0)), 0); + + return ( + + +
+
{bundle.name}
+ {bundle.description && ( +

{bundle.description}

+ )} +
+
+ + + {bundle.job_type} + + + {bundle.category} + + {formatCurrency(estRatePerSqm)} / m² + + +
+ {bundle.items.map((item, idx) => ( + + {item.labor_name} + ₱{item.rate_per_sqm || item.estimated_hourly_rate || 0} / m² + + ))} +
+
+ +
+ + +
+
+
+ ); + }) + )} +
+
+
+
+
+ {/* Skill Dictionary Tab */} - - Master Skill Dictionary - - Dictionary of construction trades, certifications, and specialized capabilities. - + +
+ Master Skill Dictionary + + Dictionary of construction trades, certifications, and specialized capabilities. + +
+
@@ -437,7 +755,6 @@ export default function Index({ labors = [], skills = [] }: Props) { - {/* Skillset Selector */}