Skip to content

Commit 63c043f

Browse files
authored
Merge pull request #3291 from codeeu/dev
Dev
2 parents b5340d3 + 862ed84 commit 63c043f

File tree

3 files changed

+141
-26
lines changed

3 files changed

+141
-26
lines changed

app/Http/Controllers/CertificateBackendController.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,17 @@ public function status(Request $request): JsonResponse
127127
}
128128
}
129129

130+
$sendRunningKey = sprintf(SendCertificateBatchJob::CACHE_KEY_SEND_RUNNING, $edition, $type);
131+
$sendRunningValue = Cache::get($sendRunningKey);
132+
$sendRunning = false;
133+
if ($sendRunningValue !== null && is_numeric($sendRunningValue) && (time() - (int) $sendRunningValue) < 7200) {
134+
$sendRunning = true;
135+
} else {
136+
if ($sendRunningValue !== null) {
137+
Cache::forget($sendRunningKey);
138+
}
139+
}
140+
130141
$hasGenErrorCol = Schema::hasColumn('excellences', 'certificate_generation_error');
131142
$hasSentErrorCol = Schema::hasColumn('excellences', 'certificate_sent_error');
132143

@@ -138,6 +149,7 @@ public function status(Request $request): JsonResponse
138149
'generation_failed' => $hasGenErrorCol ? $q()->whereNotNull('certificate_generation_error')->count() : 0,
139150
'send_failed' => $hasSentErrorCol ? $q()->whereNotNull('certificate_sent_error')->count() : 0,
140151
'generation_running' => $generationRunning,
152+
'send_running' => $sendRunning,
141153
];
142154

143155
return response()->json($stats);
@@ -151,6 +163,7 @@ public function status(Request $request): JsonResponse
151163
'generation_failed' => 0,
152164
'send_failed' => 0,
153165
'generation_running' => false,
166+
'send_running' => false,
154167
'message' => $message,
155168
], 500);
156169
}

app/Jobs/SendCertificateBatchJob.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@
1111
use Illuminate\Foundation\Bus\Dispatchable;
1212
use Illuminate\Queue\InteractsWithQueue;
1313
use Illuminate\Queue\SerializesModels;
14+
use Illuminate\Support\Facades\Cache;
1415
use Illuminate\Support\Facades\Mail;
1516

