diff --git a/src/app/Http/Controllers/Admin/CertificateController.php b/src/app/Http/Controllers/Admin/CertificateController.php index 283a678..fa209d7 100644 --- a/src/app/Http/Controllers/Admin/CertificateController.php +++ b/src/app/Http/Controllers/Admin/CertificateController.php @@ -8,6 +8,8 @@ use App\Models\Certificate; use App\Models\Program; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\Support\Facades\Storage; use Illuminate\View\View; class CertificateController extends Controller @@ -97,6 +99,31 @@ class CertificateController extends Controller return back()->with('success', "Penghantaran emel sijil dijadualkan untuk {$toEmail} peserta."); } + public function download(Program $program, Certificate $certificate): Response|RedirectResponse + { + if ($certificate->program_id !== $program->id) { + abort(404); + } + + if (! $certificate->isGenerated()) { + return back()->with('error', 'Sijil belum sedia untuk dimuat turun.'); + } + + if (! $certificate->file_path || ! Storage::disk('local')->exists($certificate->file_path)) { + return back()->with('error', 'Fail sijil tidak dijumpai.'); + } + + $certificate->loadMissing('participant'); + $content = Storage::disk('local')->get($certificate->file_path); + $filename = 'Sijil-' . str($certificate->participant->name)->slug() . '.jpg'; + + return response($content, 200, [ + 'Content-Type' => 'image/jpeg', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + 'Content-Length' => strlen($content), + ]); + } + private function buildCertNo(Program $program, int $seq): string { $year = now()->format('Y'); diff --git a/src/app/Http/Controllers/Admin/DashboardController.php b/src/app/Http/Controllers/Admin/DashboardController.php index f1c83c1..5a5098c 100644 --- a/src/app/Http/Controllers/Admin/DashboardController.php +++ b/src/app/Http/Controllers/Admin/DashboardController.php @@ -5,28 +5,36 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\Attendance; use App\Models\Certificate; -use App\Models\EmailLog; use App\Models\Participant; use App\Models\Program; use App\Models\QuestionnaireResponse; +use Illuminate\Support\Facades\DB; class DashboardController extends Controller { public function index() { + // Sijil dijana tapi belum dihantar emel (ada emel peserta) + $emailsPending = DB::table('certificates') + ->join('participants', 'participants.id', '=', 'certificates.participant_id') + ->where('certificates.status', 'generated') + ->whereNull('certificates.emailed_at') + ->whereNotNull('participants.email') + ->count(); + $stats = [ - 'total_programs' => Program::count(), - 'active_programs' => Program::where('status', 'published')->count(), - 'total_participants' => Participant::count(), - 'total_attendances' => Attendance::count(), - 'total_certificates' => Certificate::count(), - 'generated_certs' => Certificate::whereIn('status', ['generated', 'emailed', 'downloaded'])->count(), - 'downloaded_certs' => Certificate::where('status', 'downloaded')->count(), - 'total_download_count'=> (int) Certificate::sum('download_count'), - 'total_responses' => QuestionnaireResponse::count(), - 'emails_pending' => EmailLog::where('status', 'pending')->count(), - 'emails_sent' => EmailLog::where('status', 'sent')->count(), - 'emails_failed' => EmailLog::where('status', 'failed')->count(), + 'total_programs' => Program::count(), + 'active_programs' => Program::where('status', 'published')->count(), + 'total_participants' => Participant::count(), + 'total_attendances' => Attendance::count(), + 'total_certificates' => Certificate::count(), + 'generated_certs' => Certificate::whereIn('status', ['generated', 'emailed', 'downloaded'])->count(), + 'downloaded_certs' => Certificate::where('status', 'downloaded')->count(), + 'total_download_count' => (int) Certificate::sum('download_count'), + 'total_responses' => QuestionnaireResponse::count(), + 'emails_pending' => $emailsPending, + 'emails_sent' => Certificate::whereNotNull('emailed_at')->count(), + 'emails_failed' => DB::table('program_participants')->where('status_sent_emel', 'failed')->count(), ]; $recentPrograms = Program::with('creator') diff --git a/src/app/Http/Controllers/Admin/ParticipantController.php b/src/app/Http/Controllers/Admin/ParticipantController.php index 17602d4..fe07bec 100644 --- a/src/app/Http/Controllers/Admin/ParticipantController.php +++ b/src/app/Http/Controllers/Admin/ParticipantController.php @@ -4,7 +4,6 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\Certificate; -use App\Models\EmailLog; use App\Models\Participant; use App\Models\Program; use App\Models\ProgramParticipant; @@ -42,20 +41,13 @@ class ParticipantController extends Controller $programParticipants = $query->paginate(20)->withQueryString(); - // Load certificates and latest email logs for displayed participants + // Load certificates for displayed participants $participantIds = $programParticipants->pluck('participant_id'); $certificates = Certificate::where('program_id', $program->id) ->whereIn('participant_id', $participantIds) ->get() ->keyBy('participant_id'); - $certIds = $certificates->pluck('id'); - $emailLogs = EmailLog::whereIn('certificate_id', $certIds) - ->orderByDesc('id') - ->get() - ->groupBy('certificate_id') - ->map->first(); - $countRow = DB::table('program_participants') ->where('program_id', $program->id) ->selectRaw("COUNT(*) as total, SUM(is_pre_registered) as pre_registered, SUM(registration_source = 'walk_in') as walk_in, SUM(status = 'checked_in') as checked_in") @@ -68,7 +60,7 @@ class ParticipantController extends Controller 'checked_in' => (int) ($countRow->checked_in ?? 0), ]; - return view('admin.programs.participants.index', compact('program', 'programParticipants', 'counts', 'certificates', 'emailLogs')); + return view('admin.programs.participants.index', compact('program', 'programParticipants', 'counts', 'certificates')); } public function create(Program $program): View diff --git a/src/app/Http/Controllers/Admin/ProgramController.php b/src/app/Http/Controllers/Admin/ProgramController.php index f5acba6..b4cd8b0 100644 --- a/src/app/Http/Controllers/Admin/ProgramController.php +++ b/src/app/Http/Controllers/Admin/ProgramController.php @@ -78,16 +78,30 @@ class ProgramController extends Controller $certStats = \DB::table('certificates') ->where('program_id', $program->id) - ->selectRaw("COUNT(*) as total, SUM(status IN ('generated','emailed','downloaded')) as cert_generated") + ->selectRaw("COUNT(*) as total, SUM(status IN ('generated','emailed','downloaded')) as cert_generated, SUM(download_count) as total_downloads, SUM(status = 'downloaded') as downloaded") ->first(); + // Sijil dijana tapi belum dihantar emel (ada emel peserta) + $emailsPending = \DB::table('certificates') + ->join('participants', 'participants.id', '=', 'certificates.participant_id') + ->where('certificates.program_id', $program->id) + ->where('certificates.status', 'generated') + ->whereNull('certificates.emailed_at') + ->whereNotNull('participants.email') + ->count(); + $stats = [ - 'total_participants' => (int) ($ppStats->total ?? 0), - 'pre_registered' => (int) ($ppStats->pre_registered ?? 0), - 'walk_in' => (int) ($ppStats->walk_in ?? 0), + 'total_participants' => (int) ($ppStats->total ?? 0), + 'pre_registered' => (int) ($ppStats->pre_registered ?? 0), + 'walk_in' => (int) ($ppStats->walk_in ?? 0), 'total_attendances' => $program->attendances()->count(), - 'total_certificates' => (int) ($certStats->total ?? 0), + 'total_certificates' => (int) ($certStats->total ?? 0), 'generated_certificates' => (int) ($certStats->cert_generated ?? 0), + 'downloaded_certificates'=> (int) ($certStats->downloaded ?? 0), + 'total_downloads' => (int) ($certStats->total_downloads ?? 0), + 'emails_pending' => $emailsPending, + 'emails_sent' => (int) \DB::table('certificates')->where('program_id', $program->id)->whereNotNull('emailed_at')->count(), + 'emails_failed' => (int) \DB::table('program_participants')->where('program_id', $program->id)->where('status_sent_emel', 'failed')->count(), ]; return view('admin.programs.show', compact('program', 'stats')); diff --git a/src/app/Jobs/SendCertificateEmailJob.php b/src/app/Jobs/SendCertificateEmailJob.php index fde625a..195ac4a 100644 --- a/src/app/Jobs/SendCertificateEmailJob.php +++ b/src/app/Jobs/SendCertificateEmailJob.php @@ -6,6 +6,7 @@ use App\Mail\CertificateReadyMail; use App\Models\Certificate; use App\Models\EmailLog; use App\Models\Program; +use App\Models\ProgramParticipant; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -30,6 +31,7 @@ class SendCertificateEmailJob implements ShouldQueue $email = $cert->participant->email; if (! $email) { + $this->updatePpStatus($cert, null); EmailLog::where('certificate_id', $cert->id)->where('status', 'pending')->delete(); return; } @@ -42,10 +44,9 @@ class SendCertificateEmailJob implements ShouldQueue try { Mail::to($email)->send(new CertificateReadyMail($cert)); - $cert->update([ - 'status' => 'emailed', - 'emailed_at' => now(), - ]); + $cert->update(['status' => 'emailed', 'emailed_at' => now()]); + + $this->updatePpStatus($cert, 'sent'); if ($log) { $log->update(['status' => 'sent', 'sent_at' => now()]); @@ -62,6 +63,8 @@ class SendCertificateEmailJob implements ShouldQueue ]); } } catch (\Throwable $e) { + $this->updatePpStatus($cert, 'failed'); + if ($log) { $log->update(['status' => 'failed', 'error_message' => $e->getMessage()]); } else { @@ -82,13 +85,14 @@ class SendCertificateEmailJob implements ShouldQueue } /** - * Cipta pending EmailLog dahulu, kemudian dispatch job. - * Ini membolehkan dashboard tunjuk status "dalam antrian" sebelum job diproses. + * Cipta pending EmailLog dan set status_sent_emel = pending, kemudian dispatch job. */ public static function dispatchForCert(Certificate $cert): void { $cert->loadMissing(['participant', 'program']); + self::updatePpStatusStatic($cert, 'pending'); + EmailLog::create([ 'program_id' => $cert->program_id, 'participant_id' => $cert->participant_id, @@ -114,4 +118,18 @@ class SendCertificateEmailJob implements ShouldQueue } }); } + + private function updatePpStatus(Certificate $cert, ?string $status): void + { + ProgramParticipant::where('program_id', $cert->program_id) + ->where('participant_id', $cert->participant_id) + ->update(['status_sent_emel' => $status]); + } + + private static function updatePpStatusStatic(Certificate $cert, ?string $status): void + { + ProgramParticipant::where('program_id', $cert->program_id) + ->where('participant_id', $cert->participant_id) + ->update(['status_sent_emel' => $status]); + } } diff --git a/src/app/Models/ProgramParticipant.php b/src/app/Models/ProgramParticipant.php index a7db038..3f0de9b 100644 --- a/src/app/Models/ProgramParticipant.php +++ b/src/app/Models/ProgramParticipant.php @@ -9,7 +9,7 @@ class ProgramParticipant extends Model protected $fillable = [ 'program_id', 'participant_id', 'registration_source', 'is_pre_registered', 'pre_registered_session', - 'status', 'registered_at', + 'status', 'status_sent_emel', 'registered_at', ]; protected function casts(): array diff --git a/src/database/migrations/2026_05_20_000001_add_status_sent_emel_to_program_participants.php b/src/database/migrations/2026_05_20_000001_add_status_sent_emel_to_program_participants.php new file mode 100644 index 0000000..e60798a --- /dev/null +++ b/src/database/migrations/2026_05_20_000001_add_status_sent_emel_to_program_participants.php @@ -0,0 +1,26 @@ +enum('status_sent_emel', ['pending', 'sent', 'failed']) + ->nullable() + ->default(null) + ->after('status') + ->index(); + }); + } + + public function down(): void + { + Schema::table('program_participants', function (Blueprint $table) { + $table->dropColumn('status_sent_emel'); + }); + } +}; diff --git a/src/resources/views/admin/dashboard.blade.php b/src/resources/views/admin/dashboard.blade.php index 6118134..36610da 100644 --- a/src/resources/views/admin/dashboard.blade.php +++ b/src/resources/views/admin/dashboard.blade.php @@ -5,7 +5,7 @@ @section('content') -{{-- Stats Row 1 --}} +{{-- Stats Row --}}