feat: Phase 5.1 - Scheduled Report Emails
- Create SendScheduledReport job (runs daily 08:00) - Create ScheduledReportMail Mailable with Blade template - Add receive_reports boolean to users table (migration) - Register job in routes/console.php Tests: 101 passing (319 assertions)
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ReportGenerator;
|
||||||
|
use App\DTO\ReportFilters;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
class SendScheduledReport implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
public int $backoff = 60;
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
// Get users who want to receive scheduled reports
|
||||||
|
$users = User::where('receive_reports', true)
|
||||||
|
->whereHas('projects')
|
||||||
|
->with('projects')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
foreach ($user->projects as $project) {
|
||||||
|
$filters = new ReportFilters();
|
||||||
|
$filters->entityTypes = ['phases', 'features', 'inspections', 'issues', 'tasks'];
|
||||||
|
|
||||||
|
$generator = new ReportGenerator($project, $filters);
|
||||||
|
$data = $generator->generate();
|
||||||
|
|
||||||
|
Mail::to($user)->send(new \App\Mail\ScheduledReportMail(
|
||||||
|
$project,
|
||||||
|
$user,
|
||||||
|
$data,
|
||||||
|
$filters
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class ScheduledReportMail extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public Project $project,
|
||||||
|
public User $user,
|
||||||
|
public array $reportData,
|
||||||
|
public \App\DTO\ReportFilters $filters
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: "Informe programado: {$this->project->name}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
view: 'emails.scheduled-report',
|
||||||
|
with: [
|
||||||
|
'project' => $this->project,
|
||||||
|
'user' => $this->user,
|
||||||
|
'data' => $this->reportData,
|
||||||
|
'filters' => $this->filters,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?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('users', function (Blueprint $table) {
|
||||||
|
$table->boolean('receive_reports')->default(false)->after('address');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('receive_reports');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Informe Programado - {{ $project->name }}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 14px; color: #1f2937; line-height: 1.6; margin: 0; padding: 20px; background: #f8fafc; }
|
||||||
|
.container { max-width: 700px; margin: 0 auto; background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }
|
||||||
|
.header { background: #1e3a8a; color: #fff; padding: 24px; }
|
||||||
|
.header h1 { margin: 0; font-size: 24px; font-weight: 700; }
|
||||||
|
.header p { margin: 8px 0 0; opacity: 0.9; font-size: 14px; }
|
||||||
|
.content { padding: 24px; }
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 24px; }
|
||||||
|
.stat { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; text-align: center; }
|
||||||
|
.stat-value { font-size: 28px; font-weight: 700; color: #2563eb; line-height: 1; }
|
||||||
|
.stat-label { font-size: 12px; color: #6b7280; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||||
|
.stat-value.completed { color: #22c55e; }
|
||||||
|
.stat-value.issues { color: #ef4444; }
|
||||||
|
.section { margin-bottom: 24px; }
|
||||||
|
.section-title { font-size: 16px; font-weight: 700; color: #1e3a8a; border-bottom: 2px solid #e5e7eb; padding-bottom: 8px; margin-bottom: 16px; }
|
||||||
|
.phase { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
|
||||||
|
.phase-header { background: #f8fafc; padding: 12px 16px; border-left: 4px solid #3b82f6; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.phase-name { font-weight: 700; color: #1e3a8a; font-size: 14px; }
|
||||||
|
.phase-progress { font-weight: 700; color: #3b82f6; }
|
||||||
|
.phase-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.phase-table th { background: #f1f5f9; padding: 8px 12px; text-align: left; font-weight: 600; color: #374151; border-bottom: 1px solid #cbd5e1; }
|
||||||
|
.phase-table td { padding: 8px 12px; border-bottom: 1px solid #e5e7eb; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 9999px; font-size: 10px; font-weight: 600; text-transform: uppercase; }
|
||||||
|
.badge-planned { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.badge-in_progress { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-completed { background: #d1fae5; color: #065f46; }
|
||||||
|
.badge-verified { background: #ede9fe; color: #5b21b6; }
|
||||||
|
.progress-bar { background: #e5e7eb; border-radius: 4px; height: 6px; width: 80px; overflow: hidden; }
|
||||||
|
.progress-bar-fill { height: 100%; border-radius: 4px; background: #22c55e; }
|
||||||
|
.footer { background: #f8fafc; border-top: 1px solid #e5e7eb; padding: 16px 24px; font-size: 12px; color: #9ca3af; text-align: center; }
|
||||||
|
@media print { .container { box-shadow: none; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>{{ $project->name }}</h1>
|
||||||
|
<p>Informe programado generado el {{ now()->format('d/m/Y H:i') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value">{{ $data['stats']['total_features'] ?? 0 }}</div>
|
||||||
|
<div class="stat-label">Total elementos</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value completed">{{ $data['stats']['completed_features'] ?? 0 }}</div>
|
||||||
|
<div class="stat-label">Completados</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value">{{ round($data['stats']['avg_progress'] ?? 0) }}%</div>
|
||||||
|
<div class="stat-label">Progreso medio</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Detalle por Fase</div>
|
||||||
|
@forelse($data['phases'] as $phase)
|
||||||
|
@php
|
||||||
|
$phaseFeatures = $phase->layers->flatMap(fn($l) => $l->features);
|
||||||
|
$phaseColor = $phase->color ?? '#3b82f6';
|
||||||
|
@endphp
|
||||||
|
<div class="phase" style="border-left-color:{{ $phaseColor }};">
|
||||||
|
<div class="phase-header" style="border-left-color:{{ $phaseColor }};">
|
||||||
|
<div>
|
||||||
|
<div class="phase-name">{{ $phase->name }}</div>
|
||||||
|
<div style="font-size:12px;color:#6b7280;">
|
||||||
|
@if($phase->planned_start)
|
||||||
|
{{ $phase->planned_start->format('d/m/Y') }} — {{ $phase->planned_end?->format('d/m/Y') ?? 'Sin fecha fin' }}
|
||||||
|
@else
|
||||||
|
Sin fechas planificadas
|
||||||
|
@endif
|
||||||
|
• {{ $phaseFeatures->count() }} elementos
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<div style="font-size:16px;font-weight:700;color:{{ $phaseColor }};">{{ $phase->progress_percent ?? 0 }}%</div>
|
||||||
|
<div class="progress-bar" style="margin-top:4px;">
|
||||||
|
<div class="progress-bar-fill" style="width:{{ min(100, $phase->progress_percent ?? 0) }}%;background:{{ $phaseColor }};"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if($phaseFeatures->count() > 0)
|
||||||
|
<table class="phase-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Elemento</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Progreso</th>
|
||||||
|
<th>Responsable</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($phaseFeatures as $feature)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $feature->name ?? 'Sin nombre' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-{{ $feature->status ?? 'planned' }}">
|
||||||
|
{{ match($feature->status) {
|
||||||
|
'planned' => 'Planificado',
|
||||||
|
'started' => 'Iniciado',
|
||||||
|
'in_progress' => 'En progreso',
|
||||||
|
'completed' => 'Completado',
|
||||||
|
'verified' => 'Verificado',
|
||||||
|
default => ($feature->status ?? 'N/A'),
|
||||||
|
} }}
|
||||||
|
</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 class="progress-bar-fill" style="width:{{ min(100, $feature->progress ?? 0) }}%;background:{{ $phaseColor }};"></div>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:11px;color:#6b7280;white-space:nowrap;">{{ $feature->progress ?? 0 }}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ $feature->responsible ?? ($feature->responsibleUser?->name ?? '—') }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
@else
|
||||||
|
<div style="padding:12px 16px;color:#9ca3af;font-style:italic;font-size:12px;">
|
||||||
|
Sin elementos registrados en esta fase.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div style="padding:16px;color:#9ca3af;text-align:center;font-style:italic;">
|
||||||
|
No hay fases registradas en este proyecto.
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Parámetros del Informe</div>
|
||||||
|
<p><strong>Rango de fechas:</strong> {{ $filters->date_from ? $filters->date_from->format('d/m/Y') : 'Sin fecha desde' }} — {{ $filters->date_to ? $filters->date_to->format('d/m/Y') : 'Sin fecha hasta' }}</p>
|
||||||
|
<p><strong>Secciones:</strong> {{ implode(', ', collect($filters->entity_types)->map(fn ($k) => ucfirst(str_replace('_', ' ', $k)))->toArray()) }}</p>
|
||||||
|
<p><strong>Incluir fotos:</strong> {{ $filters->include_photos ? 'Sí' : 'No' }}</p>
|
||||||
|
<p><strong>Incluir gráficos:</strong> {{ $filters->include_charts ? 'Sí' : 'No' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
ConstruProgress — Sistema de Gestión de Obras<br>
|
||||||
|
Informe generado automáticamente el {{ now()->format('d/m/Y H:i') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Jobs\CaptureDailyProgressSnapshot;
|
use App\Jobs\CaptureDailyProgressSnapshot;
|
||||||
use App\Jobs\NotifyOverdueTasks;
|
use App\Jobs\NotifyOverdueTasks;
|
||||||
|
use App\Jobs\SendScheduledReport;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
@@ -18,3 +19,6 @@ Schedule::job(new NotifyOverdueTasks)->dailyAt('07:00');
|
|||||||
|
|
||||||
// Capturar snapshot diario de progreso (para curva S, EVM, desvíos)
|
// Capturar snapshot diario de progreso (para curva S, EVM, desvíos)
|
||||||
Schedule::job(new CaptureDailyProgressSnapshot)->dailyAt('06:30');
|
Schedule::job(new CaptureDailyProgressSnapshot)->dailyAt('06:30');
|
||||||
|
|
||||||
|
// Enviar informes programados diarios (08:00)
|
||||||
|
Schedule::job(new SendScheduledReport)->dailyAt('08:00');
|
||||||
|
|||||||
Reference in New Issue
Block a user