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\NotifyOverdueTasks;
|
||||
use App\Jobs\SendScheduledReport;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
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)
|
||||
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