fix sms and ticket user frindly

This commit is contained in:
javad committed 2026-09-14 15:48:13 +03:30
1 parent 1ee83374fd
commit b8b3aba855
30 files changed
+2220 -119

No files matched your search

+138 -23
View File
@@ -3,29 +3,144 @@
{% block content %}
<link rel="stylesheet" href="{% static 'css/ticket-composer.css' %}?v={{ static_asset_version }}">
<link rel="stylesheet" href="{% static 'css/ticket-composer-v2.css' %}?v={{ static_asset_version }}">
<div class="page-head"><div><a class="back-link" href="{% url 'tickets:list' %}">→ بازگشت به درخواست‌ها</a><p class="page-kicker">ثبت درخواست جدید</p><h1>چه کمکی از ما برمی‌آید؟</h1><p>درخواست خود را کامل بنویسید تا همان بار اول پاسخ دقیق‌تری دریافت کنید.</p></div></div>
<div class="request-layout">
<section class="request-form card"><form method="post" enctype="multipart/form-data" data-ticket-composer data-unit-options-url="{% url 'tickets:new_options' %}" data-ticket-search-url="{% url 'tickets:new_search' %}" data-chat-coach data-chat-coach-key="customer-new-{{ user.public_id }}">{% csrf_token %}{{ form.non_field_errors }}
<section class="intent-finder" aria-labelledby="intent-finder-title"><span class="intent-icon">⌕</span><div class="intent-copy"><h2 id="intent-finder-title">موضوع درخواستتان چیست؟</h2><p>چند کلمه بنویسید؛ واحد و دسته‌بندی مناسب را پیشنهاد می‌دهیم.</p><label class="intent-search"><span class="sr-only">جست‌وجوی موضوع درخواست</span><input type="search" data-ticket-search autocomplete="off" placeholder="مثلاً فیش واریزی، موجودی کالا یا ارسال بار..."><kbd>جست‌وجو</kbd></label><div class="intent-results" data-ticket-search-results aria-live="polite"></div></div></section>
<details class="manual-routing" open><summary><span>انتخاب دستی واحد و دسته‌بندی</span><small>در صورت تمایل خودتان مسیر درخواست را مشخص کنید</small></summary><div class="manual-routing-body">
<div class="step-title"><span class="step-number">۱</span><div><h3>موضوع درخواست</h3><p class="muted">دسته‌بندی و یک عنوان کوتاه انتخاب کنید.</p></div></div>
<div class="routing-selectors" data-routing-selectors><div class="field">{{ form.unit.label_tag }}{{ form.unit }}{{ form.unit.errors }}</div><div class="field">{{ form.category.label_tag }}{{ form.category }}{{ form.category.errors }}</div></div>
<div class="field routing-subject">{{ form.subject.label_tag }}{{ form.subject }}{{ form.subject.errors }}<small>موضوع را به‌صورت دستی و دقیق وارد کنید.</small></div>
</div></details>
<div class="step-title"><span class="step-number">۲</span><div><h3>موقعیت این درخواست</h3><p class="muted">اختیاری است؛ موقعیت ذخیره‌شده پروفایل از قبل انتخاب شده و فقط در صورت نیاز آن را تغییر دهید.</p></div></div>
<section class="location-fields" data-location-picker data-locations-url="{% static 'data/iran-locations.json' %}">
<div class="grid gap-4 sm:grid-cols-2"><div class="field">{{ form.province.label_tag }}{{ form.province }}{{ form.province.errors }}</div><div class="field">{{ form.city.label_tag }}{{ form.city }}{{ form.city.errors }}</div></div>
<p class="location-hint" data-location-hint>نام استان را جست‌وجو و انتخاب کنید.</p>
<link rel="stylesheet" href="{% static 'css/customer-ticket-wizard.css' %}?v={{ static_asset_version }}">
<div class="ticket-wizard-page">
<nav class="ticket-wizard-topbar" aria-label="مسیر صفحه">
<a class="ticket-wizard-back" href="{% url 'tickets:list' %}">→ بازگشت به درخواست‌ها</a>
<span class="ticket-wizard-save"><i></i> اطلاعات تا زمان خروج از صفحه حفظ می‌شود</span>
</nav>
<header class="ticket-wizard-intro">
<p class="page-kicker">ثبت درخواست جدید</p>
<h1>چطور می‌توانیم کمک کنیم؟</h1>
<p>قدم‌به‌قدم پیش می‌رویم؛ در هر مرحله فقط اطلاعات ضروری همان بخش را می‌بینید.</p>
</header>
<ol class="ticket-wizard-progress" data-wizard-progress aria-label="مراحل ثبت درخواست">
<li class="is-active" data-wizard-progress-item="1"><span>۱</span><div><b>موضوع</b><small>مسیر درخواست</small></div></li>
<li data-wizard-progress-item="2"><span>۲</span><div><b>موقعیت</b><small>اختیاری</small></div></li>
<li data-wizard-progress-item="3"><span>۳</span><div><b>کارشناس</b><small>انتخاب مسئول</small></div></li>
<li data-wizard-progress-item="4"><span>۴</span><div><b>جزئیات</b><small>متن و فایل</small></div></li>
<li data-wizard-progress-item="5"><span>۵</span><div><b>بازبینی</b><small>تأیید نهایی</small></div></li>
</ol>
<form class="ticket-wizard-card" method="post" enctype="multipart/form-data" novalidate
data-ticket-composer data-ticket-wizard
data-unit-options-url="{% url 'tickets:new_options' %}"
data-ticket-search-url="{% url 'tickets:new_search' %}"
data-chat-coach data-chat-coach-key="customer-new-{{ user.public_id }}">
{% csrf_token %}
<section class="ticket-wizard-step is-active" data-wizard-step="1" aria-labelledby="wizard-step-1-title">
<header class="ticket-wizard-step-head">
<span class="ticket-wizard-symbol" aria-hidden="true">⌕</span>
<div><small>مرحله ۱ از ۵</small><h2 id="wizard-step-1-title">موضوع درخواستتان چیست؟</h2><p>چند کلمه بنویسید تا مسیر مناسب پیشنهاد شود، یا واحد و دسته‌بندی را دستی انتخاب کنید.</p></div>
</header>
<label class="ticket-intent-search">
<span class="sr-only">جست‌وجوی موضوع درخواست</span><i aria-hidden="true">⌕</i>
<input type="search" data-ticket-search autocomplete="off" placeholder="مثلاً فیش واریزی، موجودی کالا یا ارسال بار...">
<kbd>جست‌وجو</kbd>
</label>
<div class="intent-results ticket-intent-results" data-ticket-search-results aria-live="polite"></div>
<details class="ticket-manual-routing manual-routing" {% if form.is_bound and form.errors %}open{% endif %}>
<summary><span>انتخاب دستی واحد و دسته‌بندی</span><small>در صورت تمایل مسیر درخواست را خودتان مشخص کنید</small></summary>
<div class="ticket-manual-body manual-routing-body">
<div class="ticket-routing-grid" data-routing-selectors>
<div class="field">{{ form.unit.label_tag }}{{ form.unit }}{{ form.unit.errors }}</div>
<div class="field">{{ form.category.label_tag }}{{ form.category }}{{ form.category.errors }}</div>
</div>
</div>
</details>
<div class="field ticket-subject-field">
<label for="{{ form.subject.id_for_label }}">موضوع دقیق درخواست <em>*</em></label>
{{ form.subject }}{{ form.subject.errors }}
<small>این عنوان فقط توسط شما نوشته می‌شود و با تغییر واحد یا دسته‌بندی عوض نخواهد شد.</small>
</div>
<p class="ticket-wizard-validation" data-wizard-error="1" role="alert"></p>
<footer class="ticket-wizard-actions"><span>حدود یک دقیقه تا پایان</span><button class="primary" type="button" data-wizard-next>ادامه <b>←</b></button></footer>
</section>
<div class="step-title"><span class="step-number">۳</span><div><h3>انتخاب کارشناس</h3><p class="muted">اختیاری است؛ صف عمومی معمولاً سریع‌ترین انتخاب است.</p></div></div>
<fieldset class="expert-picker" data-expert-picker><legend class="sr-only">کارشناس دلخواه</legend><label class="expert-card" data-general-queue data-unit-id="{{ selected_unit.pk|default:'' }}"><input type="radio" name="selected_expert" value="" {% if not form.selected_expert.value %}checked{% endif %}><span class="expert-avatar">⌁</span><strong>صف عمومی {{ selected_unit.name|default:'واحد' }}</strong><small>اولین کارشناس آزاد همین واحد</small></label>{% for expert in form.fields.selected_expert.queryset %}<label class="expert-card"><input type="radio" name="selected_expert" value="{{ expert.pk }}" {% if form.selected_expert.value|stringformat:'s' == expert.pk|stringformat:'s' %}checked{% endif %}><img src="{% url 'tickets:expert_avatar' expert.user.public_id %}?v={{ expert.updated_at|date:'U' }}" alt="تصویر {{ expert.display_name }}" width="56" height="56" loading="lazy"><strong>{{ expert.display_name }}</strong><small>{{ expert.job_title|default:'کارشناس واحد' }}</small></label>{% empty %}<p class="muted expert-empty">کارشناس قابل انتخابی ثبت نشده؛ درخواست به صف عمومی می‌رود.</p>{% endfor %}</fieldset>{{ form.selected_expert.errors }}
<div class="step-title"><span class="step-number">۴</span><div><h3>شرح و مستندات</h3><p class="muted">متن، فایل یا پیام صوتی خود را اضافه کنید.</p></div></div>
<div class="field">{{ form.body.label_tag }}{{ form.body }}{{ form.body.errors }}</div>
<div class="upload-zone media-picker"><div class="media-picker-actions"><label class="media-picker-button gallery-button" for="ticket-gallery-input">▣ انتخاب عکس از گالری</label><label class="media-picker-button" for="ticket-file-input">+ انتخاب فایل</label></div><input id="ticket-gallery-input" type="file" accept="image/*" multiple data-gallery-input><input id="ticket-file-input" type="file" multiple data-document-input><span class="canonical-files">{{ form.files }}</span>{{ form.files.errors }}<small class="compression-note" data-compression-status>تصاویر بزرگ پیش از ارسال، روی گوشی شما بهینه و کم‌حجم می‌شوند.</small><div class="file-preview" data-file-preview></div></div>
<div class="voice-recorder" data-voice-recorder><div class="voice-head"><b>پیام صوتی</b><span class="badge" data-timer>00:00</span></div><div class="voice-actions"><button type="button" data-record>● شروع ضبط</button><button type="button" data-pause disabled>مکث</button><button type="button" data-stop disabled>توقف</button><button type="button" class="ghost" data-delete hidden>حذف</button></div><audio class="mt-3 w-full" controls hidden></audio></div>
<button class="submit-ticket" type="submit">ثبت و ارسال درخواست <span>←</span></button>
</form></section>
<aside class="request-aside"><div class="help-card"><h3>برای پاسخ سریع‌تر</h3><div class="help-list"><div class="help-item"><b>۱</b><span>نام محصول یا خدمت موردنظر را دقیق بنویسید.</span></div><div class="help-item"><b>۲</b><span>تعداد، شهر مقصد و زمان موردنظر را ذکر کنید.</span></div><div class="help-item"><b>۳</b><span>تصویر یا فایل مرتبط را همین‌جا ضمیمه کنید.</span></div></div></div></aside>
<section class="ticket-wizard-step" data-wizard-step="2" aria-labelledby="wizard-step-2-title">
<header class="ticket-wizard-step-head">
<span class="ticket-wizard-symbol" aria-hidden="true">⌖</span>
<div><small>مرحله ۲ از ۵ · اختیاری</small><h2 id="wizard-step-2-title">این درخواست مربوط به کجاست؟</h2><p>اگر موقعیت در پاسخ بهتر کارشناسان مؤثر است آن را انتخاب کنید؛ در غیر این صورت عبور کنید.</p></div>
</header>
<section class="ticket-location-fields" data-location-picker data-locations-url="{% static 'data/iran-locations.json' %}">
<div class="ticket-two-columns"><div class="field">{{ form.province.label_tag }}{{ form.province }}{{ form.province.errors }}</div><div class="field">{{ form.city.label_tag }}{{ form.city }}{{ form.city.errors }}</div></div>
<p class="location-hint" data-location-hint>نام استان را جست‌وجو و انتخاب کنید.</p>
</section>
<div class="ticket-soft-tip"><span>i</span><p><b>نیازی به انتخاب ندارید</b> این بخش کاملاً اختیاری است و می‌توانید مستقیماً ادامه دهید.</p></div>
<p class="ticket-wizard-validation" data-wizard-error="2" role="alert"></p>
<footer class="ticket-wizard-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="button" data-wizard-next>ادامه <b>←</b></button></footer>
</section>
<section class="ticket-wizard-step" data-wizard-step="3" aria-labelledby="wizard-step-3-title">
<header class="ticket-wizard-step-head">
<span class="ticket-wizard-symbol" aria-hidden="true">◎</span>
<div><small>مرحله ۳ از ۵ · اختیاری</small><h2 id="wizard-step-3-title">چه کسی درخواست را بررسی کند؟</h2><p>صف عمومی پیشنهاد ماست؛ درخواست به اولین کارشناس آزاد همان واحد می‌رسد.</p></div>
</header>
<fieldset class="expert-picker ticket-expert-picker" data-expert-picker>
<legend class="sr-only">کارشناس دلخواه</legend>
<label class="expert-card" data-general-queue data-unit-id="{{ selected_unit.pk|default:'' }}"><input type="radio" name="selected_expert" value="" {% if not form.selected_expert.value %}checked{% endif %}><span class="expert-avatar">⌁</span><strong>صف عمومی {{ selected_unit.name|default:'واحد' }}</strong><small>سریع‌ترین انتخاب · اولین کارشناس آزاد</small></label>
{% for expert in form.fields.selected_expert.queryset %}<label class="expert-card"><input type="radio" name="selected_expert" value="{{ expert.pk }}" {% if form.selected_expert.value|stringformat:'s' == expert.pk|stringformat:'s' %}checked{% endif %}><img src="{% url 'tickets:expert_avatar' expert.user.public_id %}?v={{ expert.updated_at|date:'U' }}" alt="تصویر {{ expert.display_name }}" width="56" height="56" loading="lazy"><strong>{{ expert.display_name }}</strong><small>{{ expert.job_title|default:'کارشناس واحد' }}</small></label>{% empty %}<p class="muted expert-empty">کارشناس قابل انتخابی ثبت نشده؛ درخواست به صف عمومی می‌رود.</p>{% endfor %}
</fieldset>
{{ form.selected_expert.errors }}
<p class="ticket-wizard-validation" data-wizard-error="3" role="alert"></p>
<footer class="ticket-wizard-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="button" data-wizard-next>ادامه <b>←</b></button></footer>
</section>
<section class="ticket-wizard-step" data-wizard-step="4" aria-labelledby="wizard-step-4-title">
<header class="ticket-wizard-step-head">
<span class="ticket-wizard-symbol" aria-hidden="true">≡</span>
<div><small>مرحله ۴ از ۵</small><h2 id="wizard-step-4-title">جزئیات را برای کارشناس بنویسید</h2><p>شرح متنی، فایل یا پیام صوتی اضافه کنید تا کارشناس مسئله را دقیق‌تر بررسی کند.</p></div>
</header>
<div class="field ticket-body-field">{{ form.body.label_tag }}{{ form.body }}{{ form.body.errors }}<small><span data-body-count>۰</span> نویسه</small></div>
<div class="upload-zone media-picker ticket-attachment-zone">
<header class="ticket-attachment-head"><span>+</span><div><b>عکس و فایل‌های مرتبط</b><small>تصاویر بزرگ پیش از ارسال روی دستگاه شما بهینه می‌شوند.</small></div></header>
<div class="media-picker-actions ticket-attachment-actions"><label class="media-picker-button gallery-button" for="ticket-gallery-input">▣ انتخاب عکس از گالری</label><label class="media-picker-button" for="ticket-file-input">+ انتخاب فایل</label></div>
<input id="ticket-gallery-input" type="file" accept="image/*" multiple data-gallery-input><input id="ticket-file-input" type="file" multiple data-document-input>
<span class="canonical-files">{{ form.files }}</span>{{ form.files.errors }}
<small class="compression-note" data-compression-status>فایل‌های انتخاب‌شده پیش از ثبت قابل مشاهده و حذف هستند.</small>
<div class="file-preview" data-file-preview aria-live="polite"></div>
</div>
<section class="ticket-voice-recorder" data-ticket-voice-recorder aria-labelledby="ticket-voice-title">
<header class="ticket-voice-head"><span class="ticket-voice-icon" aria-hidden="true">◉</span><div><b id="ticket-voice-title">پیام صوتی</b><small data-voice-status aria-live="polite">برای توضیح سریع‌تر، صدای خود را ضبط کنید.</small></div><span class="ticket-voice-timer" data-voice-timer>۰۰:۰۰</span></header>
<div class="ticket-voice-idle" data-voice-idle><button class="ticket-voice-start" type="button" data-voice-record><span>●</span> شروع ضبط صدا</button><small>پس از اجازه‌دادن به میکروفن، ضبط آغاز می‌شود.</small></div>
<div class="ticket-voice-live" data-voice-live hidden><div class="ticket-voice-wave" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div><div class="ticket-voice-controls"><button type="button" data-voice-pause>مکث</button><button class="ticket-voice-stop" type="button" data-voice-stop>■ توقف و ذخیره</button><button class="ticket-voice-cancel" type="button" data-voice-cancel>لغو</button></div></div>
<div class="ticket-voice-preview" data-voice-preview hidden><div class="ticket-voice-ready"><span>✓</span><p><b>پیام صوتی آماده است</b><small data-voice-meta>قابل پخش و بررسی پیش از ارسال</small></p></div><audio controls preload="metadata" data-voice-audio></audio><div class="ticket-voice-preview-actions"><button type="button" data-voice-rerecord>ضبط مجدد</button><button type="button" data-voice-delete>حذف پیام صوتی</button></div></div>
<p class="ticket-voice-note"><span>ⓘ</span> با رفتن به مرحله بعد، ضبط فعال به‌صورت خودکار متوقف و ذخیره می‌شود.</p>
<p class="ticket-voice-error" data-voice-error role="alert" hidden></p>
</section>
<div class="ticket-server-errors" data-step-server-errors>{% if form.non_field_errors %}{{ form.non_field_errors }}{% endif %}</div>
<p class="ticket-wizard-validation" data-wizard-error="4" role="alert"></p>
<footer class="ticket-wizard-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="button" data-wizard-next>مشاهده پیش‌نمایش <b>←</b></button></footer>
</section>
<section class="ticket-wizard-step ticket-review-step" data-wizard-step="5" aria-labelledby="wizard-step-5-title">
<header class="ticket-wizard-step-head"><span class="ticket-wizard-symbol is-success" aria-hidden="true">✓</span><div><small>مرحله ۵ از ۵</small><h2 id="wizard-step-5-title">همه‌چیز آماده ارسال است</h2><p>اطلاعات را یک‌بار مرور کنید؛ هر بخش را می‌توانید جداگانه ویرایش کنید.</p></div></header>
<div class="ticket-review-callout"><span>✓</span><div><b>یک مرور کوتاه پیش از ارسال</b><small>اگر موردی درست نیست، همان ردیف را ویرایش کنید؛ اطلاعات مراحل دیگر حفظ می‌شود.</small></div></div>
<div class="ticket-review-list">
<section class="ticket-review-row"><span class="ticket-review-icon">۱</span><div class="ticket-review-content"><small>موضوع و مسیر رسیدگی</small><h3 data-review-subject>—</h3><p><span data-review-unit>—</span><i>←</i><span data-review-category>—</span></p></div><button type="button" data-wizard-edit="1"><span>✎</span> ویرایش</button></section>
<section class="ticket-review-row"><span class="ticket-review-icon">۲</span><div class="ticket-review-content"><small>موقعیت</small><h3 data-review-location>ثبت نشده</h3><p>اختیاری</p></div><button type="button" data-wizard-edit="2"><span>✎</span> ویرایش</button></section>
<section class="ticket-review-row"><span class="ticket-review-icon">۳</span><div class="ticket-review-content"><small>نحوه ارجاع</small><h3 data-review-expert>صف عمومی</h3><p>در همان واحد سازمانی</p></div><button type="button" data-wizard-edit="3"><span>✎</span> ویرایش</button></section>
<section class="ticket-review-row ticket-review-details"><span class="ticket-review-icon">۴</span><div class="ticket-review-content"><small>شرح و پیوست‌ها</small><p class="ticket-review-body" data-review-body>—</p><div class="ticket-review-files" data-review-files></div><audio class="ticket-review-audio" controls hidden data-review-audio></audio></div><button type="button" data-wizard-edit="4"><span>✎</span> ویرایش</button></section>
</div>
<p class="ticket-wizard-validation" data-wizard-error="5" role="alert"></p>
<footer class="ticket-wizard-actions ticket-final-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="submit" data-final-submit>ثبت و ارسال نهایی <b>←</b></button></footer>
</section>
</form>
</div>
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/location-picker.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/ticket-unit-options.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/ticket-intent-search.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'js/location-picker.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'js/ticket-unit-options.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'js/ticket-intent-search.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'js/customer-ticket-wizard.js' %}?v={{ static_asset_version }}"></script>
{% endblock %}
+1
View File
@@ -10,6 +10,7 @@
<aside class="workspace-side actions-side" id="ticket-actions" data-workspace-panel>
<button type="button" class="panel-close" data-close-panel>×</button>
<section class="info-card"><p class="panel-index">وضعیت تیکت</p><div class="info-list"><div class="info-row"><span>وضعیت</span><b>{{ ticket.get_status_display }}</b></div><div class="info-row"><span>اولویت</span><b>{{ ticket.get_priority_display }}</b></div><div class="info-row"><span>دسته</span><b>{{ ticket.category }}</b></div><div class="info-row"><span>مالک</span><b>{{ ticket.assigned_expert_name }}</b></div>{% if ticket.first_response_due_at %}<div class="info-row"><span>مهلت پاسخ</span><b>{{ ticket.first_response_due_at|jalali_datetime }}</b></div>{% endif %}</div></section>
{% if sms_jobs %}<section class="info-card"><h3>وضعیت پیامک‌های مشتری</h3><div class="info-list">{% for sms_job in sms_jobs %}<div class="info-row"><span>{% if sms_job.event_type == 'STAFF_TICKET_CREATED' %}ثبت تیکت توسط کارشناس{% elif sms_job.event_type == 'CUSTOMER_TICKET_CREATED' %}ثبت تیکت مشتری{% elif sms_job.event_type == 'STAFF_REPLY' %}اعلان پاسخ کارشناس{% else %}پیامک سیستمی{% endif %}</span><b>{% if sms_job.delivery_status %}{{ sms_job.get_delivery_status_display }}{% else %}{{ sms_job.get_status_display }}{% endif %}</b></div>{% if sms_job.delivery_description %}<p class="muted">{{ sms_job.delivery_description }}</p>{% endif %}{% if sms_job.last_error %}<div class="create-form-errors">علت دقیق: {{ sms_job.last_error }}</div>{% endif %}{% endfor %}</div></section>{% endif %}
<section class="info-card ticket-operations"><h3>عملیات تیکت</h3>{% if perms.tickets.change_ticket %}<form method="post" action="{% url 'staff_tickets:status' ticket.public_id %}">{% csrf_token %}<label>وضعیت<select name="status">{% for value,label in statuses %}<option value="{{ value }}" {% if ticket.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button>ثبت وضعیت</button></form>{% endif %}{% if perms.tickets.change_ticket_priority %}<form method="post" action="{% url 'staff_tickets:priority' ticket.public_id %}">{% csrf_token %}<label>اولویت<select name="priority">{% for value,label in priorities %}<option value="{{ value }}" {% if ticket.priority == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="secondary">ثبت اولویت</button></form>{% endif %}{% if can_transfer %}<details><summary>انتقال تیکت</summary><form method="post" action="{% url 'staff_tickets:transfer' ticket.public_id %}">{% csrf_token %}{{ transfer_form.as_p }}<button>انتقال</button></form></details>{% endif %}{% if can_share %}<details><summary>اشتراک با کارشناس</summary><form method="post" action="{% url 'staff_tickets:share' ticket.public_id %}">{% csrf_token %}{{ share_form.as_p }}<button>ایجاد دسترسی</button></form>{% for grant in grants %}<div class="grant"><span>{{ grant.user.staff_profile.display_name }}</span><form method="post" action="{% url 'staff_tickets:revoke' ticket.public_id grant.user.public_id %}">{% csrf_token %}<button class="danger-button">لغو</button></form></div>{% endfor %}</details>{% endif %}</section>
{% if can_delete %}<section class="info-card danger-zone"><h3>حذف کامل تیکت</h3><p class="muted">تمام پیام‌ها، فایل‌ها و سوابق تیکت برای همیشه حذف می‌شوند.</p><form method="post" action="{% url 'staff_tickets:delete' ticket.public_id %}" onsubmit="return confirm('آیا از حذف کامل و غیرقابل‌بازگشت این تیکت مطمئن هستید؟')">{% csrf_token %}<button type="submit" class="danger-button">حذف کامل تیکت</button></form></section>{% endif %}
</aside>
+4 -3
View File
@@ -1,12 +1,12 @@
{% extends 'base.html' %}
{% extends 'base.html' %}{% load static %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}<div class="operations-page operations-dispatch">
<section class="operations-hero"><div><p class="eyebrow">ارسال انبوه امن</p><h1>ارسال گروهی ایزوله</h1><p>برای هر مخاطب یک تیکت و Magic Link مستقل ایجاد می‌شود؛ هیچ دریافت‌کننده‌ای پاسخ دیگران را نمی‌بیند.</p></div><div class="security-callout"><span>⌾</span><div><b>حریم خصوصی فعال</b><small>تیکت مستقل برای هر گیرنده</small></div></div></section>
<section class="operations-metrics dispatch-metrics" aria-label="آمار ارسال‌های گروهی"><article><small>کل عملیات</small><b>{{ metrics.total }}</b></article><article><small>کل گیرندگان</small><b>{{ metrics.recipients }}</b></article><article><small>ارسال کامل</small><b>{{ metrics.sent }}</b></article><article><small>نیازمند پیگیری</small><b>{{ metrics.attention }}</b></article></section>
<div class="dispatch-layout"><form method="post" enctype="multipart/form-data" class="dispatch-composer" data-disable-on-submit>{% csrf_token %}{% if form.non_field_errors %}<div class="create-form-errors" role="alert">{{ form.non_field_errors }}</div>{% endif %}
<div class="dispatch-layout"><form method="post" enctype="multipart/form-data" class="dispatch-composer" data-disable-on-submit data-ticket-composer>{% csrf_token %}{% if form.non_field_errors %}<div class="create-form-errors" role="alert">{{ form.non_field_errors }}</div>{% endif %}
<section><header><span>۱</span><div><h2>نوع عملیات، مخاطبان و مالکیت</h2><p>قابلیت‌های مجاز و گروه هدف را مشخص کنید.</p></div></header><div class="editor-grid"><label><span>{{ form.operation_type.label }}</span>{{ form.operation_type }}{{ form.operation_type.errors }}</label><label><span>{{ form.unit.label }}</span>{{ form.unit }}{{ form.unit.errors }}</label><label><span>{{ form.contact_group.label }}</span>{{ form.contact_group }}{{ form.contact_group.errors }}<small>فقط گروه‌های واحد انتخاب‌شده معتبر هستند.</small></label></div></section>
<section><header><span>۲</span><div><h2>موضوع و دسته‌بندی</h2><p>عنوان کوتاه و برچسب‌های مناسب، گزارش‌گیری را دقیق‌تر می‌کند.</p></div></header><div class="editor-grid"><label><span>{{ form.category.label }}</span>{{ form.category }}{{ form.category.errors }}</label><label><span>{{ form.subject.label }}</span>{{ form.subject }}{{ form.subject.errors }}</label><label class="editor-wide"><span>{{ form.tags.label }}</span>{{ form.tags }}{{ form.tags.errors }}</label></div></section>
<section><header><span>۳</span><div><h2>متن و اسناد پیام</h2><p>درخواست را شفاف بنویسید و اسناد لازم را برای همه تیکت‌های خصوصی ضمیمه کنید.</p></div></header><label class="message-editor"><span>{{ form.body.label }}</span>{{ form.body }}{{ form.body.errors }}</label><label class="attachment-field"><span class="attachment-icon">+</span><div><b>{{ form.files.label }}</b><small>{{ form.files.help_text }}</small></div>{{ form.files }}{{ form.files.errors }}</label></section>
<section><header><span>۳</span><div><h2>متن و اسناد پیام</h2><p>درخواست را شفاف بنویسید و اسناد لازم را برای همه تیکت‌های خصوصی ضمیمه کنید.</p></div></header><label class="message-editor"><span>{{ form.body.label }}</span>{{ form.body }}{{ form.body.errors }}</label><div class="dispatch-attachment-picker media-picker"><div class="attachment-picker-copy"><span class="attachment-icon" aria-hidden="true">+</span><div><b>{{ form.files.label }}</b><small>{{ form.files.help_text }}</small></div></div><div class="media-picker-actions"><label class="media-picker-button gallery-button" for="dispatch-gallery-input">▣ افزودن عکس از گالری</label><label class="media-picker-button" for="dispatch-file-input">+ افزودن فایل</label></div><input id="dispatch-gallery-input" type="file" accept="image/*" multiple data-gallery-input><input id="dispatch-file-input" type="file" multiple data-document-input><span class="canonical-files">{{ form.files }}</span>{{ form.files.errors }}<small class="compression-note" data-compression-status>تصاویر بزرگ پیش از ارسال روی دستگاه شما بهینه می‌شوند؛ هر پیوست را می‌توانید پیش از ثبت حذف کنید.</small><div class="file-preview" data-file-preview aria-live="polite" aria-label="پیش‌نمایش پیوست‌های انتخاب‌شده"></div></div></section>
<footer><div><b>قبل از ارسال بررسی کنید</b><small>این عملیات برای همه اعضای گروه تیکت مستقل ایجاد می‌کند.</small></div><button type="submit">ایجاد تیکت‌ها و قرار دادن پیامک در صف ←</button></footer>
</form><aside class="dispatch-history"><header><div><small>ردیابی عملیات</small><h2>ارسال‌های اخیر</h2></div><span>{{ page.paginator.count }}</span></header>
<form method="get" class="dispatch-filter" aria-label="فیلتر ارسال‌ها"><label class="dispatch-search"><span>جستجو</span><input type="search" name="q" value="{{ request.GET.q }}" placeholder="موضوع، گروه یا کارشناس"></label><div><label><span>وضعیت</span><select name="status"><option value="">همه وضعیت‌ها</option>{% for value,label in dispatch_statuses %}<option value="{{ value }}" {% if request.GET.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><label><span>واحد</span><select name="unit"><option value="">همه واحدها</option>{% for item in units %}<option value="{{ item.pk }}" {% if request.GET.unit == item.pk|stringformat:'s' %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></label></div><label><span>گروه مخاطب</span><select name="group"><option value="">همه گروه‌ها</option>{% for item in groups %}<option value="{{ item.pk }}" {% if request.GET.group == item.pk|stringformat:'s' %}selected{% endif %}>{{ item.unit.name }} — {{ item.name }}</option>{% endfor %}</select></label><div><label><span>از تاریخ</span><input type="date" name="created_from" value="{{ request.GET.created_from }}"></label><label><span>تا تاریخ</span><input type="date" name="created_to" value="{{ request.GET.created_to }}"></label></div><footer><button type="submit">اعمال فیلتر</button><a href="{% url 'staff_tickets:dispatch_create' %}">پاک کردن</a></footer></form>
@@ -14,3 +14,4 @@
{% if page.paginator.num_pages > 1 %}<nav class="operations-pagination" aria-label="صفحه‌بندی ارسال‌ها">{% if page.has_previous %}<a href="?q={{ request.GET.q|urlencode }}&status={{ request.GET.status|urlencode }}&unit={{ request.GET.unit|urlencode }}&group={{ request.GET.group|urlencode }}&created_from={{ request.GET.created_from|urlencode }}&created_to={{ request.GET.created_to|urlencode }}&page={{ page.previous_page_number }}">قبلی</a>{% endif %}<span>صفحه {{ page.number }} از {{ page.paginator.num_pages }}</span>{% if page.has_next %}<a href="?q={{ request.GET.q|urlencode }}&status={{ request.GET.status|urlencode }}&unit={{ request.GET.unit|urlencode }}&group={{ request.GET.group|urlencode }}&created_from={{ request.GET.created_from|urlencode }}&created_to={{ request.GET.created_to|urlencode }}&page={{ page.next_page_number }}">بعدی</a>{% endif %}</nav>{% endif %}
</aside></div>
</div>{% endblock %}
{% block extra_scripts %}<script defer src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
+79 -23
View File
@@ -1,24 +1,80 @@
{% extends 'base.html' %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}<div class="operations-page operations-dispatch-detail">
<section class="staff-page-head">
<div><p class="eyebrow">شناسه ارسال {{ dispatch.public_id }}</p><h1>{{ dispatch.subject }}</h1><p>{{ dispatch.unit }} · گروه {{ dispatch.contact_group.name }} · {{ dispatch.recipient_count }} مخاطب</p></div>
<div><a class="button" href="{% url 'staff_tickets:dispatch_create' %}">ارسال گروهی جدید</a>{% if metrics.failed %}<form method="post" action="{% url 'staff_tickets:dispatch_retry' dispatch.public_id %}" style="margin-top:.75rem">{% csrf_token %}<button class="button" type="submit">تلاش مجدد برای {{ metrics.failed }} ارسال ناموفق</button></form>{% endif %}</div>
</section>
<section class="stats-grid operations-metrics" aria-label="وضعیت ارسال گروهی"><article class="stat-card"><div class="stat-label">پیامک موفق</div><div class="stat-value">{{ metrics.sent }}</div></article><article class="stat-card"><div class="stat-label">در صف</div><div class="stat-value">{{ metrics.pending }}</div></article><article class="stat-card"><div class="stat-label">لینک مشاهده‌شده</div><div class="stat-value">{{ metrics.opened }}</div></article><article class="stat-card"><div class="stat-label">پاسخ ثبت‌شده</div><div class="stat-value">{{ metrics.replied }}</div></article><article class="stat-card alert"><div class="stat-label">ارسال ناموفق</div><div class="stat-value">{{ metrics.failed }}</div></article></section>
<section class="ticket-stream">
<header class="stream-head"><div><span class="panel-index">نمای مقایسه‌ای</span><h2>وضعیت دریافت و پاسخ مخاطبان</h2></div><span>{{ dispatch.get_status_display }}</span></header>
<div class="table-scroll"><table><thead><tr><th>مخاطب</th><th>شماره</th><th>تیکت</th><th>وضعیت پیامک</th><th>وضعیت تیکت</th><th>بازدید لینک</th><th>پاسخ پیشنهادی</th></tr></thead><tbody>
{% for ticket in tickets %}
<tr>
<td>{{ ticket.customer.customer_profile|default:ticket.customer.username }}</td>
<td dir="ltr">{{ ticket.customer.local_mobile }}</td>
<td><a href="{% url 'staff_tickets:detail' ticket.public_id %}">{{ ticket.ticket_number }}</a></td>
<td>{% if ticket.sms_job %}{% if ticket.sms_job.status == 'SENT' %}پذیرفته‌شده توسط درگاه{% else %}{{ ticket.sms_job.get_status_display }}{% endif %}{% if ticket.sms_job.provider_message_id %}<small class="table-subtitle" dir="ltr">مرجع: {{ ticket.sms_job.provider_message_id|truncatechars:45 }}</small>{% endif %}{% if ticket.sms_job.last_error %}<small class="table-subtitle">{{ ticket.sms_job.last_error|truncatechars:60 }}</small>{% endif %}{% else %}ثبت نشده{% endif %}</td><td>{{ ticket.get_status_display }}</td>
<td>{% for link in ticket.magic_links.all %}{{ link.view_count }}{% if not forloop.last %} + {% endif %}{% empty %}ارسال نشده{% endfor %}</td>
<td>{% if ticket.latest_customer_response %}{{ ticket.latest_customer_response.body|truncatechars:120 }}<small class="table-subtitle">{{ ticket.latest_customer_response.created_at|date:'Y/m/d H:i' }}</small>{% else %}هنوز پاسخی ثبت نشده{% endif %}</td>
</tr>
{% empty %}<tr><td colspan="7">برای این ارسال تیکتی ثبت نشده است.</td></tr>{% endfor %}
</tbody></table></div>
</section></div>
{% extends 'base.html' %}{% load jalali %}
{% block title %}{{ dispatch.subject }}{% endblock %}
{% block content %}
<div class="operations-page operations-dispatch-detail">
<section class="dispatch-report-hero">
<div class="dispatch-report-title">
<div class="dispatch-title-line"><span class="report-status status-{{ dispatch.status|lower }}">{{ dispatch.get_status_display }}</span><span class="eyebrow">گزارش ارسال گروهی</span></div>
<h1>{{ dispatch.subject }}</h1>
<div class="dispatch-meta-list">
<span><b>واحد</b>{{ dispatch.unit }}</span>
<span><b>گروه مخاطبان</b>{{ dispatch.contact_group.name }}</span>
<span><b>تعداد گیرندگان</b>{{ dispatch.recipient_count }} نفر</span>
<span><b>زمان ایجاد</b><time datetime="{{ dispatch.created_at|date:'c' }}">{{ dispatch.created_at|jalali_datetime }}</time></span>
</div>
</div>
<div class="dispatch-head-actions">
<a class="button" href="{% url 'staff_tickets:dispatch_create' %}">+ ارسال گروهی جدید</a>
{% if metrics.failed %}<form method="post" action="{% url 'staff_tickets:dispatch_retry' dispatch.public_id %}">{% csrf_token %}<button class="button button-danger-soft" type="submit">↻ تلاش مجدد {{ metrics.failed }} پیامک</button></form>{% endif %}
</div>
</section>
<section class="dispatch-original-message" aria-labelledby="dispatch-message-title">
<header><div><span class="panel-index">متن اولیه کارشناس</span><h2 id="dispatch-message-title">پیام اصلی این ارسال</h2></div><span>شناسه عملیات: <b dir="ltr">{{ dispatch.public_id }}</b></span></header>
<blockquote>{{ dispatch.body|linebreaksbr }}</blockquote>
<footer><span>ایجادکننده: <b>{{ dispatch.created_by.staff_profile.display_name|default:dispatch.created_by.username }}</b></span><span>این متن در یک تیکت مستقل برای هر مخاطب ثبت شده است.</span></footer>
</section>
<section class="delivery-overview" aria-label="خلاصه دلیوری">
<div class="delivery-progress-copy"><div><span class="panel-index">پیشرفت تحویل</span><strong>{{ metrics.delivery_percent }}٪</strong></div><p>{{ metrics.delivered }} از {{ dispatch.recipient_count }} پیامک تحویل قطعی شده است.</p></div>
<div class="delivery-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ metrics.delivery_percent }}"><span style="width:{{ metrics.delivery_percent }}%"></span></div>
<div class="delivery-overview-note">{% if metrics.tracking %}<b>{{ metrics.tracking }} پیامک در حال پیگیری است</b><span>نتیجه‌های موقت هر ۳ دقیقه تا ۴۵ دقیقه دوباره استعلام می‌شوند.</span>{% elif metrics.failed %}<b>موارد ناموفق نیاز به بررسی دارند</b><span>علت دقیق و دکمه تلاش مجدد در ردیف مخاطب قرار دارد.</span>{% else %}<b>پردازش صف پایان یافته است</b><span>برای جزئیات هر مخاطب، گزارش پایین صفحه را ببینید.</span>{% endif %}</div>
</section>
<section class="dispatch-kpi-grid" aria-label="شاخص‌های ارسال گروهی">
<article class="dispatch-kpi kpi-success"><span class="kpi-icon">✓</span><div><small>تحویل قطعی</small><strong>{{ metrics.delivered }}</strong><p>رسیده به گوشی</p></div></article>
<article class="dispatch-kpi kpi-info"><span class="kpi-icon">◌</span><div><small>پیگیری فعال</small><strong>{{ metrics.tracking }}</strong><p>{{ metrics.sent }} مورد پذیرفته‌شده توسط درگاه</p></div></article>
<article class="dispatch-kpi kpi-danger"><span class="kpi-icon">!</span><div><small>نیازمند اقدام</small><strong>{{ metrics.failed }}</strong><p>ناموفق یا تحویل‌نشده قطعی</p></div></article>
<article class="dispatch-kpi kpi-view"><span class="kpi-icon">◉</span><div><small>تعامل با لینک</small><strong>{{ metrics.opened }}</strong><p>{{ metrics.total_views }} بازدید · {{ metrics.not_opened }} مخاطب بدون بازدید</p></div></article>
<article class="dispatch-kpi kpi-reply"><span class="kpi-icon">↵</span><div><small>پاسخ دریافتی</small><strong>{{ metrics.replied }}</strong><p>پاسخ ثبت‌شده در تیکت‌ها</p></div></article>
</section>
<section class="ticket-stream dispatch-recipient-report">
<header class="stream-head"><div><span class="panel-index">جزئیات هر گیرنده</span><h2>وضعیت پیامک، مشاهده و پاسخ</h2></div><span>{{ dispatch.recipient_count }} مخاطب</span></header>
<div class="table-scroll"><table class="dispatch-detail-table"><thead><tr><th>مخاطب</th><th>تیکت</th><th>وضعیت پیامک</th><th>وضعیت تیکت</th><th>مشاهده لینک</th><th>آخرین پاسخ</th></tr></thead><tbody>
{% for ticket in tickets %}
<tr>
<td data-label="مخاطب"><div class="recipient-cell"><span class="recipient-avatar">{{ ticket.customer.customer_profile.first_name|first|default:'•' }}</span><div><strong>{{ ticket.customer.customer_profile|default:ticket.customer.username }}</strong><small dir="ltr">{{ ticket.customer.local_mobile }}</small></div></div></td>
<td data-label="تیکت"><a class="ticket-reference" href="{% url 'staff_tickets:detail' ticket.public_id %}"><strong dir="ltr">{{ ticket.ticket_number }}</strong><span>مشاهده تیکت ←</span></a></td>
<td data-label="وضعیت پیامک" class="dispatch-sms-state">
<div class="sms-state-card tone-{{ ticket.sms_state_tone }}">
<span class="sms-state-icon">{% if ticket.sms_state_tone == 'success' %}✓{% elif ticket.sms_state_tone == 'danger' %}!{% elif ticket.sms_state_tone == 'warning' %}◌{% else %}↑{% endif %}</span>
<div><strong>{{ ticket.sms_state_title }}</strong><p>{{ ticket.sms_state_hint }}</p>{% if ticket.sms_job.delivery_next_check_at %}<small>استعلام بعدی: {{ ticket.sms_job.delivery_next_check_at|timeuntil }} دیگر</small>{% endif %}</div>
</div>
{% if ticket.sms_job %}
<details class="sms-technical-details">
<summary>جزئیات فنی و رهگیری</summary>
<dl>
<div><dt>ارسال</dt><dd>{% if ticket.sms_job.attempts == 1 %}موفق در اولین تلاش{% else %}{{ ticket.sms_job.attempts }} تلاش از {{ sms_max_attempts }}{% endif %}</dd></div>
{% if ticket.sms_job.provider_message_id %}<div><dt>شناسه رهگیری</dt><dd dir="ltr">{{ ticket.sms_job.provider_message_id }}</dd></div>{% endif %}
{% if ticket.sms_job.delivery_status_code %}<div><dt>کد دلیوری</dt><dd dir="ltr">{{ ticket.sms_job.delivery_status_code }}</dd></div>{% endif %}
{% if ticket.sms_job.delivery_check_attempts %}<div><dt>تعداد استعلام</dt><dd>{{ ticket.sms_job.delivery_check_attempts }} نوبت</dd></div>{% endif %}
{% if ticket.sms_job.delivery_checked_at %}<div><dt>آخرین استعلام</dt><dd>{{ ticket.sms_job.delivery_checked_at|jalali_datetime }}</dd></div>{% endif %}
</dl>
{% if ticket.sms_job.delivery_description %}<p class="gateway-technical-message">{{ ticket.sms_job.delivery_description }}</p>{% endif %}
</details>
{% if ticket.sms_job.last_error %}<div class="sms-error-panel"><b>علت دقیق عدم ارسال</b><p>{{ ticket.sms_job.last_error }}</p></div>{% endif %}
{% if ticket.sms_retryable %}<form method="post" action="{% url 'staff_tickets:dispatch_retry' dispatch.public_id %}" class="inline-retry-form">{% csrf_token %}<input type="hidden" name="job_id" value="{{ ticket.sms_job.pk }}"><button type="submit">↻ تلاش مجدد همین پیامک</button></form>{% endif %}
{% endif %}
</td>
<td data-label="وضعیت تیکت"><span class="ticket-state-pill state-{{ ticket.status|lower }}">{{ ticket.get_status_display }}</span></td>
<td data-label="مشاهده لینک">
<div class="engagement-state {% if ticket.magic_total_views %}has-engagement{% endif %}"><span>{% if ticket.magic_total_views %}◉{% else %}○{% endif %}</span><div>{% if ticket.magic_total_views %}<strong>{{ ticket.magic_total_views }} بازدید</strong>{% if ticket.magic_last_used_at %}<small>آخرین بازدید {{ ticket.magic_last_used_at|jalali_datetime }}</small>{% endif %}{% elif ticket.magic_link_count %}<strong>مشاهده نشده</strong><small>{{ ticket.magic_active_count }} لینک فعال</small>{% else %}<strong>لینک صادر نشده</strong>{% endif %}</div></div>
</td>
<td data-label="آخرین پاسخ">{% if ticket.latest_customer_response %}<div class="recipient-reply"><p>{{ ticket.latest_customer_response.body|truncatechars:140 }}</p><small>{{ ticket.latest_customer_response.created_at|jalali_datetime }}</small></div>{% else %}<span class="no-reply-state">هنوز پاسخی ثبت نشده</span>{% endif %}</td>
</tr>
{% empty %}<tr><td colspan="6">برای این ارسال تیکتی ثبت نشده است.</td></tr>{% endfor %}
</tbody></table></div>
</section>
</div>
{% endblock %}