diff --git a/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalController.php b/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalController.php index 6bfbfd0..59b2050 100644 --- a/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalController.php +++ b/Modules/ApprovalWorkflow/app/Http/Controllers/ApprovalController.php @@ -60,10 +60,43 @@ class ApprovalController extends Controller $approvalChain->approvable->load('items'); } + $totalCost = $approvalChain->approvable->total_cost ?? 0; + if ($approvalChain->approvable instanceof \Modules\ProjectManagement\Models\Project) { + $project = $approvalChain->approvable; + $project->load([ + 'materialsEstimates', + 'tasks.taskLabors.labor', + 'tasks.taskEquipments.equipment', + ]); + + $materialsCost = $project->materialsEstimates->reduce(function ($sum, $est) { + return $sum + ($est->estimated_qty * $est->unit_cost); + }, 0); + + $laborCost = 0; + foreach ($project->tasks as $task) { + foreach ($task->taskLabors as $tl) { + $laborCost += ($tl->estimated_hours * ($tl->labor->hourly_rate ?? 0)); + } + } + + $equipmentCost = 0; + foreach ($project->tasks as $task) { + foreach ($task->taskEquipments as $te) { + $equipmentCost += ($te->estimated_hours * ($te->equipment->hourly_rate ?? 0)); + } + } + + $totalCost = $materialsCost + $laborCost + $equipmentCost; + } + $breakdownData = [ - 'document_number' => $approvalChain->approvable->document_number ?? $approvalChain->approvable->po_number ?? null, - 'total_cost' => $approvalChain->approvable->total_cost ?? 0, - 'notes' => $approvalChain->approvable->notes ?? null, + 'document_number' => $approvalChain->approvable->document_number + ?? $approvalChain->approvable->po_number + ?? ($approvalChain->approvable instanceof \Modules\ProjectManagement\Models\Project ? $approvalChain->approvable->code : null), + 'total_cost' => $totalCost, + 'notes' => $approvalChain->approvable->notes + ?? ($approvalChain->approvable instanceof \Modules\ProjectManagement\Models\Project ? $approvalChain->approvable->description : null), ]; } diff --git a/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx b/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx index 067b410..babdfaf 100644 --- a/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx +++ b/Modules/ApprovalWorkflow/resources/js/Components/ApprovableBreakdown.tsx @@ -37,6 +37,16 @@ export default function ApprovableBreakdown({ chain, breakdownData }: Props) { { label: 'Notes', value: breakdownData.notes || 'None' }, ]; break; + case 'Modules\\ProjectManagement\\Models\\Project': + title = 'Project Estimation Details'; + icon = ; + linkUrl = route('projects.show', { project: approvable.ulid || approvable.id, tab: 'estimation' }); + 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' }, + ]; + break; default: title = 'Generic Document'; break; diff --git a/Modules/DailyReports/app/Http/Controllers/DailyReportExportController.php b/Modules/DailyReports/app/Http/Controllers/DailyReportExportController.php new file mode 100644 index 0000000..40d5661 --- /dev/null +++ b/Modules/DailyReports/app/Http/Controllers/DailyReportExportController.php @@ -0,0 +1,458 @@ +load(['project', 'user', 'labors', 'equipment', 'activities', 'materials', 'issues']); + + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Daily Report'); + $sheet->setShowGridlines(true); + + // Styling helpers + $headerStyle = [ + 'font' => [ + 'name' => 'Arial', + 'size' => 16, + 'bold' => true, + 'color' => ['rgb' => 'FFFFFF'], + ], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => '334155'], // Slate 700 + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + 'vertical' => Alignment::VERTICAL_CENTER, + ], + ]; + + $subHeaderStyle = [ + 'font' => [ + 'name' => 'Arial', + 'size' => 11, + 'bold' => true, + 'color' => ['rgb' => '334155'], + ], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => 'F1F5F9'], // Slate 100 + ], + 'alignment' => [ + 'vertical' => Alignment::VERTICAL_CENTER, + ], + 'borders' => [ + 'bottom' => [ + 'borderStyle' => Border::BORDER_MEDIUM, + 'color' => ['rgb' => 'CBD5E1'], + ], + ], + ]; + + $sectionTitleStyle = [ + 'font' => [ + 'name' => 'Arial', + 'size' => 12, + 'bold' => true, + 'color' => ['rgb' => '0F172A'], // Slate 900 + ], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => 'E2E8F0'], // Slate 200 + ], + 'alignment' => [ + 'vertical' => Alignment::VERTICAL_CENTER, + ], + ]; + + $boldLabelStyle = [ + 'font' => [ + 'name' => 'Arial', + 'size' => 10, + 'bold' => true, + 'color' => ['rgb' => '475569'], + ], + ]; + + $tableHeaderStyle = [ + 'font' => [ + 'name' => 'Arial', + 'size' => 10, + 'bold' => true, + 'color' => ['rgb' => '475569'], + ], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => 'F8FAFC'], + ], + 'borders' => [ + 'bottom' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['rgb' => 'E2E8F0'], + ], + ], + ]; + + $borderBottomThin = [ + 'borders' => [ + 'bottom' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['rgb' => 'F1F5F9'], + ], + ], + ]; + + // 1. Title Block + $sheet->mergeCells('A1:F1'); + $sheet->setCellValue('A1', 'GSB CONSTRUCTION - DAILY SITE REPORT'); + $sheet->getStyle('A1:F1')->applyFromArray($headerStyle); + $sheet->getRowDimension(1)->setRowHeight(40); + + // 2. Metadata Block + $sheet->mergeCells('A3:F3'); + $sheet->setCellValue('A3', ' REPORT METADATA'); + $sheet->getStyle('A3:F3')->applyFromArray($subHeaderStyle); + $sheet->getRowDimension(3)->setRowHeight(25); + + // Row 4 + $sheet->setCellValue('A4', 'Report Number:'); + $sheet->setCellValue('B4', $dailyReport->report_number); + $sheet->setCellValue('C4', 'Report Date:'); + $sheet->setCellValue('D4', $dailyReport->report_date ? $dailyReport->report_date->format('Y-m-d') : 'N/A'); + $sheet->setCellValue('E4', 'Prepared By:'); + $sheet->setCellValue('F4', $dailyReport->user ? $dailyReport->user->name : 'N/A'); + + // Row 5 + $sheet->setCellValue('A5', 'Project Code:'); + $sheet->setCellValue('B5', $dailyReport->project ? $dailyReport->project->code : 'N/A'); + $sheet->setCellValue('C5', 'Project Name:'); + $sheet->mergeCells('D5:F5'); + $sheet->setCellValue('D5', $dailyReport->project ? $dailyReport->project->name : 'N/A'); + + // Row 6 + $sheet->setCellValue('A6', 'Weather:'); + $sheet->setCellValue('B6', $dailyReport->weather ?: 'N/A'); + $sheet->setCellValue('C6', 'Temperature:'); + $sheet->setCellValue('D6', $dailyReport->temperature ? $dailyReport->temperature . ' °C' : 'N/A'); + $sheet->setCellValue('E6', 'Precipitation:'); + $sheet->setCellValue('F6', $dailyReport->precipitation ?: 'N/A'); + + // Row 7 + $sheet->setCellValue('A7', 'Wind:'); + $sheet->setCellValue('B7', $dailyReport->wind ?: 'N/A'); + + $sheet->getStyle('A4')->applyFromArray($boldLabelStyle); + $sheet->getStyle('C4')->applyFromArray($boldLabelStyle); + $sheet->getStyle('E4')->applyFromArray($boldLabelStyle); + $sheet->getStyle('A5')->applyFromArray($boldLabelStyle); + $sheet->getStyle('C5')->applyFromArray($boldLabelStyle); + $sheet->getStyle('A6')->applyFromArray($boldLabelStyle); + $sheet->getStyle('C6')->applyFromArray($boldLabelStyle); + $sheet->getStyle('E6')->applyFromArray($boldLabelStyle); + $sheet->getStyle('A7')->applyFromArray($boldLabelStyle); + + // 2.5. Summary Dashboard Block + $sheet->mergeCells('A9:F9'); + $sheet->setCellValue('A9', ' SUMMARY & KEY METRICS'); + $sheet->getStyle('A9:F9')->applyFromArray($subHeaderStyle); + $sheet->getRowDimension(9)->setRowHeight(25); + + // Calculate summary metrics + $totalWorkers = 0; + $totalManHours = 0; + foreach ($dailyReport->labors as $lab) { + $totalWorkers += $lab->workers_count; + $totalManHours += $lab->workers_count * ($lab->hours ?: 0); + } + $totalEquipmentHours = $dailyReport->equipment->sum('hours_used') ?: 0; + $avgProgress = $dailyReport->activities->isEmpty() ? 0 : ($dailyReport->activities->avg('percentage_completed') ?: 0); + + // Row 10 + $sheet->setCellValue('A10', 'Tasks Logged:'); + $sheet->setCellValue('B10', count($dailyReport->activities)); + $sheet->setCellValue('C10', 'Materials Logged:'); + $sheet->setCellValue('D10', count($dailyReport->materials)); + $sheet->setCellValue('E10', 'Manpower Count:'); + $sheet->setCellValue('F10', $totalWorkers); + + // Row 11 + $sheet->setCellValue('A11', 'Avg. Progress:'); + $sheet->setCellValue('B11', number_format($avgProgress, 1) . '%'); + $sheet->setCellValue('C11', 'Reported Issues:'); + $sheet->setCellValue('D11', count($dailyReport->issues)); + $sheet->setCellValue('E11', 'Total Man-Hours:'); + $sheet->setCellValue('F11', number_format($totalManHours, 1) . ' hrs'); + + // Row 12 + $sheet->setCellValue('A12', 'Active Equipment:'); + $sheet->setCellValue('B12', count($dailyReport->equipment)); + $sheet->setCellValue('C12', 'Equipment Hours:'); + $sheet->setCellValue('D12', number_format($totalEquipmentHours, 1) . ' hrs'); + + $sheet->getStyle('A10')->applyFromArray($boldLabelStyle); + $sheet->getStyle('C10')->applyFromArray($boldLabelStyle); + $sheet->getStyle('E10')->applyFromArray($boldLabelStyle); + $sheet->getStyle('A11')->applyFromArray($boldLabelStyle); + $sheet->getStyle('C11')->applyFromArray($boldLabelStyle); + $sheet->getStyle('E11')->applyFromArray($boldLabelStyle); + $sheet->getStyle('A12')->applyFromArray($boldLabelStyle); + $sheet->getStyle('C12')->applyFromArray($boldLabelStyle); + + $rowIdx = 14; + + // 3. Section A: Tasks + $sheet->mergeCells("A{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", ' 1. TASKS'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($sectionTitleStyle); + $sheet->getRowDimension($rowIdx)->setRowHeight(25); + $rowIdx++; + + // Table Headers + $sheet->mergeCells("A{$rowIdx}:C{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", 'Task Name / Activity'); + $sheet->setCellValue("D{$rowIdx}", 'Zone / Area'); + $sheet->setCellValue("E{$rowIdx}", 'Qty Completed'); + $sheet->setCellValue("F{$rowIdx}", 'Completion %'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($tableHeaderStyle); + $rowIdx++; + + if ($dailyReport->activities->isEmpty()) { + $sheet->mergeCells("A{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", 'No work activities reported for this shift.'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray(['font' => ['italic' => true, 'color' => ['rgb' => '94A3B8']]]); + $rowIdx++; + } else { + foreach ($dailyReport->activities as $act) { + $sheet->mergeCells("A{$rowIdx}:C{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", $act->task_name); + $sheet->setCellValue("D{$rowIdx}", $act->zone_area ?: 'N/A'); + $sheet->setCellValue("E{$rowIdx}", $act->quantity_completed ?: 'N/A'); + $sheet->setCellValue("F{$rowIdx}", $act->percentage_completed ? $act->percentage_completed . '%' : '0%'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($borderBottomThin); + $rowIdx++; + } + } + $rowIdx++; + + // 4. Section B: Materials + $sheet->mergeCells("A{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", ' 2. MATERIALS'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($sectionTitleStyle); + $sheet->getRowDimension($rowIdx)->setRowHeight(25); + $rowIdx++; + + // Table Headers + $sheet->mergeCells("A{$rowIdx}:C{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", 'Material Name'); + $sheet->setCellValue("D{$rowIdx}", 'Quantity Received / Used'); + $sheet->mergeCells("E{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("E{$rowIdx}", 'Condition / Remarks'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($tableHeaderStyle); + $rowIdx++; + + if ($dailyReport->materials->isEmpty()) { + $sheet->mergeCells("A{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", 'No materials logged for this date.'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray(['font' => ['italic' => true, 'color' => ['rgb' => '94A3B8']]]); + $rowIdx++; + } else { + foreach ($dailyReport->materials as $mat) { + $sheet->mergeCells("A{$rowIdx}:C{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", $mat->material_name); + $sheet->setCellValue("D{$rowIdx}", $mat->quantity_received); + $sheet->mergeCells("E{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("E{$rowIdx}", $mat->condition ?: 'Good'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($borderBottomThin); + $rowIdx++; + } + } + $rowIdx++; + + // 5. Section C: Labor & Manpower + $sheet->mergeCells("A{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", ' 3. MANPOWER/LABOR'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($sectionTitleStyle); + $sheet->getRowDimension($rowIdx)->setRowHeight(25); + $rowIdx++; + + // Table Headers + $sheet->mergeCells("A{$rowIdx}:B{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", 'Labor Trade / Profession'); + $sheet->setCellValue("C{$rowIdx}", 'Workers Count'); + $sheet->setCellValue("D{$rowIdx}", 'Hours Rendered'); + $sheet->mergeCells("E{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("E{$rowIdx}", 'Notes / Details'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($tableHeaderStyle); + $rowIdx++; + + if ($dailyReport->labors->isEmpty()) { + $sheet->mergeCells("A{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", 'No labor resources logged.'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray(['font' => ['italic' => true, 'color' => ['rgb' => '94A3B8']]]); + $rowIdx++; + } else { + foreach ($dailyReport->labors as $lab) { + $sheet->mergeCells("A{$rowIdx}:B{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", $lab->trade); + $sheet->setCellValue("C{$rowIdx}", $lab->workers_count); + $sheet->setCellValue("D{$rowIdx}", $lab->hours); + $sheet->mergeCells("E{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("E{$rowIdx}", $lab->notes ?: ''); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($borderBottomThin); + $rowIdx++; + } + } + $rowIdx++; + + // 6. Section D: Equipment Usage + $sheet->mergeCells("A{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", ' 4. EQUIPMENT'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($sectionTitleStyle); + $sheet->getRowDimension($rowIdx)->setRowHeight(25); + $rowIdx++; + + // Table Headers + $sheet->mergeCells("A{$rowIdx}:C{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", 'Equipment Name'); + $sheet->setCellValue("D{$rowIdx}", 'Hours Used'); + $sheet->mergeCells("E{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("E{$rowIdx}", 'Status (Active / Idle)'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($tableHeaderStyle); + $rowIdx++; + + if ($dailyReport->equipment->isEmpty()) { + $sheet->mergeCells("A{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", 'No equipment usage logged.'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray(['font' => ['italic' => true, 'color' => ['rgb' => '94A3B8']]]); + $rowIdx++; + } else { + foreach ($dailyReport->equipment as $eq) { + $sheet->mergeCells("A{$rowIdx}:C{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", $eq->equipment_name); + $sheet->setCellValue("D{$rowIdx}", $eq->hours_used); + $sheet->mergeCells("E{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("E{$rowIdx}", ucfirst($eq->status)); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($borderBottomThin); + $rowIdx++; + } + } + $rowIdx++; + + // 7. Section E: Reported Issues & Delays + $sheet->mergeCells("A{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", ' 5. ISSUES & CONCERN'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($sectionTitleStyle); + $sheet->getRowDimension($rowIdx)->setRowHeight(25); + $rowIdx++; + + // Table Headers + $sheet->mergeCells("A{$rowIdx}:B{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", 'Issue / Delay Type'); + $sheet->mergeCells("C{$rowIdx}:E{$rowIdx}"); + $sheet->setCellValue("C{$rowIdx}", 'Description'); + $sheet->setCellValue("F{$rowIdx}", 'Delay Impact (Hrs)'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($tableHeaderStyle); + $rowIdx++; + + if ($dailyReport->issues->isEmpty()) { + $sheet->mergeCells("A{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", 'No major issues or delays reported.'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray(['font' => ['italic' => true, 'color' => ['rgb' => '94A3B8']]]); + $rowIdx++; + } else { + foreach ($dailyReport->issues as $iss) { + $sheet->mergeCells("A{$rowIdx}:B{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", ucfirst(str_replace('_', ' ', $iss->issue_type))); + $sheet->mergeCells("C{$rowIdx}:E{$rowIdx}"); + $sheet->setCellValue("C{$rowIdx}", $iss->description); + $sheet->setCellValue("F{$rowIdx}", $iss->delay_impact ?: '0'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($borderBottomThin); + $rowIdx++; + } + } + $rowIdx++; + + // 8. General Remarks + $sheet->mergeCells("A{$rowIdx}:F{$rowIdx}"); + $sheet->setCellValue("A{$rowIdx}", ' GENERAL REMARKS & NOTES'); + $sheet->getStyle("A{$rowIdx}:F{$rowIdx}")->applyFromArray($subHeaderStyle); + $rowIdx++; + + $sheet->mergeCells("A{$rowIdx}:F" . ($rowIdx + 3)); + $sheet->setCellValue("A{$rowIdx}", $dailyReport->remarks ?: 'No additional remarks entered.'); + $sheet->getStyle("A{$rowIdx}:F" . ($rowIdx + 3))->applyFromArray([ + 'alignment' => [ + 'vertical' => Alignment::VERTICAL_TOP, + 'wrapText' => true + ], + 'font' => ['italic' => !$dailyReport->remarks] + ]); + + // Auto-fit column widths + foreach (range('A', 'F') as $col) { + $sheet->getColumnDimension($col)->setAutoSize(true); + } + + // Output Excel response + $filename = 'DailyReport_' . ($dailyReport->project ? $dailyReport->project->code : '') . '_' . $dailyReport->report_date->format('Ymd') . '.xlsx'; + + header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + header('Content-Disposition: attachment;filename="' . $filename . '"'); + header('Cache-Control: max-age=0'); + + $writer = new Xlsx($spreadsheet); + $writer->save('php://output'); + exit; + } + + public function pdf(DailyReport $dailyReport) + { + $dailyReport->load(['project', 'user', 'labors', 'equipment', 'activities', 'materials', 'issues']); + + // Calculate summary metrics + $totalWorkers = 0; + $totalManHours = 0; + foreach ($dailyReport->labors as $lab) { + $totalWorkers += $lab->workers_count; + $totalManHours += $lab->workers_count * ($lab->hours ?: 0); + } + + $totalEquipmentHours = 0; + foreach ($dailyReport->equipment as $eq) { + $totalEquipmentHours += $eq->hours_used ?: 0; + } + + $avgProgress = 0; + if (!$dailyReport->activities->isEmpty()) { + $avgProgress = $dailyReport->activities->avg('percentage_completed') ?: 0; + } + + $summary = [ + 'total_workers' => $totalWorkers, + 'total_man_hours' => $totalManHours, + 'total_equipment_hours' => $totalEquipmentHours, + 'avg_progress' => $avgProgress, + ]; + + $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('dailyreports::pdf.daily-report', [ + 'report' => $dailyReport, + 'summary' => $summary, + ]); + + $filename = 'DailyReport_' . ($dailyReport->project ? $dailyReport->project->code : '') . '_' . $dailyReport->report_date->format('Ymd') . '.pdf'; + + return $pdf->download($filename); + } +} diff --git a/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php b/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php index 5345d77..fe48077 100644 --- a/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php +++ b/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php @@ -29,7 +29,7 @@ class DailyReportsController extends Controller return Inertia::render('DailyReports::Index', [ 'project' => $project, - 'projects' => \Modules\ProjectManagement\Models\Project::select('id', 'ulid', 'name')->get(), + 'projects' => \Modules\ProjectManagement\Models\Project::with('parentProject:id,ulid,name,code')->get(['id', 'ulid', 'name', 'code', 'project_type', 'parent_project_id']), 'reports' => $reports, ]); } @@ -44,7 +44,7 @@ class DailyReportsController extends Controller return Inertia::render('DailyReports::Create', [ 'project' => $project, - 'projects' => \Modules\ProjectManagement\Models\Project::select('id', 'ulid', 'name')->get(), + 'projects' => \Modules\ProjectManagement\Models\Project::with('parentProject:id,ulid,name,code')->get(['id', 'ulid', 'name', 'code', 'project_type', 'parent_project_id']), ]); } @@ -114,7 +114,7 @@ class DailyReportsController extends Controller return Inertia::render('DailyReports::Edit', [ 'report' => $daily_report, - 'projects' => \Modules\ProjectManagement\Models\Project::select('id', 'ulid', 'name')->get(), + 'projects' => \Modules\ProjectManagement\Models\Project::with('parentProject:id,ulid,name,code')->get(['id', 'ulid', 'name', 'code', 'project_type', 'parent_project_id']), ]); } diff --git a/Modules/DailyReports/resources/js/Pages/Index.tsx b/Modules/DailyReports/resources/js/Pages/Index.tsx index a39d60a..3aa98b6 100644 --- a/Modules/DailyReports/resources/js/Pages/Index.tsx +++ b/Modules/DailyReports/resources/js/Pages/Index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Head, Link, usePage } from '@inertiajs/react'; import ProjectLayout from '../../../../ProjectManagement/resources/js/Layouts/ProjectLayout'; import { Button } from '@/Components/ui/button'; -import { Plus, FileText, Calendar, Clock, User } from 'lucide-react'; +import { Plus, FileText, Calendar, Clock, User, Download } from 'lucide-react'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/Components/ui/card'; export default function Index({ project, projects, reports }: any) { @@ -47,17 +47,47 @@ export default function Index({ project, projects, reports }: any) { - - - - - - - + +
+ + + + + + +
+
+ + + + + + +
))} diff --git a/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx b/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx index 62b8e71..d00f3df 100644 --- a/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx +++ b/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx @@ -105,21 +105,101 @@ export default function ReportForm({ project, report = null, isEdit = false }: a - + - Labor - Equipment - Activities + Tasks Materials - Issues + Manpower/Labor + Equipment + Issues & Concern + {/* Activities Tab (Tasks) */} + + + +
+ Tasks +
+ +
+ + {data.activities.map((row: any, i: number) => ( +
+
+
+ + updateRow('activities', i, 'task_name', e.target.value)} required /> +
+
+ + 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)} /> +
+
+ +
+ ))} + {data.activities.length === 0 &&

No activities added yet.

} +
+
+
+ + {/* Materials Tab */} + + + +
+ Materials +
+ +
+ + {data.materials.map((row: any, i: number) => ( +
+
+
+ + updateRow('materials', i, 'material_name', e.target.value)} required /> +
+
+ + updateRow('materials', i, 'quantity_received', e.target.value)} /> +
+
+ + updateRow('materials', i, 'condition', e.target.value)} /> +
+
+ +
+ ))} + {data.materials.length === 0 &&

No materials added yet.

} +
+
+
+ {/* Labor Tab */}
- Manpower & Labor + Manpower/Labor Record workforce present on site
-
- - {data.activities.map((row: any, i: number) => ( -
-
-
- - updateRow('activities', i, 'task_name', e.target.value)} required /> -
-
- - 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)} /> -
-
- -
- ))} - {data.activities.length === 0 &&

No activities added yet.

} -
-
-
- - {/* Materials Tab */} - - - -
- Materials Received -
- -
- - {data.materials.map((row: any, i: number) => ( -
-
-
- - updateRow('materials', i, 'material_name', e.target.value)} required /> -
-
- - updateRow('materials', i, 'quantity_received', e.target.value)} /> -
-
- - updateRow('materials', i, 'condition', e.target.value)} /> -
-
- -
- ))} - {data.materials.length === 0 &&

No materials added yet.

} -
-
-
- {/* Issues Tab */}
- Issues & Delays + Issues & Concern
+ + + + + + + + Page {pageNum} of {numPages} + + + +
+ + + {(scale * 100).toFixed(0)}% + + +
+ + + + + +
+ +
+ + )} + + + + ); +} + export default function Index({ documents, categories, projects, filters }: Props) { const { flash, auth } = usePage().props; const [search, setSearch] = useState(filters.search || ''); const [catFilter, setCatFilter] = useState(filters.category_id || 'all'); const [statusFilter, setStatusFilter] = useState(filters.status || 'all'); const [dialog, setDialog] = useState(false); + const [viewMode, setViewMode] = useState<'gallery' | 'list'>('gallery'); + const [previewDoc, setPreviewDoc] = useState<{ url: string; title: string } | null>(null); - // Assume the user is Contractor Admin if type is 'Contractor Admin' or similar check const isContractorAdmin = (auth.user as any).user_type === 'Contractor Admin'; const form = useForm<{ title: string; category_id: string; project_id: string; description: string; file: File | null }>({ @@ -145,89 +436,259 @@ export default function Index({ documents, categories, projects, filters }: Prop } actions={ - - }> Upload Document - - Upload Document -
-
form.setData('title', e.target.value)} />
-
-
-
-
-
-
-
- - { if (e.target.files?.[0]) form.setData('file', e.target.files[0]); }} /> - {form.errors.file &&

{form.errors.file}

} -
-
-
-
-
+
+
+ + +
+ + + }> Upload Document + + Upload Document +
+
form.setData('title', e.target.value)} />
+
+
+
+
+
+
+
+ + { if (e.target.files?.[0]) form.setData('file', e.target.files[0]); }} /> + {form.errors.file &&

{form.errors.file}

} +
+
+
+
+
+
} /> - - - - TitleCategory - StatusProject - Versions - Actions - - - {documents.data.length === 0 ? ( - No documents found. - ) : documents.data.map((doc) => ( - - {isImage(doc.mime_type) ? : } - {doc.title} - {doc.category?.name || 'Uncategorized'} - - {doc.project?.name || '-'} - - v{doc.version_count} - - -
- {isContractorAdmin && doc.status === 'Pending' && ( - <> - - - - )} - - - + + {documents.data.length === 0 ? ( +
+ +

No Documents Found

+

No items matching the selected search criteria or category filter were found.

+ +
+ ) : viewMode === 'list' ? ( +
+ + TitleCategory + StatusProject + Versions + Actions + + + {documents.data.map((doc) => ( + + {isImage(doc.mime_type) ? : } + {doc.title} + {getCategoryName(doc.category)} + + {doc.project?.name || '-'} + + v{doc.version_count} + + +
+ {isContractorAdmin && doc.status === 'Pending' && ( + <> + + + + )} + {doc.mime_type?.includes('pdf') && ( + + )} + + + +
+
+
+ ))} +
+
+ ) : ( +
+ {documents.data.map((doc) => ( +
+
doc.mime_type?.includes('pdf') && setPreviewDoc({ url: route('documents.download', doc.ulid), title: doc.title })}> + {doc.mime_type?.includes('pdf') ? ( + + ) : isImage(doc.mime_type) ? ( +
+ +
+ ) : ( +
+ +
+ )} + {doc.mime_type?.includes('pdf') && ( +
+ +
+ )} +
+ +
+
+

+ {doc.title} +

+
+ + {getCategoryName(doc.category)} + + +
+
+ Proj: {doc.project?.name || '-'} + + v{doc.version_count} + +
- - + +
+
+ {isContractorAdmin && doc.status === 'Pending' && ( + <> + + + + )} + + + + + + +
+ + +
+
+
))} - - +
+ )} + {documents.last_page > 1 && ( -
-

Showing {documents.from} to {documents.to} of {documents.total}

+
+

Showing {documents.from} to {documents.to} of {documents.total}

- {documents.prev_page_url && } - {documents.next_page_url && } + {documents.prev_page_url && } + {documents.next_page_url && }
)}
+ + {previewDoc && ( + setPreviewDoc(null)} + /> + )} ); } + + diff --git a/Modules/DocumentManagement/resources/js/Pages/Documents/Versions.tsx b/Modules/DocumentManagement/resources/js/Pages/Documents/Versions.tsx index ef95836..8e95221 100644 --- a/Modules/DocumentManagement/resources/js/Pages/Documents/Versions.tsx +++ b/Modules/DocumentManagement/resources/js/Pages/Documents/Versions.tsx @@ -16,7 +16,7 @@ interface Version { uploader?: { id: number; ulid: string; name: string }; } interface DocData { - id: number; ulid: string; title: string; category: string; description?: string; + id: number; ulid: string; title: string; category?: any; description?: string; current_file_name?: string; version_count: number; uploader?: { id: number; ulid: string; name: string }; versions: Version[]; @@ -30,6 +30,12 @@ const formatSize = (bytes: number) => { return bytes + ' B'; }; +const getCategoryName = (category: any) => { + if (!category) return 'Uncategorized'; + if (typeof category === 'object') return category.name || 'Uncategorized'; + return category; // It's a legacy string column value +}; + export default function Versions({ document: doc }: Props) { const { flash } = usePage().props; const [dialog, setDialog] = useState(false); @@ -51,7 +57,7 @@ export default function Versions({ document: doc }: Props) {

{doc.title}

-

{doc.category} · {doc.version_count} version{doc.version_count !== 1 ? 's' : ''}

+

{getCategoryName(doc.category)} · {doc.version_count} version{doc.version_count !== 1 ? 's' : ''}

diff --git a/Modules/Equipments/app/Http/Controllers/.gitkeep b/Modules/Equipments/app/Http/Controllers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Equipments/app/Http/Controllers/EquipmentController.php b/Modules/Equipments/app/Http/Controllers/EquipmentController.php new file mode 100644 index 0000000..9cac7b2 --- /dev/null +++ b/Modules/Equipments/app/Http/Controllers/EquipmentController.php @@ -0,0 +1,80 @@ + Equipment::with('specifications')->latest()->get(), + 'specifications' => EquipmentSpecification::orderBy('name')->get(), + ]); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'owner_name' => 'nullable|string|max:255', + 'hourly_rate' => 'required|numeric|min:0', + 'status' => 'required|string|in:active,inactive', + 'specifications' => 'nullable|array', + 'specifications.*' => 'integer|exists:equipment_specifications,id', + ]); + + \DB::transaction(function () use ($validated) { + $equipment = Equipment::create([ + 'ulid' => (string) Str::ulid(), + 'name' => $validated['name'], + 'owner_name' => $validated['owner_name'] ?? null, + 'hourly_rate' => $validated['hourly_rate'], + 'status' => $validated['status'], + ]); + + if (!empty($validated['specifications'])) { + $equipment->specifications()->sync($validated['specifications']); + } + }); + + return redirect()->back()->with('success', 'Equipment record created successfully.'); + } + + public function update(Request $request, Equipment $equipment) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'owner_name' => 'nullable|string|max:255', + 'hourly_rate' => 'required|numeric|min:0', + 'status' => 'required|string|in:active,inactive', + 'specifications' => 'nullable|array', + 'specifications.*' => 'integer|exists:equipment_specifications,id', + ]); + + \DB::transaction(function () use ($equipment, $validated) { + $equipment->update([ + 'name' => $validated['name'], + 'owner_name' => $validated['owner_name'] ?? null, + 'hourly_rate' => $validated['hourly_rate'], + 'status' => $validated['status'], + ]); + + $equipment->specifications()->sync($validated['specifications'] ?? []); + }); + + return redirect()->back()->with('success', 'Equipment record updated successfully.'); + } + + public function destroy(Equipment $equipment) + { + $equipment->delete(); + return redirect()->back()->with('success', 'Equipment record deleted successfully.'); + } +} diff --git a/Modules/Equipments/app/Http/Controllers/SpecificationController.php b/Modules/Equipments/app/Http/Controllers/SpecificationController.php new file mode 100644 index 0000000..f501ed8 --- /dev/null +++ b/Modules/Equipments/app/Http/Controllers/SpecificationController.php @@ -0,0 +1,45 @@ +validate([ + 'name' => 'required|string|max:255|unique:equipment_specifications,name', + 'description' => 'nullable|string', + ]); + + EquipmentSpecification::create([ + 'ulid' => (string) Str::ulid(), + 'name' => $validated['name'], + 'description' => $validated['description'] ?? null, + ]); + + return redirect()->back()->with('success', 'Specification added successfully.'); + } + + public function update(Request $request, EquipmentSpecification $equipmentSpecification) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255|unique:equipment_specifications,name,' . $equipmentSpecification->id, + 'description' => 'nullable|string', + ]); + + $equipmentSpecification->update($validated); + + return redirect()->back()->with('success', 'Specification updated successfully.'); + } + + public function destroy(EquipmentSpecification $equipmentSpecification) + { + $equipmentSpecification->delete(); + return redirect()->back()->with('success', 'Specification deleted successfully.'); + } +} diff --git a/Modules/Equipments/app/Models/Equipment.php b/Modules/Equipments/app/Models/Equipment.php new file mode 100644 index 0000000..ec4bf9e --- /dev/null +++ b/Modules/Equipments/app/Models/Equipment.php @@ -0,0 +1,43 @@ + 'decimal:2', + ]; + } + + public function scopeActive($query) + { + return $query->where('status', 'active'); + } + + public function specifications(): BelongsToMany + { + return $this->belongsToMany( + EquipmentSpecification::class, + 'equipment_specification', + 'equipment_id', + 'specification_id' + ); + } +} diff --git a/Modules/Equipments/app/Models/EquipmentSpecification.php b/Modules/Equipments/app/Models/EquipmentSpecification.php new file mode 100644 index 0000000..71b8a75 --- /dev/null +++ b/Modules/Equipments/app/Models/EquipmentSpecification.php @@ -0,0 +1,29 @@ +belongsToMany( + Equipment::class, + 'equipment_specification', + 'specification_id', + 'equipment_id' + ); + } +} diff --git a/Modules/Equipments/app/Providers/.gitkeep b/Modules/Equipments/app/Providers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Equipments/app/Providers/EquipmentsServiceProvider.php b/Modules/Equipments/app/Providers/EquipmentsServiceProvider.php new file mode 100644 index 0000000..fe258b2 --- /dev/null +++ b/Modules/Equipments/app/Providers/EquipmentsServiceProvider.php @@ -0,0 +1,135 @@ +registerCommands(); + $this->registerCommandSchedules(); + $this->registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->loadMigrationsFrom(module_path($this->name, 'database/migrations')); + } + + /** + * Register the service provider. + */ + public function register(): void + { + $this->app->register(EventServiceProvider::class); + $this->app->register(RouteServiceProvider::class); + } + + /** + * Register commands in the format of Command::class + */ + protected function registerCommands(): void + { + // $this->commands([]); + } + + /** + * Register command Schedules. + */ + protected function registerCommandSchedules(): void + { + // $this->app->booted(function () { + // $schedule = $this->app->make(Schedule::class); + // $schedule->command('inspire')->hourly(); + // }); + } + + /** + * Register translations. + */ + public function registerTranslations(): void + { + $langPath = resource_path('lang/modules/'.$this->nameLower); + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, $this->nameLower); + $this->loadJsonTranslationsFrom($langPath); + } else { + $this->loadTranslationsFrom(module_path($this->name, 'lang'), $this->nameLower); + $this->loadJsonTranslationsFrom(module_path($this->name, 'lang')); + } + } + + /** + * Register config. + */ + protected function registerConfig(): void + { + $relativeConfigPath = config('modules.paths.generator.config.path'); + $configPath = module_path($this->name, $relativeConfigPath); + + if (is_dir($configPath)) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($configPath)); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $relativePath = str_replace($configPath . DIRECTORY_SEPARATOR, '', $file->getPathname()); + $configKey = $this->nameLower . '.' . str_replace([DIRECTORY_SEPARATOR, '.php'], ['.', ''], $relativePath); + $key = ($relativePath === 'config.php') ? $this->nameLower : $configKey; + + $this->publishes([$file->getPathname() => config_path($relativePath)], 'config'); + $this->mergeConfigFrom($file->getPathname(), $key); + } + } + } + } + + /** + * Register views. + */ + public function registerViews(): void + { + $viewPath = resource_path('views/modules/'.$this->nameLower); + $sourcePath = module_path($this->name, 'resources/views'); + + $this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']); + + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->nameLower); + + $componentNamespace = $this->module_namespace($this->name, $this->app_path(config('modules.paths.generator.component-class.path'))); + Blade::componentNamespace($componentNamespace, $this->nameLower); + } + + /** + * Get the services provided by the provider. + */ + public function provides(): array + { + return []; + } + + private function getPublishableViewPaths(): array + { + $paths = []; + foreach (config('view.paths') as $path) { + if (is_dir($path.'/modules/'.$this->nameLower)) { + $paths[] = $path.'/modules/'.$this->nameLower; + } + } + + return $paths; + } +} diff --git a/Modules/Equipments/app/Providers/EventServiceProvider.php b/Modules/Equipments/app/Providers/EventServiceProvider.php new file mode 100644 index 0000000..66b9ad0 --- /dev/null +++ b/Modules/Equipments/app/Providers/EventServiceProvider.php @@ -0,0 +1,30 @@ +> + */ + protected $listen = []; + + /** + * Indicates if events should be discovered. + * + * @var bool + */ + protected static $shouldDiscoverEvents = true; + + /** + * Configure the proper event listeners for email verification. + */ + protected function configureEmailVerification(): void + { + // + } +} diff --git a/Modules/Equipments/app/Providers/RouteServiceProvider.php b/Modules/Equipments/app/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..6e29bdb --- /dev/null +++ b/Modules/Equipments/app/Providers/RouteServiceProvider.php @@ -0,0 +1,50 @@ +mapApiRoutes(); + $this->mapWebRoutes(); + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + */ + protected function mapWebRoutes(): void + { + Route::middleware('web')->group(module_path($this->name, '/routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + */ + protected function mapApiRoutes(): void + { + Route::middleware('api')->prefix('api')->name('api.')->group(module_path($this->name, '/routes/api.php')); + } +} diff --git a/Modules/Equipments/composer.json b/Modules/Equipments/composer.json new file mode 100644 index 0000000..47b63db --- /dev/null +++ b/Modules/Equipments/composer.json @@ -0,0 +1,30 @@ +{ + "name": "nwidart/equipments", + "description": "", + "authors": [ + { + "name": "Nicolas Widart", + "email": "n.widart@gmail.com" + } + ], + "extra": { + "laravel": { + "providers": [], + "aliases": { + + } + } + }, + "autoload": { + "psr-4": { + "Modules\\Equipments\\": "app/", + "Modules\\Equipments\\Database\\Factories\\": "database/factories/", + "Modules\\Equipments\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\Equipments\\Tests\\": "tests/" + } + } +} diff --git a/Modules/Equipments/config/.gitkeep b/Modules/Equipments/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Equipments/config/config.php b/Modules/Equipments/config/config.php new file mode 100644 index 0000000..a89099f --- /dev/null +++ b/Modules/Equipments/config/config.php @@ -0,0 +1,5 @@ + 'Equipments', +]; diff --git a/Modules/Equipments/database/factories/.gitkeep b/Modules/Equipments/database/factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Equipments/database/migrations/.gitkeep b/Modules/Equipments/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Equipments/database/migrations/2026_05_28_100003_create_equipments_table.php b/Modules/Equipments/database/migrations/2026_05_28_100003_create_equipments_table.php new file mode 100644 index 0000000..a5d57fc --- /dev/null +++ b/Modules/Equipments/database/migrations/2026_05_28_100003_create_equipments_table.php @@ -0,0 +1,42 @@ +id(); + $table->char('ulid', 26)->unique(); + $table->string('name')->unique(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('equipments', function (Blueprint $table) { + $table->id(); + $table->char('ulid', 26)->unique(); + $table->string('name'); + $table->string('owner_name')->nullable(); + $table->decimal('hourly_rate', 10, 2); + $table->string('status')->default('active'); // active, inactive + $table->timestamps(); + }); + + Schema::create('equipment_specification', function (Blueprint $table) { + $table->foreignId('equipment_id')->constrained('equipments')->cascadeOnDelete(); + $table->foreignId('specification_id')->constrained('equipment_specifications')->cascadeOnDelete(); + $table->primary(['equipment_id', 'specification_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('equipment_specification'); + Schema::dropIfExists('equipments'); + Schema::dropIfExists('equipment_specifications'); + } +}; diff --git a/Modules/Equipments/database/seeders/.gitkeep b/Modules/Equipments/database/seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Equipments/database/seeders/EquipmentsDatabaseSeeder.php b/Modules/Equipments/database/seeders/EquipmentsDatabaseSeeder.php new file mode 100644 index 0000000..55a4956 --- /dev/null +++ b/Modules/Equipments/database/seeders/EquipmentsDatabaseSeeder.php @@ -0,0 +1,100 @@ + 'Heavy Machinery', 'description' => 'Large earthmovers, cranes, and transport trucks.'], + ['name' => 'Light Machinery', 'description' => 'Mixers, compactors, generators, and pumps.'], + ['name' => 'Power Tools', 'description' => 'Drills, saws, jackhammers, and grinders.'], + ['name' => 'Hand Tools', 'description' => 'Shovels, hammers, wrenches, and layout tools.'], + ['name' => 'Diesel Powered', 'description' => 'Equipment running on diesel engines.'], + ['name' => 'Electric Powered', 'description' => 'Equipment running on AC mains or battery packs.'], + ]; + + $specs = []; + foreach ($specsData as $sData) { + $specs[$sData['name']] = EquipmentSpecification::create([ + 'ulid' => (string) Str::ulid(), + 'name' => $sData['name'], + 'description' => $sData['description'], + ]); + } + + // 2. Seed Equipments + $equipmentsData = [ + [ + 'name' => 'Caterpillar 320 Excavator', + 'owner_name' => 'Company Owned', + 'hourly_rate' => 1500.00, + 'status' => 'active', + 'specs' => ['Heavy Machinery', 'Diesel Powered'], + ], + [ + 'name' => '10-Wheeler Dump Truck', + 'owner_name' => 'Company Owned', + 'hourly_rate' => 1200.00, + 'status' => 'active', + 'specs' => ['Heavy Machinery', 'Diesel Powered'], + ], + [ + 'name' => 'Generac 50kW Generator', + 'owner_name' => 'ABC Rentals', + 'hourly_rate' => 800.00, + 'status' => 'active', + 'specs' => ['Light Machinery', 'Diesel Powered'], + ], + [ + 'name' => 'Concrete Mixer (One-bagger)', + 'owner_name' => 'Company Owned', + 'hourly_rate' => 350.00, + 'status' => 'active', + 'specs' => ['Light Machinery', 'Electric Powered'], + ], + [ + 'name' => 'Hilti TE-3000 Jackhammer', + 'owner_name' => 'Company Owned', + 'hourly_rate' => 150.00, + 'status' => 'active', + 'specs' => ['Power Tools', 'Electric Powered'], + ], + [ + 'name' => 'Stihl Concrete Saw', + 'owner_name' => 'XYZ Subcontractor', + 'hourly_rate' => 200.00, + 'status' => 'active', + 'specs' => ['Power Tools', 'Diesel Powered'], + ], + ]; + + foreach ($equipmentsData as $eqData) { + $equipment = Equipment::create([ + 'ulid' => (string) Str::ulid(), + 'name' => $eqData['name'], + 'owner_name' => $eqData['owner_name'], + 'hourly_rate' => $eqData['hourly_rate'], + 'status' => $eqData['status'], + ]); + + $specIds = []; + foreach ($eqData['specs'] as $specName) { + if (isset($specs[$specName])) { + $specIds[] = $specs[$specName]->id; + } + } + + if (!empty($specIds)) { + $equipment->specifications()->sync($specIds); + } + } + } +} diff --git a/Modules/Equipments/module.json b/Modules/Equipments/module.json new file mode 100644 index 0000000..86f2bf2 --- /dev/null +++ b/Modules/Equipments/module.json @@ -0,0 +1,11 @@ +{ + "name": "Equipments", + "alias": "equipments", + "description": "", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\Equipments\\Providers\\EquipmentsServiceProvider" + ], + "files": [] +} diff --git a/Modules/Equipments/package.json b/Modules/Equipments/package.json new file mode 100644 index 0000000..d6fbfc8 --- /dev/null +++ b/Modules/Equipments/package.json @@ -0,0 +1,15 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "axios": "^1.1.2", + "laravel-vite-plugin": "^0.7.5", + "sass": "^1.69.5", + "postcss": "^8.3.7", + "vite": "^4.0.0" + } +} diff --git a/Modules/Equipments/resources/assets/.gitkeep b/Modules/Equipments/resources/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Equipments/resources/assets/js/app.js b/Modules/Equipments/resources/assets/js/app.js new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Equipments/resources/assets/sass/app.scss b/Modules/Equipments/resources/assets/sass/app.scss new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Equipments/resources/js/Pages/Index.tsx b/Modules/Equipments/resources/js/Pages/Index.tsx new file mode 100644 index 0000000..3a7f149 --- /dev/null +++ b/Modules/Equipments/resources/js/Pages/Index.tsx @@ -0,0 +1,532 @@ +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { Head, useForm } from '@inertiajs/react'; +import { Button } from '@/Components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card'; +import { Input } from '@/Components/ui/input'; +import { Label } from '@/Components/ui/label'; +import { Badge } from '@/Components/ui/badge'; +import { Textarea } from '@/Components/ui/textarea'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/Components/ui/table'; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from '@/Components/ui/dialog'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/Components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/Components/ui/tabs'; +import { Truck, Plus, Pencil, Trash2, ShieldAlert, Sparkles, Layers, Check } from 'lucide-react'; +import { useState } from 'react'; + +interface Specification { + id: number; + ulid: string; + name: string; + description: string | null; +} + +interface Equipment { + id: number; + ulid: string; + name: string; + owner_name: string | null; + hourly_rate: string; + status: string; + specifications: Specification[]; +} + +interface Props { + equipments: Equipment[]; + specifications: Specification[]; +} + +export default function Index({ equipments = [], specifications = [] }: Props) { + // Tab State + const [activeTab, setActiveTab] = useState('equipment'); + + // Dialog state for Equipment + const [equipmentModalOpen, setEquipmentModalOpen] = useState(false); + const [editingEquipment, setEditingEquipment] = useState(null); + + // Dialog state for Specification + const [specificationModalOpen, setSpecificationModalOpen] = useState(false); + const [editingSpecification, setEditingSpecification] = useState(null); + + // Inertia forms + const equipmentForm = useForm({ + name: '', + owner_name: '', + hourly_rate: '', + status: 'active', + specifications: [] as number[], // selected specification IDs + }); + + const specificationForm = useForm({ + name: '', + description: '', + }); + + // Formatting currency + const formatCurrency = (v: string | number) => { + return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v)); + }; + + // Equipment actions + const openAddEquipment = () => { + setEditingEquipment(null); + equipmentForm.setData({ + name: '', + owner_name: '', + hourly_rate: '', + status: 'active', + specifications: [], + }); + equipmentForm.clearErrors(); + setEquipmentModalOpen(true); + }; + + const openEditEquipment = (equipment: Equipment) => { + setEditingEquipment(equipment); + equipmentForm.setData({ + name: equipment.name, + owner_name: equipment.owner_name || '', + hourly_rate: equipment.hourly_rate, + status: equipment.status, + specifications: equipment.specifications.map(s => s.id), + }); + equipmentForm.clearErrors(); + setEquipmentModalOpen(true); + }; + + const handleEquipmentSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingEquipment) { + equipmentForm.put(route('equipments.update', editingEquipment.id), { + onSuccess: () => { + setEquipmentModalOpen(false); + equipmentForm.reset(); + }, + }); + } else { + equipmentForm.post(route('equipments.store'), { + onSuccess: () => { + setEquipmentModalOpen(false); + equipmentForm.reset(); + }, + }); + } + }; + + const handleEquipmentDelete = (id: number) => { + if (confirm('Are you sure you want to delete this equipment record? This will remove all its task allocations.')) { + equipmentForm.delete(route('equipments.destroy', id)); + } + }; + + // Specification actions + const openAddSpecification = () => { + setEditingSpecification(null); + specificationForm.setData({ + name: '', + description: '', + }); + specificationForm.clearErrors(); + setSpecificationModalOpen(true); + }; + + const openEditSpecification = (spec: Specification) => { + setEditingSpecification(spec); + specificationForm.setData({ + name: spec.name, + description: spec.description || '', + }); + specificationForm.clearErrors(); + setSpecificationModalOpen(true); + }; + + const handleSpecificationSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingSpecification) { + specificationForm.put(route('equipment-specifications.update', editingSpecification.id), { + onSuccess: () => { + setSpecificationModalOpen(false); + specificationForm.reset(); + }, + }); + } else { + specificationForm.post(route('equipment-specifications.store'), { + onSuccess: () => { + setSpecificationModalOpen(false); + specificationForm.reset(); + }, + }); + } + }; + + const handleSpecificationDelete = (id: number) => { + if (confirm('Are you sure you want to delete this specification? It will be removed from all assigned equipment profiles.')) { + specificationForm.delete(route('equipment-specifications.destroy', id)); + } + }; + + // Helper to toggle specification selection + const toggleSpecificationSelection = (specId: number) => { + const current = [...equipmentForm.data.specifications]; + const index = current.indexOf(specId); + if (index > -1) { + current.splice(index, 1); + } else { + current.push(specId); + } + equipmentForm.setData('specifications', current); + }; + + return ( + +
+

+ Equipment & Tools Registry +

+

Manage heavy machinery, tools, ownership tracking, specifications, and billing rates

+
+ + } + > + + +
+
+ +
+ + + Equipment Profiles + + + Specification Dictionary + + + + {activeTab === 'equipment' && ( + + )} + + {activeTab === 'specification' && ( + + )} +
+ + {/* Equipment Profiles Tab */} + + + + Active Equipment & Tools + + Standard rate cards and specifications for owned, rented, or subcontractor machinery. + + + + + + + Equipment Name + Owner + Standard Hourly Rate + Specifications / Tags + Status + Actions + + + + {equipments.length === 0 ? ( + + + No equipment records registered yet. + + + ) : ( + equipments.map((eq) => ( + + {eq.name} + + {eq.owner_name ? ( + + {eq.owner_name} + + ) : ( + Not specified + )} + + {formatCurrency(eq.hourly_rate)} / hr + +
+ {eq.specifications.length === 0 ? ( + No specs defined + ) : ( + eq.specifications.map(s => ( + + {s.name} + + )) + )} +
+
+ + + {eq.status} + + + +
+ + +
+
+
+ )) + )} +
+
+
+
+
+ + {/* Specification Dictionary Tab */} + + + + Master Specification Dictionary + + Dictionary of equipment classifications, power profiles, and capability markers. + + + + + + + Specification Name + Description + Actions + + + + {specifications.length === 0 ? ( + + + No master specifications registered yet. + + + ) : ( + specifications.map((spec) => ( + + {spec.name} + {spec.description || 'N/A'} + +
+ + +
+
+
+ )) + )} +
+
+
+
+
+
+
+
+ + {/* Equipment Modal */} + + + + {editingEquipment ? 'Edit Equipment Profile' : 'Add Equipment Profile'} + + Define equipment name, owner tracking (e.g. company-owned, vendor name), hourly rate, and specifications. + + +
+
+ + equipmentForm.setData('name', e.target.value)} + placeholder="e.g. Caterpillar 320 Excavator, Hilti Jackhammer" + required + className="border-slate-200 focus:border-blue-500 focus:ring-blue-500 text-xs" + /> + {equipmentForm.errors.name && ( +

+ {equipmentForm.errors.name} +

+ )} +
+ +
+
+ + equipmentForm.setData('owner_name', e.target.value)} + placeholder="e.g. Company Owned, ABC Subcon" + className="border-slate-200 focus:border-blue-500 focus:ring-blue-500 text-xs" + /> +
+ +
+ +
+ + equipmentForm.setData('hourly_rate', e.target.value)} + placeholder="0.00" + required + className="pl-7 border-slate-200 focus:border-blue-500 focus:ring-blue-500 text-xs" + /> +
+
+
+ +
+ + +
+ + {/* Specification Selector */} +
+ + + {specifications.length === 0 ? ( +
+

No master specifications found. Create them in the dictionary tab first.

+
+ ) : ( +
+ {specifications.map((spec) => { + const isSelected = equipmentForm.data.specifications.includes(spec.id); + return ( +
toggleSpecificationSelection(spec.id)} + className={`flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors text-xs ${ + isSelected + ? 'bg-blue-50 text-blue-800 font-medium' + : 'hover:bg-slate-50 text-slate-700' + }`} + > + {spec.name} + {isSelected && } +
+ ); + })} +
+ )} +
+ + + + + +
+
+
+ + {/* Specification Modal */} + + + + {editingSpecification ? 'Edit Specification' : 'Add Specification'} + + Define new classification categories, capacity types, or fuel types for equipment records. + + +
+
+ + specificationForm.setData('name', e.target.value)} + placeholder="e.g. Heavy Equipment, Diesel Engine, 20-Ton Capacity" + required + className="border-slate-200 focus:border-blue-500 focus:ring-blue-500 text-xs" + /> + {specificationForm.errors.name && ( +

+ {specificationForm.errors.name} +

+ )} +
+ +
+ +