Files
customerTicket/static/js/dispatch-workspace.js
T

255 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);
})();