--- +++ @@ -7,3 +7,3 @@ import { - el, mount, card, button, pill, + sectionTabs, el, mount, card, button, pill, loadingBlock, errorBlock, emptyBlock, @@ -272,4 +272,4 @@ try { - return new Date(iso).toLocaleString('fa-IR', { - timeZone: 'Asia/Tehran', dateStyle: 'short', timeStyle: 'short', + return new Date(iso).toLocaleString('en-CA', { + timeZone: 'Asia/Tehran', dateStyle: 'short', timeStyle: 'short', hourCycle: 'h23', }); @@ -295,45 +295,67 @@ // --------------------------------------------------------------------------- -function renderList(view) { +function watchActivity(view, path, initial, draw) { + view._stopActivity?.(); + const generation=Symbol('activity'); + view._activityGeneration=generation; + let previous = JSON.stringify(initial), pending = false; + const timer = setInterval(async () => { + if (!view.isConnected) {clearInterval(timer);return;} + if (document.hidden || pending || document.querySelector('dialog[open]')) return; + pending = true; + try { + const data = await api(path); + const next = JSON.stringify(data); + if (view.isConnected && view._activityGeneration===generation && next !== previous && !document.querySelector('dialog[open]')) { + previous = next; + draw(view,data); + } + } catch { /* Existing data stays visible; explicit refresh exposes failures. */ } + finally {pending=false;} + },3000); + view._stopActivity = () => { + clearInterval(timer); + if(view._activityGeneration===generation)view._activityGeneration=null; + }; +} + +function renderList(view, active = 'bulk') { + view._stopActivity?.(); mount(view, loadingBlock()); api('/jobs').then(function (data) { - drawList(view, data); + drawList(view, data, active); }).catch(function () { - mount(view, errorBlock(function () { renderList(view); })); - }); -} - -function drawList(view, data) { + mount(view, errorBlock(function () { renderList(view, active); })); + }); +} + +function drawList(view, data, active = 'bulk') { var bulkJobs = data.background_jobs || []; var notifJobs = data.notification_jobs || []; - var wrap = el('div', { class: 'page' }); - - // Toolbar: new job + refresh + var wrap = el('div', { class: 'page activity-page' }); + const {tabbar, content} = sectionTabs([ + {key:'bulk',label:'act_bulk_jobs',icon:'📦',count:bulkJobs.length}, + {key:'notifications',label:'act_notif_jobs',icon:'🔔',count:notifJobs.length}, + ],active,key=>{ + drawList(view,data,key); + window.scrollTo({top:0,behavior:'instant'}); + // Replace the list URL without re-rendering; keyboard focus stays on the tab. + history.replaceState(null,'','#/activity/'+key); + }); + tabbar.classList.add('activity-tabs'); + wrap.appendChild(tabbar); + const list = el('div',{class:'activity-list'}); wrap.appendChild( - el('div', { style: { display: 'flex', justifyContent: 'space-between', marginBottom: '8px' } }, [ - button({ label: t('act_new_job'), icon: '➕', small: true, onClick: function () { navigate('activity/new'); } }), - button({ label: t('act_refresh'), icon: '🔄', kind: 'ghost', small: true, onClick: function () { renderList(view); } }), + el('div', {class:'toolbar activity-toolbar'}, [ + active === 'bulk' ? button({ label: t('act_new_job'), icon: '➕', small: true, onClick: function () { navigate('activity/new'); } }) : el('span',{class:'hint',text:'گزارش ارسال اعلان‌ها'}), + el('div',{class:'grow'}), + button({ label: t('act_refresh'), icon: '🔄', kind: 'ghost', small: true, onClick: function () { renderList(view,active); } }), ]) ); - - // --- Bulk jobs group --- - wrap.appendChild(el('div', { class: 'section-label', text: t('act_bulk_jobs') })); - if (!bulkJobs.length) { - wrap.appendChild(emptyBlock(t('act_no_bulk'))); - } else { - bulkJobs.forEach(function (job) { - wrap.appendChild(bulkJobCard(job)); - }); - } - - // --- Notification jobs group --- - wrap.appendChild(el('div', { class: 'section-label', text: t('act_notif_jobs') })); - if (!notifJobs.length) { - wrap.appendChild(emptyBlock(t('act_no_notif'))); - } else { - notifJobs.forEach(function (job) { - wrap.appendChild(notifJobCard(job)); - }); - } - + const jobs = active === 'notifications' ? notifJobs : bulkJobs; + mount(list,jobs.length ? jobs.map(active === 'notifications' ? notifJobCard : bulkJobCard) + : emptyBlock(t(active === 'notifications' ? 'act_no_notif' : 'act_no_bulk'))); + mount(content,list); + wrap.appendChild(content); mount(view, wrap); + watchActivity(view,'/jobs',data,(host,next)=>drawList(host,next,active)); } @@ -358,3 +380,3 @@ el('span', { class: 'act-stat', text: t('act_field_skipped') + ': ' + fmtNum(skipped) }), - el('span', { class: 'act-stat muted', text: '/ ' + fmtNum(total) }), + el('span', { class: 'act-stat muted', text: t('act_field_total') + ': ' + fmtNum(total) }), ]), @@ -364,3 +386,3 @@ el('div', { class: 'act-job-dates' }, [ - el('span', { class: 'muted', text: fmtDate(job.created_at) }), + el('span', { class: 'muted', text: fmtDate(job.created_at), dir: 'ltr' }), ]), @@ -379,2 +401,6 @@ var statusText = job.status_text || ''; + const statuses={pending:['در حال ارسال','info'],completed:['ارسال شد','ok'],failed:['ناموفق','danger'],cancelled:['لغو شد','']}; + const status=statuses[statusText]||[statusText,'']; + const title=job.title==='broadcast_send'?'پیام همگانی':/^bulk_adjust_\d+$/.test(job.title||'')? + 'اعلان عملیات '+job.title.split('_').at(-1):job.title||t('act_title_fallback'); @@ -382,4 +408,4 @@ el('div', { class: 'act-job-head' }, [ - el('div', { class: 'act-job-title', text: job.title || t('act_title_fallback') }), - statusText ? pill(statusText) : null, + el('div', { class: 'act-job-title', text: title }), + statusText ? pill(status[0],status[1]) : null, ]), @@ -387,3 +413,3 @@ el('span', { class: 'act-stat', text: t('act_field_sent') + ': ' + fmtNum(sent) }), - el('span', { class: 'act-stat muted', text: '/ ' + fmtNum(total) }), + el('span', { class: 'act-stat muted', text: t('act_field_total') + ': ' + fmtNum(total) }), ]), @@ -393,3 +419,3 @@ el('div', { class: 'act-job-dates' }, [ - el('span', { class: 'muted', text: fmtDate(job.created_at) }), + el('span', { class: 'muted', text: fmtDate(job.created_at), dir: 'ltr' }), ]), @@ -405,2 +431,3 @@ function renderDetail(view, jobId) { + view._stopActivity?.(); mount(view, loadingBlock()); @@ -467,2 +494,4 @@ + if ([20,30].includes(job.status_id)) watchActivity(view,'/jobs/' + job.id,job,drawDetail); + else view._stopActivity?.(); // Load items separately so header renders immediately @@ -814,2 +843,4 @@ renderCreate(view); + } else if (first === 'bulk' || first === 'notifications') { + renderList(view,first); } else if (first) {