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:
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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
@@ -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)
|
||||
| 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 — 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)
|
||||
• 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'] }} — {{ $phase['planned_end'] ?? 'Sin fecha fin' }}
|
||||
@else
|
||||
Sin fechas planificadas
|
||||
@endif
|
||||
• {{ $phase['features_count'] }} elementos
|
||||
• {{ $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'] }}% |
|
||||
@if($hasDeviation)
|
||||
<span class="{{ $deviationColor }}">
|
||||
{{ $phase['deviation_days'] > 0 ? '+' : '' }}{{ $phase['deviation_days'] }}d
|
||||
</span>
|
||||
@endif
|
||||
@if($phase['spi'] !== null)
|
||||
| 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
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
// ------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user