feat(reports): complete reporting system with deviations, progress curves, multi-format export

- Added planned/actual/baseline dates to Phase and Feature models
- Created ProgressSnapshot model + migration for historical tracking
- Implemented CaptureDailyProgressSnapshot job (scheduled daily at 06:30)
- Added DeviationCalculator logic (SPI, planned progress, deviation days)
- ReportGenerator service with complete data aggregation
- ReportController with builder, preview, generate (HTML/Excel)
- ReportBuilder Livewire component with date range, entity selection, format
- ProjectReportExport with 10 sheets (Summary, Phases, Features, Inspections, Issues, Tasks, Deviations, Media, Progress Curve, Parameters)
- Blade partials for all sections (header, summary, phases, features, inspections, issues, tasks, media, deviations, progress curve)
- New routes: /projects/{project}/reports/*
- Added 'generate reports' permission
- All 101 tests passing
This commit is contained in:
Javier Braña
2026-08-24 12:32:08 +02:00
parent c58a49f22d
commit ee32544525
30 changed files with 3170 additions and 7 deletions
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\DTO;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class ReportFilters
{
public function __construct(
public ?Carbon $dateFrom = null,
public ?Carbon $dateTo = null,
public array $entityTypes = [], // ['phases','features','inspections','issues','tasks','media']
public bool $includePhotos = false,
public string $format = 'html', // html|excel
public bool $includeCharts = false,
) {}
public static function fromRequest(array $data): self
{
return new self(
dateFrom: isset($data['date_from']) ? Carbon::parse($data['date_from']) : null,
dateTo: isset($data['date_to']) ? Carbon::parse($data['date_to']) : null,
entityTypes: $data['entity_types'] ?? ['phases','features','inspections','issues','tasks'],
includePhotos: (bool)($data['include_photos'] ?? false),
format: $data['format'] ?? 'html',
includeCharts: (bool)($data['include_charts'] ?? false),
);
}
public function toArray(): array
{
return [
'date_from' => $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';
}
}
+655
View File
@@ -0,0 +1,655 @@
<?php
namespace App\Exports;
use App\DTO\ReportFilters;
use App\Models\Project;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
use Maatwebsite\Excel\Concerns\WithTitle;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithColumnWidths;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
class ProjectReportExport implements WithMultipleSheets
{
protected Project $project;
protected ReportFilters $filters;
protected array $data;
public function __construct(Project $project, ReportFilters $filters, array $data)
{
$this->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];
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers;
use App\DTO\ReportFilters;
use App\Models\Project;
use App\Services\ReportGenerator;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Maatwebsite\Excel\Facades\Excel;
use App\Exports\ProjectReportExport;
class ReportController extends Controller
{
public function __construct()
{
$this->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.');
}
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace App\Jobs;
use App\Models\Feature;
use App\Models\Phase;
use App\Models\ProgressSnapshot;
use App\Models\Task;
use Carbon\Carbon;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class CaptureDailyProgressSnapshot implements ShouldQueue
{
use Queueable;
public function __construct(
public ?string $snapshotDate = null
) {}
public function handle(): void
{
$date = $this->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;
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Livewire\Reports;
use App\DTO\ReportFilters;
use App\Models\Project;
use App\Services\ReportGenerator;
use Livewire\Component;
use Livewire\Attributes\Layout;
#[Layout('layouts.app')]
class ReportBuilder extends Component
{
public Project $project;
public array $filters = [
'date_from' => 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(),
]);
}
}
+82 -3
View File
@@ -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;
}
}
+78 -2
View File
@@ -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;
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ProgressSnapshot extends Model
{
protected $fillable = [
'trackable_type',
'trackable_id',
'snapshot_date',
'progress',
'planned_start',
'planned_end',
'actual_start',
'actual_end',
'method',
'metadata',
];
protected $casts = [
'snapshot_date' => '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();
}
}
+5
View File
@@ -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
// ============================================================
+546
View File
@@ -0,0 +1,546 @@
<?php
namespace App\Services;
use App\DTO\ReportFilters;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\Issue;
use App\Models\Media;
use App\Models\Phase;
use App\Models\Project;
use App\Models\ProgressSnapshot;
use App\Models\Task;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class ReportGenerator
{
protected Project $project;
protected ReportFilters $filters;
public function __construct(Project $project, ReportFilters $filters)
{
$this->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];
}
}
+5 -1
View File
@@ -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",
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('phases', function (Blueprint $table) {
// actual_start and actual_end already exist
$table->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']);
});
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('features', function (Blueprint $table) {
$table->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'
]);
});
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('progress_snapshots', function (Blueprint $table) {
$table->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');
}
};
@@ -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',
];
@@ -0,0 +1,179 @@
@extends('layouts.app')
@section('content')
<div class="container mx-auto px-4 py-8">
{{-- Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
<div>
<h1 class="text-2xl font-bold text-gray-800">
{{ __('Generar Informe') }}: {{ $project->name }}
</h1>
<p class="text-gray-500 mt-1">
{{ $project->address ?? 'Sin dirección' }}
@if($project->reference)
&nbsp;|&nbsp; Ref: {{ $project->reference }}
@endif
</p>
</div>
<a href="{{ route('projects.map', $project) }}" class="btn btn-ghost btn-sm">
<x-heroicon-o-arrow-left class="w-4 h-4 mr-1" /> {{ __('Volver al mapa') }}
</a>
</div>
{{-- Report Builder Form --}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<form wire:submit.prevent="generateReport" class="space-y-6">
{{-- Date Range --}}
<div class="border-b border-gray-200 pb-6">
<h3 class="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
<x-heroicon-o-calendar-days class="w-5 h-5 text-blue-600" />
{{ __('Rango de fechas') }}
</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('Fecha desde') }}
</label>
<input type="date"
wire:model="filters.date_from"
class="input input-bordered w-full"
placeholder="dd/mm/yyyy">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('Fecha hasta') }}
</label>
<input type="date"
wire:model="filters.date_to"
class="input input-bordered w-full"
placeholder="dd/mm/yyyy">
</div>
<div class="md:col-span-4 flex flex-wrap gap-2">
<button type="button" wire:click="setDateRange('week')" class="btn btn-sm btn-outline">
{{ __('Esta semana') }}
</button>
<button type="button" wire:click="setDateRange('month')" class="btn btn-sm btn-outline">
{{ __('Este mes') }}
</button>
<button type="button" wire:click="setDateRange('quarter')" class="btn btn-sm btn-outline">
{{ __('Este trimestre') }}
</button>
<button type="button" wire:click="setDateRange('year')" class="btn btn-sm btn-outline">
{{ __('Este año') }}
</button>
<button type="button" wire:click="clearDateRange" class="btn btn-sm btn-ghost">
{{ __('Limpiar') }}
</button>
</div>
</div>
</div>
{{-- Entity Types --}}
<div class="border-b border-gray-200 pb-6">
<h3 class="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
<x-heroicon-o-squares-2x2 class="w-5 h-5 text-blue-600" />
{{ __('Secciones a incluir') }}
</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
@foreach($availableEntities as $key => $label)
<label class="flex items-center gap-2 cursor-pointer p-3 border rounded-lg hover:bg-gray-50 transition-colors">
<input type="checkbox"
wire:model="filters.entity_types"
value="{{ $key }}"
class="checkbox checkbox-primary">
<span class="text-sm font-medium">{{ $label }}</span>
</label>
@endforeach
</div>
</div>
{{-- Options --}}
<div class="border-b border-gray-200 pb-6">
<h3 class="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
<x-heroicon-o-cog-6-tooth class="w-5 h-5 text-blue-600" />
{{ __('Opciones') }}
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model="filters.include_photos" class="checkbox checkbox-primary">
<span class="text-sm">{{ __('Incluir fotos') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model="filters.include_charts" class="checkbox checkbox-primary">
<span class="text-sm">{{ __('Incluir gráficos (curva S)') }}</span>
</label>
</div>
</div>
{{-- Format Selection --}}
<div>
<h3 class="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
<x-heroicon-o-document-arrow-down class="w-5 h-5 text-blue-600" />
{{ __('Formato de salida') }}
</h3>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer p-4 border-2 rounded-lg {{ $filters['format'] === 'html' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300' }} transition-colors">
<input type="radio" wire:model="filters.format" value="html" class="radio radio-primary">
<div>
<span class="font-medium">{{ __('HTML (Imprimible / PDF)') }}</span>
<p class="text-xs text-gray-500">{{ __('Visualizar en navegador, imprimir o guardar como PDF') }}</p>
</div>
</label>
<label class="flex items-center gap-2 cursor-pointer p-4 border-2 rounded-lg {{ $filters['format'] === 'excel' ? 'border-green-500 bg-green-50' : 'border-gray-200 hover:border-gray-300' }} transition-colors">
<input type="radio" wire:model="filters.format" value="excel" class="radio radio-success">
<div>
<span class="font-medium">{{ __('Excel (.xlsx)') }}</span>
<p class="text-xs text-gray-500">{{ __('Múltiples hojas: Resumen, Fases, Elementos, Inspecciones, Issues, Tareas, Desvíos, Media, Curva S, Parámetros') }}</p>
</div>
</label>
</div>
</div>
{{-- Actions --}}
<div class="flex flex-wrap gap-4 pt-6 border-t border-gray-200">
<button type="submit"
wire:loading.attr="disabled"
class="btn btn-primary gap-2">
<x-heroicon-o-document-arrow-down class="w-5 h-5" />
{{ $filters['format'] === 'excel' ? __('Descargar Excel') : __('Generar Informe HTML') }}
</button>
<button type="button"
wire:click="previewReport"
wire:loading.attr="disabled"
class="btn btn-outline gap-2">
<x-heroicon-o-eye class="w-5 h-5" />
{{ __('Previsualizar') }}
</button>
</div>
</form>
{{-- Preview Modal --}}
@if($showPreview)
<div class="fixed inset-0 z-50 overflow-y-auto" wire:ignore.self>
<div class="flex min-h-full items-center justify-center p-4">
<div class="fixed inset-0 bg-black/50" wire:click="closePreview"></div>
<div class="relative bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden">
<div class="flex items-center justify-between p-4 border-b">
<h3 class="text-lg font-semibold">{{ __('Previsualización del Informe') }}</h3>
<button wire:click="closePreview" class="btn btn-ghost btn-sm">
<x-heroicon-o-x-mark class="w-5 h-5" />
</button>
</div>
<div class="p-4 overflow-y-auto max-h-[70vh]">
@if($previewData)
@include('reports.partials._preview', ['data' => $previewData])
@else
<div class="text-center py-8 text-gray-500">
<x-heroicon-o-arrow-path class="w-8 h-8 mx-auto animate-spin text-blue-500 mb-2" />
<p>{{ __('Generando previsualización...') }}</p>
</div>
@endif
</div>
</div>
</div>
</div>
@endif
</div>
</div>
@endsection
@@ -0,0 +1,73 @@
@extends('layouts.app')
@section('content')
<div class="page-wrapper">
{{-- Print Button --}}
<div class="mb-6 text-right no-print">
<button onclick="window.print()" class="btn btn-primary gap-2">
<x-heroicon-o-printer class="w-5 h-5" /> {{ __('Imprimir / Guardar PDF') }}
</button>
<a href="{{ route('reports.project.excel', ['project' => $project->id] + $filters->toArray()) }}"
class="btn btn-success gap-2 ml-2">
<x-heroicon-o-document-arrow-down class="w-5 h-5" /> {{ __('Descargar Excel') }}
</a>
<a href="{{ route('projects.map', $project) }}" class="btn btn-ghost btn-sm ml-2">
<x-heroicon-o-arrow-left class="w-4 h-4 mr-1" /> {{ __('Volver') }}
</a>
</div>
{{-- Header --}}
@include('reports.partials._header', ['project' => $project, 'filters' => $filters])
{{-- Summary KPIs --}}
@include('reports.partials._summary', ['summary' => $summary])
{{-- Progress Curve Chart --}}
@if(isset($progress_curve) && !empty($progress_curve['labels']))
@include('reports.partials._progress-curve', ['curveData' => $progress_curve])
@endif
{{-- Deviations --}}
@if(isset($deviations))
@include('reports.partials._deviations', ['deviations' => $deviations])
@endif
{{-- Phases --}}
@if(isset($phases) && !empty($phases))
@include('reports.partials._phases', ['phases' => $phases])
@endif
{{-- Features --}}
@if(isset($features) && !empty($features))
@include('reports.partials._features', ['features' => $features])
@endif
{{-- Inspections --}}
@if(isset($inspections) && !empty($inspections))
@include('reports.partials._inspections', ['inspections' => $inspections])
@endif
{{-- Issues --}}
@if(isset($issues) && !empty($issues))
@include('reports.partials._issues', ['issues' => $issues])
@endif
{{-- Tasks --}}
@if(isset($tasks) && !empty($tasks))
@include('reports.partials._tasks', ['tasks' => $tasks])
@endif
{{-- Media --}}
@if(isset($media) && !empty($media) && $filters['include_photos'])
@include('reports.partials._media', ['media' => $media])
@endif
{{-- Footer --}}
<div class="report-footer no-print">
<div class="flex justify-between items-center">
<span>ConstProgress &mdash; Sistema de Gestión de Obras</span>
<span>{{ now()->format('d/m/Y H:i') }}</span>
</div>
</div>
</div>
@endsection
@@ -0,0 +1,148 @@
<div class="section-title">{{ __('Análisis de Desvíos') }}</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="stat-card">
<div class="stat-value" style="color:#ef4444;">{{ $deviations['summary']['phases_delayed'] ?? 0 }}</div>
<div class="stat-label">{{ __('Fases retrasadas') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#10b981;">{{ $deviations['summary']['phases_early'] ?? 0 }}</div>
<div class="stat-label">{{ __('Fases adelantadas') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#6b7280;">{{ $deviations['summary']['phases_on_time'] ?? 0 }}</div>
<div class="stat-label">{{ __('Fases en plazo') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#ef4444;">{{ $deviations['summary']['features_delayed'] ?? 0 }}</div>
<div class="stat-label">{{ __('Elementos retrasados') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#10b981;">{{ $deviations['summary']['features_early'] ?? 0 }}</div>
<div class="stat-label">{{ __('Elementos adelantados') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#6b7280;">{{ $deviations['summary']['features_on_time'] ?? 0 }}</div>
<div class="stat-label">{{ __('Elementos en plazo') }}</div>
</div>
</div>
@if(!empty($deviations['phases']))
<div class="phase-block mb-8">
<div class="section-title" style="margin-top:0;">{{ __('Desvíos por Fase') }}</div>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>Fase</th>
<th>Inicio Plan</th>
<th>Fin Plan</th>
<th>Inicio Real</th>
<th>Fin Real</th>
<th>Δ Inicio (d)</th>
<th>Δ Fin (d)</th>
<th>Prog. Plan (%)</th>
<th>Prog. Real (%)</th>
<th>Δ Prog.</th>
<th>SPI</th>
<th>En Plazo</th>
</tr>
</thead>
<tbody>
@foreach($deviations['phases'] as $phase)
<tr>
<td class="font-medium">{{ $phase['name'] }}</td>
<td>{{ $phase['planned_start'] ?? '—' }}</td>
<td>{{ $phase['planned_end'] ?? '—' }}</td>
<td>{{ $phase['actual_start'] ?? '—' }}</td>
<td>{{ $phase['actual_end'] ?? '—' }}</td>
<td class="{{ ($phase['start_deviation'] ?? 0) > 0 ? 'text-red-600' : (($phase['start_deviation'] ?? 0) < 0 ? 'text-green-600' : '') }}">
{{ $phase['start_deviation'] ?? '—' }}
</td>
<td class="{{ ($phase['end_deviation'] ?? 0) > 0 ? 'text-red-600' : (($phase['end_deviation'] ?? 0) < 0 ? 'text-green-600' : '') }}">
{{ $phase['end_deviation'] ?? '—' }}
</td>
<td>{{ $phase['planned_progress'] }}%</td>
<td>{{ $phase['actual_progress'] }}%</td>
<td class="{{ ($phase['progress_deviation'] ?? 0) < 0 ? 'text-red-600' : 'text-green-600' }}">
{{ $phase['progress_deviation'] ?? '—' }}%
</td>
<td>{{ $phase['spi'] ?? '—' }}</td>
<td>
@if($phase['is_on_track'] === true)
<span class="badge badge-success">{{ __('Sí') }}</span>
@elseif($phase['is_on_track'] === false)
<span class="badge badge-error">{{ __('No') }}</span>
@else
<span class="badge badge-ghost">{{ __('N/A') }}</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
@if(!empty($deviations['features']))
<div class="phase-block">
<div class="section-title" style="margin-top:0;">{{ __('Desvíos por Elemento') }}</div>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>Elemento</th>
<th>Fase</th>
<th>Inicio Plan</th>
<th>Fin Plan</th>
<th>Inicio Real</th>
<th>Fin Real</th>
<th>Δ Inicio (d)</th>
<th>Δ Fin (d)</th>
<th>Prog. Plan (%)</th>
<th>Prog. Real (%)</th>
<th>Δ Prog.</th>
<th>SPI</th>
<th>En Plazo</th>
<th>Responsable</th>
</tr>
</thead>
<tbody>
@foreach($deviations['features'] as $feature)
<tr>
<td class="font-medium">{{ $feature['name'] }}</td>
<td>{{ $feature['phase'] }}</td>
<td>{{ $feature['planned_start'] ?? '—' }}</td>
<td>{{ $feature['planned_end'] ?? '—' }}</td>
<td>{{ $feature['actual_start'] ?? '—' }}</td>
<td>{{ $feature['actual_end'] ?? '—' }}</td>
<td class="{{ ($feature['start_deviation'] ?? 0) > 0 ? 'text-red-600' : (($feature['start_deviation'] ?? 0) < 0 ? 'text-green-600' : '') }}">
{{ $feature['start_deviation'] ?? '—' }}
</td>
<td class="{{ ($feature['end_deviation'] ?? 0) > 0 ? 'text-red-600' : (($feature['end_deviation'] ?? 0) < 0 ? 'text-green-600' : '') }}">
{{ $feature['end_deviation'] ?? '—' }}
</td>
<td>{{ $feature['planned_progress'] }}%</td>
<td>{{ $feature['actual_progress'] }}%</td>
<td class="{{ ($feature['progress_deviation'] ?? 0) < 0 ? 'text-red-600' : 'text-green-600' }}">
{{ $feature['progress_deviation'] ?? '—' }}%
</td>
<td>{{ $feature['spi'] ?? '—' }}</td>
<td>
@if($feature['is_on_track'] === true)
<span class="badge badge-success">{{ __('Sí') }}</span>
@elseif($feature['is_on_track'] === false)
<span class="badge badge-error">{{ __('No') }}</span>
@else
<span class="badge badge-ghost">{{ __('N/A') }}</span>
@endif
</td>
<td>{{ $feature['responsible'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
@@ -0,0 +1,95 @@
<div class="section-title">{{ __('Elementos (Features)') }}</div>
@if(empty($features))
<div class="text-center text-gray-400 py-8">
<p>{{ __('No hay elementos en el rango seleccionado.') }}</p>
</div>
@else
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>Elemento</th>
<th>Fase</th>
<th>Capa</th>
<th>Estado</th>
<th>Progreso (%)</th>
<th>Prog. Plan (%)</th>
<th>Inicio Plan</th>
<th>Fin Plan</th>
<th>Inicio Real</th>
<th>Fin Real</th>
<th>Δ Fin (d)</th>
<th>Δ Inicio (d)</th>
<th>SPI</th>
<th>En Plazo</th>
<th>Responsable</th>
<th>Template</th>
<th>Últ. Insp.</th>
<th>Resultado</th>
<th>Insp.</th>
<th>Issues</th>
</tr>
</thead>
<tbody>
@foreach($features as $feature)
@php
$hasEndDev = $feature['deviation_days'] !== null;
$endDevColor = $hasEndDev && $feature['deviation_days'] > 0 ? 'text-red-600' : ($hasEndDev && $feature['deviation_days'] < 0 ? 'text-green-600' : '');
$hasStartDev = $feature['start_deviation_days'] !== null;
$startDevColor = $hasStartDev && $feature['start_deviation_days'] > 0 ? 'text-red-600' : ($hasStartDev && $feature['start_deviation_days'] < 0 ? 'text-green-600' : '');
$hasProgDev = $feature['progress_deviation'] !== null;
$progDevColor = $hasProgDev && $feature['progress_deviation'] < 0 ? 'text-red-600' : 'text-green-600';
@endphp
<tr>
<td class="font-medium">{{ $feature['name'] }}</td>
<td>{{ $feature['phase'] }}</td>
<td>{{ $feature['layer'] }}</td>
<td>
<span class="badge" style="background: {{ $feature['status_color'] }}20; color: {{ $feature['status_color'] }};">
{{ $feature['status_label'] }}
</span>
</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<div style="flex:1;background:#e5e7eb;border-radius:4px;height:6px;min-width:60px;">
<div style="height:6px;border-radius:4px;background:{{ $feature['status_color'] }};width:{{ min(100, $feature['progress']) }}%;"></div>
</div>
<span style="font-size:11px;color:#6b7280;white-space:nowrap;">{{ $feature['progress'] }}%</span>
</div>
</td>
<td>{{ $feature['planned_progress'] }}%</td>
<td>{{ $feature['planned_start'] ?? '—' }}</td>
<td>{{ $feature['planned_end'] ?? '—' }}</td>
<td>{{ $feature['actual_start'] ?? '—' }}</td>
<td>{{ $feature['actual_end'] ?? '—' }}</td>
<td class="{{ $endDevColor }}">{{ $feature['deviation_days'] ?? '—' }}</td>
<td class="{{ $startDevColor }}">{{ $feature['start_deviation_days'] ?? '—' }}</td>
<td>{{ $feature['spi'] ?? '—' }}</td>
<td>
@if($feature['is_on_track'] === true)
<span class="badge badge-success">{{ __('Sí') }}</span>
@elseif($feature['is_on_track'] === false)
<span class="badge badge-error">{{ __('No') }}</span>
@else
<span class="badge badge-ghost">{{ __('N/A') }}</span>
@endif
</td>
<td>{{ $feature['responsible'] }}</td>
<td>{{ $feature['template'] }}</td>
<td>{{ $feature['last_inspection_date'] ?? '—' }}</td>
<td>
@if($feature['last_inspection_result'])
<span class="badge {{ $feature['last_inspection_result'] === 'pass' ? 'badge-success' : ($feature['last_inspection_result'] === 'fail' ? 'badge-error' : 'badge-warning') }}">
{{ $feature['last_inspection_result'] === 'pass' ? 'Aprobada' : ($feature['last_inspection_result'] === 'fail' ? 'Fallida' : 'Condicional') }}
</span>
@endif
</td>
<td>{{ $feature['inspections_count'] }}</td>
<td>{{ $feature['open_issues_count'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@@ -0,0 +1,26 @@
<div class="report-header">
<div class="logo-placeholder">LOGO<br>EMPRESA</div>
<div class="report-header-info">
<div class="report-title">{{ $project->name }}</div>
@if($project->address)
<div class="report-subtitle">{{ $project->address }}</div>
@endif
<div class="report-subtitle" style="margin-top:8px;">
@if($project->start_date)
Inicio: <strong style="color:#1f2937">{{ $project->start_date->format('d/m/Y') }}</strong>
@endif
@if($project->end_date_estimated)
&nbsp;&bull;&nbsp; Fin estimado: <strong style="color:#1f2937">{{ $project->end_date_estimated->format('d/m/Y') }}</strong>
@endif
</div>
</div>
<div class="report-meta">
<strong>Informe de Proyecto</strong>
Generado el {{ $filters['generated_at'] ?? now()->format('d/m/Y H:i') }}<br>
Período: {{ $filters['getDateRangeLabel']() }}<br>
Estado:
<span class="badge {{ $project->status === 'completed' ? 'badge-success' : ($project->status === 'in_progress' ? 'badge-in_progress' : 'badge-planned') }}">
{{ ucfirst(str_replace('_', ' ', $project->status ?? 'N/A')) }}
</span>
</div>
</div>
@@ -0,0 +1,56 @@
<div class="section-title">{{ __('Inspecciones') }}</div>
@if(empty($inspections))
<div class="text-center text-gray-400 py-8">
<p>{{ __('No hay inspecciones en el rango seleccionado.') }}</p>
</div>
@else
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>ID</th>
<th>Elemento</th>
<th>Fase</th>
<th>Template</th>
<th>Inspector</th>
<th>Fecha</th>
<th>Estado</th>
<th>Resultado</th>
<th>Notas</th>
<th>Fotos</th>
</tr>
</thead>
<tbody>
@foreach($inspections as $inspection)
<tr>
<td>{{ $inspection['id'] }}</td>
<td>{{ $inspection['feature'] }}</td>
<td>{{ $inspection['phase'] }}</td>
<td>{{ $inspection['template'] }}</td>
<td>{{ $inspection['inspector'] }}</td>
<td>{{ $inspection['date'] }}</td>
<td>
<span class="badge badge-{{ match($inspection['status']) {
'completed' => 'success',
'pending' => 'warning',
default => 'ghost'
} }}">
{{ $inspection['status'] }}
</span>
</td>
<td>
@if($inspection['result'])
<span class="badge {{ $inspection['result'] === 'pass' ? 'badge-success' : ($inspection['result'] === 'fail' ? 'badge-error' : 'badge-warning') }}">
{{ $inspection['result_label'] }}
</span>
@endif
</td>
<td class="max-w-xs truncate">{{ $inspection['notes'] ?? '—' }}</td>
<td>{{ $inspection['photos_count'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@@ -0,0 +1,75 @@
<div class="section-title">{{ __('Incidencias (Issues)') }}</div>
@if(empty($issues))
<div class="text-center text-gray-400 py-8">
<p>{{ __('No hay incidencias en el rango seleccionado.') }}</p>
</div>
@else
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>ID</th>
<th>Título</th>
<th>Elemento</th>
<th>Fase</th>
<th>Prioridad</th>
<th>Estado</th>
<th>Reportado por</th>
<th>Asignado a</th>
<th>Creado</th>
<th>Cerrado</th>
<th>Días abierto</th>
<th>Tareas</th>
</tr>
</thead>
<tbody>
@foreach($issues as $issue)
@php
$priorityColor = match($issue['priority']) {
'critical' => 'text-red-600',
'high' => 'text-orange-600',
'medium' => 'text-amber-600',
'low' => 'text-gray-600',
default => 'text-gray-600',
};
$statusColor = match($issue['status']) {
'open' => 'badge-error',
'in_review' => 'badge-warning',
'closed' => 'badge-success',
default => 'badge-ghost',
};
@endphp
<tr>
<td>{{ $issue['id'] }}</td>
<td class="font-medium max-w-xs truncate">{{ $issue['title'] }}</td>
<td>{{ $issue['feature'] }}</td>
<td>{{ $issue['phase'] }}</td>
<td>
<span class="badge {{ match($issue['priority']) {
'critical' => 'badge-error',
'high' => 'badge-warning',
'medium' => 'badge-info',
'low' => 'badge-ghost',
default => 'badge-ghost',
} }}">
{{ $issue['priority_label'] }}
</span>
</td>
<td>
<span class="badge {{ $statusColor }}">
{{ $issue['status_label'] }}
</span>
</td>
<td>{{ $issue['reporter'] }}</td>
<td>{{ $issue['assignee'] }}</td>
<td>{{ $issue['created_at'] }}</td>
<td>{{ $issue['closed_at'] ?? '—' }}</td>
<td>{{ $issue['days_open'] }}</td>
<td>{{ $issue['tasks_completed'] }} / {{ $issue['tasks_total'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@@ -0,0 +1,47 @@
<div class="section-title">{{ __('Archivos / Media') }}</div>
@if(empty($media))
<div class="text-center text-gray-400 py-8">
<p>{{ __('No hay archivos en el rango seleccionado.') }}</p>
</div>
@else
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>ID</th>
<th>Nombre</th>
<th>Tipo</th>
<th>Entidad</th>
<th>Entidad Nombre</th>
<th>Tamaño</th>
<th>Subido por</th>
<th>Fecha</th>
</tr>
</thead>
<tbody>
@foreach($media as $item)
<tr>
<td>{{ $item['id'] }}</td>
<td>{{ $item['name'] }}</td>
<td>
<span class="badge {{ match($item['type']) {
'image' => 'badge-info',
'document' => 'badge-success',
'video' => 'badge-warning',
default => 'badge-ghost',
} }}">
{{ $item['type'] }}
</span>
</td>
<td>{{ $item['entity'] }}</td>
<td>{{ $item['entity_name'] }}</td>
<td>{{ $item['size'] }}</td>
<td>{{ $item['uploaded_by'] }}</td>
<td>{{ $item['uploaded_at'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@@ -0,0 +1,67 @@
<div class="section-title">{{ __('Detalle por Fase') }}</div>
@forelse($phases as $phase)
@php
$phaseColor = $phase['color'] ?? '#3b82f6';
$hasDeviation = $phase['deviation_days'] !== null;
$deviationColor = $hasDeviation && $phase['deviation_days'] > 0 ? 'text-red-600' : ($hasDeviation && $phase['deviation_days'] < 0 ? 'text-green-600' : '');
@endphp
<div class="phase-block mb-8" style="border-left-color: {{ $phaseColor }};">
<div class="phase-header" style="border-left-color: {{ $phaseColor }};">
<div>
<div class="phase-name">{{ $phase['name'] }}</div>
<div class="phase-meta">
@if($phase['planned_start'])
{{ $phase['planned_start'] }} &mdash; {{ $phase['planned_end'] ?? 'Sin fecha fin' }}
@else
Sin fechas planificadas
@endif
&nbsp;&bull;&nbsp; {{ $phase['features_count'] }} elementos
&nbsp;&bull;&nbsp; {{ $phase['completed_features'] }} completados
</div>
</div>
<div style="text-align:right;">
<div style="font-size:16px;font-weight:700;color: {{ $phaseColor }};">{{ $phase['progress_percent'] }}%</div>
<div class="phase-progress-bar-wrap" style="margin-top:4px;width:160px;">
<div class="phase-progress-bar" style="width:{{ min(100, $phase['progress_percent']) }}%;background: {{ $phaseColor }};"></div>
</div>
<div class="phase-meta" style="margin-top:4px;">
Plan: {{ $phase['planned_progress'] }}% &nbsp;|&nbsp;
@if($hasDeviation)
<span class="{{ $deviationColor }}">
{{ $phase['deviation_days'] > 0 ? '+' : '' }}{{ $phase['deviation_days'] }}d
</span>
@endif
@if($phase['spi'] !== null)
&nbsp;|&nbsp; SPI: {{ $phase['spi'] }}
@endif
</div>
</div>
</div>
@if(!empty($phase['layers']))
<div class="p-4">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50">
<th class="text-left p-2">{{ __('Capa') }}</th>
<th class="text-left p-2">{{ __('Elementos') }}</th>
</tr>
</thead>
<tbody>
@foreach($phase['layers'] as $layer)
<tr class="border-t">
<td class="p-2">{{ $layer['name'] }}</td>
<td class="p-2">{{ $layer['features_count'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@empty
<div class="text-center text-gray-400 py-8">
<p>{{ __('No hay fases registradas en este proyecto.') }}</p>
</div>
@endforelse
@@ -0,0 +1,126 @@
@php
$data = $data ?? [];
$project = $data['project'] ?? null;
$filters = $data['filters'] ?? null;
$summary = $data['summary'] ?? null;
$phases = $data['phases'] ?? null;
$features = $data['features'] ?? null;
$inspections = $data['inspections'] ?? null;
$issues = $data['issues'] ?? null;
$tasks = $data['tasks'] ?? null;
$deviations = $data['deviations'] ?? null;
$progress_curve = $data['progress_curve'] ?? null;
$media = $data['media'] ?? null;
@endphp
<div class="page-wrapper" style="max-width: 100%; padding: 20px; font-size: 12px;">
{{-- Header --}}
@include('reports.partials._header', ['project' => $project, 'filters' => $filters])
{{-- Summary --}}
@if($summary)
@include('reports.partials._summary', ['summary' => $summary])
@endif
{{-- Progress Curve --}}
@if($progress_curve && !empty($progress_curve['labels']))
<div class="section-title">{{ __('Curva S: Progreso Planificado vs Real') }}</div>
<div class="card bg-base-100 shadow p-4 mb-8" style="height: 300px;">
<canvas id="previewProgressCurveChart"></canvas>
</div>
@endif
{{-- Deviations --}}
@if($deviations)
@include('reports.partials._deviations', ['deviations' => $deviations])
@endif
{{-- Phases --}}
@if($phases && !empty($phases))
@include('reports.partials._phases', ['phases' => $phases])
@endif
{{-- Features --}}
@if($features && !empty($features))
@include('reports.partials._features', ['features' => $features])
@endif
{{-- Inspections --}}
@if($inspections && !empty($inspections))
@include('reports.partials._inspections', ['inspections' => $inspections])
@endif
{{-- Issues --}}
@if($issues && !empty($issues))
@include('reports.partials._issues', ['issues' => $issues])
@endif
{{-- Tasks --}}
@if($tasks && !empty($tasks))
@include('reports.partials._tasks', ['tasks' => $tasks])
@endif
{{-- Media --}}
@if($media && !empty($media) && ($filters['include_photos'] ?? false))
@include('reports.partials._media', ['media' => $media])
@endif
</div>
<script>
function initializePreviewProgressCurveChart() {
if (typeof Chart === 'undefined') {
setTimeout(initializePreviewProgressCurveChart, 100);
return;
}
const ctx = document.getElementById('previewProgressCurveChart');
if (!ctx) return;
if (ctx.chart instanceof Chart) {
ctx.chart.destroy();
}
const labels = @json($progress_curve['labels'] ?? []);
const planned = @json($progress_curve['planned'] ?? []);
const actual = @json($progress_curve['actual'] ?? []);
if (!labels || labels.length === 0) return;
ctx.chart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '{{ __('Progreso Planificado') }} (%)',
data: planned,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3,
},
{
label: '{{ __('Progreso Real') }} (%)',
data: actual,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.3,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom' } },
scales: {
y: { beginAtZero: true, max: 100 },
}
}
});
}
document.addEventListener('DOMContentLoaded', initializePreviewProgressCurveChart);
</script>
@@ -0,0 +1,99 @@
<div class="section-title">{{ __('Curva S: Progreso Planificado vs Real') }}</div>
<div class="card bg-base-100 shadow p-6 mb-8">
<canvas id="progressCurveChart" height="100"></canvas>
</div>
<script>
document.addEventListener('livewire:load', function() {
initializeProgressCurveChart();
});
document.addEventListener('livewire:updated', function() {
initializeProgressCurveChart();
});
function initializeProgressCurveChart() {
if (typeof Chart === 'undefined') {
console.warn('Chart.js not loaded');
return;
}
const ctx = document.getElementById('progressCurveChart');
if (!ctx) return;
// Destroy existing chart
if (ctx.chart instanceof Chart) {
ctx.chart.destroy();
}
const labels = @json($curveData['labels'] ?? []);
const planned = @json($curveData['planned'] ?? []);
const actual = @json($curveData['actual'] ?? []);
if (!labels || labels.length === 0) return;
ctx.chart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '{{ __('Progreso Planificado') }} (%)',
data: planned,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6,
},
{
label: '{{ __('Progreso Real') }} (%)',
data: actual,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: '{{ __('Evolución del progreso') }}',
font: { size: 16, weight: 'bold' }
},
legend: {
position: 'bottom',
},
tooltip: {
mode: 'index',
intersect: false,
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
title: {
display: true,
text: '{{ __('Progreso') }} (%)'
}
},
x: {
title: {
display: true,
text: '{{ __('Fecha') }}'
}
}
}
}
});
}
</script>
@@ -0,0 +1,76 @@
<div class="section-title">{{ __('Resumen Ejecutivo') }}</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ $summary['total_features'] }}</div>
<div class="stat-label">{{ __('Total elementos') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#22c55e;">{{ $summary['completed_features'] }}</div>
<div class="stat-label">{{ __('Completados') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#f59e0b;">{{ $summary['completion_rate'] }}%</div>
<div class="stat-label">{{ __('Tasa completitud') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#3b82f6;">{{ $summary['avg_planned_progress'] }}%</div>
<div class="stat-label">{{ __('Progreso planificado') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#10b981;">{{ $summary['avg_actual_progress'] }}%</div>
<div class="stat-label">{{ __('Progreso real') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: {{ ($summary['overall_spi'] ?? 0) >= 1 ? '#10b981' : '#ef4444' }};">
{{ $summary['overall_spi'] ?? 'N/A' }}
</div>
<div class="stat-label">{{ __('SPI Global') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#6366f1;">{{ $summary['total_inspections'] }}</div>
<div class="stat-label">{{ __('Inspecciones') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#10b981;">{{ $summary['passed_inspections'] }}</div>
<div class="stat-label">{{ __('Aprobadas') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#ef4444;">{{ $summary['failed_inspections'] }}</div>
<div class="stat-label">{{ __('Fallidas') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#f59e0b;">{{ $summary['pass_rate'] }}%</div>
<div class="stat-label">{{ __('Tasa aprobación') }}</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ $summary['open_issues'] }}</div>
<div class="stat-label">{{ __('Issues abiertos') }}</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ $summary['closed_issues'] }}</div>
<div class="stat-label">{{ __('Issues cerrados') }}</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ $summary['total_tasks'] }}</div>
<div class="stat-label">{{ __('Total tareas') }}</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ $summary['completed_tasks'] }}</div>
<div class="stat-label">{{ __('Tareas completadas') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:{{ ($summary['task_completion_rate'] ?? 0) >= 80 ? '#10b981' : '#f59e0b' }};">
{{ $summary['task_completion_rate'] ?? 0 }}%
</div>
<div class="stat-label">{{ __('Completitud tareas') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#10b981;">{{ $summary['phases_on_track'] }}</div>
<div class="stat-label">{{ __('Fases en plazo') }}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#ef4444;">{{ $summary['phases_delayed'] }}</div>
<div class="stat-label">{{ __('Fases retrasadas') }}</div>
</div>
</div>
@@ -0,0 +1,86 @@
<div class="section-title">{{ __('Tareas') }}</div>
@if(empty($tasks))
<div class="text-center text-gray-400 py-8">
<p>{{ __('No hay tareas en el rango seleccionado.') }}</p>
</div>
@else
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>ID</th>
<th>Tarea</th>
<th>Fase</th>
<th>Estado</th>
<th>Prioridad</th>
<th>Asignado</th>
<th>Creador</th>
<th>Inicio</th>
<th>Fin</th>
<th>Completada</th>
<th>Horas Est.</th>
<th>Horas Real</th>
<th>Progreso</th>
<th>Vencida</th>
<th>Subtareas</th>
</tr>
</thead>
<tbody>
@foreach($tasks as $task)
@php
$statusBadge = match($task['status']) {
'completed' => 'badge-success',
'in_progress' => 'badge-info',
'pending' => 'badge-warning',
'cancelled' => 'badge-error',
default => 'badge-ghost',
};
$priorityBadge = match($task['priority']) {
'critical' => 'badge-error',
'high' => 'badge-warning',
'medium' => 'badge-info',
'low' => 'badge-ghost',
default => 'badge-ghost',
};
$subtaskTitles = $task['subtasks'] ? implode('; ', array_column($task['subtasks'], 'title')) : '—';
@endphp
<tr>
<td>{{ $task['id'] }}</td>
<td class="font-medium">{{ $task['title'] }}</td>
<td>{{ $task['phase'] }}</td>
<td>
<span class="badge {{ $statusBadge }}">{{ $task['status_label'] }}</span>
</td>
<td>
<span class="badge {{ $priorityBadge }}">{{ $task['priority_label'] }}</span>
</td>
<td>{{ $task['assignee'] }}</td>
<td>{{ $task['creator'] }}</td>
<td>{{ $task['start_date'] ?? '—' }}</td>
<td>{{ $task['due_date'] ?? '—' }}</td>
<td>{{ $task['completed_at'] ?? '—' }}</td>
<td>{{ $task['estimated_hours'] ?? '—' }}</td>
<td>{{ $task['actual_hours'] ?? '—' }}</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<div style="flex:1;background:#e5e7eb;border-radius:4px;height:6px;min-width:60px;">
<div style="height:6px;border-radius:4px;background:#3b82f6;width:{{ min(100, $task['progress']) }}%;"></div>
</div>
<span style="font-size:11px;color:#6b7280;white-space:nowrap;">{{ $task['progress'] }}%</span>
</div>
</td>
<td>
@if($task['is_overdue'])
<span class="badge badge-error">{{ __('Sí') }}</span>
@else
<span class="badge badge-success">{{ __('No') }}</span>
@endif
</td>
<td class="max-w-xs truncate">{{ $subtaskTitles }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
+4
View File
@@ -1,5 +1,6 @@
<?php
use App\Jobs\CaptureDailyProgressSnapshot;
use App\Jobs\NotifyOverdueTasks;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
@@ -14,3 +15,6 @@ Schedule::command('issues:notify-overdue')->dailyAt('07:00');
// Avisar de tareas vencidas (tareas independientes)
Schedule::job(new NotifyOverdueTasks)->dailyAt('07:00');
// Capturar snapshot diario de progreso (para curva S, EVM, desvíos)
Schedule::job(new CaptureDailyProgressSnapshot)->dailyAt('06:30');
+9
View File
@@ -4,6 +4,7 @@ use App\Http\Controllers\OfflineSyncController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectReportController;
use App\Http\Controllers\ReportController;
use App\Http\Controllers\Reports\ExportController;
use App\Livewire\Admin\RoleForm;
use App\Livewire\Admin\RolePermissionManager;
@@ -100,6 +101,14 @@ Route::middleware(['auth'])->group(function () {
Route::get('export/inspections', [ExportController::class, 'exportInspections'])->name('export.inspections');
});
// New Report routes
Route::prefix('projects/{project}/reports')->name('reports.project.')->middleware('can:view reports')->group(function () {
Route::get('/', [ReportController::class, 'show'])->name('builder');
Route::post('/generate', [ReportController::class, 'generate'])->name('generate');
Route::get('/preview', [ReportController::class, 'preview'])->name('preview');
Route::get('/excel', [ReportController::class, 'generate'])->name('excel');
});
// ------------------------------------------------------------
// Gestión de proyectos
// ------------------------------------------------------------