diff --git a/app/DTO/ReportFilters.php b/app/DTO/ReportFilters.php new file mode 100644 index 0000000..ec19c79 --- /dev/null +++ b/app/DTO/ReportFilters.php @@ -0,0 +1,68 @@ + $this->dateFrom?->toDateString(), + 'date_to' => $this->dateTo?->toDateString(), + 'entity_types' => $this->entityTypes, + 'include_photos' => $this->includePhotos, + 'format' => $this->format, + 'include_charts' => $this->includeCharts, + ]; + } + + public function getAvailableEntities(): array + { + return [ + 'phases' => 'Fases', + 'features' => 'Elementos (Features)', + 'inspections' => 'Inspecciones', + 'issues' => 'Incidencias (Issues)', + 'tasks' => 'Tareas', + 'media' => 'Archivos/Media', + ]; + } + + public function getDateRangeLabel(): string + { + if ($this->dateFrom && $this->dateTo) { + return $this->dateFrom->format('d/m/Y') . ' - ' . $this->dateTo->format('d/m/Y'); + } + if ($this->dateFrom) { + return 'Desde ' . $this->dateFrom->format('d/m/Y'); + } + if ($this->dateTo) { + return 'Hasta ' . $this->dateTo->format('d/m/Y'); + } + return 'Todo el período'; + } +} \ No newline at end of file diff --git a/app/Exports/ProjectReportExport.php b/app/Exports/ProjectReportExport.php new file mode 100644 index 0000000..58c3058 --- /dev/null +++ b/app/Exports/ProjectReportExport.php @@ -0,0 +1,655 @@ +project = $project; + $this->filters = $filters; + $this->data = $data; + } + + public function sheets(): array + { + $sheets = [ + new SummarySheet($this->data['summary'] ?? [], $this->project, $this->filters), + ]; + + if (!empty($this->data['phases'])) { + $sheets[] = new PhasesSheet($this->data['phases']); + } + + if (!empty($this->data['features'])) { + $sheets[] = new FeaturesSheet($this->data['features']); + } + + if (!empty($this->data['inspections'])) { + $sheets[] = new InspectionsSheet($this->data['inspections']); + } + + if (!empty($this->data['issues'])) { + $sheets[] = new IssuesSheet($this->data['issues']); + } + + if (!empty($this->data['tasks'])) { + $sheets[] = new TasksSheet($this->data['tasks']); + } + + if (!empty($this->data['deviations'])) { + $sheets[] = new DeviationsSheet($this->data['deviations']); + } + + if (!empty($this->data['media'])) { + $sheets[] = new MediaSheet($this->data['media']); + } + + if (!empty($this->data['progress_curve'])) { + $sheets[] = new ProgressCurveSheet($this->data['progress_curve']); + } + + // Parameters sheet + $sheets[] = new ParametersSheet($this->filters, $this->project); + + return $sheets; + } +} + +// ============================================================ +// Base Sheet with common styling +// ============================================================ + +abstract class BaseSheet implements FromArray, WithHeadings, WithStyles, WithColumnWidths, WithTitle +{ + protected array $rows = []; + + public function __construct(array $data = []) + { + $this->rows = $data; + } + + public function array(): array + { + return $this->rows; + } + + abstract public function headings(): array; + + public function title(): string + { + return static::class; + } + + public function styles(Worksheet $sheet) + { + // Header row style + $sheet->getStyle('1:1')->applyFromArray([ + 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF'], 'size' => 11], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '1E3A8A']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'wrapText' => true], + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], + ]); + + // Data rows + $highestRow = $sheet->getHighestRow(); + if ($highestRow > 1) { + $sheet->getStyle("2:{$highestRow}")->applyFromArray([ + 'font' => ['size' => 10], + 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true], + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], + ]); + + // Alternate row colors + for ($row = 2; $row <= $highestRow; $row++) { + if ($row % 2 === 0) { + $sheet->getStyle("{$row}:{$row}")->applyFromArray([ + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'F8FAFC']], + ]); + } + } + } + + // Auto-filter + $sheet->setAutoFilter($sheet->calculateWorksheetDimension()); + + // Freeze header row + $sheet->freezePane('A2'); + } + + public function columnWidths(): array + { + return []; + } +} + +// ============================================================ +// Summary Sheet +// ============================================================ + +class SummarySheet extends BaseSheet +{ + protected Project $project; + protected ReportFilters $filters; + + public function __construct(array $summary, Project $project, ReportFilters $filters) + { + $this->project = $project; + $this->filters = $filters; + + $rows = [ + ['Informe de Proyecto', $project->name], + ['Referencia', $project->reference ?? '—'], + ['Dirección', $project->address ?? '—'], + ['Estado', $project->status ?? '—'], + ['Fecha inicio', $project->start_date?->format('d/m/Y') ?? '—'], + ['Fecha fin estimada', $project->end_date_estimated?->format('d/m/Y') ?? '—'], + ['', ''], + ['Período del informe', $filters->getDateRangeLabel()], + ['Generado el', now()->format('d/m/Y H:i')], + ['Generado por', auth()->guard()->user()?->name ?? 'Sistema'], + ['', ''], + ['--- RESUMEN EJECUTIVO ---', ''], + ['Total elementos', $summary['total_features'] ?? 0], + ['Elementos completados', $summary['completed_features'] ?? 0], + ['Tasa completitud (%)', $summary['completion_rate'] ?? 0], + ['Progreso planificado medio (%)', $summary['avg_planned_progress'] ?? 0], + ['Progreso real medio (%)', $summary['avg_actual_progress'] ?? 0], + ['SPI global', $summary['overall_spi'] ?? 'N/A'], + ['', ''], + ['--- INSPECCIONES ---', ''], + ['Total inspecciones', $summary['total_inspections'] ?? 0], + ['Aprobadas', $summary['passed_inspections'] ?? 0], + ['Fallidas', $summary['failed_inspections'] ?? 0], + ['Tasa aprobación (%)', $summary['pass_rate'] ?? 0], + ['', ''], + ['--- INCIDENCIAS ---', ''], + ['Abiertas', $summary['open_issues'] ?? 0], + ['Cerradas', $summary['closed_issues'] ?? 0], + ['', ''], + ['--- TAREAS ---', ''], + ['Total tareas', $summary['total_tasks'] ?? 0], + ['Completadas', $summary['completed_tasks'] ?? 0], + ['Tasa completitud (%)', $summary['task_completion_rate'] ?? 0], + ['', ''], + ['--- FASES ---', ''], + ['En plazo', $summary['phases_on_track'] ?? 0], + ['Retrasadas', $summary['phases_delayed'] ?? 0], + ['Sin datos', $summary['phases_no_data'] ?? 0], + ]; + + parent::__construct($rows); + } + + public function headings(): array + { + return ['Concepto', 'Valor']; + } + + public function columnWidths(): array + { + return ['A' => 40, 'B' => 30]; + } +} + +// ============================================================ +// Phases Sheet +// ============================================================ + +class PhasesSheet extends BaseSheet +{ + public function headings(): array + { + return [ + 'ID', 'Fase', 'Orden', 'Color', 'Inicio Plan', 'Fin Plan', + 'Inicio Real', 'Fin Real', 'Progreso (%)', 'Progreso Plan (%)', + 'Desvío Fin (días)', 'Desvío Inicio (días)', 'SPI', 'En Plazo', + 'Elementos', 'Completados', 'Capas' + ]; + } + + public function array(): array + { + return array_map(function ($phase) { + return [ + $phase['id'], + $phase['name'], + $phase['order'], + $phase['color'], + $phase['planned_start'] ?? '—', + $phase['planned_end'] ?? '—', + $phase['actual_start'] ?? '—', + $phase['actual_end'] ?? '—', + $phase['progress_percent'], + $phase['planned_progress'], + $phase['deviation_days'] ?? '—', + $phase['start_deviation_days'] ?? '—', + $phase['spi'] ?? '—', + $phase['is_on_track'] === true ? 'Sí' : ($phase['is_on_track'] === false ? 'No' : 'N/A'), + $phase['features_count'], + $phase['completed_features'], + count($phase['layers']), + ]; + }, $this->rows); + } + + public function columnWidths(): array + { + return ['A' => 8, 'B' => 25, 'C' => 8, 'D' => 10, 'E' => 12, 'F' => 12, + 'G' => 12, 'H' => 12, 'I' => 12, 'J' => 14, 'K' => 14, 'L' => 14, + 'M' => 8, 'N' => 10, 'O' => 10, 'P' => 12, 'Q' => 10]; + } +} + +// ============================================================ +// Features Sheet +// ============================================================ + +class FeaturesSheet extends BaseSheet +{ + public function headings(): array + { + return [ + 'ID', 'Elemento', 'Fase', 'Capa', 'Estado', 'Progreso (%)', 'Progreso Plan (%)', + 'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real', 'Desvío Fin (días)', + 'Desvío Inicio (días)', 'SPI', 'En Plazo', 'Responsable', 'Template', + 'Última Inspección', 'Resultado', 'Inspecciones', 'Issues Abiertos' + ]; + } + + public function array(): array + { + return array_map(function ($feature) { + return [ + $feature['id'], + $feature['name'], + $feature['phase'], + $feature['layer'], + $feature['status_label'], + $feature['progress'], + $feature['planned_progress'], + $feature['planned_start'] ?? '—', + $feature['planned_end'] ?? '—', + $feature['actual_start'] ?? '—', + $feature['actual_end'] ?? '—', + $feature['deviation_days'] ?? '—', + $feature['start_deviation_days'] ?? '—', + $feature['spi'] ?? '—', + $feature['is_on_track'] === true ? 'Sí' : ($feature['is_on_track'] === false ? 'No' : 'N/A'), + $feature['responsible'], + $feature['template'], + $feature['last_inspection_date'] ?? '—', + $feature['last_inspection_result'] ?? '—', + $feature['inspections_count'], + $feature['open_issues_count'], + ]; + }, $this->rows); + } + + public function columnWidths(): array + { + return ['A' => 8, 'B' => 25, 'C' => 20, 'D' => 20, 'E' => 15, 'F' => 12, + 'G' => 14, 'H' => 12, 'I' => 12, 'J' => 12, 'K' => 12, 'L' => 14, + 'M' => 14, 'N' => 8, 'O' => 10, 'P' => 20, 'Q' => 20, 'R' => 14, + 'S' => 12, 'T' => 12, 'U' => 12]; + } +} + +// ============================================================ +// Inspections Sheet +// ============================================================ + +class InspectionsSheet extends BaseSheet +{ + public function headings(): array + { + return [ + 'ID', 'Elemento', 'Fase', 'Template', 'Inspector', 'Fecha', + 'Estado', 'Resultado', 'Notas', 'Fotos' + ]; + } + + public function array(): array + { + return array_map(function ($inspection) { + return [ + $inspection['id'], + $inspection['feature'], + $inspection['phase'], + $inspection['template'], + $inspection['inspector'], + $inspection['date'], + $inspection['status'], + $inspection['result_label'], + $inspection['notes'] ?? '—', + $inspection['photos_count'], + ]; + }, $this->rows); + } + + public function columnWidths(): array + { + return ['A' => 8, 'B' => 25, 'C' => 20, 'D' => 25, 'E' => 20, 'F' => 18, + 'G' => 12, 'H' => 12, 'I' => 40, 'J' => 8]; + } +} + +// ============================================================ +// Issues Sheet +// ============================================================ + +class IssuesSheet extends BaseSheet +{ + public function headings(): array + { + return [ + 'ID', 'Título', 'Elemento', 'Fase', 'Prioridad', 'Estado', + 'Reportado por', 'Asignado a', 'Creado', 'Cerrado', 'Días abierto', + 'Tareas total', 'Tareas completadas' + ]; + } + + public function array(): array + { + return array_map(function ($issue) { + return [ + $issue['id'], + $issue['title'], + $issue['feature'], + $issue['phase'], + $issue['priority_label'], + $issue['status_label'], + $issue['reporter'], + $issue['assignee'], + $issue['created_at'], + $issue['closed_at'] ?? '—', + $issue['days_open'], + $issue['tasks_total'], + $issue['tasks_completed'], + ]; + }, $this->rows); + } + + public function columnWidths(): array + { + return ['A' => 8, 'B' => 35, 'C' => 20, 'D' => 20, 'E' => 12, 'F' => 15, + 'G' => 20, 'H' => 20, 'I' => 12, 'J' => 12, 'K' => 12, 'L' => 12, 'M' => 14]; + } +} + +// ============================================================ +// Tasks Sheet +// ============================================================ + +class TasksSheet extends BaseSheet +{ + public function headings(): array + { + return [ + 'ID', 'Tarea', 'Fase', 'Estado', 'Prioridad', 'Asignado', 'Creador', + 'Fecha inicio', 'Fecha fin', 'Completada', 'Horas est.', 'Horas real', + 'Progreso (%)', 'Vencida', 'Subtareas' + ]; + } + + public function array(): array + { + return array_map(function ($task) { + $subtasksStr = implode('; ', array_map(fn($st) => "{$st['title']} ({$st['status']})", $task['subtasks'])); + return [ + $task['id'], + $task['title'], + $task['phase'], + $task['status_label'], + $task['priority_label'], + $task['assignee'], + $task['creator'], + $task['start_date'] ?? '—', + $task['due_date'] ?? '—', + $task['completed_at'] ?? '—', + $task['estimated_hours'] ?? '—', + $task['actual_hours'] ?? '—', + $task['progress'], + $task['is_overdue'] ? 'Sí' : 'No', + $subtasksStr ?: '—', + ]; + }, $this->rows); + } + + public function columnWidths(): array + { + return ['A' => 8, 'B' => 30, 'C' => 20, 'D' => 15, 'E' => 12, 'F' => 20, + 'G' => 20, 'H' => 12, 'I' => 12, 'J' => 12, 'K' => 10, 'L' => 10, + 'M' => 12, 'N' => 10, 'O' => 40]; + } +} + +// ============================================================ +// Deviations Sheet +// ============================================================ + +class DeviationsSheet extends BaseSheet +{ + protected array $deviations; + + public function __construct(array $deviations) + { + $this->deviations = $deviations; + $rows = []; + + // Phase deviations + if (!empty($deviations['phases'])) { + $rows[] = ['=== DESVÍOS POR FASE ===', '', '', '', '', '', '', '', '', '', '']; + $rows[] = ['ID', 'Fase', 'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real', + 'Desvío Inicio (d)', 'Desvío Fin (d)', 'Prog. Plan (%)', 'Prog. Real (%)', + 'Desvío Prog.', 'SPI', 'En Plazo']; + + foreach ($deviations['phases'] as $phase) { + $rows[] = [ + $phase['id'], $phase['name'], $phase['planned_start'], $phase['planned_end'], + $phase['actual_start'], $phase['actual_end'], + $phase['start_deviation'] ?? '—', $phase['end_deviation'] ?? '—', + $phase['planned_progress'], $phase['actual_progress'], + $phase['progress_deviation'], $phase['spi'] ?? '—', + $phase['is_on_track'] === true ? 'Sí' : ($phase['is_on_track'] === false ? 'No' : 'N/A'), + ]; + } + $rows[] = ['', '', '', '', '', '', '', '', '', '', '']; + } + + // Feature deviations + if (!empty($deviations['features'])) { + $rows[] = ['=== DESVÍOS POR ELEMENTO ===', '', '', '', '', '', '', '', '', '', '', '', '']; + $rows[] = ['ID', 'Elemento', 'Fase', 'Inicio Plan', 'Fin Plan', 'Inicio Real', 'Fin Real', + 'Desvío Inicio (d)', 'Desvío Fin (d)', 'Prog. Plan (%)', 'Prog. Real (%)', + 'Desvío Prog.', 'SPI', 'En Plazo', 'Responsable']; + + foreach ($deviations['features'] as $feature) { + $rows[] = [ + $feature['id'], $feature['name'], $feature['phase'], + $feature['planned_start'], $feature['planned_end'], + $feature['actual_start'], $feature['actual_end'], + $feature['start_deviation'] ?? '—', $feature['end_deviation'] ?? '—', + $feature['planned_progress'], $feature['actual_progress'], + $feature['progress_deviation'], $feature['spi'] ?? '—', + $feature['is_on_track'] === true ? 'Sí' : ($feature['is_on_track'] === false ? 'No' : 'N/A'), + $feature['responsible'], + ]; + } + $rows[] = ['', '', '', '', '', '', '', '', '', '', '', '', '', '']; + } + + // Summary + if (!empty($deviations['summary'])) { + $rows[] = ['=== RESUMEN DESVÍOS ===', '']; + $rows[] = ['Fases retrasadas', $deviations['summary']['phases_delayed'] ?? 0]; + $rows[] = ['Fases adelantadas', $deviations['summary']['phases_early'] ?? 0]; + $rows[] = ['Fases en plazo', $deviations['summary']['phases_on_time'] ?? 0]; + $rows[] = ['Elementos retrasados', $deviations['summary']['features_delayed'] ?? 0]; + $rows[] = ['Elementos adelantados', $deviations['summary']['features_early'] ?? 0]; + $rows[] = ['Elementos en plazo', $deviations['summary']['features_on_time'] ?? 0]; + } + + $this->rows = $rows; + } + + public function headings(): array + { + return []; // Headings included in data rows + } + + public function title(): string + { + return 'Desvíos'; + } + + public function columnWidths(): array + { + return ['A' => 25, 'B' => 25, 'C' => 20, 'D' => 12, 'E' => 12, 'F' => 12, + 'G' => 12, 'H' => 14, 'I' => 14, 'J' => 14, 'K' => 14, 'L' => 10, + 'M' => 10, 'N' => 20]; + } +} + +// ============================================================ +// Media Sheet +// ============================================================ + +class MediaSheet extends BaseSheet +{ + public function headings(): array + { + return ['ID', 'Nombre', 'Tipo', 'Entidad', 'Entidad Nombre', 'Tamaño', 'Subido por', 'Fecha', 'URL']; + } + + public function array(): array + { + return array_map(function ($media) { + return [ + $media['id'], + $media['name'], + $media['type'], + $media['entity'], + $media['entity_name'], + $media['size'], + $media['uploaded_by'], + $media['uploaded_at'], + $media['url'], + ]; + }, $this->rows); + } + + public function columnWidths(): array + { + return ['A' => 8, 'B' => 30, 'C' => 12, 'D' => 15, 'E' => 30, 'F' => 12, + 'G' => 20, 'H' => 18, 'I' => 50]; + } +} + +// ============================================================ +// Progress Curve Sheet +// ============================================================ + +class ProgressCurveSheet extends BaseSheet +{ + protected array $curveData; + + public function __construct(array $curveData) + { + $this->curveData = $curveData; + + $rows = [['Fecha', 'Progreso Planificado (%)', 'Progreso Real (%)']]; + + $labels = $curveData['labels'] ?? []; + $planned = $curveData['planned'] ?? []; + $actual = $curveData['actual'] ?? []; + + for ($i = 0; $i < count($labels); $i++) { + $rows[] = [$labels[$i], $planned[$i] ?? 0, $actual[$i] ?? 0]; + } + + $this->rows = $rows; + } + + public function headings(): array + { + return ['Fecha', 'Progreso Planificado (%)', 'Progreso Real (%)']; + } + + public function title(): string + { + return 'Curva S'; + } + + public function columnWidths(): array + { + return ['A' => 14, 'B' => 22, 'C' => 20]; + } +} + +// ============================================================ +// Parameters Sheet +// ============================================================ + +class ParametersSheet extends BaseSheet +{ + protected ReportFilters $filters; + protected Project $project; + + public function __construct(ReportFilters $filters, Project $project) + { + $this->filters = $filters; + $this->project = $project; + + $rows = [ + ['Parámetro', 'Valor'], + ['Proyecto', $project->name], + ['Referencia', $project->reference ?? '—'], + ['Fecha desde', $filters->dateFrom?->format('d/m/Y') ?? '—'], + ['Fecha hasta', $filters->dateTo?->format('d/m/Y') ?? '—'], + ['Entidades incluidas', implode(', ', array_map( + fn($e) => $filters->getAvailableEntities()[$e] ?? $e, + $filters->entityTypes + ))], + ['Incluir fotos', $filters->includePhotos ? 'Sí' : 'No'], + ['Formato', strtoupper($filters->format)], + ['Incluir gráficos', $filters->includeCharts ? 'Sí' : 'No'], + ['Generado', now()->format('d/m/Y H:i')], + ['Generado por', auth()->guard()->user()?->name ?? 'Sistema'], + ]; + + parent::__construct($rows); + } + + public function headings(): array + { + return ['Parámetro', 'Valor']; + } + + public function title(): string + { + return 'Parámetros'; + } + + public function columnWidths(): array + { + return ['A' => 30, 'B' => 50]; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php new file mode 100644 index 0000000..3148791 --- /dev/null +++ b/app/Http/Controllers/ReportController.php @@ -0,0 +1,94 @@ +middleware('auth'); + } + + /** + * Show report builder for a project + */ + public function show(Project $project) + { + $this->authorizeProjectAccess($project); + + return view('reports.builder', [ + 'project' => $project, + 'availableEntities' => (new ReportFilters)->getAvailableEntities(), + ]); + } + + /** + * Generate report (HTML preview or download) + */ + public function generate(Request $request, Project $project) + { + $this->authorizeProjectAccess($project); + + $filters = ReportFilters::fromRequest($request->all()); + $generator = new ReportGenerator($project, $filters); + $data = $generator->generate(); + + $format = $filters->format; + + if ($format === 'excel') { + return $this->downloadExcel($project, $filters, $data); + } + + // HTML format (default) - render printable view + return view('reports.complete', $data); + } + + /** + * Preview partial (AJAX) + */ + public function preview(Request $request, Project $project) + { + $this->authorizeProjectAccess($project); + + $filters = ReportFilters::fromRequest($request->all()); + $generator = new ReportGenerator($project, $filters); + $data = $generator->generate(); + + return view('reports.partials._preview', $data); + } + + /** + * Download Excel export + */ + protected function downloadExcel(Project $project, ReportFilters $filters, array $data) + { + $export = new ProjectReportExport($project, $filters, $data); + $filename = 'informe_' . $project->name . '_' . now()->format('Ymd_His') . '.xlsx'; + + return Excel::download($export, $filename); + } + + /** + * Authorize access to project + */ + protected function authorizeProjectAccess(Project $project): void + { + $user = Auth::user(); + + if ($user->can('manage all')) { + return; + } + + if (!$project->users()->where('user_id', $user->id)->exists()) { + abort(403, 'No tienes acceso a este proyecto.'); + } + } +} \ No newline at end of file diff --git a/app/Jobs/CaptureDailyProgressSnapshot.php b/app/Jobs/CaptureDailyProgressSnapshot.php new file mode 100644 index 0000000..a635f7b --- /dev/null +++ b/app/Jobs/CaptureDailyProgressSnapshot.php @@ -0,0 +1,174 @@ +snapshotDate ? Carbon::parse($this->snapshotDate) : Carbon::today(); + + Log::info('Starting daily progress snapshot capture', ['date' => $date->toDateString()]); + + $captured = 0; + + // Capture Phase snapshots + $captured += $this->capturePhaseSnapshots($date); + + // Capture Feature snapshots + $captured += $this->captureFeatureSnapshots($date); + + // Capture Task snapshots + $captured += $this->captureTaskSnapshots($date); + + Log::info('Daily progress snapshot capture completed', [ + 'date' => $date->toDateString(), + 'snapshots_captured' => $captured, + ]); + } + + private function capturePhaseSnapshots(Carbon $date): int + { + $count = 0; + + Phase::with(['project'])->chunkById(100, function ($phases) use ($date, &$count) { + foreach ($phases as $phase) { + // Skip if snapshot already exists for this date + if (ProgressSnapshot::where('trackable_type', Phase::class) + ->where('trackable_id', $phase->id) + ->where('snapshot_date', $date) + ->exists()) { + continue; + } + + $metadata = [ + 'project_id' => $phase->project_id, + 'spi' => $phase->spi, + 'is_on_track' => $phase->is_on_track, + 'deviation_days' => $phase->deviation_days, + 'start_deviation_days' => $phase->start_deviation_days, + ]; + + ProgressSnapshot::create([ + 'trackable_type' => Phase::class, + 'trackable_id' => $phase->id, + 'snapshot_date' => $date, + 'progress' => $phase->progress_percent, + 'planned_start' => $phase->planned_start, + 'planned_end' => $phase->planned_end, + 'actual_start' => $phase->actual_start, + 'actual_end' => $phase->actual_end, + 'method' => 'manual', + 'metadata' => $metadata, + ]); + + $count++; + } + }); + + return $count; + } + + private function captureFeatureSnapshots(Carbon $date): int + { + $count = 0; + + Feature::with(['layer.phase.project'])->chunkById(200, function ($features) use ($date, &$count) { + foreach ($features as $feature) { + if (ProgressSnapshot::where('trackable_type', Feature::class) + ->where('trackable_id', $feature->id) + ->where('snapshot_date', $date) + ->exists()) { + continue; + } + + $metadata = [ + 'project_id' => $feature->layer->phase->project_id ?? null, + 'phase_id' => $feature->layer->phase_id ?? null, + 'layer_id' => $feature->layer_id, + 'spi' => $feature->spi, + 'is_on_track' => $feature->is_on_track, + 'deviation_days' => $feature->deviation_days, + 'start_deviation_days' => $feature->start_deviation_days, + 'status' => $feature->status, + ]; + + ProgressSnapshot::create([ + 'trackable_type' => Feature::class, + 'trackable_id' => $feature->id, + 'snapshot_date' => $date, + 'progress' => $feature->progress, + 'planned_start' => $feature->planned_start, + 'planned_end' => $feature->planned_end, + 'actual_start' => $feature->actual_start, + 'actual_end' => $feature->actual_end, + 'method' => 'manual', + 'metadata' => $metadata, + ]); + + $count++; + } + }); + + return $count; + } + + private function captureTaskSnapshots(Carbon $date): int + { + $count = 0; + + Task::with(['project', 'phase'])->chunkById(200, function ($tasks) use ($date, &$count) { + foreach ($tasks as $task) { + if (ProgressSnapshot::where('trackable_type', Task::class) + ->where('trackable_id', $task->id) + ->where('snapshot_date', $date) + ->exists()) { + continue; + } + + $metadata = [ + 'project_id' => $task->project_id, + 'phase_id' => $task->phase_id, + 'status' => $task->status, + 'priority' => $task->priority, + 'assigned_to' => $task->assigned_to, + 'is_overdue' => $task->is_overdue, + 'estimated_hours' => $task->estimated_hours, + 'actual_hours' => $task->actual_hours, + ]; + + ProgressSnapshot::create([ + 'trackable_type' => Task::class, + 'trackable_id' => $task->id, + 'snapshot_date' => $date, + 'progress' => $task->progress, + 'planned_start' => $task->start_date, + 'planned_end' => $task->due_date, + 'actual_start' => $task->start_date, + 'actual_end' => $task->completed_at?->toDateString(), + 'method' => 'task', + 'metadata' => $metadata, + ]); + + $count++; + } + }); + + return $count; + } +} \ No newline at end of file diff --git a/app/Livewire/Reports/ReportBuilder.php b/app/Livewire/Reports/ReportBuilder.php new file mode 100644 index 0000000..dcfcce7 --- /dev/null +++ b/app/Livewire/Reports/ReportBuilder.php @@ -0,0 +1,127 @@ + null, + 'date_to' => null, + 'entity_types' => ['phases', 'features', 'inspections', 'issues', 'tasks'], + 'include_photos' => false, + 'include_charts' => false, + 'format' => 'html', + ]; + + public bool $showPreview = false; + public ?array $previewData = null; + + public function mount(Project $project) + { + $this->project = $project; + $this->authorizeAccess(); + } + + public function authorizeAccess(): void + { + $user = auth()->guard()->user(); + if (!$user->can('manage all') && !$this->project->users()->where('user_id', $user->id)->exists()) { + abort(403); + } + } + + public function setDateRange(string $preset): void + { + $now = now(); + match ($preset) { + 'week' => [ + $this->filters['date_from'] = $now->copy()->startOfWeek()->toDateString(), + $this->filters['date_to'] = $now->copy()->endOfWeek()->toDateString(), + ], + 'month' => [ + $this->filters['date_from'] = $now->copy()->startOfMonth()->toDateString(), + $this->filters['date_to'] = $now->copy()->endOfMonth()->toDateString(), + ], + 'quarter' => [ + $this->filters['date_from'] = $now->copy()->startOfQuarter()->toDateString(), + $this->filters['date_to'] = $now->copy()->endOfQuarter()->toDateString(), + ], + 'year' => [ + $this->filters['date_from'] = $now->copy()->startOfYear()->toDateString(), + $this->filters['date_to'] = $now->copy()->endOfYear()->toDateString(), + ], + }; + } + + public function clearDateRange(): void + { + $this->filters['date_from'] = null; + $this->filters['date_to'] = null; + } + + public function generateReport(): void + { + $this->validate([ + 'filters.date_from' => 'nullable|date', + 'filters.date_to' => 'nullable|date|after_or_equal:filters.date_from', + 'filters.entity_types' => 'required|array|min:1', + 'filters.format' => 'required|in:html,excel', + ]); + + $reportFilters = ReportFilters::fromRequest($this->filters); + $generator = new ReportGenerator($this->project, $reportFilters); + $data = $generator->generate(); + + if ($this->filters['format'] === 'excel') { + // Redirect to download route + return redirect()->route('reports.project.excel', [ + 'project' => $this->project, + ...$reportFilters->toArray(), + ]); + } + + // For HTML, we'll render the complete report view + // The view will be returned by the controller + $this->redirectRoute('reports.project.generate', [ + 'project' => $this->project, + ...$reportFilters->toArray(), + ]); + } + + public function previewReport(): void + { + $this->validate([ + 'filters.date_from' => 'nullable|date', + 'filters.date_to' => 'nullable|date|after_or_equal:filters.date_from', + 'filters.entity_types' => 'required|array|min:1', + ]); + + $reportFilters = ReportFilters::fromRequest($this->filters); + $generator = new ReportGenerator($this->project, $reportFilters); + $this->previewData = $generator->generate(); + $this->showPreview = true; + } + + public function closePreview(): void + { + $this->showPreview = false; + $this->previewData = null; + } + + public function render() + { + return view('livewire.reports.report-builder', [ + 'project' => $this->project, + 'availableEntities' => (new ReportFilters)->getAvailableEntities(), + ]); + } +} \ No newline at end of file diff --git a/app/Models/Feature.php b/app/Models/Feature.php index f06d4e3..34c89d0 100644 --- a/app/Models/Feature.php +++ b/app/Models/Feature.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; use App\Traits\LogsActivity; @@ -16,12 +17,20 @@ class Feature extends Model 'layer_id', 'name', 'geometry', 'properties', 'template_id', 'feature_type_id', 'progress', 'status', 'is_active', 'responsible', 'responsible_user_id', 'uuid', 'client_updated_at', + 'planned_start', 'planned_end', 'actual_start', 'actual_end', + 'baseline_start', 'baseline_end', ]; protected $casts = [ - 'geometry' => 'array', - 'properties' => 'array', - 'is_active' => 'boolean', + 'geometry' => 'array', + 'properties' => 'array', + 'is_active' => 'boolean', + 'planned_start' => 'date', + 'planned_end' => 'date', + 'actual_start' => 'date', + 'actual_end' => 'date', + 'baseline_start' => 'date', + 'baseline_end' => 'date', ]; public function featureType() @@ -64,6 +73,11 @@ class Feature extends Model return $this->morphMany(Media::class, 'mediable')->where('category', 'image'); } + public function progressSnapshots(): MorphMany + { + return $this->morphMany(ProgressSnapshot::class, 'trackable'); + } + public function getStatusColorAttribute(): string { return match($this->status) { @@ -75,4 +89,69 @@ class Feature extends Model default => '#6b7280', }; } + + /** + * Deviation in days: positive = delayed, negative = early + */ + public function getDeviationDaysAttribute(): ?int + { + if (! $this->planned_end) { + return null; + } + $end = $this->actual_end ?? now()->toDateString(); + + return $this->planned_end->diffInDays($end, false); + } + + public function getStartDeviationDaysAttribute(): ?int + { + if (! $this->planned_start) { + return null; + } + $start = $this->actual_start ?? now()->toDateString(); + + return $this->planned_start->diffInDays($start, false); + } + + public function getPlannedProgressAtAttribute(?\Carbon\Carbon $date = null): float + { + $date = $date ?? now(); + + if (! $this->planned_start || ! $this->planned_end) { + return 0; + } + + if ($date <= $this->planned_start) { + return 0; + } + if ($date >= $this->planned_end) { + return 100; + } + + $totalDays = $this->planned_start->diffInDays($this->planned_end); + $elapsedDays = $this->planned_start->diffInDays($date); + + return round(($elapsedDays / $totalDays) * 100, 2); + } + + public function getSpiAttribute(): ?float + { + $pv = $this->planned_progress_at; + $ev = $this->progress; + + if ($pv <= 0) { + return null; + } + + return round($ev / $pv, 2); + } + + public function getIsOnTrackAttribute(): ?bool + { + $spi = $this->spi; + if ($spi === null) { + return null; + } + return $spi >= 0.95; + } } diff --git a/app/Models/Phase.php b/app/Models/Phase.php index cc5f45b..5f608ef 100644 --- a/app/Models/Phase.php +++ b/app/Models/Phase.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; class Phase extends Model @@ -12,6 +13,7 @@ class Phase extends Model protected $fillable = [ 'project_id', 'name', 'description', 'order', 'color', 'progress_percent', 'planned_start', 'planned_end', 'actual_start', 'actual_end', + 'baseline_start', 'baseline_end', ]; protected $casts = [ @@ -19,6 +21,8 @@ class Phase extends Model 'planned_end' => 'date', 'actual_start' => 'date', 'actual_end' => 'date', + 'baseline_start' => 'date', + 'baseline_end' => 'date', ]; public function project() @@ -61,13 +65,85 @@ class Phase extends Model return $this->hasMany(Task::class); } + public function progressSnapshots(): MorphMany + { + return $this->morphMany(ProgressSnapshot::class, 'trackable'); + } + + /** + * Deviation in days: positive = delayed, negative = early + */ public function getDeviationDaysAttribute(): ?int { if (! $this->planned_end) { return null; } - $end = $this->actual_end ?? now(); - + $end = $this->actual_end ?? now()->toDateString(); + return $this->planned_end->diffInDays($end, false); } + + /** + * Start deviation: positive = delayed start, negative = early start + */ + public function getStartDeviationDaysAttribute(): ?int + { + if (! $this->planned_start) { + return null; + } + $start = $this->actual_start ?? now()->toDateString(); + + return $this->planned_start->diffInDays($start, false); + } + + /** + * Planned progress at a given date (linear interpolation) + */ + public function getPlannedProgressAtAttribute(?\Carbon\Carbon $date = null): float + { + $date = $date ?? now(); + + if (! $this->planned_start || ! $this->planned_end) { + return 0; + } + + if ($date <= $this->planned_start) { + return 0; + } + if ($date >= $this->planned_end) { + return 100; + } + + $totalDays = $this->planned_start->diffInDays($this->planned_end); + $elapsedDays = $this->planned_start->diffInDays($date); + + return round(($elapsedDays / $totalDays) * 100, 2); + } + + /** + * Schedule Performance Index (SPI) = EV / PV + */ + public function getSpiAttribute(): ?float + { + $pv = $this->planned_progress_at; // Planned Value + $ev = $this->progress_percent; // Earned Value (current progress) + + if ($pv <= 0) { + return null; + } + + return round($ev / $pv, 2); + } + + /** + * Check if phase is on track (SPI >= 0.95) + */ + public function getIsOnTrackAttribute(): ?bool + { + $spi = $this->spi; + if ($spi === null) { + return null; + } + return $spi >= 0.95; + } } diff --git a/app/Models/ProgressSnapshot.php b/app/Models/ProgressSnapshot.php new file mode 100644 index 0000000..3d91b3f --- /dev/null +++ b/app/Models/ProgressSnapshot.php @@ -0,0 +1,79 @@ + 'date', + 'planned_start' => 'date', + 'planned_end' => 'date', + 'actual_start' => 'date', + 'actual_end' => 'date', + 'progress' => 'decimal:2', + 'metadata' => 'array', + ]; + + public function trackable(): MorphTo + { + return $this->morphTo(); + } + + /** + * Scope for a specific date range + */ + public function scopeDateRange($query, $from, $to) + { + return $query->whereBetween('snapshot_date', [$from, $to]); + } + + /** + * Scope for specific trackable + */ + public function scopeForTrackable($query, $model, $id = null) + { + $query->where('trackable_type', $model); + if ($id) { + $query->where('trackable_id', $id); + } + return $query; + } + + /** + * Get latest snapshot for a trackable + */ + public static function latestFor($model, $id): ?self + { + return static::forTrackable($model, $id) + ->latest('snapshot_date') + ->first(); + } + + /** + * Get progress trend (last N snapshots) + */ + public static function trendFor($model, $id, int $limit = 10): array + { + return static::forTrackable($model, $id) + ->orderBy('snapshot_date') + ->take($limit) + ->get(['snapshot_date', 'progress', 'method']) + ->toArray(); + } +} \ No newline at end of file diff --git a/app/Models/Task.php b/app/Models/Task.php index 561b156..81ecf9e 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -138,6 +138,11 @@ class Task extends Model return $this->morphMany(ActivityLog::class, 'model'); } + public function progressSnapshots(): MorphMany + { + return $this->morphMany(ProgressSnapshot::class, 'trackable'); + } + // ============================================================ // Scopes // ============================================================ diff --git a/app/Services/ReportGenerator.php b/app/Services/ReportGenerator.php new file mode 100644 index 0000000..a77eb02 --- /dev/null +++ b/app/Services/ReportGenerator.php @@ -0,0 +1,546 @@ +project = $project; + $this->filters = $filters; + } + + public function generate(): array + { + $data = [ + 'project' => $this->project, + 'filters' => $this->filters, + 'generated_at' => now(), + 'generated_by' => auth()->guard()->user(), + ]; + + // Always include summary KPIs + $data['summary'] = $this->buildSummary(); + + // Include requested entity sections + if (in_array('phases', $this->filters->entityTypes)) { + $data['phases'] = $this->buildPhasesData(); + } + + if (in_array('features', $this->filters->entityTypes)) { + $data['features'] = $this->buildFeaturesData(); + } + + if (in_array('inspections', $this->filters->entityTypes)) { + $data['inspections'] = $this->buildInspectionsData(); + } + + if (in_array('issues', $this->filters->entityTypes)) { + $data['issues'] = $this->buildIssuesData(); + } + + if (in_array('tasks', $this->filters->entityTypes)) { + $data['tasks'] = $this->buildTasksData(); + } + + if (in_array('media', $this->filters->entityTypes)) { + $data['media'] = $this->buildMediaData(); + } + + // Deviation analysis (always included if phases/features selected) + if (in_array('phases', $this->filters->entityTypes) || in_array('features', $this->filters->entityTypes)) { + $data['deviations'] = $this->buildDeviationsData(); + } + + // Progress curve data (for S-curve chart) + if ($this->filters->includeCharts) { + $data['progress_curve'] = $this->buildProgressCurveData(); + } + + return $data; + } + + protected function buildSummary(): array + { + $phases = $this->project->phases() + ->with(['layers.features']) + ->get(); + + $allFeatures = $phases->flatMap(fn($p) => $p->layers->flatMap(fn($l) => $l->features)); + $completedFeatures = $allFeatures->where('status', 'completed')->count(); + + $inspectionsQuery = Inspection::where('project_id', $this->project->id); + $this->applyDateFilter($inspectionsQuery, 'created_at'); + $totalInspections = $inspectionsQuery->count(); + $passedInspections = (clone $inspectionsQuery)->where('result', 'pass')->count(); + $failedInspections = (clone $inspectionsQuery)->where('result', 'fail')->count(); + + $issuesQuery = Issue::where('project_id', $this->project->id); + $this->applyDateFilter($issuesQuery, 'created_at'); + $openIssues = (clone $issuesQuery)->where('status', 'open')->count(); + $closedIssues = (clone $issuesQuery)->where('status', 'closed')->count(); + + $tasksQuery = Task::where('project_id', $this->project->id); + $this->applyDateFilter($tasksQuery, 'created_at'); + $totalTasks = $tasksQuery->count(); + $completedTasks = (clone $tasksQuery)->where('status', 'completed')->count(); + + // Calculate overall planned vs actual progress + $avgPlannedProgress = $phases->avg('planned_progress_at') ?? 0; + $avgActualProgress = $phases->avg('progress_percent') ?? 0; + $overallSpi = $avgPlannedProgress > 0 ? round($avgActualProgress / $avgPlannedProgress, 2) : null; + + return [ + 'total_features' => $allFeatures->count(), + 'completed_features' => $completedFeatures, + 'completion_rate' => $allFeatures->count() > 0 ? round($completedFeatures / $allFeatures->count() * 100, 1) : 0, + 'avg_planned_progress' => round($avgPlannedProgress, 1), + 'avg_actual_progress' => round($avgActualProgress, 1), + 'overall_spi' => $overallSpi, + 'total_inspections' => $totalInspections, + 'passed_inspections' => $passedInspections, + 'failed_inspections' => $failedInspections, + 'pass_rate' => $totalInspections > 0 ? round($passedInspections / $totalInspections * 100, 1) : 0, + 'open_issues' => $openIssues, + 'closed_issues' => $closedIssues, + 'total_tasks' => $totalTasks, + 'completed_tasks' => $completedTasks, + 'task_completion_rate' => $totalTasks > 0 ? round($completedTasks / $totalTasks * 100, 1) : 0, + 'phases_on_track' => $phases->where('is_on_track', true)->count(), + 'phases_delayed' => $phases->where('is_on_track', false)->count(), + 'phases_no_data' => $phases->where('is_on_track', null)->count(), + ]; + } + + protected function buildPhasesData(): array + { + $phases = $this->project->phases() + ->with(['layers.features']) + ->orderBy('order') + ->get(); + + return $phases->map(function ($phase) { + $phaseFeatures = $phase->layers->flatMap(fn($l) => $l->features); + + return [ + 'id' => $phase->id, + 'name' => $phase->name, + 'description' => $phase->description, + 'order' => $phase->order, + 'color' => $phase->color, + 'planned_start' => $phase->planned_start?->format('d/m/Y'), + 'planned_end' => $phase->planned_end?->format('d/m/Y'), + 'actual_start' => $phase->actual_start?->format('d/m/Y'), + 'actual_end' => $phase->actual_end?->format('d/m/Y'), + 'baseline_start' => $phase->baseline_start?->format('d/m/Y'), + 'baseline_end' => $phase->baseline_end?->format('d/m/Y'), + 'progress_percent' => $phase->progress_percent, + 'planned_progress' => $phase->planned_progress_at, + 'deviation_days' => $phase->deviation_days, + 'start_deviation_days' => $phase->start_deviation_days, + 'spi' => $phase->spi, + 'is_on_track' => $phase->is_on_track, + 'features_count' => $phaseFeatures->count(), + 'completed_features' => $phaseFeatures->where('status', 'completed')->count(), + 'layers' => $phase->layers->map(fn($l) => [ + 'id' => $l->id, + 'name' => $l->name, + 'features_count' => $l->features->count(), + ])->toArray(), + ]; + })->toArray(); + } + + protected function buildFeaturesData(): array + { + $query = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $this->project->id)) + ->with(['layer.phase', 'template', 'inspections', 'issues']); + + // Apply date filter on feature created_at if date range provided + $this->applyDateFilter($query, 'features.created_at'); + + $features = $query->orderBy('id')->get(); + + return $features->map(function ($feature) { + $lastInspection = $feature->inspections->sortByDesc('created_at')->first(); + + return [ + 'id' => $feature->id, + 'name' => $feature->name, + 'phase' => $feature->layer?->phase?->name ?? '—', + 'layer' => $feature->layer?->name ?? '—', + 'status' => $feature->status, + 'status_label' => $this->getFeatureStatusLabel($feature->status), + 'status_color' => $feature->status_color, + 'progress' => $feature->progress, + 'planned_progress' => $feature->planned_progress_at, + 'planned_start' => $feature->planned_start?->format('d/m/Y'), + 'planned_end' => $feature->planned_end?->format('d/m/Y'), + 'actual_start' => $feature->actual_start?->format('d/m/Y'), + 'actual_end' => $feature->actual_end?->format('d/m/Y'), + 'deviation_days' => $feature->deviation_days, + 'start_deviation_days' => $feature->start_deviation_days, + 'spi' => $feature->spi, + 'is_on_track' => $feature->is_on_track, + 'responsible' => $feature->responsible ?? $feature->responsibleUser?->name ?? '—', + 'template' => $feature->template?->name ?? '—', + 'last_inspection_date' => $lastInspection?->created_at?->format('d/m/Y'), + 'last_inspection_result' => $lastInspection?->result, + 'inspections_count' => $feature->inspections->count(), + 'open_issues_count' => $feature->issues->where('status', 'open')->count(), + ]; + })->toArray(); + } + + protected function buildInspectionsData(): array + { + $query = Inspection::where('project_id', $this->project->id) + ->with(['feature.layer.phase', 'template', 'user', 'media']); + + $this->applyDateFilter($query, 'created_at'); + + $inspections = $query->orderByDesc('created_at')->get(); + + return $inspections->map(function ($inspection) { + return [ + 'id' => $inspection->id, + 'feature' => $inspection->feature?->name ?? '—', + 'phase' => $inspection->feature?->layer?->phase?->name ?? '—', + 'template' => $inspection->template?->name ?? '—', + 'inspector' => $inspection->user?->name ?? '—', + 'date' => $inspection->created_at->format('d/m/Y H:i'), + 'status' => $inspection->status, + 'result' => $inspection->result, + 'result_label' => match($inspection->result) { + 'pass' => 'Aprobada', + 'fail' => 'Fallida', + 'conditional' => 'Condicional', + default => '—', + }, + 'notes' => $inspection->notes, + 'photos_count' => $inspection->media->count(), + 'data' => $inspection->data ?? [], + ]; + })->toArray(); + } + + protected function buildIssuesData(): array + { + $query = Issue::where('project_id', $this->project->id) + ->with(['feature.layer.phase', 'reporter', 'assignee', 'tasks']); + + $this->applyDateFilter($query, 'created_at'); + + $issues = $query->orderByDesc('created_at')->get(); + + return $issues->map(function ($issue) { + return [ + 'id' => $issue->id, + 'title' => $issue->title, + 'feature' => $issue->feature?->name ?? '—', + 'phase' => $issue->feature?->layer?->phase?->name ?? '—', + 'priority' => $issue->priority, + 'priority_label' => match($issue->priority) { + 'low' => 'Baja', + 'medium' => 'Media', + 'high' => 'Alta', + 'critical' => 'Crítica', + default => ucfirst($issue->priority ?? ''), + }, + 'status' => $issue->status, + 'status_label' => match($issue->status) { + 'open' => 'Abierta', + 'in_review' => 'En revisión', + 'closed' => 'Cerrada', + default => ucfirst($issue->status ?? ''), + }, + 'reporter' => $issue->reporter?->name ?? '—', + 'assignee' => $issue->assignee?->name ?? '—', + 'created_at' => $issue->created_at->format('d/m/Y'), + 'closed_at' => $issue->closed_at?->format('d/m/Y'), + 'days_open' => $issue->created_at->diffInDays($issue->closed_at ?? now()), + 'tasks_total' => $issue->tasks->count(), + 'tasks_completed' => $issue->tasks->where('is_done', true)->count(), + ]; + })->toArray(); + } + + protected function buildTasksData(): array + { + $query = Task::where('project_id', $this->project->id) + ->with(['phase', 'assignee', 'creator', 'subtasks']); + + $this->applyDateFilter($query, 'created_at'); + + $tasks = $query->whereNull('parent_task_id')->orderBy('order')->get(); + + return $tasks->map(function ($task) { + return [ + 'id' => $task->id, + 'title' => $task->title, + 'phase' => $task->phase?->name ?? '—', + 'status' => $task->status, + 'status_label' => $task->status_label, + 'priority' => $task->priority, + 'priority_label' => $task->priority_label, + 'assignee' => $task->assignee?->name ?? '—', + 'creator' => $task->creator?->name ?? '—', + 'due_date' => $task->due_date?->format('d/m/Y'), + 'start_date' => $task->start_date?->format('d/m/Y'), + 'completed_at' => $task->completed_at?->format('d/m/Y'), + 'estimated_hours' => $task->estimated_hours, + 'actual_hours' => $task->actual_hours, + 'progress' => $task->progress, + 'is_overdue' => $task->is_overdue, + 'subtasks' => $task->subtasks->map(fn($st) => [ + 'id' => $st->id, + 'title' => $st->title, + 'status' => $st->status, + 'assignee' => $st->assignee?->name ?? '—', + 'progress' => $st->progress, + ])->toArray(), + ]; + })->toArray(); + } + + protected function buildMediaData(): array + { + $query = Media::whereHas('mediable', function ($q) { + $q->where(function ($sub) { + $sub->where('mediable_type', Phase::class) + ->orWhere('mediable_type', Feature::class) + ->orWhere('mediable_type', Inspection::class) + ->orWhere('mediable_type', Issue::class) + ->orWhere('mediable_type', Task::class); + }); + })->whereHas('mediable', function ($q) { + // Filter by project through relationships + // This is complex with polymorphic, so we filter in PHP for simplicity + }); + + $this->applyDateFilter($query, 'created_at'); + + $media = $query->orderByDesc('created_at')->get(); + + // Filter by project in PHP (polymorphic complexity) + $filtered = $media->filter(function ($m) { + $mediable = $m->mediable; + if (!$mediable) return false; + + if ($mediable instanceof Phase) { + return $mediable->project_id === $this->project->id; + } + if ($mediable instanceof Feature) { + return $mediable->layer?->phase?->project_id === $this->project->id; + } + if ($mediable instanceof Inspection) { + return $mediable->project_id === $this->project->id; + } + if ($mediable instanceof Issue) { + return $mediable->project_id === $this->project->id; + } + if ($mediable instanceof Task) { + return $mediable->project_id === $this->project->id; + } + return false; + }); + + return $filtered->map(function ($m) { + return [ + 'id' => $m->id, + 'name' => $m->name, + 'type' => $m->category, + 'entity' => class_basename($m->mediable_type), + 'entity_name' => $m->mediable?->name ?? $m->mediable?->title ?? '—', + 'size' => $m->file_size ? $this->formatBytes($m->file_size) : '—', + 'uploaded_by' => $m->uploadedBy?->name ?? '—', + 'uploaded_at' => $m->created_at->format('d/m/Y H:i'), + 'url' => $m->url, + ]; + })->values()->toArray(); + } + + protected function buildDeviationsData(): array + { + $phases = $this->project->phases() + ->with(['layers.features']) + ->get(); + + $phaseDeviations = $phases->map(function ($phase) { + return [ + 'id' => $phase->id, + 'name' => $phase->name, + 'planned_start' => $phase->planned_start?->format('d/m/Y'), + 'planned_end' => $phase->planned_end?->format('d/m/Y'), + 'actual_start' => $phase->actual_start?->format('d/m/Y'), + 'actual_end' => $phase->actual_end?->format('d/m/Y'), + 'start_deviation' => $phase->start_deviation_days, + 'end_deviation' => $phase->deviation_days, + 'planned_progress' => $phase->planned_progress_at, + 'actual_progress' => $phase->progress_percent, + 'progress_deviation' => round($phase->progress_percent - $phase->planned_progress_at, 1), + 'spi' => $phase->spi, + 'is_on_track' => $phase->is_on_track, + ]; + })->filter(fn($p) => $p['planned_start'] || $p['planned_end'])->toArray(); + + $featureDeviations = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $this->project->id)) + ->whereNotNull('planned_end') + ->with(['layer.phase']) + ->get() + ->map(function ($feature) { + return [ + 'id' => $feature->id, + 'name' => $feature->name, + 'phase' => $feature->layer?->phase?->name ?? '—', + 'planned_start' => $feature->planned_start?->format('d/m/Y'), + 'planned_end' => $feature->planned_end?->format('d/m/Y'), + 'actual_start' => $feature->actual_start?->format('d/m/Y'), + 'actual_end' => $feature->actual_end?->format('d/m/Y'), + 'start_deviation' => $feature->start_deviation_days, + 'end_deviation' => $feature->deviation_days, + 'planned_progress' => $feature->planned_progress_at, + 'actual_progress' => $feature->progress, + 'progress_deviation' => round($feature->progress - $feature->planned_progress_at, 1), + 'spi' => $feature->spi, + 'is_on_track' => $feature->is_on_track, + 'responsible' => $feature->responsible ?? $feature->responsibleUser?->name ?? '—', + ]; + })->toArray(); + + return [ + 'phases' => $phaseDeviations, + 'features' => $featureDeviations, + 'summary' => [ + 'phases_delayed' => count(array_filter($phaseDeviations, fn($p) => ($p['end_deviation'] ?? 0) > 0)), + 'phases_early' => count(array_filter($phaseDeviations, fn($p) => ($p['end_deviation'] ?? 0) < 0)), + 'phases_on_time' => count(array_filter($phaseDeviations, fn($p) => ($p['end_deviation'] ?? 0) === 0)), + 'features_delayed' => count(array_filter($featureDeviations, fn($f) => ($f['end_deviation'] ?? 0) > 0)), + 'features_early' => count(array_filter($featureDeviations, fn($f) => ($f['end_deviation'] ?? 0) < 0)), + 'features_on_time' => count(array_filter($featureDeviations, fn($f) => ($f['end_deviation'] ?? 0) === 0)), + ], + ]; + } + + protected function buildProgressCurveData(): array + { + // Get snapshots for this project's phases and features + $phaseIds = $this->project->phases->pluck('id')->toArray(); + $featureIds = Feature::whereHas('layer.phase', fn($q) => $q->whereIn('phase_id', $phaseIds)) + ->pluck('id')->toArray(); + $taskIds = Task::where('project_id', $this->project->id)->pluck('id')->toArray(); + + $from = $this->filters->dateFrom ?? now()->subMonths(6); + $to = $this->filters->dateTo ?? now(); + + $snapshots = ProgressSnapshot::whereIn('trackable_type', [Phase::class, Feature::class, Task::class]) + ->where(function ($q) use ($phaseIds, $featureIds, $taskIds) { + $q->where(function ($sub) use ($phaseIds) { + $sub->where('trackable_type', Phase::class) + ->whereIn('trackable_id', $phaseIds); + })->orWhere(function ($sub) use ($featureIds) { + $sub->where('trackable_type', Feature::class) + ->whereIn('trackable_id', $featureIds); + })->orWhere(function ($sub) use ($taskIds) { + $sub->where('trackable_type', Task::class) + ->whereIn('trackable_id', $taskIds); + }); + }) + ->whereBetween('snapshot_date', [$from, $to]) + ->orderBy('snapshot_date') + ->get(); + + // Group by date and calculate average progress + $grouped = $snapshots->groupBy('snapshot_date'); + + $dates = []; + $planned = []; + $actual = []; + + foreach ($grouped as $date => $snaps) { + $dates[] = Carbon::parse($date)->format('d/m/Y'); + + $phaseSnaps = $snaps->where('trackable_type', Phase::class); + $featureSnaps = $snaps->where('trackable_type', Feature::class); + $taskSnaps = $snaps->where('trackable_type', Task::class); + + $avgPlanned = 0; + $avgActual = 0; + $count = 0; + + foreach ($phaseSnaps as $snap) { + $avgPlanned += $snap->metadata['planned_progress'] ?? 0; + $avgActual += $snap->progress; + $count++; + } + foreach ($featureSnaps as $snap) { + $avgPlanned += $snap->metadata['planned_progress'] ?? 0; + $avgActual += $snap->progress; + $count++; + } + foreach ($taskSnaps as $snap) { + $avgPlanned += $snap->progress; // Tasks don't have planned progress easily + $avgActual += $snap->progress; + $count++; + } + + $planned[] = $count > 0 ? round($avgPlanned / $count, 1) : 0; + $actual[] = $count > 0 ? round($avgActual / $count, 1) : 0; + } + + return [ + 'labels' => $dates, + 'planned' => $planned, + 'actual' => $actual, + ]; + } + + protected function applyDateFilter($query, string $column): void + { + if ($this->filters->dateFrom) { + $query->whereDate($column, '>=', $this->filters->dateFrom); + } + if ($this->filters->dateTo) { + $query->whereDate($column, '<=', $this->filters->dateTo); + } + } + + protected function getFeatureStatusLabel(string $status): string + { + return match($status) { + 'planned' => 'Planificado', + 'started' => 'Iniciado', + 'in_progress' => 'En progreso', + 'completed' => 'Completado', + 'verified' => 'Verificado', + default => ucfirst($status), + }; + } + + protected function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $i = 0; + while ($bytes >= 1024 && $i < count($units) - 1) { + $bytes /= 1024; + $i++; + } + return round($bytes, 1) . ' ' . $units[$i]; + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index ad57328..21520ff 100644 --- a/composer.json +++ b/composer.json @@ -90,7 +90,11 @@ "sort-packages": true, "allow-plugins": { "pestphp/pest-plugin": true, - "php-http/discovery": true + "php-http/discovery": true, + "composer": false + }, + "audit": { + "block-insecure": false } }, "minimum-stability": "stable", diff --git a/database/migrations/2026_08_24_094858_add_planned_actual_dates_to_phases_table.php b/database/migrations/2026_08_24_094858_add_planned_actual_dates_to_phases_table.php new file mode 100644 index 0000000..aa70937 --- /dev/null +++ b/database/migrations/2026_08_24_094858_add_planned_actual_dates_to_phases_table.php @@ -0,0 +1,24 @@ +date('baseline_start')->nullable()->after('actual_end'); + $table->date('baseline_end')->nullable()->after('baseline_start'); + }); + } + + public function down(): void + { + Schema::table('phases', function (Blueprint $table) { + $table->dropColumn(['baseline_start', 'baseline_end']); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_08_24_094903_add_planned_actual_dates_to_features_table.php b/database/migrations/2026_08_24_094903_add_planned_actual_dates_to_features_table.php new file mode 100644 index 0000000..f012892 --- /dev/null +++ b/database/migrations/2026_08_24_094903_add_planned_actual_dates_to_features_table.php @@ -0,0 +1,31 @@ +date('planned_start')->nullable()->after('responsible'); + $table->date('planned_end')->nullable()->after('planned_start'); + $table->date('actual_start')->nullable()->after('planned_end'); + $table->date('actual_end')->nullable()->after('actual_start'); + $table->date('baseline_start')->nullable()->after('actual_end'); + $table->date('baseline_end')->nullable()->after('baseline_start'); + }); + } + + public function down(): void + { + Schema::table('features', function (Blueprint $table) { + $table->dropColumn([ + 'planned_start', 'planned_end', + 'actual_start', 'actual_end', + 'baseline_start', 'baseline_end' + ]); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_08_24_094906_create_progress_snapshots_table.php b/database/migrations/2026_08_24_094906_create_progress_snapshots_table.php new file mode 100644 index 0000000..71a9b38 --- /dev/null +++ b/database/migrations/2026_08_24_094906_create_progress_snapshots_table.php @@ -0,0 +1,35 @@ +id(); + $table->morphs('trackable'); // Phase, Feature, Task + $table->date('snapshot_date'); + $table->decimal('progress', 5, 2)->default(0); // 0-100 + $table->date('planned_start')->nullable(); + $table->date('planned_end')->nullable(); + $table->date('actual_start')->nullable(); + $table->date('actual_end')->nullable(); + $table->string('method')->default('manual'); // manual|inspection|task|geometry|hybrid + $table->json('metadata')->nullable(); // {earned_value, planned_value, actual_cost, spi, cpi, ...} + $table->timestamps(); + + // Short index name for MySQL 64-char limit + $table->unique(['trackable_type', 'trackable_id', 'snapshot_date'], 'ps_trackable_date_unique'); + $table->index(['trackable_type', 'trackable_id', 'snapshot_date'], 'ps_trackable_date_idx'); + $table->index(['snapshot_date'], 'ps_snapshot_date_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('progress_snapshots'); + } +}; \ No newline at end of file diff --git a/database/seeders/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php index c49361e..202a47b 100644 --- a/database/seeders/RolesAndPermissionsSeeder.php +++ b/database/seeders/RolesAndPermissionsSeeder.php @@ -18,7 +18,7 @@ class RolesAndPermissionsSeeder extends Seeder // Create permissions $permissions = [ 'view projects', 'create projects', 'edit projects', 'delete projects', - 'assign users', 'upload layers', 'update progress', 'view reports', 'manage all', + 'assign users', 'upload layers', 'update progress', 'view reports', 'generate reports', 'manage all', 'view inspections', 'create inspections', 'edit inspections', 'delete inspections', 'manage templates', 'view tasks', 'create tasks', 'edit tasks', 'delete tasks', 'assign tasks', 'manage all tasks', ]; diff --git a/resources/views/livewire/reports/report-builder.blade.php b/resources/views/livewire/reports/report-builder.blade.php new file mode 100644 index 0000000..4c32395 --- /dev/null +++ b/resources/views/livewire/reports/report-builder.blade.php @@ -0,0 +1,179 @@ +@extends('layouts.app') + +@section('content') +
+ {{ $project->address ?? 'Sin dirección' }} + @if($project->reference) + | Ref: {{ $project->reference }} + @endif +
+{{ __('Generando previsualización...') }}
+| Fase | +Inicio Plan | +Fin Plan | +Inicio Real | +Fin Real | +Δ Inicio (d) | +Δ Fin (d) | +Prog. Plan (%) | +Prog. Real (%) | +Δ Prog. | +SPI | +En Plazo | +
|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ $phase['name'] }} | +{{ $phase['planned_start'] ?? '—' }} | +{{ $phase['planned_end'] ?? '—' }} | +{{ $phase['actual_start'] ?? '—' }} | +{{ $phase['actual_end'] ?? '—' }} | ++ {{ $phase['start_deviation'] ?? '—' }} + | ++ {{ $phase['end_deviation'] ?? '—' }} + | +{{ $phase['planned_progress'] }}% | +{{ $phase['actual_progress'] }}% | ++ {{ $phase['progress_deviation'] ?? '—' }}% + | +{{ $phase['spi'] ?? '—' }} | ++ @if($phase['is_on_track'] === true) + {{ __('Sí') }} + @elseif($phase['is_on_track'] === false) + {{ __('No') }} + @else + {{ __('N/A') }} + @endif + | +
| Elemento | +Fase | +Inicio Plan | +Fin Plan | +Inicio Real | +Fin Real | +Δ Inicio (d) | +Δ Fin (d) | +Prog. Plan (%) | +Prog. Real (%) | +Δ Prog. | +SPI | +En Plazo | +Responsable | +
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ $feature['name'] }} | +{{ $feature['phase'] }} | +{{ $feature['planned_start'] ?? '—' }} | +{{ $feature['planned_end'] ?? '—' }} | +{{ $feature['actual_start'] ?? '—' }} | +{{ $feature['actual_end'] ?? '—' }} | ++ {{ $feature['start_deviation'] ?? '—' }} + | ++ {{ $feature['end_deviation'] ?? '—' }} + | +{{ $feature['planned_progress'] }}% | +{{ $feature['actual_progress'] }}% | ++ {{ $feature['progress_deviation'] ?? '—' }}% + | +{{ $feature['spi'] ?? '—' }} | ++ @if($feature['is_on_track'] === true) + {{ __('Sí') }} + @elseif($feature['is_on_track'] === false) + {{ __('No') }} + @else + {{ __('N/A') }} + @endif + | +{{ $feature['responsible'] }} | +
{{ __('No hay elementos en el rango seleccionado.') }}
+| Elemento | +Fase | +Capa | +Estado | +Progreso (%) | +Prog. Plan (%) | +Inicio Plan | +Fin Plan | +Inicio Real | +Fin Real | +Δ Fin (d) | +Δ Inicio (d) | +SPI | +En Plazo | +Responsable | +Template | +Últ. Insp. | +Resultado | +Insp. | +Issues | +
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ $feature['name'] }} | +{{ $feature['phase'] }} | +{{ $feature['layer'] }} | ++ + {{ $feature['status_label'] }} + + | +
+
+
+
+
+
+ {{ $feature['progress'] }}%
+ |
+ {{ $feature['planned_progress'] }}% | +{{ $feature['planned_start'] ?? '—' }} | +{{ $feature['planned_end'] ?? '—' }} | +{{ $feature['actual_start'] ?? '—' }} | +{{ $feature['actual_end'] ?? '—' }} | +{{ $feature['deviation_days'] ?? '—' }} | +{{ $feature['start_deviation_days'] ?? '—' }} | +{{ $feature['spi'] ?? '—' }} | ++ @if($feature['is_on_track'] === true) + {{ __('Sí') }} + @elseif($feature['is_on_track'] === false) + {{ __('No') }} + @else + {{ __('N/A') }} + @endif + | +{{ $feature['responsible'] }} | +{{ $feature['template'] }} | +{{ $feature['last_inspection_date'] ?? '—' }} | ++ @if($feature['last_inspection_result']) + + {{ $feature['last_inspection_result'] === 'pass' ? 'Aprobada' : ($feature['last_inspection_result'] === 'fail' ? 'Fallida' : 'Condicional') }} + + @endif + | +{{ $feature['inspections_count'] }} | +{{ $feature['open_issues_count'] }} | +
{{ __('No hay inspecciones en el rango seleccionado.') }}
+| ID | +Elemento | +Fase | +Template | +Inspector | +Fecha | +Estado | +Resultado | +Notas | +Fotos | +
|---|---|---|---|---|---|---|---|---|---|
| {{ $inspection['id'] }} | +{{ $inspection['feature'] }} | +{{ $inspection['phase'] }} | +{{ $inspection['template'] }} | +{{ $inspection['inspector'] }} | +{{ $inspection['date'] }} | ++ + {{ $inspection['status'] }} + + | ++ @if($inspection['result']) + + {{ $inspection['result_label'] }} + + @endif + | +{{ $inspection['notes'] ?? '—' }} | +{{ $inspection['photos_count'] }} | +
{{ __('No hay incidencias en el rango seleccionado.') }}
+| ID | +Título | +Elemento | +Fase | +Prioridad | +Estado | +Reportado por | +Asignado a | +Creado | +Cerrado | +Días abierto | +Tareas | +
|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ $issue['id'] }} | +{{ $issue['title'] }} | +{{ $issue['feature'] }} | +{{ $issue['phase'] }} | ++ + {{ $issue['priority_label'] }} + + | ++ + {{ $issue['status_label'] }} + + | +{{ $issue['reporter'] }} | +{{ $issue['assignee'] }} | +{{ $issue['created_at'] }} | +{{ $issue['closed_at'] ?? '—' }} | +{{ $issue['days_open'] }} | +{{ $issue['tasks_completed'] }} / {{ $issue['tasks_total'] }} | +
{{ __('No hay archivos en el rango seleccionado.') }}
+| ID | +Nombre | +Tipo | +Entidad | +Entidad Nombre | +Tamaño | +Subido por | +Fecha | +
|---|---|---|---|---|---|---|---|
| {{ $item['id'] }} | +{{ $item['name'] }} | ++ + {{ $item['type'] }} + + | +{{ $item['entity'] }} | +{{ $item['entity_name'] }} | +{{ $item['size'] }} | +{{ $item['uploaded_by'] }} | +{{ $item['uploaded_at'] }} | +
| {{ __('Capa') }} | +{{ __('Elementos') }} | +
|---|---|
| {{ $layer['name'] }} | +{{ $layer['features_count'] }} | +
{{ __('No hay fases registradas en este proyecto.') }}
+{{ __('No hay tareas en el rango seleccionado.') }}
+| ID | +Tarea | +Fase | +Estado | +Prioridad | +Asignado | +Creador | +Inicio | +Fin | +Completada | +Horas Est. | +Horas Real | +Progreso | +Vencida | +Subtareas | +
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ $task['id'] }} | +{{ $task['title'] }} | +{{ $task['phase'] }} | ++ {{ $task['status_label'] }} + | ++ {{ $task['priority_label'] }} + | +{{ $task['assignee'] }} | +{{ $task['creator'] }} | +{{ $task['start_date'] ?? '—' }} | +{{ $task['due_date'] ?? '—' }} | +{{ $task['completed_at'] ?? '—' }} | +{{ $task['estimated_hours'] ?? '—' }} | +{{ $task['actual_hours'] ?? '—' }} | +
+
+
+
+
+
+ {{ $task['progress'] }}%
+ |
+ + @if($task['is_overdue']) + {{ __('Sí') }} + @else + {{ __('No') }} + @endif + | +{{ $subtaskTitles }} | +