This commit is contained in:
javad committed 2026-10-10 14:21:04 +03:30
1 parent 250cdbab7f
commit 5c6069c3ef
87 files changed
+29209 -3697

No files matched your search

@@ -0,0 +1,70 @@
{% if perms.admin_panel.ManageSMS %}
<div class="box" dir="rtl">
<h2 class="title is-5">پیامک</h2>
<div class="columns is-multiline">
<div class="column is-6-desktop is-12-mobile">
<h3 class="subtitle is-6">شماره‌های اصلی (گیرنده پیامک)</h3>
{# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند نمایش داده می‌شوند (جدول شماره‌ها یا فیلدهای قدیمی) #}
{% for entry in sms_phone_entries %}
<form method="post" action="{% url 'admin_panel:sms_primary_toggle' customer.id %}" class="field has-addons">
{% csrf_token %}
<input type="hidden" name="phone" value="{{ entry.raw }}">
<input type="hidden" name="is_primary" value="{% if entry.is_primary %}false{% else %}true{% endif %}">
<div class="control is-expanded">
<input class="input" type="text"
value="{{ entry.title }} — {{ entry.display }}{% if not entry.is_mobile %} (غیرقابل ارسال پیامک){% endif %}"
disabled>
</div>
<div class="control">
{% if entry.is_mobile %}
<button class="button {% if entry.is_primary %}is-success{% else %}is-light{% endif %}" type="submit">
{% if entry.is_primary %}اصلی ✓{% else %}تعیین به‌عنوان اصلی{% endif %}
</button>
{% else %}
<button class="button is-light" type="button" disabled>غیرموبایل</button>
{% endif %}
</div>
</form>
{% empty %}
<p class="help">شماره‌ای برای این مشتری ثبت نشده است.</p>
{% endfor %}
<p class="help">اگر شمارهٔ اصلی تعیین نشده باشد، شمارهٔ اولِ معتبر به‌صورت خودکار استفاده می‌شود.</p>
<p class="help">گیرندگان فعلی: {% for r in sms_recipients %}<span dir="ltr">{{ r }}</span>{% if not forloop.last %}، {% endif %}{% empty %}—{% endfor %}</p>
</div>
<div class="column is-6-desktop is-12-mobile">
<h3 class="subtitle is-6">فعال/غیرفعال پیامک‌ها برای این مشتری</h3>
{% for item in sms_toggles %}
<form method="post" action="{% url 'admin_panel:sms_customer_toggle' customer.id %}" class="field has-addons">
{% csrf_token %}
<input type="hidden" name="sms_type" value="{{ item.code }}">
<input type="hidden" name="is_enabled" value="{% if item.enabled %}false{% else %}true{% endif %}">
<div class="control is-expanded">
<input class="input" type="text" value="{{ item.label }}" disabled>
</div>
<div class="control">
<button class="button {% if item.enabled %}is-success{% else %}is-danger{% endif %}" type="submit">
{% if item.enabled %}فعال{% else %}غیرفعال{% endif %}
</button>
</div>
</form>
{% endfor %}
</div>
<div class="column is-12">
<h3 class="subtitle is-6">ارسال پیامک سفارشی</h3>
<form method="post" action="{% url 'admin_panel:sms_send_custom' customer.id %}">
{% csrf_token %}
<div class="field">
<div class="control">
<textarea class="textarea" name="text" rows="2" placeholder="متن پیامک... (خالی بگذارید تا متن قالب «پیام سفارشی» استفاده شود)"></textarea>
</div>
</div>
<p class="help">می‌توانید از متغیرهایی مثل {name} (نام مشتری) استفاده کنید.</p>
<button class="button is-info" type="submit">ارسال به شماره‌های اصلی</button>
</form>
</div>
</div>
</div>
{% endif %}
+30 -1
View File
@@ -19,6 +19,7 @@
<script type="text/javascript" src="{% static 'admin_panel/persian-datepicker.min.js' %}"></script>
<link rel="stylesheet" type="text/css" href="{% static 'admin_panel/style.css' %}">
{% block cssEnd %}{% endblock %}
<style>
</style>
</head>
@@ -119,9 +120,26 @@
{% if perms.admin_panel.ManageSMS %}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">پیامک</a>
<div class="navbar-dropdown">
<a href="{% url 'admin_panel:sms_settings' %}" style="padding-right: 10px;" class="navbar-item">
تنظیمات سامانه
</a>
<a href="{% url 'admin_panel:sms_templates' %}" style="padding-right: 10px;" class="navbar-item">
قالب‌ها
</a>
<a href="{% url 'admin_panel:sms_queue' %}" style="padding-right: 10px;" class="navbar-item">
صف ارسال
</a>
</div>
</div>
{% endif %}
<a class="navbar-item">
کاربران
</a>
</a>
</div>
<div class="navbar-end ml-0">
@@ -201,6 +219,17 @@
{% endif %}
</div>
{% if notice_text %}
<div class="columns m-0">
<div class="column is-12 px-2 pt-3 pb-0">
<div class="notification {{ notice_kind }} is-light mb-2">
<button class="delete close-notification" type="button"></button>
<strong>{{ notice_text }}</strong>
</div>
</div>
</div>
{% endif %}
{% block bodyPage %}
{% endblock %}
@@ -45,86 +45,29 @@
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">شماره‌های تماس</label>
<div id="dynamic-phones-edit">
{% if customer.phones.all %}
{% for phone_obj in customer.phones.all %}
<div class="field has-addons mt-2 ss02">
<div class="control" style="width: 30%;">
<input disabled value="{{ phone_obj.title }}" name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input disabled value="0{{ phone_obj.phone_number }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
</div>
<div class="control">
<a href="tel:0{{ phone_obj.phone_number }}" class="button is-success" title="تماس با {{ phone_obj.phone_number }}">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control">
<button type="button" disabled class="button is-danger remove-phone-btn">-</button>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
{% if customer.phone1 %}
<div class="field has-addons ss02 mt-2">
<div class="control" style="width: 30%;">
<input disabled value="تلفن 1" name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input disabled value="0{{ customer.phone1 }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
</div>
<div class="control">
<a href="tel:0{{ customer.phone1 }}" class="button is-success" title="تماس">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
{% endif %}
</div>
{% endif %}
{% if customer.phone2 %}
<div class="field has-addons ss02 mt-2">
<div class="control" style="width: 30%;">
<input disabled value="تلفن 2" name="phone_titles" class="input" type="text">
</div>
<div class="control is-expanded">
<input disabled value="0{{ customer.phone2 }}" name="phones" class="input an_phone" type="text">
</div>
<div class="control">
<a href="tel:0{{ customer.phone2 }}" class="button is-success" title="تماس">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
{% endif %}
</div>
{% endif %}
{% if customer.phone3 %}
<div class="field has-addons ss02 mt-2">
<div class="control" style="width: 30%;">
<input disabled value="تلفن 3" name="phone_titles" class="input" type="text">
</div>
<div class="control is-expanded">
<input disabled value="0{{ customer.phone3 }}" name="phones" class="input an_phone" type="text">
</div>
<div class="control">
<a href="tel:0{{ customer.phone3 }}" class="button is-success" title="تماس">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
{% endif %}
</div>
{% endif %}
{% endif %}
{# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند (جدول شماره‌ها + فیلدهای قدیمی)، بدون تکرار #}
{% for entry in sms_phone_entries %}
<div class="field has-addons mt-2 ss02">
<div class="control" style="width: 30%;">
<input disabled value="{{ entry.title }}" name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input disabled value="{{ entry.display }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
</div>
<div class="control">
<a href="tel:{{ entry.display }}" class="button is-success" title="تماس با {{ entry.display }}">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control">
<button type="button" disabled class="button is-danger remove-phone-btn">-</button>
</div>
{% endif %}
</div>
{% empty %}
<p class="help mt-2">شماره‌ای برای این مشتری ثبت نشده است.</p>
{% endfor %}
</div>
{% if perms.admin_panel.ViewStore %}
<button type="button" disabled id="add-more-phone-edit" class="button is-small is-info mt-2 w-100 add-phone-btn">
@@ -148,6 +91,13 @@
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">توضیحات</label>
<div class="control">
<textarea disabled name="notes" class="textarea ss02 an_notes" rows="4" placeholder="یادداشت آزاد">{{ customer.notes }}</textarea>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">لغو بازدید</label>
<div class="control">
@@ -686,7 +636,7 @@ $(document).ready(function() {
var test = 0;
$("#enEdit").click(function(){
$("#formcsEdit").find("input").prop('disabled', false);
$("#formcsEdit").find("input, textarea").prop('disabled', false);
// فعال کردن دکمه‌های کنترل تلفن
$("#formcsEdit").find("button.remove-phone-btn, button.add-phone-btn").prop('disabled', false);
@@ -180,9 +180,11 @@
</form>
</div>
{% include "./_customer_sms.html" %}
</div>
{% endblock %}
{% block BodyCustomer2 %}
<div id="addNewIteam" class="modal">
+212 -103
View File
@@ -136,63 +136,87 @@
<button class="modal-close is-large closeSearch" aria-label="close"></button>
</div>
<div class="modal addNewUser">
<div class="modal addNewUser" role="dialog" aria-modal="true" aria-labelledby="addUserTitle">
<div class="modal-background closeAddUser"></div>
<div class="modal-content">
<form action="{% url 'admin_panel:customersAdd' %}" class="columns is-mobile is-multiline" method="post">
{% csrf_token %}
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">نام ونام خانوادگی</label>
<div class="control">
<input required name="an_name" class="input an_name" type="text" placeholder="نام نام خانوادگی">
</div>
</div>
<div class="field column is-8-desktop is-12-mobile m-0">
<label class="label">آدرس</label>
<div class="control">
<input name="an_address" class="input an_address" type="text" placeholder="آدرس کامل">
</div>
</div>
<div class="field column is-6-desktop is-12-mobile m-0">
<label class="label">شماره‌های تماس</label>
<div id="dynamic-phones-add">
<div class="field has-addons">
<div class="control" style="width: 35%;">
<input name="phone_titles" class="input" type="text" placeholder="عنوان (مثلاً موبایل)">
<div class="modal-content add-modal">
<div class="add-modal-card">
<header class="add-modal-head">
<h2 class="title is-5 mb-0" id="addUserTitle">ایجاد مشتری جدید</h2>
<button type="button" class="delete closeAddUser" aria-label="بستن"></button>
</header>
<form action="{% url 'admin_panel:customersAdd' %}" method="post" id="addCustomerForm">
{% csrf_token %}
<div class="add-modal-body">
<div class="columns is-mobile is-multiline m-0">
<div class="field column is-8-desktop is-12-mobile py-2 px-2">
<label class="label" for="addName">نام و نام خانوادگی <span class="has-text-danger">*</span></label>
<div class="control">
<input required name="an_name" id="addName" class="input an_name" type="text"
autocomplete="name" placeholder="نام و نام خانوادگی">
</div>
</div>
<div class="control is-expanded">
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس">
<div class="field column is-4-desktop is-12-mobile py-2 px-2">
<label class="label" for="addIdnCode">کدشناسایی</label>
<div class="control">
<input name="idnCode" id="addIdnCode" class="input idnCode" type="text"
placeholder="کد شناسایی مشتری">
</div>
</div>
<div class="control">
<button type="button" class="button is-info add-phone-btn">+</button>
<div class="field column is-4-desktop is-12-mobile py-2 px-2">
<label class="label" for="persianDateP">تاریخ نصب</label>
<div class="control">
<input name="installDate" id="persianDateP" class="input installDate" type="text"
autocomplete="off" placeholder="مثل: 1400/06/01">
</div>
<p class="help">مبنای محاسبهٔ یادآوری بازدید دوره‌ای.</p>
</div>
<div class="field column is-8-desktop is-12-mobile py-2 px-2">
<label class="label" for="addAddress">آدرس</label>
<div class="control">
<input name="an_address" id="addAddress" class="input an_address" type="text"
autocomplete="street-address" placeholder="آدرس کامل">
</div>
</div>
<div class="field column is-12 py-2 px-2">
<label class="label">شماره‌های تماس</label>
<div id="dynamic-phones-add">
<div class="phone-row">
<div class="phone-row-fields">
<input name="phone_titles" class="input phone-title" type="text"
aria-label="عنوان شماره" placeholder="عنوان (مثلاً موبایل)">
<input name="phones" class="input phone-number an_phone" type="tel"
inputmode="tel" autocomplete="tel" aria-label="شماره تماس"
placeholder="شماره تماس">
</div>
<div class="phone-row-actions">
<label class="checkbox phone-primary" title="گیرندهٔ پیامک‌های این مشتری">
<input type="checkbox" name="phone_primary" value="0" checked> اصلی
</label>
<button type="button" class="button is-small is-danger is-light remove-phone-btn"
aria-label="حذف این شماره">حذف</button>
</div>
</div>
</div>
<button type="button" class="button is-info is-light is-small add-phone-btn"
data-target="#dynamic-phones-add">+ افزودن شماره</button>
<p class="help">تیک «اصلی» یعنی گیرندهٔ پیامک‌های این مشتری (می‌تواند چند شماره باشد).</p>
</div>
</div>
</div>
</div>
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">تاریخ نصب</label>
<div class="control">
<input name="installDate" id="persianDateP" class="input installDate" type="text" placeholder="مثل: 1400/06/01">
</div>
</div>
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">کدشناسایی</label>
<div class="control">
<input name="idnCode" class="input idnCode" type="text" placeholder="">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<input class="button is-success" id="DcreateCS" value="ایجاد مشتری" type="submit">
</div>
</form>
<footer class="add-modal-foot">
<button class="button is-success" id="DcreateCS" type="submit">ایجاد مشتری</button>
<button class="button is-light closeAddUser" type="button">انصراف</button>
</footer>
</form>
</div>
</div>
<button class="modal-close is-large closeAddUser" aria-label="close"></button>
</div>
@@ -252,83 +276,168 @@
</div>
{% endblock %}
{% block cssEnd %}
<style>
/* --- مودال ایجاد مشتری: کارت با سربرگ/پابرگ چسبان و بدنهٔ اسکرول‌شو --- */
/* .add-modal همان .modal-content بولماست؛ سقف ارتفاع و max-width پیش‌فرضش را خنثی
می‌کنیم تا کارت خودش اندازه را تعیین کند (وگرنه پابرگ بریده می‌شود). */
.add-modal {
overflow: visible; max-height: none; max-width: 760px;
width: 100%; margin: 0 auto; padding: 0 .5rem;
}
.add-modal-card {
display: flex; flex-direction: column;
background: #fff; border-radius: 10px; overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, .25);
width: 100%; max-width: 720px; margin: 0 auto;
max-height: calc(100vh - 120px);
}
/* فرم باید خودش ستون فلکس باشد تا بدنه بتواند اسکرول شود و پابرگ پایین بماند. */
.add-modal-card > form {
display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto;
}
.add-modal-head {
display: flex; align-items: center; justify-content: space-between;
gap: .5rem; padding: .75rem 1rem;
border-bottom: 1px solid #eee; background: #fff; flex: 0 0 auto;
}
.add-modal-body {
overflow-y: auto; -webkit-overflow-scrolling: touch;
padding: .25rem .5rem; flex: 1 1 auto; min-height: 0;
}
.add-modal-foot {
display: flex; gap: .5rem; padding: .75rem 1rem;
border-top: 1px solid #eee; background: #fafafa; flex: 0 0 auto;
}
.add-modal-foot .button { min-height: 2.6em; }
/* --- ردیف شمارهٔ تماس: دسکتاپ یک‌خطی، موبایل عمودی --- */
.phone-row {
display: flex; flex-wrap: wrap; align-items: center; gap: .5rem;
padding: .5rem; margin-bottom: .5rem;
border: 1px solid #e6e6e6; border-radius: 8px; background: #fafafa;
}
.phone-row-fields { display: flex; flex-wrap: wrap; gap: .5rem; flex: 1 1 320px; min-width: 0; }
.phone-row-fields .phone-title { flex: 0 0 140px; min-width: 0; }
.phone-row-fields .phone-number { flex: 1 1 180px; min-width: 0; }
.phone-row-actions { display: flex; align-items: center; gap: .75rem; white-space: nowrap; }
.phone-primary { display: flex; align-items: center; gap: .35rem; font-size: .9rem; }
@media (max-width: 768px) {
.add-modal-card { max-height: calc(100vh - 32px); border-radius: 8px; }
/* dvh روی مرورگرهای موبایل نوار آدرس را هم حساب می‌کند. */
.add-modal-card { max-height: calc(100dvh - 32px); }
.phone-row-fields { flex: 1 1 100%; }
.phone-row-fields .phone-title,
.phone-row-fields .phone-number { flex: 1 1 100%; }
.phone-row-actions { flex: 1 1 100%; justify-content: space-between; }
.add-modal-foot { flex-direction: column-reverse; }
.add-modal-foot .button { width: 100%; }
}
</style>
{% endblock %}
{% block jsEnd %}
<script>
$(document).ready(function() {
kamaDatepicker('persianDateP', { buttonsColor: "red", forceFarsiDigits: true });
$(document).on('click', '.add-phone-btn', function() {
var phoneRow = `
<div class="field has-addons mt-2">
<div class="control" style="width: 35%;">
<input name="phone_titles" class="input" type="text" placeholder="عنوان">
var PHONE_ROW = `
<div class="phone-row">
<div class="phone-row-fields">
<input name="phone_titles" class="input phone-title" type="text"
aria-label="عنوان شماره" placeholder="عنوان (مثلاً موبایل)">
<input name="phones" class="input phone-number an_phone" type="tel"
inputmode="tel" autocomplete="tel" aria-label="شماره تماس" placeholder="شماره تماس">
</div>
<div class="control is-expanded">
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس">
</div>
<div class="control">
<button type="button" class="button is-danger remove-phone-btn">-</button>
<div class="phone-row-actions">
<label class="checkbox phone-primary" title="گیرندهٔ پیامک‌های این مشتری">
<input type="checkbox" name="phone_primary" value="0"> اصلی
</label>
<button type="button" class="button is-small is-danger is-light remove-phone-btn"
aria-label="حذف این شماره">حذف</button>
</div>
</div>`;
$(this).closest('div[id^="dynamic-phones"]').append(phoneRow);
function reindexPhones() {
$('#dynamic-phones-add .phone-row').each(function(i) {
$(this).find('input[name="phone_primary"]').val(i);
});
}
// همیشه دست‌کم یک شماره «اصلی» بماند (شمارهٔ اول پیش‌فرضِ گیرندهٔ پیامک است).
function ensurePrimary() {
var $rows = $('#dynamic-phones-add .phone-row');
if ($rows.length && $rows.find('input[name="phone_primary"]:checked').length === 0) {
$rows.first().find('input[name="phone_primary"]').prop('checked', true);
}
}
reindexPhones();
$(document).on('click', '.add-phone-btn', function() {
// data-target صریح (وقتی دکمه بیرونِ ظرف است)؛ وگرنه نزدیک‌ترین ظرف شماره‌ها.
var $target = $(this).data('target')
? $($(this).data('target'))
: $(this).closest('div[id^="dynamic-phones"]');
$target.append(PHONE_ROW);
reindexPhones();
$target.find('.phone-row').last().find('.phone-number').focus();
});
$(document).on('click', '.remove-phone-btn', function() {
$(this).closest('.field.has-addons').remove();
$(this).closest('.phone-row').remove();
reindexPhones();
ensurePrimary();
});
$("#createCS").click(function(){
var u_name = $(".addNewUser").find('.an_name').val();
var u_address = $(".addNewUser").find('.an_address').val();
var u_phone1 = $(".addNewUser").find('#phone-1').val();
var u_phone2 = $(".addNewUser").find('#phone-2').val();
var u_phone3 = $(".addNewUser").find('#phone-3').val();
var u_installDate = $(".addNewUser").find('.installDate').val();
alert("Ok send To server");
function resetAddForm() {
$('#addCustomerForm').find('input.an_name, input.an_address, input.installDate, input.idnCode').val('');
$('#dynamic-phones-add').empty().append(PHONE_ROW);
reindexPhones();
ensurePrimary();
}
$(".closeAddUser").click(function(){
$(".addNewUser").removeClass("is-active");
$(".addNewUser").find('.an_name').val('');
$(".addNewUser").find('.an_address').val('');
$(".addNewUser").find('#phone-1').val('');
$(".addNewUser").find('#phone-2').val('');
$(".addNewUser").find('#phone-3').val('');
$(".addNewUser").find('.installDate').val('');
//Refresh page
});
$(".modal-close, .modal-background").click(function(){
$(".mEdit1").removeClass("is-active")
});
$(".closeSearch").click(function(){
resetAddForm();
});
$(".modal-close, .modal-background").click(function(){
$(".mEdit1").removeClass("is-active")
});
$(".closeSearch").click(function(){
$(".SearchBox").removeClass("is-active")
});
$(".closeAddUser").click(function(){
$(".addNewUser").removeClass("is-active");
$(".addNewUser").find('.an_name').val('');
$(".addNewUser").find('.an_address').val('');
$(".addNewUser").find('#phone-1').val('');
$(".addNewUser").find('#phone-2').val('');
$(".addNewUser").find('#phone-3').val('');
$(".addNewUser").find('.installDate').val('');
});
});
$("#addNewUser").click(function(){
$(".addNewUser").addClass("is-active")
});
$("#openSearchBox").click(function(){
$("#addCustomerForm").on('submit', function(){
// جلوگیری از ارسال دوباره؛ خودِ فرم همین حالا داده‌ها را بسته است.
$(this).find('#DcreateCS').prop('disabled', true).text('در حال ثبت…');
});
$("#addNewUser").click(function(){
resetAddForm();
$(".addNewUser").addClass("is-active");
$('#addName').trigger('focus');
});
$(document).on('keydown', function(e){
if (e.key === 'Escape' && $(".addNewUser").hasClass("is-active")) {
$(".addNewUser").removeClass("is-active");
resetAddForm();
}
});
$("#openSearchBox").click(function(){
$(".SearchBox").addClass("is-active")
});
$("#bedehiCN01").click(function(){
});
$("#bedehiCN01").click(function(){
var factorId = $(this).parent().parent().find(".fid").val();
if ($("#bedehiStatus").val() == "tasfiye"){
alert("send To server to tasfiye");
$("tbody").find('.fidv-'+factorId).parent().parent().remove();
$(".mEdit1").removeClass("is-active");
}
});
});
} );
</script>
{% endblock %}
@@ -0,0 +1,149 @@
{% extends "./base.html" %}
{% load custom_tags %}
{% block bodyPage %}
<div class="container mt-5" dir="rtl">
<h1 class="title is-4">صف پیامک‌ها</h1>
<div class="notification is-warning is-light">
پیامک‌ها تا زمانی که مدیر آن‌ها را تأیید نکند ارسال نمی‌شوند. صف را بازبینی
کنید، موارد نامناسب را حذف کنید و سپس تأیید بزنید.
<br>
حذف یک مورد، فقط همان نوبت را کنسل می‌کند. یادآوری‌های دوره‌ای (بازدید و ثبت
اطلاعات چک) در <strong>دورهٔ بعدی</strong> دوباره ساخته می‌شوند؛ پس با حذف یک
مورد، پیامک برای آن مشتری برای همیشه قطع نمی‌شود.
</div>
<div class="columns is-multiline mb-2" dir="rtl">
<div class="column is-narrow">
<div class="box py-3 px-4 has-text-centered">
<p class="heading mb-0">کل صف</p>
<p class="title is-5 mb-0">{{ total_count }}</p>
</div>
</div>
{% for code, label, count in status_stats %}
<div class="column is-narrow">
<a href="?status={{ code }}" class="box py-3 px-4 has-text-centered"
{% if status == code %}style="border:2px solid #3273dc"{% endif %}>
<p class="heading mb-0">{{ label }}</p>
<p class="title is-5 mb-0">{{ count }}</p>
</a>
</div>
{% endfor %}
</div>
{% if status or delivery %}
<p class="mb-3">نتایج فیلترشده: <strong>{{ filtered_count }}</strong> مورد — <a href="{% url 'admin_panel:sms_queue' %}">حذف فیلتر</a></p>
{% endif %}
<form method="get" class="field has-addons mb-3">
<div class="control">
<div class="select">
<select name="status">
<option value="">همهٔ وضعیت‌ها</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<div class="select">
<select name="delivery">
<option value="">همهٔ دلیوری‌ها</option>
{% for value, label in delivery_choices %}
<option value="{{ value }}" {% if delivery == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control"><button class="button" type="submit">فیلتر</button></div>
</form>
<form method="post" action="{% url 'admin_panel:sms_queue_bulk' %}" id="bulk-form" class="mb-3">
{% csrf_token %}
<button class="button is-success" name="action" value="approve_all"
onclick="return confirm('همهٔ پیامک‌های «در انتظار تأیید» تأیید و ارسال شوند؟');">
تأیید همهٔ موارد در انتظار ({{ pending_count }})
</button>
<button class="button is-info" name="action" value="approve_selected">تأیید انتخاب‌شده‌ها</button>
<button class="button is-danger" name="action" value="delete_selected"
onclick="return confirm('موارد انتخاب‌شده از صف حذف شوند؟');">حذف انتخاب‌شده‌ها</button>
</form>
<div class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between mb-3">
<span>خالی کردن کل صف: همهٔ پیامک‌ها (در هر وضعیتی) حذف می‌شوند و دیگر بازنمی‌گردند.</span>
<form method="post" action="{% url 'admin_panel:sms_queue_empty' %}"
onsubmit="return confirm('کل صف پیامک خالی شود؟ این پیامک‌ها دیگر هرگز ارسال یا بازسازی نمی‌شوند.');">
{% csrf_token %}
<button class="button is-danger" type="submit">خالی کردن کل صف</button>
</form>
</div>
<div class="table-container">
<table class="table is-fullwidth is-narrow is-hoverable is-striped">
<thead>
<tr>
<th><input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(c => c.checked = this.checked)"></th>
<th>شناسه</th><th>مشتری</th><th>نوع</th><th>شماره</th>
<th>وضعیت</th><th>دلیوری</th><th>تلاش</th><th>ایجاد</th><th>ارسال</th><th>خطا</th><th></th>
</tr>
</thead>
<tbody>
{% for item in queue %}
<tr>
<td><input class="row-check" type="checkbox" form="bulk-form" name="ids" value="{{ item.id }}"></td>
<td>{{ item.id }}</td>
<td>
{% if item.customer_id %}
<a href="{% url 'admin_panel:customer' item.customer_id %}">{{ item.customer.name_family }}</a>
{% else %}—{% endif %}
</td>
<td>{{ item.get_sms_type_display }}</td>
<td dir="ltr">{{ item.phone_number }}</td>
<td>{{ item.get_status_display }}</td>
<td>{{ item.get_delivery_status_display|default:'—' }}</td>
<td>{{ item.attempts }}</td>
<td>{{ item.created_at|toJalaliDT }}</td>
<td>{{ item.sent_at|toJalaliDT|default:'—' }}</td>
<td class="is-size-7">{{ item.last_error|truncatechars:60 }}</td>
<td>
<div class="buttons are-small">
{% if item.status == 'pending' %}
<form method="post" action="{% url 'admin_panel:sms_queue_approve' item.id %}">
{% csrf_token %}
<button class="button is-small is-success" type="submit">تأیید</button>
</form>
{% elif item.status == 'failed' %}
<form method="post" action="{% url 'admin_panel:sms_queue_retry' item.id %}">
{% csrf_token %}
<button class="button is-small is-warning" type="submit">ارسال مجدد</button>
</form>
{% endif %}
<form method="post" action="{% url 'admin_panel:sms_queue_delete' item.id %}"
onclick="return confirm('این پیامک از صف حذف شود؟');">
{% csrf_token %}
<button class="button is-small is-danger" type="submit">حذف</button>
</form>
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="12" class="has-text-centered">موردی یافت نشد.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if queue.has_other_pages %}
<nav class="pagination" role="navigation" aria-label="pagination">
{% if queue.has_previous %}
<a class="pagination-previous" href="?page={{ queue.previous_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">قبلی</a>
{% endif %}
{% if queue.has_next %}
<a class="pagination-next" href="?page={{ queue.next_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">بعدی</a>
{% endif %}
<ul class="pagination-list"><li><span class="pagination-link">صفحه {{ queue.number }} از {{ queue.paginator.num_pages }}</span></li></ul>
</nav>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,188 @@
{% extends "./base.html" %}
{% block bodyPage %}
<div class="container mt-5" dir="rtl">
<h1 class="title is-4">تنظیمات سامانه پیامک</h1>
<div class="box">
<form method="post" action="{% url 'admin_panel:sms_settings' %}">
{% csrf_token %}
<div class="columns is-multiline">
<div class="field column is-4-desktop is-12-mobile">
<label class="label">سرویس‌دهنده</label>
<div class="control">
<div class="select is-fullwidth">
<select name="provider">
{% for value, label in provider_choices %}
<option value="{{ value }}" {% if config.provider == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">وضعیت ارسال</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" name="is_enabled" {% if config.is_enabled %}checked{% endif %}>
فعال باشد
</label>
</div>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">مهلت ارتباط (ثانیه)</label>
<div class="control">
<input class="input" type="number" name="timeout_seconds" value="{{ config.timeout_seconds }}">
</div>
</div>
<div class="field column is-12-mobile">
<label class="label">آدرس سرویس پیامک (URL)</label>
<div class="control">
<input class="input" type="text" name="api_url" value="{{ config.api_url }}" dir="ltr">
</div>
<p class="help">
برای 0098SMS همین آدرس لینکی ارسال است
(<span dir="ltr">http://www.0098sms.com/sendsmslink.aspx</span>)؛ این سرویس
WSDL/SOAP ندارد و آدرس WSDL باعث خطای «Invalid XML content» می‌شود.
</p>
</div>
<div class="field column is-12-mobile">
<label class="label">آدرس وب‌سرویس (SOAP/WSDL) — فقط provider وب‌سرویس</label>
<div class="control">
<input class="input" type="text" name="webservice_url" value="{{ config.webservice_url }}" dir="ltr">
</div>
<p class="help">
ارسال با شناسهٔ پیامک (<span dir="ltr">smsid</span>) و استعلام تحویل به گوشی
از همین آدرس انجام می‌شود. پیش‌فرض:
<span dir="ltr">https://webservice.0098sms.com/service.asmx</span>
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">شمارهٔ پنل (PnlNo)</label>
<div class="control">
<input class="input" type="text" name="panel_no" value="{{ config.panel_no }}" dir="ltr">
</div>
<p class="help">اگر خالی بماند از «شماره خط فرستنده» استفاده می‌شود.</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">نام کاربری</label>
<div class="control">
<input class="input" type="text" name="username" value="{{ config.username }}" dir="ltr">
</div>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">رمز عبور</label>
<div class="control">
<input class="input" type="password" name="password" placeholder="{% if config.password %}(بدون تغییر)‌{% endif %}" dir="ltr">
</div>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">شماره خط فرستنده</label>
<div class="control">
<input class="input" type="text" name="sender" value="{{ config.sender }}" dir="ltr">
</div>
</div>
<div class="field column is-12-mobile">
<label class="label">توکن API (فقط برای provider HTTP)</label>
<div class="control">
<input class="input" type="password" name="api_token" placeholder="{% if config.api_token %}(بدون تغییر)‌{% endif %}" dir="ltr">
</div>
</div>
</div>
<hr>
<h2 class="title is-5">کنترل جریان ارسال</h2>
<p class="help mb-3">
برای جلوگیری از سیل پیامک: تنها مواردی ارسال می‌شوند که پنجرهٔ یادآوری‌شان از
«خط شروع» به بعد باز شود. موارد قدیمی‌تر تنها با روشن‌کردن «اجازهٔ موارد قدیمی»
و در سقف تعیین‌شده ارسال می‌شوند. بین هر پیامک هم فاصلهٔ زمانی رعایت می‌شود.
</p>
<div class="columns is-multiline">
<div class="field column is-6-desktop is-12-mobile">
<label class="label">تاریخ شروع دوره</label>
<div class="control">
<input class="input" type="text" name="scan_since"
value="{{ scan_since_jalali }}"
placeholder="مثلاً 1403/07/18 — خالی = همین حالا" dir="ltr">
</div>
<p class="help">
فقط یادآوری‌هایی صف می‌شوند که «پنجرهٔ» آن‌ها از این تاریخ به بعد باز شده باشد.
مشتریان قدیمی‌تری که پنجره‌شان پیش از این تاریخ باز شده، وارد صف نمی‌شوند.
قالب: <span dir="ltr">YYYY/MM/DD</span> (شمسی) — خالی = همین حالا.
</p>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">حداکثر پیامک هر اسکنر در هر اجرا</label>
<div class="control">
<input class="input" type="number" min="0" name="max_per_run" value="{{ config.max_per_run }}">
</div>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">فاصلهٔ بین هر پیامک (ثانیه)</label>
<div class="control">
<input class="input" type="number" min="0" name="send_interval_seconds" value="{{ config.send_interval_seconds }}">
</div>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">اجازهٔ ارسال موارد قدیمی</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" name="allow_backlog" {% if config.allow_backlog %}checked{% endif %}>
فعال باشد
</label>
</div>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">حداکثر موارد قدیمی در هر اجرا</label>
<div class="control">
<input class="input" type="number" min="0" name="backlog_max" value="{{ config.backlog_max }}">
</div>
<p class="help">صفر = هیچ مورد قدیمی ارسال نمی‌شود.</p>
</div>
</div>
<div class="buttons">
<button class="button is-success" type="submit">ذخیره تنظیمات</button>
</div>
</form>
</div>
<div class="box">
<h2 class="title is-5">ارسال پیامک آزمایشی</h2>
<form method="post" action="{% url 'admin_panel:sms_settings' %}">
{% csrf_token %}
<input type="hidden" name="action" value="test">
<div class="field">
<label class="label">متن پیامک آزمایشی</label>
<div class="control">
<textarea class="textarea" name="test_message" rows="3">{{ config.test_message }}</textarea>
</div>
<p class="help">این متن ذخیره می‌شود و در ارسال‌های بعدی هم استفاده خواهد شد.</p>
</div>
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" type="text" name="test_phone" placeholder="مثلاً 09120000000" dir="ltr">
</div>
<div class="control">
<button class="button is-info" type="submit">ارسال آزمایشی</button>
</div>
</div>
</form>
<p class="help">پیامک آزمایشی در صف قرار می‌گیرد و توسط worker ارسال می‌شود.</p>
</div>
</div>
{% endblock %}
@@ -0,0 +1,455 @@
{% extends "./base.html" %}
{% block bodyPage %}
<style>
{% verbatim %}
/* --- صفحهٔ قالب‌های پیامک: استایل‌ها با پیشوند smsx و محدود به همین صفحه --- */
/* هیچ عنصری از این صفحه نباید از کادر خودش بیرون بزند؛ ریشهٔ به‌هم‌ریختگی‌ها
معمولاً همین box-sizing است (width:100% + padding). */
.smsx, .smsx *, .smsx *::before, .smsx *::after { box-sizing: border-box; }
.smsx { max-width: 60rem; margin-inline: auto; }
.smsx img, .smsx textarea, .smsx input, .smsx button { max-width: 100%; }
.smsx-head { display: flex; flex-wrap: wrap; gap: .75rem; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.smsx-head__actions { display: flex; flex-wrap: wrap; gap: .5rem; }
/* ---------- کارت هر نوع پیامک ---------- */
.smsx-card { background: #fff; border: 1px solid #e8e8e8; border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,.04); margin-bottom: .9rem; overflow: hidden; }
.smsx-card[open] { box-shadow: 0 6px 18px rgba(0,0,0,.07); border-color: #d9d9d9; }
.smsx-card.is-off .smsx-summary__title { color: #9a9a9a; }
.smsx-card.is-dirty { border-color: #ffdd57; }
.smsx-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: .75rem; align-items: center; padding: .9rem 1rem; cursor: pointer; list-style: none; }
.smsx-summary::-webkit-details-marker { display: none; }
.smsx-summary:hover { background: #fafafa; }
.smsx-summary:focus-visible { outline: 2px solid #3273dc; outline-offset: -2px; }
.smsx-summary__icon { font-size: 1.35rem; line-height: 1; }
.smsx-summary__main { min-width: 0; }
.smsx-summary__title { display: block; font-weight: 600; color: #363636; overflow-wrap: anywhere; }
.smsx-summary__sub { display: block; font-size: .78rem; color: #7a7a7a; line-height: 1.7; overflow-wrap: anywhere; }
.smsx-summary__meta { display: flex; gap: .35rem; align-items: center; flex-wrap: wrap; }
.smsx-summary__chev { flex: 0 0 auto; width: .55rem; height: .55rem; border-right: 2px solid #b5b5b5; border-bottom: 2px solid #b5b5b5; transform: rotate(45deg); transition: transform .18s; }
.smsx-card[open] .smsx-summary__chev { transform: rotate(-135deg); }
/* ---------- بدنهٔ فرم ---------- */
.smsx-body { padding: 1rem; border-top: 1px solid #f0f0f0; }
/* گرید سه‌ستونی مقاوم: ستون عنوان کشسان، دو ستون دیگر به‌قدر محتوا */
.smsx-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 10rem) max-content; gap: .9rem 1rem; align-items: start; }
.smsx-cell { display: flex; flex-direction: column; gap: .4rem; min-width: 0; }
.smsx-label { display: block; margin: 0; font-size: .8rem; font-weight: 600; color: #4a4a4a; line-height: 1.6; overflow-wrap: anywhere; }
.smsx-cell--switch .smsx-label { white-space: nowrap; }
.smsx-note { margin-top: .75rem; margin-bottom: 0; line-height: 1.9; }
/* کلید فعال/غیرفعال */
.smsx-switch { display: inline-flex; align-items: center; gap: .5rem; cursor: pointer; user-select: none; min-height: 2.5rem; }
.smsx-switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.smsx-switch__track { position: relative; flex: 0 0 auto; width: 46px; height: 26px; border-radius: 13px; background: #dbdbdb; transition: background .18s; }
.smsx-switch__track::after { content: ""; position: absolute; top: 3px; inset-inline-start: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform .18s; }
.smsx-switch input:checked + .smsx-switch__track { background: #48c774; }
.smsx-switch input:checked + .smsx-switch__track::after { transform: translateX(-20px); }
.smsx-switch input:focus-visible + .smsx-switch__track { outline: 2px solid #3273dc; outline-offset: 2px; }
.smsx-switch__text { font-size: .85rem; color: #4a4a4a; white-space: nowrap; }
/* ---------- کادر نوشتن متن ---------- */
.smsx-editor { margin-top: 1rem; border: 1px solid #e2e2e2; border-radius: 10px; background: #fff; overflow: hidden; transition: border-color .15s, box-shadow .15s; }
.smsx-editor:focus-within { border-color: #3273dc; box-shadow: 0 0 0 3px rgba(50,115,220,.12); }
/* padding افقی سر/پا هم‌اندازهٔ padding خود textarea (1rem) تا متن و برچسب هم‌تراز بمانند */
.smsx-editor__head { display: flex; flex-wrap: wrap; gap: .5rem .75rem; align-items: center; justify-content: space-between; padding: .65rem 1rem; background: #fbfbfb; border-bottom: 1px solid #eee; }
.smsx-chips { display: flex; flex-wrap: wrap; gap: .35rem; }
.smsx-chip { display: inline-block; font-size: .75rem; line-height: 1.6; padding: .35rem .65rem; border-radius: 999px; border: 1px solid #dbdbdb; background: #fff; color: #4a4a4a; cursor: pointer; direction: ltr; font-family: ui-monospace, Menlo, Consolas, monospace; white-space: nowrap; }
.smsx-chip:hover { border-color: #3273dc; color: #3273dc; background: #f5f9ff; }
.smsx-chip:active { transform: translateY(1px); }
/* textarea دقیقاً به اندازهٔ کادر؛ بدون border/padding سرکش */
.smsx-editor textarea.smsx-textarea {
display: block; width: 100%; max-width: 100%; margin: 0;
border: 0; border-radius: 0; box-shadow: none; background: transparent;
padding: .85rem 1rem; font-size: 1rem; line-height: 1.9; font-family: inherit;
resize: vertical; overflow-y: auto;
unicode-bidi: plaintext; text-align: start; overflow-wrap: anywhere;
}
.smsx-editor textarea.smsx-textarea:focus { border: 0; box-shadow: none; outline: none; }
.smsx-editor__foot { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .6rem; padding: .5rem 1rem; border-top: 1px solid #f0f0f0; background: #fbfbfb; font-size: .78rem; color: #7a7a7a; }
.smsx-dot { color: #dbdbdb; }
.smsx-metric { font-variant-numeric: tabular-nums; }
.smsx-metric.is-ok { color: #257942; }
.smsx-metric.is-warn { color: #b8860b; }
.smsx-metric.is-bad { color: #cc0f35; font-weight: 600; }
.smsx-warn { color: #cc0f35; overflow-wrap: anywhere; }
/* ---------- پیش‌نمایش ---------- */
.smsx-preview { margin-top: 1rem; }
.smsx-preview__label { display: block; font-size: .8rem; font-weight: 600; color: #4a4a4a; margin-bottom: .4rem; }
.smsx-bubble { background: #ecf7ee; border: 1px solid #d5ecda; border-radius: 14px 14px 4px 14px; padding: .75rem .95rem; line-height: 1.9; font-size: .9rem; white-space: pre-wrap; overflow-wrap: anywhere; min-height: 2.6rem; }
.smsx-preview__hint { margin: .4rem 0 0; font-size: .75rem; line-height: 1.8; }
.smsx-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; margin-top: 1rem; }
.smsx-dirty { font-size: .8rem; color: #b8860b; }
.smsx-help { margin-bottom: 1rem; background: #f5f9ff; border: 1px solid #dbe9ff; border-radius: 10px; padding: .7rem .95rem; }
.smsx-help > summary { cursor: pointer; font-size: .85rem; color: #3273dc; font-weight: 600; }
.smsx-help .smsx-chips { margin-top: .6rem; }
.smsx-help .smsx-chip { cursor: default; white-space: normal; }
/* ---------- موبایل ---------- */
@media (max-width: 768px) {
.smsx-body { padding: .85rem; }
.smsx-summary { grid-template-columns: auto minmax(0, 1fr) auto; padding: .8rem .85rem; row-gap: .5rem; }
.smsx-summary__meta { grid-column: 1 / -1; }
/* همهٔ فیلدها زیر هم؛ دیگر هیچ چیز به هم نمی‌چسبد */
.smsx-grid { grid-template-columns: minmax(0, 1fr); }
.smsx-cell--switch .smsx-label { white-space: normal; }
.smsx-editor textarea.smsx-textarea { font-size: 16px; } /* جلوگیری از زوم خودکار iOS */
.smsx-actions .button { width: 100%; }
}
{% endverbatim %}
</style>
<div class="container mt-5" dir="rtl">
<div class="smsx">
<div class="smsx-head">
<div>
<h1 class="title is-4 mb-1">قالب‌های پیامک</h1>
<p class="subtitle is-6 has-text-grey mb-0">
متن هر نوع پیامک و زمان ارسال آن را اینجا تعیین کنید. برای ویرایش، روی هر کارت بزنید.
</p>
</div>
<div class="smsx-head__actions">
<button type="button" class="button is-small is-light" data-smsx-expand-all>باز کردن همه</button>
<button type="button" class="button is-small is-light" data-smsx-collapse-all>بستن همه</button>
</div>
</div>
<details class="smsx-help">
<summary>متغیرهای قابل استفاده در متن پیامک</summary>
<p class="help mb-0">کافی است روی هر متغیر بزنید تا در کادر متن همان پیامک درج شود.</p>
<div class="smsx-chips">
{% for token, desc in variables %}
<span class="smsx-chip" title="{{ desc }}">{{ token }} <span class="has-text-grey">{{ desc }}</span></span>
{% endfor %}
</div>
</details>
{{ smsx_config|json_script:"smsx-config" }}
{% for row in rows %}
<details class="smsx-card" id="type-{{ row.code }}" {% if forloop.first %}open{% endif %} data-smsx-card>
<summary class="smsx-summary">
<span class="smsx-summary__icon" aria-hidden="true">{{ row.icon }}</span>
<span class="smsx-summary__main">
<span class="smsx-summary__title">{{ row.label }}</span>
<span class="smsx-summary__sub">
{{ row.when }}{% if row.timing_value is not None %} · {{ row.timing_label }}: {{ row.timing_value }}{% endif %}
</span>
</span>
<span class="smsx-summary__meta">
<span class="tag is-light" data-smsx-badge>—</span>
{% if not row.template.is_enabled %}
<span class="tag is-warning is-light">غیرفعال</span>
{% endif %}
{% if row.template.auto_send %}
<span class="tag is-success is-light">خودکار</span>
{% endif %}
</span>
<span class="smsx-summary__chev" aria-hidden="true"></span>
</summary>
<form class="smsx-body" method="post" action="{% url 'admin_panel:sms_template_edit' row.template.id %}">
{% csrf_token %}
<div class="smsx-grid">
<div class="smsx-cell">
<label class="smsx-label" for="title-{{ row.code }}">عنوان</label>
<input id="title-{{ row.code }}" class="input" type="text" name="title" value="{{ row.template.title }}">
</div>
{% if row.timing_value is not None %}
<div class="smsx-cell">
<label class="smsx-label" for="timing-{{ row.code }}">{{ row.timing_label }}</label>
<input id="timing-{{ row.code }}" class="input" type="number" inputmode="numeric" min="0"
name="timing_value" value="{{ row.timing_value|default_if_none:'' }}">
</div>
{% endif %}
<div class="smsx-cell smsx-cell--switch">
<span class="smsx-label">وضعیت ارسال</span>
<label class="smsx-switch">
<input type="checkbox" name="is_enabled" {% if row.template.is_enabled %}checked{% endif %}>
<span class="smsx-switch__track"></span>
<span class="smsx-switch__text">{% if row.template.is_enabled %}فعال{% else %}غیرفعال{% endif %}</span>
</label>
</div>
<div class="smsx-cell smsx-cell--switch">
<span class="smsx-label">ارسال</span>
<label class="smsx-switch" title="روشن = ارسال خودکار بدون صف تأیید مدیر / خاموش = نیازمند تأیید مدیر">
<input type="checkbox" name="auto_send" {% if row.template.auto_send %}checked{% endif %}>
<span class="smsx-switch__track"></span>
<span class="smsx-switch__text">{% if row.template.auto_send %}خودکار{% else %}با تأیید مدیر{% endif %}</span>
</label>
</div>
</div>
{% if row.timing.note %}
<p class="help smsx-note">{{ row.timing.note }}</p>
{% endif %}
<div class="smsx-editor">
<div class="smsx-editor__head">
<label class="smsx-label" for="text-{{ row.code }}">متن پیامک</label>
<div class="smsx-chips" role="group" aria-label="درج متغیر در متن">
{% for token, desc in variables %}
<button type="button" class="smsx-chip" data-smsx-insert="{{ token }}"
title="{{ desc }}" aria-label="درج {{ token }} — {{ desc }}">{{ token }}</button>
{% endfor %}
</div>
</div>
<textarea id="text-{{ row.code }}" class="textarea smsx-textarea" name="text" rows="6"
data-smsx-text aria-describedby="meta-{{ row.code }}"
placeholder="متن پیامک را اینجا بنویسید…">{{ row.template.text }}</textarea>
<div class="smsx-editor__foot" id="meta-{{ row.code }}">
<span class="smsx-metric" data-smsx-counter>—</span>
<span class="smsx-dot" aria-hidden="true">•</span>
<span class="smsx-metric" data-smsx-segments>—</span>
<span class="smsx-warn" data-smsx-warn hidden></span>
</div>
</div>
<div class="smsx-preview">
<span class="smsx-preview__label">پیش‌نمایش (با دادهٔ نمونه)</span>
<div class="smsx-bubble" data-smsx-preview>{{ row.preview }}</div>
<p class="help smsx-preview__hint">
مقادیر نمونه فقط برای نمایش است؛ هنگام ارسال، اطلاعات واقعی همان مشتری جای متغیرها می‌نشیند.
</p>
</div>
<div class="smsx-actions">
<button class="button is-success" type="submit">ذخیره</button>
<span class="smsx-dirty" data-smsx-dirty hidden>● تغییرات ذخیره‌نشده</span>
</div>
</form>
</details>
{% empty %}
<div class="notification is-warning">هیچ قالبی تعریف نشده است.</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block jsEnd %}
<script>
{% verbatim %}
(function () {
"use strict";
var configEl = document.getElementById("smsx-config");
if (!configEl) return;
var CFG = JSON.parse(configEl.textContent);
var TOKENS = CFG.tokens || [];
var SAMPLE = CFG.sample || {};
var TOKEN_RE = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
// GSM-7 پایه (یک نویسه) و نویسه‌های افزودنی (دو نویسه)
var GSM_BASIC = {};
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\n\r".split("").forEach(function (c) { GSM_BASIC[c] = true; });
var GSM_EXT = {};
["^", "{", "}", "[", "]", "~", "|", "\\", "€"].forEach(function (c) { GSM_EXT[c] = true; });
function toFa(value) {
return String(value).replace(/\d/g, function (d) { return "۰۱۲۳۴۵۶۷۸۹"[d]; });
}
// شمارش نویسه و بخش‌های پیامک: GSM-7 ⇒ ۱۶۰/۱۵۳، یونیکد ⇒ ۷۰/۶۷
function measure(text) {
var chars = Array.from(text);
var gsm = true, len = 0;
for (var i = 0; i < chars.length; i++) {
var c = chars[i];
if (GSM_BASIC[c]) { len += 1; }
else if (GSM_EXT[c]) { len += 2; }
else { gsm = false; break; }
}
if (!gsm) len = chars.length;
var single = gsm ? 160 : 70, multi = gsm ? 153 : 67;
var parts = len === 0 ? 0 : (len <= single ? 1 : Math.ceil(len / multi));
return { len: len, parts: parts, gsm: gsm };
}
function fillSample(text) {
return text.replace(TOKEN_RE, function (match, name) {
return Object.prototype.hasOwnProperty.call(SAMPLE, name) ? SAMPLE[name] : "";
});
}
function unknownTokens(text) {
var found, seen = {}, out = [];
while ((found = TOKEN_RE.exec(text)) !== null) {
var name = "{" + found[1] + "}";
if (TOKENS.indexOf(name) === -1 && !seen[name]) { seen[name] = true; out.push(name); }
}
TOKEN_RE.lastIndex = 0;
return out;
}
function cardOf(el) { return el ? el.closest("[data-smsx-card]") : null; }
function refresh(card) {
var ta = card.querySelector("[data-smsx-text]");
if (!ta) return;
var text = ta.value;
var shown = fillSample(text);
var info = measure(shown);
var counter = card.querySelector("[data-smsx-counter]");
var segments = card.querySelector("[data-smsx-segments]");
var warn = card.querySelector("[data-smsx-warn]");
var preview = card.querySelector("[data-smsx-preview]");
var badge = card.querySelector("[data-smsx-badge]");
counter.textContent = toFa(info.len) + " نویسه";
if (info.parts === 0) {
segments.textContent = "متن خالی است";
segments.className = "smsx-metric";
} else {
segments.textContent = toFa(info.parts) + (info.parts === 1 ? " پیامک" : " پیامک (چندبخشی)");
segments.className = "smsx-metric " + (info.parts <= 1 ? "is-ok" : (info.parts <= 3 ? "is-warn" : "is-bad"));
}
if (badge) badge.textContent = info.parts === 0 ? "—" : toFa(info.parts) + " پیامک";
var unknown = unknownTokens(text);
if (unknown.length) {
warn.hidden = false;
warn.textContent = "متغیر ناشناخته: " + unknown.join("، ") + " — خالی رندر می‌شود.";
} else {
warn.hidden = true;
warn.textContent = "";
}
if (preview) preview.textContent = shown;
}
var DIRTY = false;
function markDirty(card) {
if (!card || card.classList.contains("is-dirty")) return;
card.classList.add("is-dirty");
var flag = card.querySelector("[data-smsx-dirty]");
if (flag) flag.hidden = false;
DIRTY = true;
}
function insertAtCursor(ta, token) {
ta.focus();
var value = ta.value;
var start = ta.selectionStart == null ? value.length : ta.selectionStart;
var end = ta.selectionEnd == null ? value.length : ta.selectionEnd;
var before = value.slice(0, start);
var prefix = (before && !/\s$/.test(before)) ? " " : "";
ta.value = before + prefix + token + value.slice(end);
var pos = start + prefix.length + token.length;
ta.setSelectionRange(pos, pos);
}
document.addEventListener("click", function (event) {
// بستن اعلان‌ها (Bulma JS بارگذاری نشده است).
var dismiss = event.target.closest(".notification .delete");
if (dismiss) {
var box = dismiss.closest(".notification");
if (box) box.remove();
return;
}
var insert = event.target.closest("[data-smsx-insert]");
if (insert) {
event.preventDefault();
var card = cardOf(insert);
var ta = card && card.querySelector("[data-smsx-text]");
if (!ta) return;
insertAtCursor(ta, insert.getAttribute("data-smsx-insert"));
refresh(card);
markDirty(card);
return;
}
if (event.target.closest("[data-smsx-expand-all]")) {
document.querySelectorAll("[data-smsx-card]").forEach(function (d) { d.open = true; refresh(d); });
return;
}
if (event.target.closest("[data-smsx-collapse-all]")) {
document.querySelectorAll("[data-smsx-card]").forEach(function (d) { d.open = false; });
}
});
document.addEventListener("input", function (event) {
var card = cardOf(event.target);
if (!card) return;
if (event.target.matches("[data-smsx-text]")) {
refresh(card);
markDirty(card);
} else if (event.target.matches('input[name="title"], input[name="timing_value"]')) {
markDirty(card);
}
});
document.addEventListener("change", function (event) {
var input = event.target;
if (!input.matches('input[name="is_enabled"], input[name="auto_send"]')) return;
var card = cardOf(input);
if (!card) return;
markDirty(card);
if (input.name === "is_enabled") card.classList.toggle("is-off", !input.checked);
// متن سوئیچِ همان کلید به‌روز می‌شود (نه اولین سوئیچ کارت).
var sw = input.closest(".smsx-switch");
var label = sw && sw.querySelector(".smsx-switch__text");
if (label) {
label.textContent = input.name === "is_enabled"
? (input.checked ? "فعال" : "غیرفعال")
: (input.checked ? "خودکار" : "با تأیید مدیر");
}
});
// «toggle» رویداد حباب‌شو نیست؛ با capture گرفته می‌شود.
document.addEventListener("toggle", function (event) {
if (event.target.matches && event.target.matches("[data-smsx-card]") && event.target.open) {
refresh(event.target);
}
}, true);
document.addEventListener("submit", function () { DIRTY = false; });
window.addEventListener("beforeunload", function (event) {
if (!DIRTY) return;
event.preventDefault();
event.returnValue = "";
});
function init() {
document.querySelectorAll("[data-smsx-card]").forEach(function (card) {
var toggle = card.querySelector('input[name="is_enabled"]');
if (toggle) card.classList.toggle("is-off", !toggle.checked);
refresh(card);
});
// پس از ذخیره، همان کارت باز و در نمای دید باقی می‌ماند (لنگر #type-…).
var hash = window.location.hash;
if (hash.indexOf("#type-") === 0) {
var target = document.getElementById(hash.slice(1));
if (target) {
document.querySelectorAll("[data-smsx-card][open]").forEach(function (d) {
if (d !== target) d.open = false;
});
target.open = true;
refresh(target);
target.scrollIntoView({ block: "start" });
}
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
{% endverbatim %}
</script>
{% endblock %}