diff --git a/Modules/ApprovalWorkflow/app/Services/ApprovalService.php b/Modules/ApprovalWorkflow/app/Services/ApprovalService.php index 13c28f9..8523e91 100644 --- a/Modules/ApprovalWorkflow/app/Services/ApprovalService.php +++ b/Modules/ApprovalWorkflow/app/Services/ApprovalService.php @@ -123,8 +123,8 @@ class ApprovalService } $isAdminOrPm = $approver->user_type === 'admin' - || $approver->hasRole(['Super Admin', 'admin', 'Project Manager', 'project_manager']) - || $approver->hasPermissionTo('approve_mr'); + || $approver->hasRole(['Super Admin', 'admin', 'Project Manager', 'project_manager', 'Main Contractor Admin']) + || $approver->can('approve_mr'); if ($step->approver_id !== $approver->id && !$isAdminOrPm) { throw new \InvalidArgumentException('You are not the current approver for this step.'); diff --git a/Modules/ContractorManagement/app/Http/Controllers/ContractorController.php b/Modules/ContractorManagement/app/Http/Controllers/ContractorController.php index f00ce8d..bb4d5b8 100644 --- a/Modules/ContractorManagement/app/Http/Controllers/ContractorController.php +++ b/Modules/ContractorManagement/app/Http/Controllers/ContractorController.php @@ -70,11 +70,11 @@ class ContractorController extends Controller $validated = $request->validate([ 'company_name' => 'required|string|max:255', 'contact_person' => 'required|string|max:255', - 'email' => 'required|email|unique:contractors,email', - 'phone' => 'nullable|string|max:50', + 'email' => 'required|email|max:255|unique:contractors,email', + 'phone' => 'required|string|max:50', 'specialization' => 'nullable|string|max:100', - 'address' => 'nullable|string', - 'tax_id' => 'nullable|string|max:50', + 'address' => 'required|string', + 'tax_id' => 'required|string|max:50', 'payment_terms' => 'required|in:net_15,net_30,net_60', 'shares_materials_catalog' => 'nullable|boolean', 'create_admin' => 'nullable|boolean', @@ -171,11 +171,11 @@ class ContractorController extends Controller $validated = $request->validate([ 'company_name' => 'required|string|max:255', 'contact_person' => 'required|string|max:255', - 'email' => "required|email|unique:contractors,email,{$contractor->id}", - 'phone' => 'nullable|string|max:50', + 'email' => "required|email|max:255|unique:contractors,email,{$contractor->id}", + 'phone' => 'required|string|max:50', 'specialization' => 'nullable|string|max:100', - 'address' => 'nullable|string', - 'tax_id' => 'nullable|string|max:50', + 'address' => 'required|string', + 'tax_id' => 'required|string|max:50', 'payment_terms' => 'required|in:net_15,net_30,net_60', 'shares_materials_catalog' => 'nullable|boolean', ]); diff --git a/Modules/ContractorManagement/resources/js/Pages/Contractors/Form.tsx b/Modules/ContractorManagement/resources/js/Pages/Contractors/Form.tsx index f52d930..9d5ca82 100644 --- a/Modules/ContractorManagement/resources/js/Pages/Contractors/Form.tsx +++ b/Modules/ContractorManagement/resources/js/Pages/Contractors/Form.tsx @@ -86,25 +86,26 @@ export default function Form({ contractor }: Props) {
- + form.setData('company_name', e.target.value)} /> {form.errors.company_name &&

{form.errors.company_name}

}
- + form.setData('contact_person', e.target.value)} /> {form.errors.contact_person &&

{form.errors.contact_person}

}
- + form.setData('email', e.target.value)} /> {form.errors.email &&

{form.errors.email}

}
- + form.setData('phone', e.target.value)} /> + {form.errors.phone &&

{form.errors.phone}

}
@@ -123,9 +124,10 @@ export default function Form({ contractor }: Props) { Landscaping + {form.errors.specialization &&

{form.errors.specialization}

}
- + + {form.errors.payment_terms &&

{form.errors.payment_terms}

}
- - form.setData('tax_id', e.target.value)} /> + + form.setData('tax_id', e.target.value)} placeholder="000-000-000-000" /> + {form.errors.tax_id &&

{form.errors.tax_id}

}
- - form.setData('address', e.target.value)} /> + + form.setData('address', e.target.value)} placeholder="Complete business/office address..." /> + {form.errors.address &&

{form.errors.address}

}
download($filename); } + + public function exportRange(Request $request) + { + $request->validate([ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + 'project' => 'nullable|string', + ]); + + $startDate = Carbon::parse($request->input('start_date'))->startOfDay(); + $endDate = Carbon::parse($request->input('end_date'))->endOfDay(); + $projectUlid = $request->input('project'); + + $project = $projectUlid ? Project::where('ulid', $projectUlid)->first() : null; + + $query = DailyReport::with(['project', 'user', 'labors', 'equipment', 'activities', 'materials', 'issues']) + ->whereBetween('report_date', [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')]) + ->orderBy('report_date', 'asc'); + + if ($project) { + $query->where('project_id', $project->id); + } + + $reports = $query->get(); + + // 1. Manpower Category Aggregation + $allLabors = $reports->flatMap->labors; + $totalPeriodManHours = $allLabors->sum(fn ($l) => $l->workers_count * ($l->hours ?: 0)); + $laborCategorySummary = $allLabors->groupBy(fn ($l) => $l->trade ?: 'Unspecified Trade') + ->map(function ($group, $trade) use ($totalPeriodManHours) { + $workerDays = $group->sum('workers_count'); + $manHours = $group->sum(fn ($l) => $l->workers_count * ($l->hours ?: 0)); + $activeCount = $group->count(); + $percent = $totalPeriodManHours > 0 ? ($manHours / $totalPeriodManHours) * 100 : 0; + + return [ + 'trade' => $trade, + 'worker_days' => $workerDays, + 'total_man_hours' => $manHours, + 'days_active' => $activeCount, + 'percent_share' => round($percent, 1), + ]; + })->values()->sortByDesc('total_man_hours')->values(); + + // 2. Equipment Category Aggregation + $allEquipment = $reports->flatMap->equipment; + $totalPeriodEquipHours = $allEquipment->sum('hours_used'); + $equipmentCategorySummary = $allEquipment->groupBy(fn ($eq) => $eq->equipment_name ?: 'Unspecified Equipment') + ->map(function ($group, $name) use ($totalPeriodEquipHours) { + $hours = $group->sum('hours_used'); + $activeCount = $group->count(); + $percent = $totalPeriodEquipHours > 0 ? ($hours / $totalPeriodEquipHours) * 100 : 0; + $latestStatus = $group->last()->status ?? 'Active'; + + return [ + 'equipment_name' => $name, + 'total_hours' => $hours, + 'days_active' => $activeCount, + 'percent_share' => round($percent, 1), + 'latest_status' => $latestStatus, + ]; + })->values()->sortByDesc('total_hours')->values(); + + // 3. Materials Category Aggregation + $allMaterials = $reports->flatMap->materials; + $materialsCategorySummary = $allMaterials->groupBy(fn ($m) => $m->material_name ?: 'General Material') + ->map(function ($group, $name) { + $deliveriesCount = $group->count(); + $quantities = $group->pluck('quantity_received')->filter()->all(); + $numericSum = 0; + $unit = ''; + foreach ($quantities as $q) { + if (preg_match('/^([0-9.]+)\s*(.*)$/', trim($q), $matches)) { + $numericSum += (float) $matches[1]; + if (empty($unit) && !empty($matches[2])) { + $unit = $matches[2]; + } + } + } + $qtyDisplay = $numericSum > 0 ? (string)$numericSum : (implode(', ', array_slice($quantities, 0, 3)) ?: '—'); + $conditions = $group->pluck('condition')->filter()->unique()->implode(', ') ?: 'Good'; + + return [ + 'material_name' => $name, + 'total_quantity' => $qtyDisplay, + 'unit' => $unit ?: 'units', + 'deliveries_count' => $deliveriesCount, + 'condition_summary' => $conditions, + ]; + })->values()->sortByDesc('deliveries_count')->values(); + + // 4. Activities Category Aggregation + $allActivities = $reports->flatMap->activities; + $activitiesCategorySummary = $allActivities->groupBy(fn ($a) => $a->task_name ?: 'General Works') + ->map(function ($group, $name) { + $zones = $group->pluck('zone_area')->filter()->unique()->implode(', ') ?: 'Site Wide'; + $highestProgress = $group->max('percentage_completed') ?: 0; + $daysWorked = $group->count(); + + return [ + 'task_name' => $name, + 'zones' => $zones, + 'highest_progress' => $highestProgress, + 'days_worked' => $daysWorked, + ]; + })->values()->sortByDesc('days_worked')->values(); + + // 5. Issues Category Aggregation + $allIssues = $reports->flatMap->issues; + $issuesCategorySummary = $allIssues->groupBy(fn ($i) => $i->issue_type ?: 'General') + ->map(function ($group, $type) { + $count = $group->count(); + $resolved = $group->whereNotNull('resolved_at')->count(); + $delays = $group->pluck('delay_impact')->filter()->all(); + + return [ + 'issue_type' => $type, + 'incident_count' => $count, + 'resolved_count' => $resolved, + 'pending_count' => $count - $resolved, + 'delay_impact_summary' => !empty($delays) ? implode('; ', array_slice($delays, 0, 3)) : 'None reported', + ]; + })->values()->sortByDesc('incident_count')->values(); + + $spreadsheet = new Spreadsheet(); + + // Style definitions + $headerStyle = [ + 'font' => ['name' => 'Arial', 'size' => 13, 'bold' => true, 'color' => ['rgb' => 'FFFFFF']], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '1E293B']], // Slate 800 + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], + ]; + + $sectionHeaderStyle = [ + 'font' => ['name' => 'Arial', 'size' => 11, 'bold' => true, 'color' => ['rgb' => 'FFFFFF']], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '0F766E']], // Teal 700 + 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], + ]; + + $tableHeaderStyle = [ + 'font' => ['name' => 'Arial', 'size' => 9.5, 'bold' => true, 'color' => ['rgb' => 'FFFFFF']], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '334155']], // Slate 700 + 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => '64748B']]], + ]; + + $totalRowStyle = [ + 'font' => ['name' => 'Arial', 'size' => 9.5, 'bold' => true, 'color' => ['rgb' => '0F172A']], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'E2E8F0']], // Slate 200 + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => '94A3B8']]], + ]; + + $thinBorder = [ + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'CBD5E1']]], + ]; + + // ---------------------------------------------------- + // 1. SHEET: CONSOLIDATED OVERVIEW & DAILY SUMMARY + // ---------------------------------------------------- + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Consolidated Overview'); + $sheet->setShowGridlines(true); + + $sheet->mergeCells('A1:J1'); + $sheet->setCellValue('A1', 'CONSOLIDATED DAILY CONSTRUCTION REPORTS — PERIOD OVERVIEW'); + $sheet->getStyle('A1:J1')->applyFromArray($headerStyle); + $sheet->getRowDimension(1)->setRowHeight(32); + + $sheet->setCellValue('A2', 'Project: ' . ($project ? "{$project->name} ({$project->code})" : 'All Projects')); + $sheet->setCellValue('A3', 'Date Range: ' . $startDate->format('M d, Y') . ' to ' . $endDate->format('M d, Y')); + $sheet->setCellValue('A4', 'Total Reports Filed: ' . $reports->count() . ' day(s)'); + $sheet->setCellValue('D2', 'Total Man-Days: ' . number_format($allLabors->sum('workers_count'))); + $sheet->setCellValue('D3', 'Total Man-Hours: ' . number_format($totalPeriodManHours, 1) . ' hrs'); + $sheet->setCellValue('D4', 'Total Equip. Hours: ' . number_format($totalPeriodEquipHours, 1) . ' hrs'); + $sheet->setCellValue('G2', 'Activities Logged: ' . $allActivities->count()); + $sheet->setCellValue('G3', 'Materials Tracked: ' . $allMaterials->count()); + $sheet->setCellValue('G4', 'Issues & Delays: ' . $allIssues->count()); + $sheet->getStyle('A2:G4')->getFont()->setBold(true)->setSize(9.5); + + $headers = ['Date', 'Report #', 'Project', 'Prepared By', 'Weather', 'Workers', 'Man-Hours', 'Equip. Hours', 'Tasks', 'Issues']; + $cols = range('A', 'J'); + $rowIdx = 6; + + foreach ($headers as $k => $h) { + $sheet->setCellValue("{$cols[$k]}{$rowIdx}", $h); + } + $sheet->getStyle("A{$rowIdx}:J{$rowIdx}")->applyFromArray($tableHeaderStyle); + $sheet->getRowDimension($rowIdx)->setRowHeight(24); + $rowIdx++; + + foreach ($reports as $r) { + $workers = $r->labors->sum('workers_count'); + $manHours = $r->labors->sum(fn ($l) => $l->workers_count * ($l->hours ?: 0)); + $equipHours = $r->equipment->sum('hours_used'); + + $sheet->setCellValue("A{$rowIdx}", $r->report_date ? $r->report_date->format('Y-m-d') : ''); + $sheet->setCellValue("B{$rowIdx}", $r->report_number ?: '#' . $r->id); + $sheet->setCellValue("C{$rowIdx}", $r->project ? $r->project->code : '—'); + $sheet->setCellValue("D{$rowIdx}", $r->user ? $r->user->name : 'N/A'); + $sheet->setCellValue("E{$rowIdx}", $r->weather ?: '—'); + $sheet->setCellValue("F{$rowIdx}", $workers); + $sheet->setCellValue("G{$rowIdx}", number_format($manHours, 1)); + $sheet->setCellValue("H{$rowIdx}", number_format($equipHours, 1)); + $sheet->setCellValue("I{$rowIdx}", $r->activities->count()); + $sheet->setCellValue("J{$rowIdx}", $r->issues->count()); + + $sheet->getStyle("A{$rowIdx}:J{$rowIdx}")->applyFromArray($thinBorder); + $rowIdx++; + } + + // Summary Row + $sheet->setCellValue("A{$rowIdx}", 'TOTAL FOR PERIOD'); + $sheet->mergeCells("A{$rowIdx}:E{$rowIdx}"); + $sheet->setCellValue("F{$rowIdx}", $allLabors->sum('workers_count')); + $sheet->setCellValue("G{$rowIdx}", number_format($totalPeriodManHours, 1)); + $sheet->setCellValue("H{$rowIdx}", number_format($totalPeriodEquipHours, 1)); + $sheet->setCellValue("I{$rowIdx}", $allActivities->count()); + $sheet->setCellValue("J{$rowIdx}", $allIssues->count()); + $sheet->getStyle("A{$rowIdx}:J{$rowIdx}")->applyFromArray($totalRowStyle); + + foreach ($cols as $col) { + $sheet->getColumnDimension($col)->setAutoSize(true); + } + + // ---------------------------------------------------- + // 2. SHEET: MANPOWER & LABOR CATEGORY SUMMATION + // ---------------------------------------------------- + $sheetLabor = $spreadsheet->createSheet(); + $sheetLabor->setTitle('Manpower by Category'); + $sheetLabor->setShowGridlines(true); + + $sheetLabor->mergeCells('A1:F1'); + $sheetLabor->setCellValue('A1', 'MANPOWER & LABOR — SUMMATION PER TRADE / CATEGORY'); + $sheetLabor->getStyle('A1:F1')->applyFromArray($sectionHeaderStyle); + $sheetLabor->getRowDimension(1)->setRowHeight(28); + + $labSumHeaders = ['Trade / Labor Category', 'Total Worker-Days', 'Total Man-Hours (hrs)', 'Days Active', 'Avg Workers / Active Day', '% Share']; + $labSumCols = range('A', 'F'); + $lRow = 3; + + foreach ($labSumHeaders as $k => $h) { + $sheetLabor->setCellValue("{$labSumCols[$k]}{$lRow}", $h); + } + $sheetLabor->getStyle("A{$lRow}:F{$lRow}")->applyFromArray($tableHeaderStyle); + $lRow++; + + foreach ($laborCategorySummary as $cat) { + $avgW = $cat['days_active'] > 0 ? round($cat['worker_days'] / $cat['days_active'], 1) : $cat['worker_days']; + $sheetLabor->setCellValue("A{$lRow}", $cat['trade']); + $sheetLabor->setCellValue("B{$lRow}", $cat['worker_days']); + $sheetLabor->setCellValue("C{$lRow}", number_format($cat['total_man_hours'], 1)); + $sheetLabor->setCellValue("D{$lRow}", $cat['days_active']); + $sheetLabor->setCellValue("E{$lRow}", $avgW); + $sheetLabor->setCellValue("F{$lRow}", $cat['percent_share'] . '%'); + $sheetLabor->getStyle("A{$lRow}:F{$lRow}")->applyFromArray($thinBorder); + $lRow++; + } + + // Total Row + $sheetLabor->setCellValue("A{$lRow}", 'TOTAL MANPOWER'); + $sheetLabor->setCellValue("B{$lRow}", $allLabors->sum('workers_count')); + $sheetLabor->setCellValue("C{$lRow}", number_format($totalPeriodManHours, 1)); + $sheetLabor->setCellValue("D{$lRow}", $reports->count()); + $sheetLabor->setCellValue("E{$lRow}", $reports->count() > 0 ? round($allLabors->sum('workers_count') / $reports->count(), 1) : 0); + $sheetLabor->setCellValue("F{$lRow}", '100.0%'); + $sheetLabor->getStyle("A{$lRow}:F{$lRow}")->applyFromArray($totalRowStyle); + $lRow += 3; + + // Itemized Daily Log Section + $sheetLabor->mergeCells("A{$lRow}:G{$lRow}"); + $sheetLabor->setCellValue("A{$lRow}", 'ITEMIZED DAILY MANPOWER LOG'); + $sheetLabor->getStyle("A{$lRow}:G{$lRow}")->applyFromArray($headerStyle); + $lRow++; + + $labDetailHeaders = ['Date', 'Report #', 'Trade / Category', 'Workers Count', 'Hours/Worker', 'Total Man-Hours', 'Notes / Location']; + $labDetailCols = range('A', 'G'); + foreach ($labDetailHeaders as $k => $h) { + $sheetLabor->setCellValue("{$labDetailCols[$k]}{$lRow}", $h); + } + $sheetLabor->getStyle("A{$lRow}:G{$lRow}")->applyFromArray($tableHeaderStyle); + $lRow++; + + foreach ($reports as $r) { + foreach ($r->labors as $lab) { + $totalH = $lab->workers_count * ($lab->hours ?: 0); + $sheetLabor->setCellValue("A{$lRow}", $r->report_date ? $r->report_date->format('Y-m-d') : ''); + $sheetLabor->setCellValue("B{$lRow}", $r->report_number ?: '#' . $r->id); + $sheetLabor->setCellValue("C{$lRow}", $lab->trade ?: 'Labor'); + $sheetLabor->setCellValue("D{$lRow}", $lab->workers_count); + $sheetLabor->setCellValue("E{$lRow}", $lab->hours ?: 0); + $sheetLabor->setCellValue("F{$lRow}", number_format($totalH, 1)); + $sheetLabor->setCellValue("G{$lRow}", $lab->notes ?: '—'); + $sheetLabor->getStyle("A{$lRow}:G{$lRow}")->applyFromArray($thinBorder); + $lRow++; + } + } + foreach ($labDetailCols as $col) { + $sheetLabor->getColumnDimension($col)->setAutoSize(true); + } + + // ---------------------------------------------------- + // 3. SHEET: EQUIPMENT & MACHINERY CATEGORY SUMMATION + // ---------------------------------------------------- + $sheetEquip = $spreadsheet->createSheet(); + $sheetEquip->setTitle('Equipment by Category'); + $sheetEquip->setShowGridlines(true); + + $sheetEquip->mergeCells('A1:F1'); + $sheetEquip->setCellValue('A1', 'EQUIPMENT & MACHINERY — SUMMATION PER MODEL / CATEGORY'); + $sheetEquip->getStyle('A1:F1')->applyFromArray($sectionHeaderStyle); + $sheetEquip->getRowDimension(1)->setRowHeight(28); + + $eqSumHeaders = ['Equipment Name / Model', 'Total Hours Operated (hrs)', 'Days Utilized', 'Avg Hours / Utilized Day', 'Latest Status', '% Share']; + $eqSumCols = range('A', 'F'); + $eRow = 3; + + foreach ($eqSumHeaders as $k => $h) { + $sheetEquip->setCellValue("{$eqSumCols[$k]}{$eRow}", $h); + } + $sheetEquip->getStyle("A{$eRow}:F{$eRow}")->applyFromArray($tableHeaderStyle); + $eRow++; + + foreach ($equipmentCategorySummary as $eqCat) { + $avgH = $eqCat['days_active'] > 0 ? round($eqCat['total_hours'] / $eqCat['days_active'], 1) : $eqCat['total_hours']; + $sheetEquip->setCellValue("A{$eRow}", $eqCat['equipment_name']); + $sheetEquip->setCellValue("B{$eRow}", number_format($eqCat['total_hours'], 1)); + $sheetEquip->setCellValue("C{$eRow}", $eqCat['days_active']); + $sheetEquip->setCellValue("D{$eRow}", $avgH); + $sheetEquip->setCellValue("E{$eRow}", $eqCat['latest_status']); + $sheetEquip->setCellValue("F{$eRow}", $eqCat['percent_share'] . '%'); + $sheetEquip->getStyle("A{$eRow}:F{$eRow}")->applyFromArray($thinBorder); + $eRow++; + } + + // Total Row + $sheetEquip->setCellValue("A{$eRow}", 'TOTAL EQUIPMENT'); + $sheetEquip->setCellValue("B{$eRow}", number_format($totalPeriodEquipHours, 1)); + $sheetEquip->setCellValue("C{$eRow}", $reports->count()); + $sheetEquip->setCellValue("D{$eRow}", $reports->count() > 0 ? round($totalPeriodEquipHours / $reports->count(), 1) : 0); + $sheetEquip->setCellValue("E{$eRow}", '—'); + $sheetEquip->setCellValue("F{$eRow}", '100.0%'); + $sheetEquip->getStyle("A{$eRow}:F{$eRow}")->applyFromArray($totalRowStyle); + $eRow += 3; + + // Itemized Daily Equipment Log + $sheetEquip->mergeCells("A{$eRow}:E{$eRow}"); + $sheetEquip->setCellValue("A{$eRow}", 'ITEMIZED DAILY EQUIPMENT LOG'); + $sheetEquip->getStyle("A{$eRow}:E{$eRow}")->applyFromArray($headerStyle); + $eRow++; + + $eqDetailHeaders = ['Date', 'Report #', 'Equipment Name / Model', 'Hours Used', 'Status']; + $eqDetailCols = range('A', 'E'); + foreach ($eqDetailHeaders as $k => $h) { + $sheetEquip->setCellValue("{$eqDetailCols[$k]}{$eRow}", $h); + } + $sheetEquip->getStyle("A{$eRow}:E{$eRow}")->applyFromArray($tableHeaderStyle); + $eRow++; + + foreach ($reports as $r) { + foreach ($r->equipment as $eq) { + $sheetEquip->setCellValue("A{$eRow}", $r->report_date ? $r->report_date->format('Y-m-d') : ''); + $sheetEquip->setCellValue("B{$eRow}", $r->report_number ?: '#' . $r->id); + $sheetEquip->setCellValue("C{$eRow}", $eq->equipment_name ?: 'Equipment'); + $sheetEquip->setCellValue("D{$eRow}", $eq->hours_used ?: 0); + $sheetEquip->setCellValue("E{$eRow}", $eq->status ?: 'Active'); + $sheetEquip->getStyle("A{$eRow}:E{$eRow}")->applyFromArray($thinBorder); + $eRow++; + } + } + foreach ($eqDetailCols as $col) { + $sheetEquip->getColumnDimension($col)->setAutoSize(true); + } + + // ---------------------------------------------------- + // 4. SHEET: MATERIALS CATEGORY SUMMATION + // ---------------------------------------------------- + $sheetMat = $spreadsheet->createSheet(); + $sheetMat->setTitle('Materials by Category'); + $sheetMat->setShowGridlines(true); + + $sheetMat->mergeCells('A1:E1'); + $sheetMat->setCellValue('A1', 'MATERIALS TRACKED — SUMMATION PER MATERIAL CATEGORY'); + $sheetMat->getStyle('A1:E1')->applyFromArray($sectionHeaderStyle); + $sheetMat->getRowDimension(1)->setRowHeight(28); + + $matSumHeaders = ['Material Name / Category', 'Cumulative Quantity Received / Used', 'Unit', 'Entries Count', 'Condition Summary']; + $matSumCols = range('A', 'E'); + $mRow = 3; + + foreach ($matSumHeaders as $k => $h) { + $sheetMat->setCellValue("{$matSumCols[$k]}{$mRow}", $h); + } + $sheetMat->getStyle("A{$mRow}:E{$mRow}")->applyFromArray($tableHeaderStyle); + $mRow++; + + foreach ($materialsCategorySummary as $mCat) { + $sheetMat->setCellValue("A{$mRow}", $mCat['material_name']); + $sheetMat->setCellValue("B{$mRow}", $mCat['total_quantity']); + $sheetMat->setCellValue("C{$mRow}", $mCat['unit']); + $sheetMat->setCellValue("D{$mRow}", $mCat['deliveries_count']); + $sheetMat->setCellValue("E{$mRow}", $mCat['condition_summary']); + $sheetMat->getStyle("A{$mRow}:E{$mRow}")->applyFromArray($thinBorder); + $mRow++; + } + $mRow += 2; + + // Itemized Daily Materials Log + $sheetMat->mergeCells("A{$mRow}:E{$mRow}"); + $sheetMat->setCellValue("A{$mRow}", 'ITEMIZED DAILY MATERIALS LOG'); + $sheetMat->getStyle("A{$mRow}:E{$mRow}")->applyFromArray($headerStyle); + $mRow++; + + $matDetailHeaders = ['Date', 'Report #', 'Material Name', 'Quantity Received', 'Condition / Remarks']; + $matDetailCols = range('A', 'E'); + foreach ($matDetailHeaders as $k => $h) { + $sheetMat->setCellValue("{$matDetailCols[$k]}{$mRow}", $h); + } + $sheetMat->getStyle("A{$mRow}:E{$mRow}")->applyFromArray($tableHeaderStyle); + $mRow++; + + foreach ($reports as $r) { + foreach ($r->materials as $mat) { + $sheetMat->setCellValue("A{$mRow}", $r->report_date ? $r->report_date->format('Y-m-d') : ''); + $sheetMat->setCellValue("B{$mRow}", $r->report_number ?: '#' . $r->id); + $sheetMat->setCellValue("C{$mRow}", $mat->material_name ?: 'Material'); + $sheetMat->setCellValue("D{$mRow}", $mat->quantity_received ?: '—'); + $sheetMat->setCellValue("E{$mRow}", $mat->condition ?: 'Good'); + $sheetMat->getStyle("A{$mRow}:E{$mRow}")->applyFromArray($thinBorder); + $mRow++; + } + } + foreach ($matDetailCols as $col) { + $sheetMat->getColumnDimension($col)->setAutoSize(true); + } + + // ---------------------------------------------------- + // 5. SHEET: ACTIVITIES & WORK PROGRESS + // ---------------------------------------------------- + $sheetAct = $spreadsheet->createSheet(); + $sheetAct->setTitle('Activities & Progress'); + $sheetAct->setShowGridlines(true); + + $sheetAct->mergeCells('A1:D1'); + $sheetAct->setCellValue('A1', 'WORK ACTIVITIES & TASKS — PROGRESS BY TASK / AREA'); + $sheetAct->getStyle('A1:D1')->applyFromArray($sectionHeaderStyle); + $sheetAct->getRowDimension(1)->setRowHeight(28); + + $actSumHeaders = ['Task Name', 'Location / Zone Area', 'Highest Progress (%)', 'Days Worked']; + $actSumCols = range('A', 'D'); + $aRow = 3; + + foreach ($actSumHeaders as $k => $h) { + $sheetAct->setCellValue("{$actSumCols[$k]}{$aRow}", $h); + } + $sheetAct->getStyle("A{$aRow}:D{$aRow}")->applyFromArray($tableHeaderStyle); + $aRow++; + + foreach ($activitiesCategorySummary as $aCat) { + $sheetAct->setCellValue("A{$aRow}", $aCat['task_name']); + $sheetAct->setCellValue("B{$aRow}", $aCat['zones']); + $sheetAct->setCellValue("C{$aRow}", $aCat['highest_progress'] . '%'); + $sheetAct->setCellValue("D{$aRow}", $aCat['days_worked']); + $sheetAct->getStyle("A{$aRow}:D{$aRow}")->applyFromArray($thinBorder); + $aRow++; + } + $aRow += 2; + + // Itemized Daily Activities Log + $sheetAct->mergeCells("A{$aRow}:F{$aRow}"); + $sheetAct->setCellValue("A{$aRow}", 'ITEMIZED DAILY ACTIVITIES LOG'); + $sheetAct->getStyle("A{$aRow}:F{$aRow}")->applyFromArray($headerStyle); + $aRow++; + + $actDetailHeaders = ['Date', 'Report #', 'Task Name', 'Zone / Area', 'Qty Completed', 'Progress (%)']; + $actDetailCols = range('A', 'F'); + foreach ($actDetailHeaders as $k => $h) { + $sheetAct->setCellValue("{$actDetailCols[$k]}{$aRow}", $h); + } + $sheetAct->getStyle("A{$aRow}:F{$aRow}")->applyFromArray($tableHeaderStyle); + $aRow++; + + foreach ($reports as $r) { + foreach ($r->activities as $act) { + $sheetAct->setCellValue("A{$aRow}", $r->report_date ? $r->report_date->format('Y-m-d') : ''); + $sheetAct->setCellValue("B{$aRow}", $r->report_number ?: '#' . $r->id); + $sheetAct->setCellValue("C{$aRow}", $act->task_name ?: '—'); + $sheetAct->setCellValue("D{$aRow}", $act->zone_area ?: '—'); + $sheetAct->setCellValue("E{$aRow}", $act->quantity_completed ?: '—'); + $sheetAct->setCellValue("F{$aRow}", ($act->percentage_completed ?: 0) . '%'); + $sheetAct->getStyle("A{$aRow}:F{$aRow}")->applyFromArray($thinBorder); + $aRow++; + } + } + foreach ($actDetailCols as $col) { + $sheetAct->getColumnDimension($col)->setAutoSize(true); + } + + // ---------------------------------------------------- + // 6. SHEET: ISSUES & DELAYS CATEGORY SUMMATION + // ---------------------------------------------------- + $sheetIss = $spreadsheet->createSheet(); + $sheetIss->setTitle('Issues & Delays'); + $sheetIss->setShowGridlines(true); + + $sheetIss->mergeCells('A1:E1'); + $sheetIss->setCellValue('A1', 'ISSUES & DELAYS — SUMMATION PER ISSUE CATEGORY'); + $sheetIss->getStyle('A1:E1')->applyFromArray($sectionHeaderStyle); + $sheetIss->getRowDimension(1)->setRowHeight(28); + + $issSumHeaders = ['Issue Category / Type', 'Incident Count', 'Resolved Count', 'Pending Count', 'Delay Impact Summary']; + $issSumCols = range('A', 'E'); + $iRow = 3; + + foreach ($issSumHeaders as $k => $h) { + $sheetIss->setCellValue("{$issSumCols[$k]}{$iRow}", $h); + } + $sheetIss->getStyle("A{$iRow}:E{$iRow}")->applyFromArray($tableHeaderStyle); + $iRow++; + + foreach ($issuesCategorySummary as $iCat) { + $sheetIss->setCellValue("A{$iRow}", $iCat['issue_type']); + $sheetIss->setCellValue("B{$iRow}", $iCat['incident_count']); + $sheetIss->setCellValue("C{$iRow}", $iCat['resolved_count']); + $sheetIss->setCellValue("D{$iRow}", $iCat['pending_count']); + $sheetIss->setCellValue("E{$iRow}", $iCat['delay_impact_summary']); + $sheetIss->getStyle("A{$iRow}:E{$iRow}")->applyFromArray($thinBorder); + $iRow++; + } + $iRow += 2; + + // Itemized Daily Issues Log + $sheetIss->mergeCells("A{$iRow}:F{$iRow}"); + $sheetIss->setCellValue("A{$iRow}", 'ITEMIZED DAILY ISSUES & CONCERNS LOG'); + $sheetIss->getStyle("A{$iRow}:F{$iRow}")->applyFromArray($headerStyle); + $iRow++; + + $issDetailHeaders = ['Date', 'Report #', 'Issue Type', 'Description', 'Delay Impact', 'Status']; + $issDetailCols = range('A', 'F'); + foreach ($issDetailHeaders as $k => $h) { + $sheetIss->setCellValue("{$issDetailCols[$k]}{$iRow}", $h); + } + $sheetIss->getStyle("A{$iRow}:F{$iRow}")->applyFromArray($tableHeaderStyle); + $iRow++; + + foreach ($reports as $r) { + foreach ($r->issues as $iss) { + $status = $iss->resolved_at ? 'Resolved' : ($iss->status ?: 'Pending'); + $sheetIss->setCellValue("A{$iRow}", $r->report_date ? $r->report_date->format('Y-m-d') : ''); + $sheetIss->setCellValue("B{$iRow}", $r->report_number ?: '#' . $r->id); + $sheetIss->setCellValue("C{$iRow}", $iss->issue_type ?: 'General'); + $sheetIss->setCellValue("D{$iRow}", $iss->description ?: '—'); + $sheetIss->setCellValue("E{$iRow}", $iss->delay_impact ?: '—'); + $sheetIss->setCellValue("F{$iRow}", $status); + $sheetIss->getStyle("A{$iRow}:F{$iRow}")->applyFromArray($thinBorder); + $iRow++; + } + } + foreach ($issDetailCols as $col) { + $sheetIss->getColumnDimension($col)->setAutoSize(true); + } + + // Set active sheet back to 0 + $spreadsheet->setActiveSheetIndex(0); + + $projectSlug = $project ? $project->code : 'All'; + $filename = 'DailyReports_' . $projectSlug . '_' . $startDate->format('Ymd') . '_' . $endDate->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 pdfRange(Request $request) + { + $request->validate([ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + 'project' => 'nullable|string', + ]); + + $startDate = Carbon::parse($request->input('start_date'))->startOfDay(); + $endDate = Carbon::parse($request->input('end_date'))->endOfDay(); + $projectUlid = $request->input('project'); + + $project = $projectUlid ? Project::where('ulid', $projectUlid)->first() : null; + + $query = DailyReport::with(['project', 'user', 'labors', 'equipment', 'activities', 'materials', 'issues']) + ->whereBetween('report_date', [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')]) + ->orderBy('report_date', 'asc'); + + if ($project) { + $query->where('project_id', $project->id); + } + + $reports = $query->get(); + + $allLabors = $reports->flatMap->labors; + $totalPeriodManHours = $allLabors->sum(fn ($l) => $l->workers_count * ($l->hours ?: 0)); + $allEquipment = $reports->flatMap->equipment; + $totalPeriodEquipHours = $allEquipment->sum('hours_used'); + $allMaterials = $reports->flatMap->materials; + $allActivities = $reports->flatMap->activities; + $allIssues = $reports->flatMap->issues; + + // 1. Manpower Category Summary + $laborCategorySummary = $allLabors->groupBy(fn ($l) => $l->trade ?: 'Unspecified Trade') + ->map(function ($group, $trade) use ($totalPeriodManHours) { + $workerDays = $group->sum('workers_count'); + $manHours = $group->sum(fn ($l) => $l->workers_count * ($l->hours ?: 0)); + $activeCount = $group->count(); + $percent = $totalPeriodManHours > 0 ? ($manHours / $totalPeriodManHours) * 100 : 0; + + return [ + 'trade' => $trade, + 'worker_days' => $workerDays, + 'total_man_hours' => $manHours, + 'days_active' => $activeCount, + 'percent_share' => round($percent, 1), + ]; + })->values()->sortByDesc('total_man_hours')->values(); + + // 2. Equipment Category Summary + $equipmentCategorySummary = $allEquipment->groupBy(fn ($eq) => $eq->equipment_name ?: 'Unspecified Equipment') + ->map(function ($group, $name) use ($totalPeriodEquipHours) { + $hours = $group->sum('hours_used'); + $activeCount = $group->count(); + $percent = $totalPeriodEquipHours > 0 ? ($hours / $totalPeriodEquipHours) * 100 : 0; + $latestStatus = $group->last()->status ?? 'Active'; + + return [ + 'equipment_name' => $name, + 'total_hours' => $hours, + 'days_active' => $activeCount, + 'percent_share' => round($percent, 1), + 'latest_status' => $latestStatus, + ]; + })->values()->sortByDesc('total_hours')->values(); + + // 3. Materials Category Summary + $materialsCategorySummary = $allMaterials->groupBy(fn ($m) => $m->material_name ?: 'General Material') + ->map(function ($group, $name) { + $deliveriesCount = $group->count(); + $quantities = $group->pluck('quantity_received')->filter()->all(); + $numericSum = 0; + $unit = ''; + foreach ($quantities as $q) { + if (preg_match('/^([0-9.]+)\s*(.*)$/', trim($q), $matches)) { + $numericSum += (float) $matches[1]; + if (empty($unit) && !empty($matches[2])) { + $unit = $matches[2]; + } + } + } + $qtyDisplay = $numericSum > 0 ? (string)$numericSum : (implode(', ', array_slice($quantities, 0, 3)) ?: '—'); + $conditions = $group->pluck('condition')->filter()->unique()->implode(', ') ?: 'Good'; + + return [ + 'material_name' => $name, + 'total_quantity' => $qtyDisplay, + 'unit' => $unit ?: 'units', + 'deliveries_count' => $deliveriesCount, + 'condition_summary' => $conditions, + ]; + })->values()->sortByDesc('deliveries_count')->values(); + + // 4. Activities Category Summary + $activitiesCategorySummary = $allActivities->groupBy(fn ($a) => $a->task_name ?: 'General Works') + ->map(function ($group, $name) { + $zones = $group->pluck('zone_area')->filter()->unique()->implode(', ') ?: 'Site Wide'; + $highestProgress = $group->max('percentage_completed') ?: 0; + $daysWorked = $group->count(); + + return [ + 'task_name' => $name, + 'zones' => $zones, + 'highest_progress' => $highestProgress, + 'days_worked' => $daysWorked, + ]; + })->values()->sortByDesc('days_worked')->values(); + + // 5. Issues Category Summary + $issuesCategorySummary = $allIssues->groupBy(fn ($i) => $i->issue_type ?: 'General') + ->map(function ($group, $type) { + $count = $group->count(); + $resolved = $group->whereNotNull('resolved_at')->count(); + $delays = $group->pluck('delay_impact')->filter()->all(); + + return [ + 'issue_type' => $type, + 'incident_count' => $count, + 'resolved_count' => $resolved, + 'pending_count' => $count - $resolved, + 'delay_impact_summary' => !empty($delays) ? implode('; ', array_slice($delays, 0, 3)) : 'None reported', + ]; + })->values()->sortByDesc('incident_count')->values(); + + $summary = [ + 'total_workers' => $allLabors->sum('workers_count'), + 'total_man_hours' => $totalPeriodManHours, + 'total_equipment_hours' => $totalPeriodEquipHours, + 'total_activities' => $allActivities->count(), + 'total_materials' => $allMaterials->count(), + 'total_issues' => $allIssues->count(), + ]; + + $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('dailyreports::pdf.daily-reports-range', [ + 'project' => $project, + 'reports' => $reports, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'summary' => $summary, + 'laborCategorySummary' => $laborCategorySummary, + 'equipmentCategorySummary' => $equipmentCategorySummary, + 'materialsCategorySummary' => $materialsCategorySummary, + 'activitiesCategorySummary' => $activitiesCategorySummary, + 'issuesCategorySummary' => $issuesCategorySummary, + ]); + + $projectSlug = $project ? $project->code : 'All'; + $filename = 'DailyReports_' . $projectSlug . '_' . $startDate->format('Ymd') . '_' . $endDate->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 2f3e2fa..70c7b30 100644 --- a/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php +++ b/Modules/DailyReports/app/Http/Controllers/DailyReportsController.php @@ -72,17 +72,44 @@ class DailyReportsController extends Controller )); if ($request->has('labors')) { - $report->labors()->createMany($request->labors); + $laborsData = collect($request->labors) + ->filter(fn ($l) => !empty($l['trade'])) + ->map(fn ($l) => [ + 'trade' => $l['trade'], + 'workers_count' => max(1, (int) ($l['workers_count'] ?? 1)), + 'hours' => !empty($l['hours']) ? (float) $l['hours'] : 8.0, + 'notes' => $l['notes'] ?? null, + ])->values()->all(); + + if (!empty($laborsData)) { + $report->labors()->createMany($laborsData); + } } + if ($request->has('equipment')) { - $report->equipment()->createMany($request->equipment); + $equipData = collect($request->equipment) + ->filter(fn ($e) => !empty($e['equipment_name'])) + ->map(fn ($e) => [ + 'equipment_name' => $e['equipment_name'], + 'hours_used' => !empty($e['hours_used']) ? (float) $e['hours_used'] : 8.0, + 'status' => $e['status'] ?? 'Active', + ])->values()->all(); + + if (!empty($equipData)) { + $report->equipment()->createMany($equipData); + } } + if ($request->has('activities')) { foreach ($request->activities as $index => $actData) { + if (empty($actData['task_name'])) { + continue; + } + $activity = $report->activities()->create([ - 'task_name' => $actData['task_name'] ?? '', - 'quantity_completed' => $actData['quantity_completed'] ?? 0, - 'percentage_completed' => $actData['percentage_completed'] ?? 0, + 'task_name' => $actData['task_name'], + 'quantity_completed' => !empty($actData['quantity_completed']) ? (string) $actData['quantity_completed'] : null, + 'percentage_completed' => !empty($actData['percentage_completed']) ? (float) $actData['percentage_completed'] : 0, 'zone_area' => $actData['zone_area'] ?? null, ]); @@ -93,11 +120,39 @@ class DailyReportsController extends Controller } } } + if ($request->has('materials')) { - $report->materials()->createMany($request->materials); + $materialsData = collect($request->materials) + ->filter(fn ($m) => !empty($m['material_name'])) + ->map(function ($m) { + $qty = $m['quantity_received'] ?? ($m['quantity'] ?? null); + $unit = $m['unit'] ?? ''; + $qtyStr = $qty !== null ? trim("{$qty} {$unit}") : null; + + return [ + 'material_name' => $m['material_name'], + 'quantity_received' => $qtyStr ?: ($m['quantity_received'] ?? null), + 'condition' => $m['condition'] ?? ($m['remarks'] ?? 'Good'), + ]; + })->values()->all(); + + if (!empty($materialsData)) { + $report->materials()->createMany($materialsData); + } } + if ($request->has('issues')) { - $report->issues()->createMany($request->issues); + $issuesData = collect($request->issues) + ->filter(fn ($i) => !empty($i['description']) || !empty($i['issue_type'])) + ->map(fn ($i) => [ + 'issue_type' => $i['issue_type'] ?? 'Delay', + 'description' => $i['description'] ?? ($i['issue_type'] ?? 'General Issue'), + 'delay_impact' => $i['delay_impact'] ?? null, + ])->values()->all(); + + if (!empty($issuesData)) { + $report->issues()->createMany($issuesData); + } } if ($request->hasFile('attachments')) { @@ -133,7 +188,13 @@ class DailyReportsController extends Controller */ public function edit(DailyReport $daily_report) { - $daily_report->load(['project', 'labors', 'equipment', 'activities.media', 'materials', 'issues', 'media']); + $daily_report->load([ + 'project.tasks:id,ulid,project_id,name', + 'project.materialsEstimates.material:id,ulid,name,unit', + 'project.tasks.taskLabors.labor:id,ulid,name,category', + 'project.tasks.taskEquipments.equipment:id,ulid,name', + 'labors', 'equipment', 'activities.media', 'materials', 'issues', 'media' + ]); return Inertia::render('DailyReports::Edit', [ 'report' => $daily_report, @@ -154,19 +215,46 @@ class DailyReportsController extends Controller // Sync nested relations (delete and recreate for simplicity) if ($request->has('labors')) { $daily_report->labors()->delete(); - $daily_report->labors()->createMany($request->labors); + $laborsData = collect($request->labors) + ->filter(fn ($l) => !empty($l['trade'])) + ->map(fn ($l) => [ + 'trade' => $l['trade'], + 'workers_count' => max(1, (int) ($l['workers_count'] ?? 1)), + 'hours' => !empty($l['hours']) ? (float) $l['hours'] : 8.0, + 'notes' => $l['notes'] ?? null, + ])->values()->all(); + + if (!empty($laborsData)) { + $daily_report->labors()->createMany($laborsData); + } } + if ($request->has('equipment')) { $daily_report->equipment()->delete(); - $daily_report->equipment()->createMany($request->equipment); + $equipData = collect($request->equipment) + ->filter(fn ($e) => !empty($e['equipment_name'])) + ->map(fn ($e) => [ + 'equipment_name' => $e['equipment_name'], + 'hours_used' => !empty($e['hours_used']) ? (float) $e['hours_used'] : 8.0, + 'status' => $e['status'] ?? 'Active', + ])->values()->all(); + + if (!empty($equipData)) { + $daily_report->equipment()->createMany($equipData); + } } + if ($request->has('activities')) { $daily_report->activities()->delete(); foreach ($request->activities as $index => $actData) { + if (empty($actData['task_name'])) { + continue; + } + $activity = $daily_report->activities()->create([ - 'task_name' => $actData['task_name'] ?? '', - 'quantity_completed' => $actData['quantity_completed'] ?? 0, - 'percentage_completed' => $actData['percentage_completed'] ?? 0, + 'task_name' => $actData['task_name'], + 'quantity_completed' => !empty($actData['quantity_completed']) ? (string) $actData['quantity_completed'] : null, + 'percentage_completed' => !empty($actData['percentage_completed']) ? (float) $actData['percentage_completed'] : 0, 'zone_area' => $actData['zone_area'] ?? null, ]); @@ -177,13 +265,41 @@ class DailyReportsController extends Controller } } } + if ($request->has('materials')) { $daily_report->materials()->delete(); - $daily_report->materials()->createMany($request->materials); + $materialsData = collect($request->materials) + ->filter(fn ($m) => !empty($m['material_name'])) + ->map(function ($m) { + $qty = $m['quantity_received'] ?? ($m['quantity'] ?? null); + $unit = $m['unit'] ?? ''; + $qtyStr = $qty !== null ? trim("{$qty} {$unit}") : null; + + return [ + 'material_name' => $m['material_name'], + 'quantity_received' => $qtyStr ?: ($m['quantity_received'] ?? null), + 'condition' => $m['condition'] ?? ($m['remarks'] ?? 'Good'), + ]; + })->values()->all(); + + if (!empty($materialsData)) { + $daily_report->materials()->createMany($materialsData); + } } + if ($request->has('issues')) { $daily_report->issues()->delete(); - $daily_report->issues()->createMany($request->issues); + $issuesData = collect($request->issues) + ->filter(fn ($i) => !empty($i['description']) || !empty($i['issue_type'])) + ->map(fn ($i) => [ + 'issue_type' => $i['issue_type'] ?? 'Delay', + 'description' => $i['description'] ?? ($i['issue_type'] ?? 'General Issue'), + 'delay_impact' => $i['delay_impact'] ?? null, + ])->values()->all(); + + if (!empty($issuesData)) { + $daily_report->issues()->createMany($issuesData); + } } if ($request->hasFile('attachments')) { diff --git a/Modules/DailyReports/app/Http/Requests/StoreDailyReportRequest.php b/Modules/DailyReports/app/Http/Requests/StoreDailyReportRequest.php index 04b1371..aacfc1b 100644 --- a/Modules/DailyReports/app/Http/Requests/StoreDailyReportRequest.php +++ b/Modules/DailyReports/app/Http/Requests/StoreDailyReportRequest.php @@ -11,7 +11,7 @@ class StoreDailyReportRequest extends FormRequest */ public function authorize(): bool { - return true; // Authorize in controller or via policies + return true; } /** @@ -21,46 +21,49 @@ class StoreDailyReportRequest extends FormRequest { return [ 'project_id' => ['required', 'exists:projects,id'], - 'report_date' => ['required', 'date', 'after_or_equal:today'], // No backdating + 'report_date' => ['required', 'date'], 'report_number' => ['nullable', 'string', 'max:255'], 'temperature' => ['nullable', 'string', 'max:255'], 'precipitation' => ['nullable', 'string', 'max:255'], 'wind' => ['nullable', 'string', 'max:255'], 'weather' => ['nullable', 'string', 'max:255'], - 'start_time' => ['nullable', 'date_format:H:i'], - 'end_time' => ['nullable', 'date_format:H:i'], + 'start_time' => ['nullable', 'string', 'max:20'], + 'end_time' => ['nullable', 'string', 'max:20'], 'remarks' => ['nullable', 'string'], // Nested relations 'labors' => ['nullable', 'array'], - 'labors.*.trade' => ['required_with:labors', 'string', 'max:255'], - 'labors.*.workers_count' => ['required_with:labors', 'integer', 'min:1'], + 'labors.*.trade' => ['nullable', 'string', 'max:255'], + 'labors.*.workers_count' => ['nullable', 'numeric', 'min:0'], 'labors.*.hours' => ['nullable', 'numeric', 'min:0'], 'labors.*.notes' => ['nullable', 'string'], 'equipment' => ['nullable', 'array'], - 'equipment.*.equipment_name' => ['required_with:equipment', 'string', 'max:255'], + 'equipment.*.equipment_name' => ['nullable', 'string', 'max:255'], 'equipment.*.hours_used' => ['nullable', 'numeric', 'min:0'], 'equipment.*.status' => ['nullable', 'string', 'max:255'], 'activities' => ['nullable', 'array'], - 'activities.*.task_name' => ['required_with:activities', 'string', 'max:255'], + 'activities.*.task_name' => ['nullable', 'string', 'max:255'], 'activities.*.quantity_completed' => ['nullable', 'string', 'max:255'], 'activities.*.percentage_completed' => ['nullable', 'numeric', 'min:0', 'max:100'], 'activities.*.zone_area' => ['nullable', 'string', 'max:255'], 'materials' => ['nullable', 'array'], - 'materials.*.material_name' => ['required_with:materials', 'string', 'max:255'], + 'materials.*.material_name' => ['nullable', 'string', 'max:255'], 'materials.*.quantity_received' => ['nullable', 'string', 'max:255'], + 'materials.*.quantity' => ['nullable', 'numeric'], + 'materials.*.unit' => ['nullable', 'string', 'max:50'], 'materials.*.condition' => ['nullable', 'string', 'max:255'], + 'materials.*.remarks' => ['nullable', 'string', 'max:255'], 'issues' => ['nullable', 'array'], - 'issues.*.issue_type' => ['required_with:issues', 'string', 'max:255'], - 'issues.*.description' => ['required_with:issues', 'string'], + 'issues.*.issue_type' => ['nullable', 'string', 'max:255'], + 'issues.*.description' => ['nullable', 'string'], 'issues.*.delay_impact' => ['nullable', 'string', 'max:255'], 'attachments' => ['nullable', 'array'], - 'attachments.*' => ['file', 'max:10240'], // max 10MB per file + 'attachments.*' => ['file', 'max:10240'], ]; } } diff --git a/Modules/DailyReports/app/Http/Requests/UpdateDailyReportRequest.php b/Modules/DailyReports/app/Http/Requests/UpdateDailyReportRequest.php index 50321ff..ff62bb0 100644 --- a/Modules/DailyReports/app/Http/Requests/UpdateDailyReportRequest.php +++ b/Modules/DailyReports/app/Http/Requests/UpdateDailyReportRequest.php @@ -21,41 +21,45 @@ class UpdateDailyReportRequest extends FormRequest { return [ 'project_id' => ['required', 'exists:projects,id'], - 'report_date' => ['required', 'date'], // Can't change to past, but existing might be past. Controller logic handles edits. + 'report_date' => ['required', 'date'], 'report_number' => ['nullable', 'string', 'max:255'], 'temperature' => ['nullable', 'string', 'max:255'], 'precipitation' => ['nullable', 'string', 'max:255'], 'wind' => ['nullable', 'string', 'max:255'], 'weather' => ['nullable', 'string', 'max:255'], - 'start_time' => ['nullable', 'date_format:H:i'], - 'end_time' => ['nullable', 'date_format:H:i'], + 'start_time' => ['nullable', 'string', 'max:20'], + 'end_time' => ['nullable', 'string', 'max:20'], 'remarks' => ['nullable', 'string'], + // Nested relations 'labors' => ['nullable', 'array'], - 'labors.*.trade' => ['required_with:labors', 'string', 'max:255'], - 'labors.*.workers_count' => ['required_with:labors', 'integer', 'min:1'], + 'labors.*.trade' => ['nullable', 'string', 'max:255'], + 'labors.*.workers_count' => ['nullable', 'numeric', 'min:0'], 'labors.*.hours' => ['nullable', 'numeric', 'min:0'], 'labors.*.notes' => ['nullable', 'string'], 'equipment' => ['nullable', 'array'], - 'equipment.*.equipment_name' => ['required_with:equipment', 'string', 'max:255'], + 'equipment.*.equipment_name' => ['nullable', 'string', 'max:255'], 'equipment.*.hours_used' => ['nullable', 'numeric', 'min:0'], 'equipment.*.status' => ['nullable', 'string', 'max:255'], 'activities' => ['nullable', 'array'], - 'activities.*.task_name' => ['required_with:activities', 'string', 'max:255'], + 'activities.*.task_name' => ['nullable', 'string', 'max:255'], 'activities.*.quantity_completed' => ['nullable', 'string', 'max:255'], 'activities.*.percentage_completed' => ['nullable', 'numeric', 'min:0', 'max:100'], 'activities.*.zone_area' => ['nullable', 'string', 'max:255'], 'materials' => ['nullable', 'array'], - 'materials.*.material_name' => ['required_with:materials', 'string', 'max:255'], + 'materials.*.material_name' => ['nullable', 'string', 'max:255'], 'materials.*.quantity_received' => ['nullable', 'string', 'max:255'], + 'materials.*.quantity' => ['nullable', 'numeric'], + 'materials.*.unit' => ['nullable', 'string', 'max:50'], 'materials.*.condition' => ['nullable', 'string', 'max:255'], + 'materials.*.remarks' => ['nullable', 'string', 'max:255'], 'issues' => ['nullable', 'array'], - 'issues.*.issue_type' => ['required_with:issues', 'string', 'max:255'], - 'issues.*.description' => ['required_with:issues', 'string'], + 'issues.*.issue_type' => ['nullable', 'string', 'max:255'], + 'issues.*.description' => ['nullable', 'string'], 'issues.*.delay_impact' => ['nullable', 'string', 'max:255'], 'attachments' => ['nullable', 'array'], diff --git a/Modules/DailyReports/resources/js/Pages/Index.tsx b/Modules/DailyReports/resources/js/Pages/Index.tsx index 3aa98b6..284cc83 100644 --- a/Modules/DailyReports/resources/js/Pages/Index.tsx +++ b/Modules/DailyReports/resources/js/Pages/Index.tsx @@ -1,25 +1,136 @@ -import React from 'react'; -import { Head, Link, usePage } from '@inertiajs/react'; +import React, { useState } from 'react'; +import { Head, Link } from '@inertiajs/react'; import ProjectLayout from '../../../../ProjectManagement/resources/js/Layouts/ProjectLayout'; import { Button } from '@/Components/ui/button'; -import { Plus, FileText, Calendar, Clock, User, Download } from 'lucide-react'; +import { Plus, FileText, Calendar, Clock, User, Download, FileSpreadsheet, FileDown } from 'lucide-react'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/Components/ui/card'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/Components/ui/dialog'; +import { Label } from '@/Components/ui/label'; +import { Input } from '@/Components/ui/input'; export default function Index({ project, projects, reports }: any) { + const [exportModalOpen, setExportModalOpen] = useState(false); + const [startDate, setStartDate] = useState(() => { + const d = new Date(); + d.setDate(d.getDate() - 30); + return d.toISOString().split('T')[0]; + }); + const [endDate, setEndDate] = useState(() => new Date().toISOString().split('T')[0]); + const [exportFormat, setExportFormat] = useState<'pdf' | 'excel'>('pdf'); + + const handleExportRange = (e: React.FormEvent) => { + e.preventDefault(); + const routeName = exportFormat === 'pdf' ? 'projects.daily-reports.pdf-range' : 'projects.daily-reports.export-range'; + const url = route(routeName, { + project: project?.ulid, + start_date: startDate, + end_date: endDate, + }); + + window.open(url, '_blank'); + setExportModalOpen(false); + }; + return ( -
+

Daily Reports

Manage daily construction reports for {project?.name}

- - - + + + +
+ {/* Date Range Export Modal */} + + + + + + Export Daily Reports by Date Range + + + +
+ Select the preferred date range and format to generate a consolidated report for {project?.name || 'this project'}. +
+ +
+
+ + setStartDate(e.target.value)} + className="text-xs" + /> +
+
+ + setEndDate(e.target.value)} + className="text-xs" + /> +
+
+ +
+ +
+ + +
+
+ + + + + + +
+
+ {reports.data.length > 0 ? (
{reports.data.map((report: any) => ( diff --git a/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx b/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx index 3d20f36..6b54517 100644 --- a/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx +++ b/Modules/DailyReports/resources/js/Pages/Partials/ReportForm.tsx @@ -1,51 +1,141 @@ import React, { useState } from 'react'; -import { useForm } from '@inertiajs/react'; +import { router, useForm, usePage } from '@inertiajs/react'; import { Button } from '@/Components/ui/button'; import { Input } from '@/Components/ui/input'; import { Label } from '@/Components/ui/label'; import { Textarea } from '@/Components/ui/textarea'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/Components/ui/card'; -import { Trash2, Plus, Save } from 'lucide-react'; +import { Trash2, Plus, Save, AlertCircle } 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 + const { flash } = usePage().props; + + // Project-scoped estimation lookup lists const projectTasks = project?.tasks || []; const projectMaterials = project?.materials_estimates?.map((m: any) => m.material).filter(Boolean) || []; - // Fallbacks to master catalogs if project allocations are empty + // Extract unique labors from project tasks estimation + const estimatedLaborsMap = new Map(); + projectTasks.forEach((task: any) => { + const laborsList = task.task_labors || task.taskLabors || []; + laborsList.forEach((tl: any) => { + if (tl.labor?.name) { + estimatedLaborsMap.set(tl.labor.name, { + id: tl.labor.id || tl.id, + name: tl.labor.name, + category: tl.labor.category || 'General', + }); + } else if (tl.bundle_name) { + estimatedLaborsMap.set(tl.bundle_name, { + id: `bundle-${tl.id}`, + name: tl.bundle_name, + category: 'Bundle', + }); + } + }); + }); + const availableLabors = Array.from(estimatedLaborsMap.values()); + + // Extract unique equipments from project tasks estimation + const estimatedEquipmentsMap = new Map(); + projectTasks.forEach((task: any) => { + const equipmentsList = task.task_equipments || task.taskEquipments || []; + equipmentsList.forEach((te: any) => { + if (te.equipment?.name) { + estimatedEquipmentsMap.set(te.equipment.name, { + id: te.equipment.id || te.id, + name: te.equipment.name, + }); + } + }); + }); + const availableEquipments = Array.from(estimatedEquipmentsMap.values()); + + // Scope materials to project estimates const availableMaterials = projectMaterials.length > 0 ? projectMaterials : masterMaterials; - const availableLabors = masterLabors; - const availableEquipments = masterEquipments; + + const initialLabors = (report?.labors || []).map((l: any) => ({ + trade: l.trade || '', + workers_count: l.workers_count !== undefined ? l.workers_count : 1, + hours: l.hours !== undefined ? l.hours : 8, + notes: l.notes || '', + })); + + const initialEquipment = (report?.equipment || []).map((eq: any) => ({ + equipment_name: eq.equipment_name || '', + hours_used: eq.hours_used !== undefined ? eq.hours_used : 8, + status: eq.status || 'Active', + })); + + const initialActivities = (report?.activities || []).map((a: any) => ({ + task_name: a.task_name || '', + quantity_completed: a.quantity_completed || '', + percentage_completed: a.percentage_completed !== undefined ? a.percentage_completed : 0, + zone_area: a.zone_area || '', + evidence_urls: a.evidence_urls || [], + evidence: [], + })); + + const initialMaterials = (report?.materials || []).map((m: any) => ({ + material_name: m.material_name || '', + quantity: m.quantity !== undefined ? m.quantity : (m.quantity_received ? parseFloat(m.quantity_received) || '' : ''), + unit: m.unit || (m.quantity_received ? String(m.quantity_received).replace(/^[0-9.]+\s*/, '') : ''), + remarks: m.remarks || m.condition || '', + quantity_received: m.quantity_received || '', + condition: m.condition || 'Good', + })); + + const initialIssues = (report?.issues || []).map((i: any) => ({ + issue_type: i.issue_type || 'Delay', + description: i.description || '', + delay_impact: i.delay_impact || '', + })); + const { data, setData, post, put, processing, errors } = useForm({ - project_id: project?.id || '', + project_id: project?.id || report?.project_id || '', report_number: report?.report_number || '', report_date: report?.report_date || new Date().toISOString().split('T')[0], temperature: report?.temperature || '', precipitation: report?.precipitation || '', wind: report?.wind || '', weather: report?.weather || '', - start_time: report?.start_time || '08:00', - end_time: report?.end_time || '17:00', + start_time: report?.start_time ? String(report.start_time).substring(0, 5) : '08:00', + end_time: report?.end_time ? String(report.end_time).substring(0, 5) : '17:00', remarks: report?.remarks || '', - labors: report?.labors || [], - equipment: report?.equipment || [], - activities: report?.activities || [], - materials: report?.materials || [], - issues: report?.issues || [], + labors: initialLabors, + equipment: initialEquipment, + activities: initialActivities, + materials: initialMaterials, + issues: initialIssues, }); const submit = (e: React.FormEvent) => { e.preventDefault(); - - // Since we are uploading files, if we had attachments we would use post even for update with _method: 'put' - // For now, standard forms + + // Sanitize and filter out empty rows before submit + const cleanPayload = { + ...data, + labors: (data.labors || []).filter((l: any) => l.trade && String(l.trade).trim() !== ''), + equipment: (data.equipment || []).filter((eq: any) => eq.equipment_name && String(eq.equipment_name).trim() !== ''), + activities: (data.activities || []).filter((a: any) => a.task_name && String(a.task_name).trim() !== ''), + materials: (data.materials || []).filter((m: any) => m.material_name && String(m.material_name).trim() !== ''), + issues: (data.issues || []).filter((i: any) => (i.description && String(i.description).trim() !== '') || (i.issue_type && String(i.issue_type).trim() !== '')), + }; + if (isEdit) { - put(route('projects.daily-reports.update', report.id)); + router.post(route('projects.daily-reports.update', report.id), { + ...cleanPayload, + _method: 'put', + }, { + forceFormData: true, + }); } else { - post(route('projects.daily-reports.store')); + router.post(route('projects.daily-reports.store'), cleanPayload, { + forceFormData: true, + }); } }; @@ -67,6 +157,27 @@ export default function ReportForm({ project, report = null, isEdit = false, mas return (
+ {flash?.error && ( +
+ +
{flash.error}
+
+ )} + + {Object.keys(errors).length > 0 && ( +
+
+ + Please correct the following errors before saving: +
+
    + {Object.entries(errors).map(([key, msg]) => ( +
  • {msg}
  • + ))} +
+
+ )} + Metadata & Conditions @@ -80,7 +191,6 @@ export default function ReportForm({ project, report = null, isEdit = false, mas value={data.report_date} onChange={e => setData('report_date', e.target.value)} required - disabled={isEdit} // Prevent changing date on edit usually, but let's allow it unless strict /> {errors.report_date &&

{errors.report_date}

}
@@ -119,29 +229,30 @@ export default function ReportForm({ project, report = null, isEdit = false, mas Tasks Materials - Manpower/Labor + Manpower Equipment - Issues & Concern + Issues - {/* Activities Tab (Tasks) */} + {/* Activities Tab */}
- Tasks + Task Activities & Progress + Log work items accomplished today
-
{data.activities.map((row: any, i: number) => ( -
-
-
-
- +
+
+
+ + {projectTasks.length > 0 ? ( - {(!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)} /> -
+ ) : null} + {(projectTasks.length === 0 || !projectTasks.some((t: any) => t.name === row.task_name)) && ( + updateRow('activities', i, 'task_name', e.target.value)} + required + /> + )} +
+
+ + updateRow('activities', i, 'zone_area', e.target.value)} placeholder="e.g. Ground Floor, Block A" /> +
+
+ + updateRow('activities', i, 'quantity_completed', e.target.value)} /> +
+
+ + updateRow('activities', i, 'percentage_completed', e.target.value)} /> +
+
+ updateRow('activities', i, 'evidence', files)} + />
- -
-
- updateRow('activities', i, 'evidence', files)} - />
+
))} - {data.activities.length === 0 &&

No activities added yet.

} + {data.activities.length === 0 &&

No tasks added yet.

} @@ -207,18 +319,19 @@ export default function ReportForm({ project, report = null, isEdit = false, mas
- Materials + Materials Used / Received + Track daily material consumption and deliveries
-
{data.materials.map((row: any, i: number) => (
-
+
- + {(!availableMaterials.some((m: any) => m.name === row.material_name) || row.material_name === '') && ( updateRow('materials', i, 'material_name', e.target.value)} /> @@ -250,11 +367,15 @@ export default function ReportForm({ project, report = null, isEdit = false, mas
- updateRow('materials', i, 'quantity_received', e.target.value)} /> + updateRow('materials', i, 'quantity', e.target.value)} required />
- - updateRow('materials', i, 'condition', e.target.value)} /> + + updateRow('materials', i, 'unit', e.target.value)} placeholder="e.g., bags, pcs, m3" required /> +
+
+ + updateRow('materials', i, 'remarks', e.target.value)} />
- - -

- {isEditing ? `Edit Purchase Order: ${purchaseOrder.document_number}` : 'Create Purchase Order'} -

+
+
+ + + +
+ +
+
+

+ {isEditing ? `Edit Purchase Order: ${purchaseOrder.document_number}` : 'Create Purchase Order'} +

+

+ Procure and fulfill required materials for active construction projects +

+
+
} > -
- -
- - General Information - -
-
- - {isEditing ? ( - - ) : ( - +
+
+ +
+ + {/* Main Card */} + + +
+
+
+ +
+
+ + Purchase Order Information + +

+ Configure project allocation, destination warehouse, and supplier details. +

+
+
+ {data.requisition_ulids.length > 0 && ( + + {data.requisition_ulids.length} MR{data.requisition_ulids.length > 1 ? 's' : ''} Linked + )} - {errors.project_ulid &&

{errors.project_ulid}

}
-
- -
- - {data.target_warehouse_ulid && warehouses ? ( - {warehouses.find(w => w.ulid === data.target_warehouse_ulid)?.name} ({warehouses.find(w => w.ulid === data.target_warehouse_ulid)?.code}) - ) : data.target_warehouse_ulid && purchaseOrder?.targetWarehouse ? ( - {purchaseOrder.targetWarehouse.name} ({purchaseOrder.targetWarehouse.code || ''}) - ) : ( - Select target warehouse... - )} - - -
- {errors.target_warehouse_ulid &&

{errors.target_warehouse_ulid}

} -
-
- - setData('supplier', e.target.value)} placeholder="Supplier name..." className="mt-1 h-10" /> - {errors.supplier &&

{errors.supplier}

} -
-
- - setData('notes', e.target.value)} placeholder="Optional notes..." className="mt-1 h-10" /> -
-
- -
-
+ + + + {/* General Information Section */} +
- -

To order items, manually link approved Material Requisitions below.

+ + {isEditing ? ( + + ) : ( + + )} + {errors.project_ulid &&

{errors.project_ulid}

} +
+ +
+ +
+ + {data.target_warehouse_ulid && warehouses ? ( + {warehouses.find(w => w.ulid === data.target_warehouse_ulid)?.name} ({warehouses.find(w => w.ulid === data.target_warehouse_ulid)?.code}) + ) : data.target_warehouse_ulid && purchaseOrder?.targetWarehouse ? ( + {purchaseOrder.targetWarehouse.name} ({purchaseOrder.targetWarehouse.code || ''}) + ) : ( + Select target warehouse... + )} + + +
+ {errors.target_warehouse_ulid &&

{errors.target_warehouse_ulid}

} +
+ +
+ + setData('supplier', e.target.value)} + placeholder="Enter vendor or supplier name..." + className="bg-white border-slate-200 h-10 shadow-sm text-sm" + /> + {errors.supplier &&

{errors.supplier}

} +
+ +
+ + setData('notes', e.target.value)} + placeholder="Optional delivery instructions or notes..." + className="bg-white border-slate-200 h-10 shadow-sm text-sm" + />
-
- {errors.items &&

{errors.items}

} + {/* Ordered Items Section */} +
+
+
+
+

Ordered Materials

+ + {data.items.length} {data.items.length === 1 ? 'item' : 'items'} + +
+

+ Materials are imported from approved Material Requisitions with requested limits enforced. +

+
+ +
- {data.items.length === 0 ? ( -
- -

Click "Select Material Requests" above to choose approved MRs.

+ {errors.items && ( +
+ {errors.items} +
+ )} + + {data.items.length === 0 ? ( +
+
+ +
+

No items selected yet

+

+ Click "Select Material Requests" above to choose approved MRs and fulfill their materials. +

+ +
+ ) : ( +
+ {data.items.map((item, idx) => { + const mat = materials.find(m => m.ulid === item.material_ulid); + if (!mat) return null; + const maxRequested = getRequestedQty(item); + const orderQty = parseFloat(item.quantity) || 0; + const isExceeded = maxRequested !== null && orderQty > maxRequested + 0.0001; + + return ( +
+ {/* Material details */} +
+
+ + {mat.sku && ( + + {mat.sku} + + )} +
+
+ {mat.name} + {mat.unit && ( + + {mat.unit} + + )} +
+
+ + {/* Order Quantity Stepper */} +
+
+ + {maxRequested !== null && ( + + )} +
+
+ + updateItem(idx, 'quantity', e.target.value)} + className="border-0 shadow-none text-center font-mono font-bold text-slate-800 focus-visible:ring-0 h-full [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none px-2 text-sm" + placeholder="0.00" + /> + +
+ {isExceeded && ( +

+ + Exceeds requested quantity ({maxRequested?.toFixed(2)} {mat.unit}) +

+ )} +
+ + {/* Unit Price */} +
+ +
+ + updateItem(idx, 'unit_cost', e.target.value)} + className="pl-7 h-10 border-slate-300 rounded-xl font-mono text-sm font-bold text-slate-800 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none shadow-sm" + /> +
+
+ + {/* Line Total */} +
+ +
+ {new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format( + (parseFloat(item.quantity) || 0) * (parseFloat(item.unit_cost) || 0) + )} +
+
+
+ )})} +
+ )} + + {hasExceededRequested && ( +
+ + One or more items exceed the requested quantities from the linked Material Requisitions. Please adjust quantities before saving. +
+ )} +
+ + {/* Order Summary & Actions */} +
+
+
+ Total Items: + {data.items.length} materials ({totalUnitsCount.toFixed(2)} units) +
+
+
+ Estimated Total: + + {new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(totalOrderAmount)} + +
+
+ +
+ + + + +
+
+ + + + {/* Right Sidebar: Supporting Documents */} + + +
+ + + Supporting Documents + + {data.requisition_ulids.length > 0 && ( + + {data.requisition_ulids.length} + + )} +
+
+ + {data.requisition_ulids.length === 0 ? ( +
+
+ +
+

No Requisitions linked yet.

+

Use the button on the left to link approved MRs.

) : ( -
- {data.items.map((item, idx) => { - const mat = materials.find(m => m.ulid === item.material_ulid); - if (!mat) return null; +
+ {data.requisition_ulids.map((ulid: string) => { + const mr = approvedRequisitions.find(r => r.ulid === ulid); + if (!mr) return null; return ( -
-
- -
- {mat.name} - {mat.sku && {mat.sku}} +
+
+
+ {mr.document_number} + {mr.items.length} material{mr.items.length > 1 ? 's' : ''} requested
+
-
- -
- updateItem(idx, 'quantity', e.target.value)} className="pr-12" /> - {mat.unit} -
-
-
- -
- - updateItem(idx, 'unit_cost', e.target.value)} className="pl-6" /> -
+
+
{mr.requester?.name || 'System'}
+
{mr.created_at ? new Date(mr.created_at).toLocaleDateString() : 'Unknown'}
)})}
)} -
-
- - -
- - - - {/* Right sidebar */} - - - Supporting Documents - - - {data.requisition_ulids.length === 0 ? ( -
- -

No Requisitions linked yet.

-
- ) : ( -
- {data.requisition_ulids.map((ulid: string) => { - const mr = approvedRequisitions.find(r => r.ulid === ulid); - if (!mr) return null; - return ( -
-
-
- {mr.document_number} - {mr.items.length} materials linked -
- -
-
-
{mr.requester?.name || 'System'}
-
{mr.created_at ? new Date(mr.created_at).toLocaleDateString() : 'Unknown'}
-
-
- )})} -
- )} -
-
-
- -
+ + +
+ +
+
{/* MR Picker Modal */} @@ -564,62 +896,120 @@ export default function Form({ purchaseOrder, materials, approvedRequisitions, w {/* Requisition Preview Modal */} !open && setPreviewMr(null)}> - + {previewMr && ( <> - -
-
- - - Requisition Details - - - {previewMr.document_number} + +
+
+ +
+
+
+ + Requisition Details + + + {previewMr.status ? previewMr.status.toUpperCase() : 'APPROVED'} + +
+ + #{previewMr.document_number} + {previewMr.project && ( + <> + · + {previewMr.project.name} + + )}
- Approved
-
-
-
-

Requested By

-

{previewMr.requester?.name || 'Unknown User'}

+
+ {/* Key Highlights Grid */} +
+
+
Requested By
+
+ + {previewMr.requester?.name || 'Unknown User'} +
+
{previewMr.requester?.email || 'Authorized Personnel'}
-
-

Date Requested

-

{previewMr.created_at ? new Date(previewMr.created_at).toLocaleDateString() : 'Unknown Date'}

+
+
Date Requested
+
+ + {previewMr.created_at ? new Date(previewMr.created_at).toLocaleDateString() : 'N/A'} +
+
Logged in system
-
-

Notes / Instructions

-

{previewMr.notes || 'No notes provided.'}

+
+
Materials Scope
+
+ + {previewMr.items.length} Item(s) +
+
Approved for PO
-
-

- Requested Materials - {previewMr.items.length} items -

-
- - + {/* Notes / Instructions Box */} +
+
+ Notes / Special Instructions +
+

+ {previewMr.notes || 'No special notes or instructions provided for this requisition.'} +

+
+ + {/* Requested Materials Table */} +
+
+

+ Requested Materials ({previewMr.items.length}) +

+ Requisition Line Items +
+
+
+ - - + + + - - {previewMr.items.map(item => ( - - + {previewMr.items.map((item: any, idx: number) => ( + + - + ))} @@ -629,8 +1019,15 @@ export default function Form({ purchaseOrder, materials, approvedRequisitions, w -
- +
+
)} diff --git a/Modules/MaterialLogistics/resources/js/Pages/PurchaseOrders/Index.tsx b/Modules/MaterialLogistics/resources/js/Pages/PurchaseOrders/Index.tsx index fb412af..08c191d 100644 --- a/Modules/MaterialLogistics/resources/js/Pages/PurchaseOrders/Index.tsx +++ b/Modules/MaterialLogistics/resources/js/Pages/PurchaseOrders/Index.tsx @@ -4,22 +4,14 @@ import { Badge } from '@/Components/ui/badge'; import { Button } from '@/Components/ui/button'; import { Card, CardContent } from '@/Components/ui/card'; import { DataTableToolbar } from '@/Components/DataTableToolbar'; -import { Input } from '@/Components/ui/input'; -import { Label } from '@/Components/ui/label'; -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/Components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/Components/ui/table'; -import { - Dialog, DialogContent, DialogHeader, DialogTitle, -} from '@/Components/ui/dialog'; import { PageProps, PaginatedData } from '@/types'; import { - CreditCard, Download, FileDown, Loader2, Plus, SendHorizontal, ShoppingCart, Eye, + Plus, ShoppingCart, } from 'lucide-react'; -import { FormEvent, Fragment, useMemo, useRef, useState } from 'react'; +import { Fragment, useState } from 'react'; interface MaterialOption { id: number; ulid: string; name: string; unit: string; unit_cost: string; @@ -57,50 +49,20 @@ interface WarehouseOption { interface Props extends PageProps { purchaseOrders: PaginatedData; - materials: MaterialOption[]; approvedRequisitions: ApprovedMr[]; warehouses: WarehouseOption[]; } const fmt = (v: string | number) => new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v)); -const statusColor = (s: string) => ({ draft: 'outline', submitted: 'secondary', approved: 'default', rejected: 'destructive' }[s] ?? 'outline') as 'outline' | 'secondary' | 'default' | 'destructive'; -const paymentColor = (s: string) => ({ unpaid: 'destructive', partial: 'secondary', paid: 'default' }[s] ?? 'outline') as 'destructive' | 'secondary' | 'default'; +const statusColor = (s: string) => ({ draft: 'outline', submitted: 'secondary', approved: 'default', rejected: 'destructive', delivered: 'default' }[s] ?? 'outline') as 'outline' | 'secondary' | 'default' | 'destructive'; -interface ItemEntry { material_ulid: string; quantity: string; unit_cost: string; requisition_item_id?: number; } - -export default function Index({ purchaseOrders, warehouses }: Props) { - const { flash, errors } = usePage().props; - const [payDialog, setPayDialog] = useState(null); +export default function Index({ purchaseOrders }: Props) { + const { flash } = usePage().props; const [search, setSearch] = useState(''); - const [payWarehouse, setPayWarehouse] = useState(''); - const [paying, setPaying] = useState(false); - const receiptRef = useRef(null); - - const warehouseSelectItems = useMemo(() => (warehouses || []).map(w => ({ value: w.ulid, label: `${w.name} (${w.code})` })), [warehouses]); - - const handleSubmitApproval = (ulid: string) => { - if (confirm('Submit this Purchase Order for approval?')) { - router.patch(route('purchase-orders.submit', ulid)); - } - }; const lineTotal = (item: PoItem) => Number(item.quantity) * Number(item.unit_cost); - const handlePayment = (e: FormEvent) => { - e.preventDefault(); - if (!payDialog || !receiptRef.current?.files?.[0]) return; - setPaying(true); - const formData = new FormData(); - formData.append('warehouse_ulid', payWarehouse); - formData.append('receipt', receiptRef.current.files[0]); - router.post(route('purchase-orders.pay', payDialog), formData, { - forceFormData: true, - onSuccess: () => { setPayDialog(null); setPayWarehouse(''); }, - onFinish: () => setPaying(false), - }); - }; - return ( Document #Supplier Requested ByItems - TotalStatusPayment + TotalStatus {purchaseOrders.data.length === 0 ? ( - No purchase orders yet. + No purchase orders yet. ) : purchaseOrders.data.filter(po => !search || po.document_number.toLowerCase().includes(search.toLowerCase()) || (po.supplier || '').toLowerCase().includes(search.toLowerCase()) ).map(po => { @@ -150,11 +112,6 @@ export default function Index({ purchaseOrders, warehouses }: Props) { {po.items?.length || 0} {fmt(total)} {po.status} - - {po.status === 'approved' || po.status === 'submitted' ? ( - {po.payment_status} - ) : '—'} - ); @@ -173,40 +130,6 @@ export default function Index({ purchaseOrders, warehouses }: Props) {
- - {/* Mark as Paid Dialog */} - { if (!o) { setPayDialog(null); setPayWarehouse(''); } }}> - - Mark Purchase Order as Paid -
-
- Upload the invoice/receipt document. Materials will be automatically added to the selected warehouse inventory. -
-
- - -
-
- - -

PDF documents only — max 10MB

- {errors.receipt && ( -

{errors.receipt}

- )} -
-
- -
- -
-
); } diff --git a/Modules/MaterialLogistics/resources/js/Pages/PurchaseOrders/Show.tsx b/Modules/MaterialLogistics/resources/js/Pages/PurchaseOrders/Show.tsx index 960a541..2873aaf 100644 --- a/Modules/MaterialLogistics/resources/js/Pages/PurchaseOrders/Show.tsx +++ b/Modules/MaterialLogistics/resources/js/Pages/PurchaseOrders/Show.tsx @@ -6,13 +6,14 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table'; import { PageProps } from '@/types'; import { - CheckCircle2, ChevronLeft, CreditCard, Download, Edit, Eye, SendHorizontal, ShoppingCart, Truck, CheckCircle, AlertTriangle + CheckCircle2, ChevronLeft, Download, Edit, Eye, SendHorizontal, ShoppingCart, Truck, CheckCircle, AlertTriangle, PackageCheck, AlertCircle, XCircle, Loader2 } from 'lucide-react'; -import { FormEvent, useRef, useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/Components/ui/dialog'; +import { useEffect, useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/Components/ui/dialog'; import { Label } from '@/Components/ui/label'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select'; import { Input } from '@/Components/ui/input'; +import { Textarea } from '@/Components/ui/textarea'; +import { ConfirmModal } from '@/Components/ConfirmModal'; interface WarehouseOption { ulid: string; name: string; code: string; @@ -34,47 +35,132 @@ interface Props extends PageProps { } const fmt = (v: string | number) => new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(Number(v)); -const statusColor = (s: string) => ({ draft: 'outline', submitted: 'secondary', approved: 'default', rejected: 'destructive' }[s] ?? 'outline') as 'outline' | 'secondary' | 'default' | 'destructive'; -const paymentColor = (s: string) => ({ unpaid: 'destructive', partial: 'secondary', paid: 'default' }[s] ?? 'outline') as 'destructive' | 'secondary' | 'default'; +const statusColor = (s: string) => ({ draft: 'outline', submitted: 'secondary', approved: 'default', rejected: 'destructive', delivered: 'default' }[s] ?? 'outline') as 'outline' | 'secondary' | 'default' | 'destructive'; const isImageFile = (name?: string) => /\.(jpe?g|png)$/i.test(name || ''); -import { ConfirmModal } from '@/Components/ConfirmModal'; - export default function Show({ purchaseOrder, warehouses, budgetAnalysis }: Props) { const { auth, flash, errors } = usePage().props; - const [payDialog, setPayDialog] = useState(false); - const [paying, setPaying] = useState(false); const [submitModalOpen, setSubmitModalOpen] = useState(false); const [deliverModalOpen, setDeliverModalOpen] = useState(false); + const [reviewModalOpen, setReviewModalOpen] = useState(false); + const [reviewAction, setReviewAction] = useState<'approve' | 'reject'>('approve'); + const [approvalNotes, setApprovalNotes] = useState(''); + const [rejectionNotes, setRejectionNotes] = useState(''); + const [processingApproval, setProcessingApproval] = useState(false); + const [delivering, setDelivering] = useState(false); + const [deliveryNotes, setDeliveryNotes] = useState(''); + const [deliveryQuantities, setDeliveryQuantities] = useState<{ [id: number]: number }>({}); const [receiptViewerOpen, setReceiptViewerOpen] = useState(false); - const receiptRef = useRef(null); + + useEffect(() => { + if (purchaseOrder?.items) { + const initial: { [id: number]: number } = {}; + purchaseOrder.items.forEach((item: any) => { + initial[item.id] = Number(item.quantity); + }); + setDeliveryQuantities(initial); + } + }, [purchaseOrder]); const handleSubmitApproval = () => { + setSubmitModalOpen(false); router.patch(route('purchase-orders.submit', purchaseOrder.ulid)); }; - const handlePayment = (e: FormEvent) => { - e.preventDefault(); - if (!receiptRef.current?.files?.[0]) return; - setPaying(true); - const formData = new FormData(); - formData.append('receipt', receiptRef.current.files[0]); - router.post(route('purchase-orders.pay', purchaseOrder.ulid), formData, { - forceFormData: true, - onSuccess: () => setPayDialog(false), - onFinish: () => setPaying(false), + const chains = purchaseOrder.approval_chains || purchaseOrder.approvalChains || []; + const pendingApprovalChain = chains.find((chain: any) => ['pending', 'in_review'].includes(chain.status)) || chains[0]; + + const handleConfirmApprove = (e?: React.FormEvent) => { + if (e) e.preventDefault(); + setProcessingApproval(true); + + const targetUrl = pendingApprovalChain?.ulid + ? route('approvals.approve', pendingApprovalChain.ulid) + : route('purchase-orders.approve', purchaseOrder.ulid); + + router.patch(targetUrl, { + notes: approvalNotes || undefined, + }, { + onSuccess: () => { + setReviewModalOpen(false); + setApprovalNotes(''); + }, + onError: () => { + setProcessingApproval(false); + }, + onFinish: () => { + setProcessingApproval(false); + setReviewModalOpen(false); + }, }); }; - const handleDelivery = () => { - router.patch(route('purchase-orders.deliver', purchaseOrder.ulid)); + const handleConfirmReject = (e?: React.FormEvent) => { + if (e) e.preventDefault(); + if (!rejectionNotes.trim()) return; + setProcessingApproval(true); + + const targetUrl = pendingApprovalChain?.ulid + ? route('approvals.reject', pendingApprovalChain.ulid) + : route('purchase-orders.reject', purchaseOrder.ulid); + + router.patch(targetUrl, { + notes: rejectionNotes, + }, { + onSuccess: () => { + setReviewModalOpen(false); + setRejectionNotes(''); + }, + onError: () => { + setProcessingApproval(false); + }, + onFinish: () => { + setProcessingApproval(false); + setReviewModalOpen(false); + }, + }); }; - const pendingApprovalChain = purchaseOrder.approvalChains?.find((chain: any) => chain.status === 'pending'); + const handleDeliverySubmit = (e: React.FormEvent) => { + e.preventDefault(); + setDelivering(true); + + const itemsPayload = purchaseOrder.items.map((item: any) => ({ + id: item.id, + delivered_quantity: deliveryQuantities[item.id] !== undefined ? deliveryQuantities[item.id] : Number(item.quantity), + })); + + router.patch(route('purchase-orders.deliver', purchaseOrder.ulid), { + delivery_notes: deliveryNotes, + items: itemsPayload, + }, { + onSuccess: () => { + setDeliverModalOpen(false); + }, + onError: () => { + setDelivering(false); + }, + onFinish: () => { + setDelivering(false); + setDeliverModalOpen(false); + }, + }); + }; + + const setAllDeliveredFull = () => { + const full: { [id: number]: number } = {}; + purchaseOrder.items.forEach((item: any) => { + full[item.id] = Number(item.quantity); + }); + setDeliveryQuantities(full); + }; const lineTotal = (item: any) => Number(item.quantity) * Number(item.unit_cost); const total = purchaseOrder.items.reduce((s: number, i: any) => s + lineTotal(i), 0); + const isDelivered = purchaseOrder.status === 'delivered'; + const hasShortages = purchaseOrder.items.some((i: any) => Number(i.missing_quantity) > 0); + return ( {purchaseOrder.status.toUpperCase()} - {(purchaseOrder.status === 'approved' || purchaseOrder.status === 'submitted') && ( - - {purchaseOrder.payment_status.toUpperCase()} - - )} } > @@ -103,10 +184,32 @@ export default function Show({ purchaseOrder, warehouses, budgetAnalysis }: Prop {flash?.success &&
{flash.success}
} {flash?.error &&
{flash.error}
} + {/* Delivery Shortage Notice */} + {isDelivered && hasShortages && ( +
+
+ +
+

Delivery Shortage Recorded

+

+ One or more materials were received with missing quantities upon delivery. The missing quantities have been released back into the project's requisition pool and are available for re-procurement. +

+
+
+ {purchaseOrder.project?.ulid && ( + + + + )} +
+ )} + {/* Unprofitable Project Alert */} {purchaseOrder.project?.is_unprofitable && (
- +

Special Handling: Unprofitable / Loss-Making Project Flagged

@@ -162,17 +265,17 @@ export default function Show({ purchaseOrder, warehouses, budgetAnalysis }: Prop

In Transit

-

Materials are currently expected at {purchaseOrder.target_warehouse.name}. Mark as delivered once they physically arrive.

+

Materials are currently expected at {purchaseOrder.target_warehouse.name}. Mark as delivered once they arrive.

-
)} {purchaseOrder.status === 'submitted' && (
- This Purchase Order is awaiting Project Manager or executive approval. Mark as Paid and Mark as Delivered will become available after approval. + This Purchase Order is awaiting Project Manager or executive approval. Mark as Delivered will become available after approval.
)} @@ -188,17 +291,325 @@ export default function Show({ purchaseOrder, warehouses, budgetAnalysis }: Prop onCancel={() => {}} /> - {}} - /> + {/* Mark as Delivered Modal with Itemized Quantities */} + + + + + + Confirm Goods Delivery & Verify Received Quantities + + +
+
+
+ Delivering to warehouse: {purchaseOrder.target_warehouse?.name || 'Target Warehouse'}. +
Specify the actual delivered quantity for each line item. Any shortages will be tracked. +
+ +
+ +
+
Material / SKURequested Qty#Material / SKURequested Qty
-
{item.material.name}
- {item.material.sku ? {item.material.sku} : null} +
+ {idx + 1} - {item.quantity} {item.material.unit} + +
{item.material.name}
+
+ {item.material.sku ? ( + + {item.material.sku} + + ) : null} + {item.material.category && ( + + {item.material.category} + + )} +
+
+ + {Number(item.quantity).toFixed(2)} + + + {item.material.unit} +
+ + + Material + Ordered + Delivered Qty + Shortage + + + + {purchaseOrder.items.map((item: any) => { + const ordered = Number(item.quantity); + const delivered = deliveryQuantities[item.id] !== undefined ? deliveryQuantities[item.id] : ordered; + const missing = Math.max(0, ordered - delivered); + + return ( + + + {item.material?.name || 'Material'} + Unit: {item.material?.unit} + + + {ordered.toFixed(2)} + + + { + const val = Math.max(0, Math.min(ordered, parseFloat(e.target.value) || 0)); + setDeliveryQuantities(prev => ({ ...prev, [item.id]: val })); + }} + /> + + + {missing > 0 ? ( + + -{missing.toFixed(2)} + + ) : ( + + )} + + + ); + })} + +
+
+ +
+ +