1617
class SendCertificateBatchJob implements ShouldQueue
1718
{
1819
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
1920

2021
public const BATCH_SIZE = 100;
22+
public const CACHE_KEY_SEND_RUNNING = 'certificate_send_running_%s_%s';
23+
public const CACHE_TTL = 86400;
2124

2225
public function __construct(
2326
public int $edition,
@@ -27,6 +30,9 @@ public function __construct(
2730

2831
public function handle(): void
2932
{
33+
$runningKey = sprintf(self::CACHE_KEY_SEND_RUNNING, $this->edition, $this->type);
34+
Cache::put($runningKey, time(), self::CACHE_TTL);
35+
3036
// Send to: has cert and (not yet sent or had send error)
3137
$query = Excellence::query()
3238
->where('edition', $this->edition)
@@ -78,6 +84,8 @@ public function handle(): void
7884

7985
if ($hasMore) {
8086
self::dispatch($this->edition, $this->type, $nextOffset);
87+
} else {
88+
Cache::forget($runningKey);
8189
}
8290
}
8391
}

resources/views/certificate-backend/index.blade.php

Lines changed: 120 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,37 @@
3030
</select>
3131
</p>
3232

33+
{{-- Server note: queue worker required for bulk actions --}}
34+
<div class="mb-4 p-3 rounded bg-amber-100 text-amber-900" role="alert">
35+
<strong>On the server</strong>, bulk Generate and Send run in the background. You must run <code class="bg-amber-200 px-1">php artisan queue:work</code> (or use a process manager like Supervisor) so jobs are processed. Manual buttons and single Resend work immediately without the queue.
36+
</div>
37+
38+
{{-- Visible error (in case alerts are blocked) --}}
39+
<div id="last-error" class="hidden mb-4 p-3 rounded bg-red-100 text-red-800" role="alert"></div>
40+
3341
{{-- Stats --}}
3442
<div id="stats" class="cert-backend-stats" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
3543
<div>Total: <strong id="stat-total">–</strong></div>
3644
<div>Generated: <strong id="stat-generated">–</strong></div>
3745
<div>Sent: <strong id="stat-sent">–</strong></div>
3846
<div>Generation failed: <strong id="stat-gen-failed">–</strong></div>
3947
<div>Send failed: <strong id="stat-send-failed">–</strong></div>
40-
<div id="stat-running" style="display: none;">Generation in progress…</div>
48+
</div>
49+
50+
{{-- Progress: Generating --}}
51+
<div id="progress-generate" class="hidden mb-4">
52+
<p class="mb-1"><strong>Generating certificates…</strong> <span id="progress-generate-text">0 of 0</span></p>
53+
<div class="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
54+
<div id="progress-generate-bar" class="h-full bg-primary rounded-full transition-all duration-300" style="width: 0%;"></div>
55+
</div>
56+
</div>
57+
58+
{{-- Progress: Sending --}}
59+
<div id="progress-send" class="hidden mb-4">
60+
<p class="mb-1"><strong>Sending emails…</strong> <span id="progress-send-text">0 of 0</span></p>
61+
<div class="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
62+
<div id="progress-send-bar" class="h-full bg-primary rounded-full transition-all duration-300" style="width: 0%;"></div>
63+
</div>
4164
</div>
4265

4366
{{-- Step 1: Generate (always separate from Step 2: Send) --}}
@@ -100,6 +123,18 @@
100123
const basePath = '{{ url("/admin/certificate-backend") }}'.replace(/\/$/, '');
101124
let currentPage = 1;
102125
let searchQuery = '';
126+
let statusInterval = null;
127+
128+
function showError(msg) {
129+
const el = document.getElementById('last-error');
130+
if (!el) return;
131+
el.textContent = msg || '';
132+
el.classList.toggle('hidden', !msg);
133+
}
134+
135+
function clearError() {
136+
showError('');
137+
}
103138
104139
function apiUrl(path, params = {}) {
105140
const segment = path.replace(/^\//, '');
@@ -151,15 +186,52 @@ function postJson(url, body = {}) {
151186
}).then(handleResponse);
152187
}
153188
189+
function updateProgress(data) {
190+
const total = Number(data.total) || 0;
191+
const generated = Number(data.generated) || 0;
192+
const sent = Number(data.sent) || 0;
193+
const genRunning = !!data.generation_running;
194+
const sendRunning = !!data.send_running;
195+
196+
const progGen = document.getElementById('progress-generate');
197+
const progGenText = document.getElementById('progress-generate-text');
198+
const progGenBar = document.getElementById('progress-generate-bar');
199+
const progSend = document.getElementById('progress-send');
200+
const progSendText = document.getElementById('progress-send-text');
201+
const progSendBar = document.getElementById('progress-send-bar');
202+
203+
if (progGen) {
204+
progGen.classList.toggle('hidden', !genRunning);
205+
if (genRunning) {
206+
progGenText.textContent = generated + ' of ' + total + ' generated';
207+
progGenBar.style.width = (total > 0 ? Math.round((generated / total) * 100) : 0) + '%';
208+
}
209+
}
210+
if (progSend) {
211+
progSend.classList.toggle('hidden', !sendRunning);
212+
if (sendRunning) {
213+
progSendText.textContent = sent + ' of ' + total + ' sent';
214+
progSendBar.style.width = (total > 0 ? Math.round((sent / total) * 100) : 0) + '%';
215+
}
216+
}
217+
218+
if (genRunning || sendRunning) {
219+
if (!statusInterval) statusInterval = setInterval(loadStatus, 2000);
220+
} else {
221+
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
222+
}
223+
}
224+
154225
function loadStatus() {
155226
fetchJson(apiUrl('/status')).then(data => {
156227
document.getElementById('stat-total').textContent = data.total ?? '';
157228
document.getElementById('stat-generated').textContent = data.generated ?? '';
158229
document.getElementById('stat-sent').textContent = data.sent ?? '';
159230
document.getElementById('stat-gen-failed').textContent = data.generation_failed ?? '';
160231
document.getElementById('stat-send-failed').textContent = data.send_failed ?? '';
161-
const runEl = document.getElementById('stat-running');
162-
if (data.generation_running) runEl.style.display = 'block'; else runEl.style.display = 'none';
232+
updateProgress(data);
233+
}).catch(function(err) {
234+
showError(err.message || 'Could not load status.');
163235
});
164236
}
165237
@@ -212,52 +284,73 @@ function pagination(current, last, total) {
212284
window.location.href = '{{ url("/admin/certificate-backend") }}?edition=' + this.value + '&type=' + typeSlug;
213285
});
214286
215-
document.getElementById('btn-generate').addEventListener('click', function() {
287+
document.getElementById('btn-generate').addEventListener('click', function(e) {
288+
e.preventDefault();
216289
const btn = this;
290+
clearError();
217291
btn.disabled = true;
218292
postJson(apiUrl('/generate/start')).then(r => {
219-
alert(r.message || (r.ok ? 'Started.' : 'Error'));
220-
loadStatus();
221-
}).catch(function(err) {
222-
alert(err.message || 'Request failed.');
223-
}).finally(function() { btn.disabled = false; });
293+
showError(r.ok ? '' : (r.message || 'Error'));
294+
if (r.ok) loadStatus();
295+
}).catch(function(err) { showError(err.message || 'Request failed.'); }).finally(function() { btn.disabled = false; });
224296
});
225297
226-
document.getElementById('btn-cancel').addEventListener('click', function() {
227-
postJson(apiUrl('/generate/cancel')).then(r => { alert(r.message); loadStatus(); }).catch(function(err) { alert(err.message || 'Request failed.'); });
298+
document.getElementById('btn-cancel').addEventListener('click', function(e) {
299+
e.preventDefault();
300+
clearError();
301+
postJson(apiUrl('/generate/cancel')).then(r => { showError(r.ok ? '' : r.message); loadStatus(); }).catch(function(err) { showError(err.message || 'Request failed.'); });
228302
});
229303
230-
document.getElementById('btn-send').addEventListener('click', function() {
231-
postJson(apiUrl('/send/start')).then(r => { alert(r.message); loadStatus(); loadList(currentPage); }).catch(function(err) { alert(err.message || 'Request failed.'); });
304+
document.getElementById('btn-send').addEventListener('click', function(e) {
305+
e.preventDefault();
306+
clearError();
307+
postJson(apiUrl('/send/start')).then(r => {
308+
showError(r.ok ? '' : (r.message || 'Error'));
309+
if (r.ok) loadStatus();
310+
loadList(currentPage);
311+
}).catch(function(err) { showError(err.message || 'Request failed.'); });
232312
});
233313
234-
document.getElementById('btn-resend-all-failed').addEventListener('click', function() {
235-
postJson(apiUrl('/resend-all-failed')).then(r => { alert(r.message); loadStatus(); loadList(currentPage); }).catch(function(err) { alert(err.message || 'Request failed.'); });
314+
document.getElementById('btn-resend-all-failed').addEventListener('click', function(e) {
315+
e.preventDefault();
316+
clearError();
317+
postJson(apiUrl('/resend-all-failed')).then(r => {
318+
showError(r.ok ? '' : (r.message || 'Error'));
319+
if (r.ok) loadStatus();
320+
loadList(currentPage);
321+
}).catch(function(err) { showError(err.message || 'Request failed.'); });
236322
});
237323
238-
document.getElementById('btn-manual-generate').addEventListener('click', function() {
324+
document.getElementById('btn-manual-generate').addEventListener('click', function(e) {
325+
e.preventDefault();
239326
const email = document.getElementById('manual-email').value.trim();
240327
const resultEl = document.getElementById('manual-result');
241-
if (!email) { resultEl.textContent = 'Enter email.'; return; }
328+
if (!email) { showError('Enter email.'); return; }
329+
clearError();
242330
resultEl.textContent = 'Generating…';
243331
postJson(apiUrl('/manual-create-send'), { user_email: email, generate_only: true }).then(r => {
244332
resultEl.textContent = r.ok ? ('Generated. ' + (r.certificate_url ? 'URL: ' + r.certificate_url : '')) : r.message;
333+
if (!r.ok) showError(r.message);
245334
if (r.ok) { loadStatus(); loadList(currentPage); }
246-
}).catch(function(err) { resultEl.textContent = err.message || 'Request failed.'; });
335+
}).catch(function(err) { resultEl.textContent = ''; showError(err.message || 'Request failed.'); });
247336
});
248337
249-
document.getElementById('btn-manual-send').addEventListener('click', function() {
338+
document.getElementById('btn-manual-send').addEventListener('click', function(e) {
339+
e.preventDefault();
250340
const email = document.getElementById('manual-email').value.trim();
251341
const resultEl = document.getElementById('manual-result');
252-
if (!email) { resultEl.textContent = 'Enter email.'; return; }
342+
if (!email) { showError('Enter email.'); return; }
343+
clearError();
253344
resultEl.textContent = 'Sending…';
254345
postJson(apiUrl('/manual-create-send'), { user_email: email, send_only: true }).then(r => {
255346
resultEl.textContent = r.ok ? (r.message || 'Email sent.') : r.message;
347+
if (!r.ok) showError(r.message);
256348
if (r.ok) { loadStatus(); loadList(currentPage); }
257-
}).catch(function(err) { resultEl.textContent = err.message || 'Request failed.'; });
349+
}).catch(function(err) { resultEl.textContent = ''; showError(err.message || 'Request failed.'); });
258350
});
259351
260-
document.getElementById('btn-search').addEventListener('click', function() {
352+
document.getElementById('btn-search').addEventListener('click', function(e) {
353+
e.preventDefault();
261354
searchQuery = document.getElementById('search-input').value.trim();
262355
currentPage = 1;
263356
loadList(1);
@@ -267,14 +360,15 @@ function pagination(current, last, total) {
267360
document.getElementById('recipients-tbody').addEventListener('click', function(e) {
268361
const btn = e.target.closest('.resend-one');
269362
if (!btn) return;
363+
e.preventDefault();
270364
const id = btn.dataset.id;
271365
btn.disabled = true;
366+
clearError();
272367
const resendUrl = '{{ url("/admin/certificate-backend/resend") }}'.replace(/\/$/, '') + '/' + id;
273368
postJson(resendUrl, {}).then(r => {
274-
alert(r.message);
275-
loadStatus();
276-
loadList(currentPage);
277-
}).catch(function(err) { alert(err.message || 'Request failed.'); }).finally(function() { btn.disabled = false; });
369+
showError(r.ok ? '' : r.message);
370+
if (r.ok) { loadStatus(); loadList(currentPage); }
371+
}).catch(function(err) { showError(err.message || 'Request failed.'); }).finally(function() { btn.disabled = false; });
278372
});
279373
280374
loadStatus();

0 commit comments

Comments
 (0)