fix status hantar emel dan jana sijil

This commit is contained in:
Saufi
2026-05-20 10:20:59 +08:00
parent 899507070c
commit 7027651dd7
12 changed files with 240 additions and 141 deletions

View File

@@ -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');

View File

@@ -5,15 +5,23 @@ 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(),
@@ -22,11 +30,11 @@ class DashboardController extends Controller
'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_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(),
'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')

View File

@@ -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

View File

@@ -78,9 +78,18 @@ 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),
@@ -88,6 +97,11 @@ class ProgramController extends Controller
'total_attendances' => $program->attendances()->count(),
'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'));

View File

@@ -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]);
}
}

View File

@@ -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

View File

@@ -0,0 +1,26 @@
<?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('program_participants', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -5,7 +5,7 @@
@section('content')
{{-- Stats Row 1 --}}
{{-- Stats Row --}}
<div class="row g-3 mb-4">
<div class="col-sm-6 col-xl-3">
<div class="card stat-card h-100">
@@ -56,78 +56,25 @@
<div class="card stat-card h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="stat-icon bg-info bg-opacity-10">
<i class="bi bi-clipboard2-check-fill text-info"></i>
<i class="bi bi-envelope-fill text-info"></i>
</div>
<div>
<div class="text-muted small">Soalselidik Dijawab</div>
<div class="fs-3 fw-bold">{{ $stats['total_responses'] }}</div>
<div class="text-muted small">E-Sijil Dihantar</div>
<div class="fs-3 fw-bold">{{ $stats['emails_sent'] }}</div>
<div class="d-flex gap-2 flex-wrap mt-1">
@if($stats['emails_pending'] > 0)
<div class="text-warning small"><i class="bi bi-envelope-fill me-1"></i>{{ $stats['emails_pending'] }} emel tertunda</div>
@else
<div class="text-muted small">Tiada emel tertunda</div>
<span class="badge bg-warning-subtle text-warning border border-warning-subtle" style="font-size:.65rem;">
<i class="bi bi-clock me-1"></i>{{ $stats['emails_pending'] }} belum dihantar
</span>
@endif
@if($stats['emails_failed'] > 0)
<span class="badge bg-danger-subtle text-danger border border-danger-subtle" style="font-size:.65rem;">
<i class="bi bi-x-circle me-1"></i>{{ $stats['emails_failed'] }} gagal
</span>
@endif
@if($stats['emails_pending'] === 0 && $stats['emails_failed'] === 0)
<span class="text-muted" style="font-size:.7rem;">Semua selesai</span>
@endif
</div>
</div>
</div>
</div>
</div>
{{-- Email & Download Status --}}
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-bottom py-3">
<span class="fw-semibold"><i class="bi bi-envelope-fill me-2 text-primary"></i>Status Penghantaran E-Sijil</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6 col-md-3">
<div class="d-flex align-items-center gap-3 p-3 rounded-3 bg-secondary bg-opacity-10">
<div class="flex-shrink-0">
<i class="bi bi-hourglass-split text-secondary fs-3"></i>
</div>
<div>
<div class="text-muted small">Dalam Antrian</div>
<div class="fs-3 fw-bold lh-1">{{ $stats['emails_pending'] }}</div>
<div class="text-muted" style="font-size:.7rem;">menunggu / sedang cuba</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="d-flex align-items-center gap-3 p-3 rounded-3 bg-success bg-opacity-10">
<div class="flex-shrink-0">
<i class="bi bi-envelope-check-fill text-success fs-3"></i>
</div>
<div>
<div class="text-muted small">Berjaya Dihantar</div>
<div class="fs-3 fw-bold lh-1">{{ $stats['emails_sent'] }}</div>
<div class="text-muted" style="font-size:.7rem;">emel e-sijil</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="d-flex align-items-center gap-3 p-3 rounded-3 bg-danger bg-opacity-10">
<div class="flex-shrink-0">
<i class="bi bi-envelope-x-fill text-danger fs-3"></i>
</div>
<div>
<div class="text-muted small">Gagal Dihantar</div>
<div class="fs-3 fw-bold lh-1">{{ $stats['emails_failed'] }}</div>
<div class="text-muted" style="font-size:.7rem;">semua percubaan gagal</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="d-flex align-items-center gap-3 p-3 rounded-3 bg-warning bg-opacity-10">
<div class="flex-shrink-0">
<i class="bi bi-download text-warning fs-3"></i>
</div>
<div>
<div class="text-muted small">Sijil Dimuat Turun</div>
<div class="fs-3 fw-bold lh-1">{{ $stats['downloaded_certs'] }}</div>
<div class="text-muted" style="font-size:.7rem;">{{ $stats['total_download_count'] }} kali klik pautan</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -45,7 +45,7 @@
@csrf
<button class="btn btn-primary"
onclick="return confirm('Jana sijil untuk semua peserta hadir?')">
<i class="bi bi-gear me-1"></i> Jana Semua Sijil
<i class="bi bi-gear me-1"></i> Jana E-Sijil
</button>
</form>
@@ -117,7 +117,7 @@
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<i class="bi bi-award d-block fs-1 mb-3 opacity-25"></i>
Belum ada sijil dijana. Klik "Jana Semua Sijil" untuk mula.
Belum ada sijil dijana. Klik "Jana E-Sijil" untuk mula.
</td>
</tr>
@endforelse

View File

@@ -109,7 +109,6 @@
@php
$p = $pp->participant;
$cert = $certificates[$pp->participant_id] ?? null;
$emailLog = $cert ? ($emailLogs[$cert->id] ?? null) : null;
@endphp
<tr>
<td class="text-muted small">{{ $programParticipants->firstItem() + $i }}</td>
@@ -168,23 +167,21 @@
@endif
{{-- Emel --}}
@if($emailLog)
@if($emailLog->status === 'sent')
@if($pp->status_sent_emel === 'sent')
<div class="text-success mt-1" style="font-size:.7rem;">
<i class="bi bi-envelope-check me-1"></i>Emel Dihantar
</div>
@elseif($emailLog->status === 'failed')
@elseif($pp->status_sent_emel === 'failed')
<div class="text-danger mt-1" style="font-size:.7rem;">
<i class="bi bi-envelope-x me-1"></i>Emel Gagal
</div>
@elseif($emailLog->status === 'pending')
@elseif($pp->status_sent_emel === 'pending')
<div class="text-warning mt-1" style="font-size:.7rem;">
<i class="bi bi-hourglass-split me-1"></i>Dalam Antrian
</div>
@endif
@elseif($cert->isGenerated())
@elseif($cert && $cert->isGenerated() && ! $cert->emailed_at)
<div class="text-muted mt-1" style="font-size:.7rem;">
<i class="bi bi-envelope me-1"></i>Belum Dihantar
<i class="bi bi-clock me-1"></i>Belum Dihantar
</div>
@endif
@@ -197,6 +194,13 @@
@endif
</td>
<td class="text-end">
<div class="d-flex justify-content-end gap-1">
@if($cert && $cert->isGenerated())
<a href="{{ route('admin.programs.certificates.download', [$program, $cert]) }}"
class="btn btn-sm btn-outline-primary" title="Muat Turun Sijil">
<i class="bi bi-download"></i>
</a>
@endif
@if($pp->status !== 'checked_in')
<form method="POST"
action="{{ route('admin.programs.participants.destroy', [$program, $pp]) }}"
@@ -207,6 +211,7 @@
</button>
</form>
@endif
</div>
</td>
</tr>
@endforeach

View File

@@ -107,6 +107,60 @@
</div>
</div>
{{-- Status Penghantaran E-Sijil --}}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-bottom py-3 d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-envelope-fill me-2 text-primary"></i>Status Penghantaran E-Sijil</span>
@if($stats['emails_sent'] + $stats['emails_pending'] + $stats['emails_failed'] === 0)
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">Belum Dihantar</span>
@endif
</div>
<div class="card-body py-3">
<div class="row g-3">
<div class="col-6 col-md-3">
<div class="d-flex align-items-center gap-3 p-3 rounded-3 bg-secondary bg-opacity-10">
<i class="bi bi-clock text-secondary fs-3 flex-shrink-0"></i>
<div>
<div class="text-muted small">Belum Dihantar</div>
<div class="fs-3 fw-bold lh-1">{{ $stats['emails_pending'] }}</div>
<div class="text-muted" style="font-size:.7rem;">sijil sedia, emel belum hantar</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="d-flex align-items-center gap-3 p-3 rounded-3 bg-success bg-opacity-10">
<i class="bi bi-envelope-check-fill text-success fs-3 flex-shrink-0"></i>
<div>
<div class="text-muted small">Berjaya Dihantar</div>
<div class="fs-3 fw-bold lh-1">{{ $stats['emails_sent'] }}</div>
<div class="text-muted" style="font-size:.7rem;">emel e-sijil</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="d-flex align-items-center gap-3 p-3 rounded-3 bg-danger bg-opacity-10">
<i class="bi bi-envelope-x-fill text-danger fs-3 flex-shrink-0"></i>
<div>
<div class="text-muted small">Gagal Dihantar</div>
<div class="fs-3 fw-bold lh-1">{{ $stats['emails_failed'] }}</div>
<div class="text-muted" style="font-size:.7rem;">semua percubaan gagal</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="d-flex align-items-center gap-3 p-3 rounded-3 bg-warning bg-opacity-10">
<i class="bi bi-download text-warning fs-3 flex-shrink-0"></i>
<div>
<div class="text-muted small">Sijil Dimuat Turun</div>
<div class="fs-3 fw-bold lh-1">{{ $stats['downloaded_certificates'] }}</div>
<div class="text-muted" style="font-size:.7rem;">{{ $stats['total_downloads'] }} kali klik pautan</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{-- Tab Navigation --}}
<ul class="nav nav-tabs mb-0" id="programTabs">
<li class="nav-item">
@@ -148,10 +202,17 @@
{{ $stats['walk_in'] }} walk-in
</span>
</div>
<div class="d-flex gap-2">
<div class="d-flex gap-2 flex-wrap">
<a href="{{ route('admin.programs.participants.import.form', $program) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-upload me-1"></i> Import CSV
</a>
<form method="POST" action="{{ route('admin.programs.certificates.generate-all', $program) }}" class="d-inline">
@csrf
<button class="btn btn-sm btn-outline-warning"
onclick="return confirm('Jana e-sijil untuk semua peserta yang hadir?')">
<i class="bi bi-gear me-1"></i> Jana E-Sijil
</button>
</form>
<a href="{{ route('admin.programs.participants.create', $program) }}" class="btn btn-sm btn-primary">
<i class="bi bi-person-plus me-1"></i> Tambah Peserta
</a>

View File

@@ -89,6 +89,7 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
Route::get('/', [AdminCertificateController::class, 'index'])->name('index');
Route::post('/generate-all', [AdminCertificateController::class, 'generateAll'])->name('generate-all');
Route::post('/email-all', [AdminCertificateController::class, 'emailAll'])->name('email-all');
Route::get('/{certificate}/download', [AdminCertificateController::class, 'download'])->name('download');
});
// User Management (Super Admin only)