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) --}}
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