estelam ticket, varizha , others

This commit is contained in:
javad committed 2026-10-13 15:48:24 +03:30
1 parent 35a1ec1794
commit 6b546aad76
71 files changed
+2865 -191

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
/* Progressive enhancement: native forms and private-ticket links work without this script. */
(() => {
'use strict';
const number = new Intl.NumberFormat('fa-IR');
const displayCount = count => `${number.format(count)} نفر`;
function initSections(root) {
const tabs = Array.from(root.querySelectorAll('[data-dispatch-tab]'));
const panels = Array.from(root.querySelectorAll('[data-dispatch-panel]'));
const hasErrors = Boolean(root.querySelector('.dispatch-error-summary'));
const filters = new URLSearchParams(location.search);
const hasFilters = ['q', 'status', 'unit', 'group', 'created_from', 'created_to', 'page'].some(key => filters.get(key));
const selectedSection = () => {
if (hasErrors) return 'compose';
if (location.hash === '#dispatch-history-panel') return 'history';
if (location.hash === '#dispatch-compose-panel') return 'compose';
return hasFilters ? 'history' : 'compose';
};
const show = section => {
tabs.forEach(tab => {
const active = tab.dataset.dispatchTab === section;
tab.setAttribute('aria-selected', String(active));
tab.setAttribute('aria-current', String(active));
tab.tabIndex = active ? 0 : -1;
});
panels.forEach(panel => { panel.hidden = panel.dataset.dispatchPanel !== section; });
};
root.querySelector('.dispatch-view-switch').setAttribute('role', 'tablist');
tabs.forEach((tab, index) => {
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-controls', `dispatch-${tab.dataset.dispatchTab}-panel`);
tab.addEventListener('click', event => {
event.preventDefault();
history.pushState(null, '', tab.getAttribute('href'));
show(tab.dataset.dispatchTab);
});
tab.addEventListener('keydown', event => {
let next;
if (event.key === 'ArrowLeft') next = (index + 1) % tabs.length;
if (event.key === 'ArrowRight') next = (index + tabs.length - 1) % tabs.length;
if (event.key === 'Home') next = 0;
if (event.key === 'End') next = tabs.length - 1;
if (next === undefined) return;
event.preventDefault();
tabs[next].click();
tabs[next].focus();
});
});
panels.forEach(panel => panel.setAttribute('role', 'tabpanel'));
window.addEventListener('popstate', () => show(selectedSection()));
show(selectedSection());
}
function fillOptions(select, items, labelKey, placeholder, autoSelect = false) {
const previous = select.value;
select.replaceChildren(new Option(placeholder, ''));
items.forEach(item => select.add(new Option(item[labelKey], String(item.id))));
if (items.some(item => String(item.id) === previous)) select.value = previous;
else if (autoSelect && items.length === 1) select.value = String(items[0].id);
}
function initRecipients(root, form, data) {
const operation = form.elements.operation_type;
const unit = form.elements.unit;
const group = form.elements.contact_group;
const category = form.elements.category;
const groupHint = root.querySelector('[data-dispatch-group-hint]');
const text = (selector, value) => { root.querySelector(selector).textContent = value; };
const updateSummary = () => {
const selected = data.groups.find(item => String(item.id) === group.value);
updateResponseOptions(root, form, selected);
text('[data-dispatch-unit-summary]', unit.value ? unit.selectedOptions[0].textContent : 'انتخاب نشده');
text('[data-dispatch-group-summary]', selected ? selected.name : 'انتخاب نشده');
text('[data-dispatch-count-summary]', selected ? displayCount(selected.recipient_count) : 'پس از انتخاب گروه');
group.setCustomValidity(selected && !selected.recipient_count ? 'این گروه مخاطب فعال دارای شماره همراه ندارد؛ گروه دیگری انتخاب کنید.' : '');
groupHint.textContent = selected ? `${displayCount(selected.recipient_count)} فعال دارای شماره همراه؛ تعداد نهایی هنگام ثبت بررسی می‌شود.` : 'فقط گروه‌های واحد انتخاب‌شده نمایش داده می‌شوند.';
};
const updateGroupOptions = () => {
const hasUnit = Boolean(unit.value);
const groups = data.groups.filter(item => String(item.unit_id) === unit.value);
const categories = data.categories.filter(item => String(item.unit_id) === unit.value);
const applicableCategories = categories.length ? categories : data.categories.filter(item => item.unit_id === null);
fillOptions(group, hasUnit ? groups : [], 'name', hasUnit ? 'گروه مخاطبان را انتخاب کنید' : 'ابتدا واحد را انتخاب کنید');
fillOptions(category, hasUnit ? applicableCategories : [], 'title', hasUnit ? 'دسته‌بندی را انتخاب کنید' : 'ابتدا واحد را انتخاب کنید');
group.disabled = !hasUnit;
category.disabled = !hasUnit;
updateSummary();
};
const updateUnitOptions = () => {
const units = data.units.filter(item => item.operations.includes(operation.value));
fillOptions(unit, units, 'name', 'واحد ارسال‌کننده را انتخاب کنید', !root.querySelector('.dispatch-error-summary'));
updateGroupOptions();
};
operation.addEventListener('change', updateUnitOptions);
unit.addEventListener('change', updateGroupOptions);
group.addEventListener('change', updateSummary);
updateUnitOptions();
return () => data.groups.find(item => String(item.id) === group.value);
}
function updateResponseOptions(root, form, group) {
const sms = form.elements.notify_opening_sms;
const individual = form.elements.response_mode.value === 'INDIVIDUAL';
const oversized = Boolean(group && group.recipient_count > 15);
if (sms.dataset.openingPreference === undefined) sms.dataset.openingPreference = String(sms.checked);
sms.disabled = individual || oversized;
sms.checked = !sms.disabled && sms.dataset.openingPreference === 'true';
root.querySelector('[data-opening-sms-hint]').textContent = individual
? 'پیامک بازگشایی فقط در حالت یکجا مجاز است.'
: oversized ? 'بیش از ۱۵ گیرندهٔ واجد شرایط؛ اطلاع‌رسانی بازگشایی با پیامک مجاز نیست.'
: 'پس از بازگشایی فقط پاسخ‌دهندگان پیامک دریافت می‌کنند؛ حداکثر ۱۵ گیرنده مجاز است.';
}
function initReview(root, form, selectedGroup) {
const dialog = root.querySelector('[data-dispatch-review]');
if (typeof dialog.showModal !== 'function') return;
const send = form.querySelector('[data-dispatch-send]');
const confirm = dialog.querySelector('[data-dispatch-confirm]');
let confirmed = false;
send.textContent = 'بررسی و ادامه';
const setText = (selector, value) => { dialog.querySelector(selector).textContent = value; };
const review = () => {
const group = selectedGroup();
if (!form.reportValidity() || !group) return;
// The shared uploader empties picker inputs only after image preparation completes.
if (Array.from(form.querySelectorAll('[data-gallery-input], [data-document-input]')).some(input => input.files.length)) {
root.querySelector('[data-compression-status]').textContent = 'آماده‌سازی پیوست‌ها ادامه دارد؛ پس از پایان دوباره بررسی و ادامه را بزنید.';
return;
}
setText('[data-review-unit]', form.elements.unit.selectedOptions[0].textContent);
setText('[data-review-group]', group.name);
setText('[data-review-count]', displayCount(group.recipient_count));
setText('[data-review-response-policy]', `${form.elements.response_mode.value === 'SEALED' ? 'بازگشایی یکجا' : 'مشاهده جداگانه'}؛ پیامک بازگشایی: ${form.elements.notify_opening_sms.checked && !form.elements.notify_opening_sms.disabled ? 'فعال' : 'غیرفعال'}`);
dialog.querySelector('[data-review-inquiry-recommendation]').hidden = form.elements.response_mode.value === 'SEALED' && form.elements.notify_opening_sms.checked && !form.elements.notify_opening_sms.disabled;
dialog.querySelector('[data-review-inquiry-limit]').hidden = group.recipient_count <= 15;
const files = Array.from(form.elements.files.files);
setText('[data-review-files]', files.length ? files.map(file => file.name).join('، ') : 'بدون پیوست');
setText('[data-review-subject]', form.elements.subject.value);
setText('[data-review-body]', form.elements.body.value);
dialog.showModal();
dialog.querySelector('[data-dispatch-review-close]').focus();
};
// Capture before shared uploader/UI listeners: reviewing must not disable submission controls.
form.addEventListener('submit', event => {
if (confirmed) return;
event.preventDefault();
event.stopImmediatePropagation();
review();
}, true);
dialog.querySelectorAll('[data-dispatch-review-close]').forEach(button => button.addEventListener('click', () => dialog.close()));
dialog.addEventListener('close', () => { if (!confirmed) send.focus(); });
confirm.addEventListener('click', () => {
if (confirmed || !form.reportValidity()) return;
confirmed = true;
confirm.disabled = true;
confirm.textContent = 'در حال ثبت…';
dialog.close();
form.requestSubmit(send);
});
}
function initErrors(root, form) {
form.querySelectorAll('.errorlist').forEach((list, index) => {
const control = list.parentElement.querySelector('input, select, textarea');
if (!control) return;
list.id = `dispatch-error-${index}`;
control.setAttribute('aria-invalid', 'true');
control.setAttribute('aria-describedby', list.id);
});
const summary = root.querySelector('.dispatch-error-summary');
if (!summary) return;
summary.querySelectorAll('a').forEach(link => link.addEventListener('click', event => {
const control = document.getElementById(link.hash.slice(1));
if (!control) return;
event.preventDefault();
const disclosure = control.closest('details');
if (disclosure) disclosure.open = true;
control.focus();
}));
summary.focus();
}
function initComposer(root) {
const form = root.querySelector('#dispatch-compose-form');
const data = JSON.parse(root.querySelector('#dispatch-options').textContent);
initSections(root);
const selectedGroup = initRecipients(root, form, data);
form.elements.notify_opening_sms.addEventListener('change', event => {
event.target.dataset.openingPreference = String(event.target.checked);
});
form.querySelectorAll('[name="response_mode"]').forEach(input => input.addEventListener('change', () => updateResponseOptions(root, form, selectedGroup())));
initReview(root, form, selectedGroup);
initErrors(root, form);
// Visible, keyboard-operable file labels retain the shared uploader's original inputs.
root.querySelectorAll('.media-picker-button').forEach(label => {
label.tabIndex = 0;
label.setAttribute('role', 'button');
label.addEventListener('keydown', event => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
document.getElementById(label.htmlFor).click();
});
});
}
function normalizeSearch(value) {
return value.toLowerCase().replace(/[يى]/g, 'ی').replace(/ك/g, 'ک')
.replace(/[۰-۹]/g, digit => String(digit.charCodeAt(0) - 1776))
.replace(/[٠-٩]/g, digit => String(digit.charCodeAt(0) - 1632))
.replace(/[\s\u200c-]+/g, '');
}
function initReport(root) {
const rows = Array.from(root.querySelectorAll('[data-recipient-row]'));
if (!rows.length) return;
const tools = root.querySelector('[data-recipient-tools]');
const search = tools.querySelector('[data-recipient-search]');
const filter = tools.querySelector('[data-recipient-filter]');
const status = tools.querySelector('[data-recipient-result-count]');
const empty = root.querySelector('[data-recipient-empty]');
const matches = {
all: () => true,
answered: row => row.dataset.answered === 'true',
unanswered: row => row.dataset.answered === 'false',
opened: row => row.dataset.opened === 'true',
unopened: row => row.dataset.opened === 'false',
retryable: row => row.dataset.retryable === 'true',
active: row => row.dataset.active === 'true',
};
const update = () => {
const query = normalizeSearch(search.value);
let visible = 0;
rows.forEach(row => {
row.hidden = !normalizeSearch(row.dataset.search).includes(query) || !matches[filter.value](row);
if (!row.hidden) visible += 1;
});
status.textContent = `${number.format(visible)} از ${number.format(rows.length)} مخاطب`;
empty.hidden = visible > 0;
};
search.addEventListener('input', update);
filter.addEventListener('change', update);
root.querySelectorAll('[data-recipient-reset]').forEach(button => button.addEventListener('click', () => {
search.value = '';
filter.value = 'all';
update();
search.focus();
}));
tools.hidden = false;
update();
}
document.querySelectorAll('[data-dispatch-workspace]').forEach(initComposer);
document.querySelectorAll('[data-dispatch-report]').forEach(initReport);
})();