diff --git a/.env.example b/.env.example
index 288d95f..92b9090 100644
--- a/.env.example
+++ b/.env.example
@@ -29,7 +29,7 @@ SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider
SMS0098_USERNAME=
SMS0098_PASSWORD=
SMS0098_FROM=
-SMS0098_API_URL=http://www.0098sms.com/sendsmslink.aspx
+SMS0098_WSDL_URL=https://webservice.0098sms.com/service.asmx?WSDL
BEHIN_SMS_USERNAME=
BEHIN_SMS_PASSWORD=
BEHIN_SMS_SPECIAL_NUMBER=
@@ -41,6 +41,10 @@ BALE_API_BASE=https://tapi.bale.ai
BALE_BOT_TOKEN=
INTEGRATION_HTTP_TIMEOUT_SECONDS=10
MAX_NOTIFICATION_ATTEMPTS=5
+SMS_MAX_RETRIES=3
+SMS_RETRY_DELAY_SECONDS=180
+SMS_DELIVERY_CHECK_DELAY_SECONDS=180
+SMS_DELIVERY_MAX_CHECKS=15
SECURE_SSL_REDIRECT=true
SECURE_HSTS_SECONDS=31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS=true
diff --git a/apps/integrations/sms/base.py b/apps/integrations/sms/base.py
index f1de2cc..ffcb239 100644
--- a/apps/integrations/sms/base.py
+++ b/apps/integrations/sms/base.py
@@ -7,6 +7,14 @@ class SMSResult:
success: bool
provider_id: str = ""
error: str = ""
+ provider_code: str = ""
+
+@dataclass(frozen=True, slots=True)
+class SMSDeliveryResult:
+ status: str
+ provider_code: str
+ description: str
+ is_final: bool = True
def webotp_message(code):
host=settings.WEBOTP_HOST or urlparse(settings.PUBLIC_BASE_URL).hostname
if not host: raise ValueError("WEBOTP_HOST یا PUBLIC_BASE_URL معتبر تنظیم نشده است.")
@@ -16,3 +24,7 @@ class SMSProvider(ABC):
def send_otp(self,mobile,code): return self.send_message(mobile,webotp_message(code))
@abstractmethod
def send_message(self,mobile,message): raise NotImplementedError
+
+ def get_delivery_status(self, provider_id):
+ """Return None when a provider does not support delivery tracking."""
+ return None
diff --git a/apps/integrations/sms/providers.py b/apps/integrations/sms/providers.py
index 1faa4c0..b15592e 100644
--- a/apps/integrations/sms/providers.py
+++ b/apps/integrations/sms/providers.py
@@ -1,7 +1,7 @@
"""Backward-compatible public facade for the SMS integration."""
-from .base import SMSProvider,SMSResult,webotp_message
+from .base import SMSDeliveryResult,SMSProvider,SMSResult,webotp_message
from .behin import BehinSMSProvider
from .console import ConsoleSMSProvider
from .factory import get_sms_provider,reset_sms_provider
from .http import HTTPSMSProvider
-__all__=["SMSProvider","SMSResult","webotp_message","BehinSMSProvider","ConsoleSMSProvider","HTTPSMSProvider","get_sms_provider","reset_sms_provider"]
+__all__=["SMSProvider","SMSResult","SMSDeliveryResult","webotp_message","BehinSMSProvider","ConsoleSMSProvider","HTTPSMSProvider","get_sms_provider","reset_sms_provider"]
diff --git a/apps/integrations/sms/sms0098.py b/apps/integrations/sms/sms0098.py
index 5a99400..eb88979 100644
--- a/apps/integrations/sms/sms0098.py
+++ b/apps/integrations/sms/sms0098.py
@@ -1,36 +1,172 @@
import logging
+import re
-import requests
+import zeep
from django.conf import settings
+from zeep.transports import Transport
from apps.accounts.models import normalize_mobile
-from .base import SMSProvider, SMSResult
+from .base import SMSDeliveryResult, SMSProvider, SMSResult
logger = logging.getLogger(__name__)
+# Source: the vendor's "کدهای خطا وب سرویس" table. Keeping the mapping here
+# makes worker logs and the staff UI report the same exact reason.
+SEND_RESULT_MESSAGES = {
+ "2": "ارسال با موفقیت انجام شد.",
+ "-3": "نام کاربری و کلمه عبور با هم تطابق ندارند؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
+ "10": "نام کاربری یا کلمه عبور اشتباه است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
+ "11": "در متن پیامک کاراکتر غیرمجاز وجود دارد.",
+ "-17": "متن پیامک خالی است.",
+ "-18": "خطای شارژ رخ داده است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
+ "-19": "شارژ پنل برای ارسال کافی نیست؛ لطفاً پنل را شارژ کنید.",
+ "-22": "شماره موبایل گیرنده صحیح نیست.",
+ "66": "نام کاربری و کلمه عبور با هم تطابق ندارند؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
+ "1111": "در متن پیامک کاراکتر غیرمجاز وجود دارد.",
+ "hang": "حساب کاربری مسدود است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
+ "no doc": "مرحله دوم ثبتنام انجام نشده است؛ لطفاً ثبتنام را کامل کنید.",
+ "-23": "شماره پنل مجوز ارسال از لینک را ندارد.",
+ "-24": "در شماره پنل خطا وجود دارد؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
+ "-25": "اعتبار شماره پنل به پایان رسیده است و باید تمدید شود.",
+ "-26": "تنظیمات کد OTP انجام نشده است.",
+ "-27": "فرمت کد OTP صحیح نیست.",
+ "-28": "تنظیمات کد OTP توسط ادمین تأیید نشده است.",
+ "wrong timestamp": "مقدار timestamp ارسالشده صحیح نیست.",
+ "wrong time": "زمان ارسال خارج از بازه مجاز است.",
+ "-29": "فرمت کد OTP صحیح نیست.",
+ "-31": "شناسه پیامک (smsid) یافت نشد.",
+ "-32": "شناسه پیامک (smsid) مطابقت ندارد.",
+ "13": "ارسال از IP غیرمجاز انجام شده است.",
+}
+
+DELIVERY_RESULTS = {
+ "4": ("BLOCKED", "مخاطب دریافت پیامکهای تبلیغاتی را مسدود کرده است.", True),
+ "5": ("DELIVERED", "پیامک به گوشی گیرنده رسیده است.", True),
+ # 0098SMS can initially return 6 or 8 and publish the definitive result
+ # much later. Keep polling these states instead of freezing a stale result.
+ "6": ("UNDELIVERED", "هنوز تحویل پیامک به گوشی تأیید نشده است.", False),
+ "7": ("EXPIRED", "مهلت دریافت پیامک منقضی شده است.", True),
+ "8": ("UNKNOWN", "وضعیت تحویل هنوز توسط اپراتور مشخص نشده است.", False),
+ "9": ("ERROR", "اطلاعات مالک شماره پیامک ثبت یا تأیید نشده است.", True),
+ "12": ("ERROR", "هنوز اجازه ارسال با این شماره پنل داده نشده است.", True),
+ "13": ("ERROR", "ارسال از IP غیرمجاز انجام شده است.", True),
+}
+
+
+def _scalar(value):
+ if isinstance(value, (list, tuple)) and value:
+ value = value[0]
+ return str(value if value is not None else "").strip()
+
+
class ZeroZeroNinetyEightSMSProvider(SMSProvider):
- """Adapter for the 0098SMS HTTP gateway."""
+ """SOAP adapter for 0098SMS (0098vip-compatible web service)."""
def __init__(self):
self.username = settings.SMS0098_USERNAME
self.password = settings.SMS0098_PASSWORD
self.sender = settings.SMS0098_FROM
- self.api_url = settings.SMS0098_API_URL
+ self.wsdl_url = settings.SMS0098_WSDL_URL
+ self._client = None
+
+ def _configuration_error(self):
+ missing = [name for name, value in (
+ ("SMS0098_USERNAME", self.username),
+ ("SMS0098_PASSWORD", self.password),
+ ("SMS0098_FROM", self.sender),
+ ) if not value]
+ return f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}" if missing else ""
+
+ @property
+ def client(self):
+ if self._client is None:
+ timeout = settings.INTEGRATION_HTTP_TIMEOUT_SECONDS
+ self._client = zeep.Client(
+ wsdl=self.wsdl_url,
+ transport=Transport(timeout=timeout, operation_timeout=timeout),
+ )
+ return self._client
+
+ def _safe_exception(self, exc):
+ message = str(exc)
+ for secret in (self.username, self.password):
+ if secret:
+ message = message.replace(secret, "***")
+ return message[:500]
+
+ def _call(self, operation, parameters):
+ method = getattr(self.client.service, operation)
+ # The live 0098sms.com WSDL exposes separate scalar arguments. This
+ # deliberately follows the WSDL signature instead of the outdated
+ # dict-shaped invocation printed in the vendor PDF sample.
+ return method(**parameters)
+
+ def _parse_send_result(self, response):
+ raw = _scalar(response)
+ normalized = raw.casefold()
+ if re.fullmatch(r"\d{9,}", raw):
+ return SMSResult(True, provider_id=raw, provider_code=raw)
+ # 2 is the SOAP success code. 0 is retained only for compatibility
+ # with the vendor's older link endpoint.
+ if raw in {"0", "2"}:
+ return SMSResult(True, provider_code=raw)
+ reason = SEND_RESULT_MESSAGES.get(normalized)
+ if reason:
+ return SMSResult(False, error=f"0098SMS [کد {raw}]: {reason}", provider_code=raw)
+ shown = raw[:120] or "پاسخ خالی"
+ return SMSResult(False, error=f"0098SMS پاسخ ناشناخته برگرداند: {shown}", provider_code=raw[:50])
def send_message(self, mobile, message):
- missing = [name for name, value in (("SMS0098_USERNAME", self.username), ("SMS0098_PASSWORD", self.password), ("SMS0098_FROM", self.sender)) if not value]
- if missing:
- return SMSResult(False, error=f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}")
+ error = self._configuration_error()
+ if error:
+ return SMSResult(False, error=error, provider_code="CONFIG")
try:
- recipient = int(normalize_mobile(str(mobile)).removeprefix("+98"))
- response = requests.get(self.api_url, params={"USERNAME": self.username, "PASSWORD": self.password, "FROM": self.sender, "TO": recipient, "TEXT": str(message)}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
- response.raise_for_status()
- # This legacy endpoint may append an HTML page containing the
- # original query string. Persist only its first scalar result so
- # credentials can never leak into notification logs.
- provider_id = response.text.strip().splitlines()[0][:100]
- return SMSResult(True, provider_id=provider_id)
- except (ValueError, requests.RequestException) as exc:
- logger.exception("0098SMS delivery failed")
- return SMSResult(False, error=str(exc)[:1000])
+ recipient = normalize_mobile(str(mobile)).removeprefix("+98")
+ if not recipient.startswith("0"):
+ recipient = f"0{recipient}"
+ response = self._call("SendSMSWithID", {
+ "username": self.username,
+ "password": self.password,
+ "mobileno": recipient,
+ "pnlno": str(self.sender),
+ "text": str(message),
+ })
+ return self._parse_send_result(response)
+ except Exception as exc:
+ logger.exception("0098SMS send failed")
+ return SMSResult(
+ False,
+ error=f"خطا در ارتباط با وبسرویس 0098SMS: {self._safe_exception(exc)}",
+ provider_code="CONNECTION",
+ )
+
+ def get_delivery_status(self, provider_id):
+ error = self._configuration_error()
+ if error:
+ return SMSDeliveryResult("ERROR", "CONFIG", error)
+ if not re.fullmatch(r"\d{9,}", str(provider_id or "")):
+ return SMSDeliveryResult("ERROR", "INVALID_ID", "شناسه رهگیری 0098SMS معتبر نیست.")
+ try:
+ response = self._call("smsdeliveryState", {
+ "username": self.username,
+ "password": self.password,
+ "smsid": str(provider_id),
+ })
+ code = _scalar(response)
+ mapped = DELIVERY_RESULTS.get(code)
+ if mapped:
+ status, description, is_final = mapped
+ return SMSDeliveryResult(status, code, f"0098SMS [کد {code}]: {description}", is_final=is_final)
+ reason = SEND_RESULT_MESSAGES.get(code.casefold())
+ if reason:
+ return SMSDeliveryResult("ERROR", code, f"0098SMS [کد {code}]: {reason}")
+ return SMSDeliveryResult("UNKNOWN", code[:50], f"0098SMS وضعیت دلیوری ناشناخته برگرداند: {code[:120] or 'پاسخ خالی'}")
+ except Exception as exc:
+ logger.exception("0098SMS delivery status lookup failed")
+ return SMSDeliveryResult(
+ "CHECK_FAILED",
+ "CONNECTION",
+ f"خطا در استعلام دلیوری 0098SMS: {self._safe_exception(exc)}",
+ is_final=False,
+ )
diff --git a/apps/notifications/admin.py b/apps/notifications/admin.py
index f1dabaa..eb7bbc5 100644
--- a/apps/notifications/admin.py
+++ b/apps/notifications/admin.py
@@ -1,7 +1,7 @@
from django.contrib import admin
from .models import NotificationJob,NotificationPolicy,SMSTemplate
@admin.register(NotificationJob)
-class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_message_id","created_at"); list_filter=("channel","status"); readonly_fields=("provider_message_id","dedupe_key","created_at","sent_at")
+class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_status_code","delivery_status","provider_message_id","created_at"); list_filter=("channel","status","delivery_status"); readonly_fields=("provider_message_id","provider_status_code","delivery_status_code","delivery_description","delivery_checked_at","dedupe_key","created_at","sent_at")
@admin.register(SMSTemplate)
class SMSTemplateAdmin(admin.ModelAdmin):
list_display=("event_type","unit","title","is_active","updated_at")
diff --git a/apps/notifications/management/commands/process_notifications.py b/apps/notifications/management/commands/process_notifications.py
index b6b56c4..e8f5229 100644
--- a/apps/notifications/management/commands/process_notifications.py
+++ b/apps/notifications/management/commands/process_notifications.py
@@ -6,6 +6,7 @@ import jdatetime
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
+from django.db.models import Q
from django.utils import timezone
from apps.accounts.models import TicketMagicLink
@@ -130,6 +131,103 @@ def _prepare_customer_reply_bale(job):
locked.save(update_fields=["status","next_attempt_at","last_error"]); return False
return True
+
+def _record_failure(job, error, provider_code=""):
+ """Persist a provider failure using the channel-specific retry policy."""
+ job.attempts += 1
+ job.last_error = str(error)[:1000]
+ job.provider_status_code = str(provider_code or "")[:50]
+ job.provider_message_id = ""
+ if job.channel == NotificationJob.Channel.SMS:
+ if job.attempts < 1 + settings.SMS_MAX_RETRIES:
+ job.status = NotificationJob.Status.RETRYING
+ job.next_attempt_at = timezone.now() + timedelta(seconds=settings.SMS_RETRY_DELAY_SECONDS)
+ else:
+ job.status = NotificationJob.Status.FAILED
+ else:
+ job.status = NotificationJob.Status.FAILED
+ job.next_attempt_at = timezone.now() + timedelta(seconds=min(3600, 2**job.attempts * 10))
+
+
+def _record_success(job, result):
+ job.attempts += 1
+ job.status = NotificationJob.Status.SENT
+ job.sent_at = timezone.now()
+ job.last_error = ""
+ job.provider_message_id = getattr(result, "provider_id", "") if result is not None else ""
+ job.provider_status_code = getattr(result, "provider_code", "") if result is not None else ""
+ if job.channel == NotificationJob.Channel.SMS and job.provider_message_id:
+ job.delivery_status = NotificationJob.DeliveryStatus.PENDING
+ job.delivery_next_check_at = timezone.now() + timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS)
+
+
+def _process_delivery_checks(options):
+ if options.get("channel") not in (None, NotificationJob.Channel.SMS):
+ return
+ now = timezone.now()
+ with transaction.atomic():
+ pollable_statuses = [
+ NotificationJob.DeliveryStatus.PENDING,
+ NotificationJob.DeliveryStatus.CHECK_FAILED,
+ NotificationJob.DeliveryStatus.UNKNOWN,
+ NotificationJob.DeliveryStatus.UNDELIVERED,
+ ]
+ # Resume provisional results created by older worker versions, but do
+ # not revive messages beyond the configured 45-minute monitoring span.
+ monitor_cutoff = now - timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS * settings.SMS_DELIVERY_MAX_CHECKS)
+ NotificationJob.objects.filter(
+ channel=NotificationJob.Channel.SMS,
+ status=NotificationJob.Status.SENT,
+ delivery_status__in=[NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED],
+ delivery_next_check_at__isnull=True,
+ delivery_check_attempts__lt=settings.SMS_DELIVERY_MAX_CHECKS,
+ sent_at__gte=monitor_cutoff,
+ ).update(delivery_next_check_at=now)
+ queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(
+ channel=NotificationJob.Channel.SMS,
+ status=NotificationJob.Status.SENT,
+ delivery_status__in=pollable_statuses,
+ delivery_next_check_at__lte=now,
+ )
+ if options.get("job_id"):
+ queryset = queryset.filter(pk=options["job_id"])
+ elif options.get("min_id"):
+ queryset = queryset.filter(pk__gte=options["min_id"])
+ jobs = list(queryset.order_by("delivery_next_check_at")[:20])
+ # Lease each status check so parallel workers cannot query it twice.
+ NotificationJob.objects.filter(pk__in=[job.pk for job in jobs]).update(
+ delivery_next_check_at=now + timedelta(minutes=10),
+ )
+ provider = get_sms_provider()
+ for job in jobs:
+ result = provider.get_delivery_status(job.provider_message_id)
+ if result is None:
+ job.delivery_status = ""
+ job.delivery_next_check_at = None
+ job.delivery_description = "این ارائهدهنده از استعلام دلیوری پشتیبانی نمیکند."
+ else:
+ job.delivery_check_attempts += 1
+ job.delivery_checked_at = timezone.now()
+ job.delivery_status_code = result.provider_code[:50]
+ job.delivery_description = result.description[:500]
+ if result.is_final:
+ job.delivery_status = result.status
+ job.delivery_next_check_at = None
+ elif job.delivery_check_attempts < settings.SMS_DELIVERY_MAX_CHECKS:
+ job.delivery_status = result.status
+ job.delivery_next_check_at = timezone.now() + timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS)
+ else:
+ job.delivery_status = result.status if result.status in NotificationJob.DeliveryStatus.values else NotificationJob.DeliveryStatus.ERROR
+ job.delivery_next_check_at = None
+ job.delivery_description = f"{result.description} نتیجه پس از {job.delivery_check_attempts} نوبت استعلام در بازه پایش نهایی شد."[:500]
+ job.save(update_fields=[
+ "delivery_status", "delivery_status_code", "delivery_description",
+ "delivery_checked_at", "delivery_check_attempts", "delivery_next_check_at",
+ ])
+ if job.ticket_id and job.ticket.group_dispatch_id:
+ from apps.tickets.services import refresh_group_dispatch_status
+ refresh_group_dispatch_status(job.ticket.group_dispatch_id)
+
class Command(BaseCommand):
help = "Process notification outbox"
def add_arguments(self, parser):
@@ -140,9 +238,15 @@ class Command(BaseCommand):
def handle(self, *args, **options):
while True:
stale = timezone.now() - timedelta(minutes=10)
- NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale).update(status="FAILED", next_attempt_at=timezone.now(), last_error="Recovered stale processing job")
+ NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale, channel=NotificationJob.Channel.SMS).update(status=NotificationJob.Status.RETRYING, next_attempt_at=timezone.now(), last_error="بازیابی تلاش ارسال متوقفشده")
+ NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale, channel=NotificationJob.Channel.BALE).update(status=NotificationJob.Status.FAILED, next_attempt_at=timezone.now(), last_error="Recovered stale processing job")
with transaction.atomic():
- queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(status__in=["PENDING", "FAILED"], attempts__lt=settings.MAX_NOTIFICATION_ATTEMPTS, next_attempt_at__lte=timezone.now())
+ sms_limit = 1 + settings.SMS_MAX_RETRIES
+ queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(
+ Q(channel=NotificationJob.Channel.SMS, attempts__lt=sms_limit, status__in=[NotificationJob.Status.PENDING, NotificationJob.Status.RETRYING])
+ | Q(channel=NotificationJob.Channel.BALE, attempts__lt=settings.MAX_NOTIFICATION_ATTEMPTS, status__in=[NotificationJob.Status.PENDING, NotificationJob.Status.FAILED]),
+ next_attempt_at__lte=timezone.now(),
+ )
if options.get("channel"):
queryset = queryset.filter(channel=options["channel"])
if options.get("job_id"):
@@ -160,15 +264,22 @@ class Command(BaseCommand):
continue
if job.channel == NotificationJob.Channel.SMS: result, issued_link = _send_sms(job)
else: result = _send_bale(job)
- if result is not None and not result.success: raise RuntimeError(result.error or "Provider rejected notification")
- job.attempts += 1; job.status = "SENT"; job.sent_at = timezone.now(); job.last_error = ""; job.provider_message_id = getattr(result,"provider_id","") if result is not None else ""
+ if result is not None and not result.success:
+ _record_failure(job, result.error or "ارائهدهنده پیام را نپذیرفت.", getattr(result, "provider_code", ""))
+ if issued_link:
+ issued_link.revoked_at = timezone.now()
+ issued_link.save(update_fields=["revoked_at"])
+ else:
+ _record_success(job, result)
except Exception as exc:
if issued_link:
issued_link.revoked_at = timezone.now(); issued_link.save(update_fields=["revoked_at"])
- job.attempts += 1; job.last_error = str(exc)[:1000]; job.provider_message_id = ""; job.status = "FAILED"; job.next_attempt_at = timezone.now() + timedelta(seconds=min(3600, 2**job.attempts * 10)); logger.exception("notification failed id=%s", job.pk)
+ _record_failure(job, exc)
+ logger.exception("notification failed id=%s", job.pk)
job.save()
if job.ticket_id and job.ticket.group_dispatch_id:
from apps.tickets.services import refresh_group_dispatch_status
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
+ _process_delivery_checks(options)
if options["once"] or options.get("job_id"): break
time.sleep(5)
diff --git a/apps/notifications/migrations/0011_notification_delivery_tracking.py b/apps/notifications/migrations/0011_notification_delivery_tracking.py
new file mode 100644
index 0000000..4711eb2
--- /dev/null
+++ b/apps/notifications/migrations/0011_notification_delivery_tracking.py
@@ -0,0 +1,32 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [("notifications", "0010_use_actual_unit_in_staff_ticket_sms")]
+
+ operations = [
+ migrations.AlterField(
+ model_name="notificationjob",
+ name="status",
+ field=models.CharField(
+ choices=[
+ ("PENDING", "در صف"),
+ ("PROCESSING", "در حال ارسال"),
+ ("RETRYING", "در انتظار تلاش مجدد"),
+ ("SENT", "ارسالشده"),
+ ("FAILED", "ناموفق"),
+ ("CANCELLED", "لغوشده"),
+ ],
+ db_index=True,
+ default="PENDING",
+ max_length=15,
+ ),
+ ),
+ migrations.AddField(model_name="notificationjob", name="provider_status_code", field=models.CharField(blank=True, max_length=50)),
+ migrations.AddField(model_name="notificationjob", name="delivery_status", field=models.CharField(blank=True, choices=[("PENDING", "در انتظار استعلام"), ("DELIVERED", "رسیده به گوشی"), ("UNDELIVERED", "نرسیده به گوشی"), ("EXPIRED", "منقضیشده"), ("BLOCKED", "مسدود توسط مخاطب"), ("UNKNOWN", "نامشخص"), ("ERROR", "خطای دلیوری"), ("CHECK_FAILED", "خطای موقت استعلام")], db_index=True, max_length=20)),
+ migrations.AddField(model_name="notificationjob", name="delivery_status_code", field=models.CharField(blank=True, max_length=50)),
+ migrations.AddField(model_name="notificationjob", name="delivery_description", field=models.CharField(blank=True, max_length=500)),
+ migrations.AddField(model_name="notificationjob", name="delivery_checked_at", field=models.DateTimeField(blank=True, null=True)),
+ migrations.AddField(model_name="notificationjob", name="delivery_check_attempts", field=models.PositiveSmallIntegerField(default=0)),
+ migrations.AddField(model_name="notificationjob", name="delivery_next_check_at", field=models.DateTimeField(blank=True, db_index=True, null=True)),
+ ]
diff --git a/apps/notifications/models.py b/apps/notifications/models.py
index 742e65c..e9a49bc 100644
--- a/apps/notifications/models.py
+++ b/apps/notifications/models.py
@@ -44,7 +44,23 @@ class NotificationPolicy(models.Model):
class NotificationJob(models.Model):
class Channel(models.TextChoices): SMS="SMS","پیامک"; BALE="BALE","بله"
- class Status(models.TextChoices): PENDING="PENDING","در صف"; PROCESSING="PROCESSING","در حال ارسال"; SENT="SENT","ارسالشده"; FAILED="FAILED","ناموفق"; CANCELLED="CANCELLED","لغوشده"
+ class Status(models.TextChoices): PENDING="PENDING","در صف"; PROCESSING="PROCESSING","در حال ارسال"; RETRYING="RETRYING","در انتظار تلاش مجدد"; SENT="SENT","ارسالشده"; FAILED="FAILED","ناموفق"; CANCELLED="CANCELLED","لغوشده"
+ class DeliveryStatus(models.TextChoices):
+ PENDING="PENDING","در انتظار استعلام"
+ DELIVERED="DELIVERED","رسیده به گوشی"
+ UNDELIVERED="UNDELIVERED","نرسیده به گوشی"
+ EXPIRED="EXPIRED","منقضیشده"
+ BLOCKED="BLOCKED","مسدود توسط مخاطب"
+ UNKNOWN="UNKNOWN","نامشخص"
+ ERROR="ERROR","خطای دلیوری"
+ CHECK_FAILED="CHECK_FAILED","خطای موقت استعلام"
channel=models.CharField(max_length=10,choices=Channel.choices); event_type=models.CharField(max_length=50); customer=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="customer_notifications"); staff_user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="staff_notifications"); ticket=models.ForeignKey("tickets.Ticket",on_delete=models.CASCADE,null=True,blank=True); payload=models.JSONField(default=dict); status=models.CharField(max_length=15,choices=Status.choices,default=Status.PENDING,db_index=True); attempts=models.PositiveSmallIntegerField(default=0); next_attempt_at=models.DateTimeField(db_index=True); last_error=models.TextField(blank=True); provider_message_id=models.CharField(max_length=500,blank=True); dedupe_key=models.CharField(max_length=200,unique=True); created_at=models.DateTimeField(auto_now_add=True); sent_at=models.DateTimeField(null=True,blank=True)
+ provider_status_code=models.CharField(max_length=50,blank=True)
+ delivery_status=models.CharField(max_length=20,choices=DeliveryStatus.choices,blank=True,db_index=True)
+ delivery_status_code=models.CharField(max_length=50,blank=True)
+ delivery_description=models.CharField(max_length=500,blank=True)
+ delivery_checked_at=models.DateTimeField(null=True,blank=True)
+ delivery_check_attempts=models.PositiveSmallIntegerField(default=0)
+ delivery_next_check_at=models.DateTimeField(null=True,blank=True,db_index=True)
class Meta:
indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")]
diff --git a/apps/tickets/services.py b/apps/tickets/services.py
index 1dfb4db..d9f1bd6 100644
--- a/apps/tickets/services.py
+++ b/apps/tickets/services.py
@@ -1,7 +1,7 @@
from datetime import timedelta
from django.core.exceptions import PermissionDenied,ValidationError
from django.db import transaction
-from django.db.models import Count
+from django.db.models import Count,Q
from django.utils import timezone
from apps.attachments.services import save_attachments
from apps.core.persian_text import normalize_persian_text
@@ -100,11 +100,12 @@ def refresh_group_dispatch_status(dispatch_id):
"""Persist delivery state independently of whether a report page is opened."""
dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first()
if not dispatch: return None
- counts={row["status"]:row["count"] for row in NotificationJob.objects.filter(ticket__group_dispatch_id=dispatch_id,channel=NotificationJob.Channel.SMS,event_type="STAFF_TICKET_CREATED").values("status").annotate(count=Count("id"))}
+ jobs=NotificationJob.objects.filter(ticket__group_dispatch_id=dispatch_id,channel=NotificationJob.Channel.SMS,event_type="STAFF_TICKET_CREATED")
+ counts={row["status"]:row["count"] for row in jobs.values("status").annotate(count=Count("id"))}
sent=counts.get(NotificationJob.Status.SENT,0)
- failed=counts.get(NotificationJob.Status.FAILED,0)
- if dispatch.recipient_count and sent>=dispatch.recipient_count: status=GroupDispatch.Status.SENT
- elif failed: status=GroupDispatch.Status.PARTIAL
+ failed=jobs.filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT,delivery_status__in=[NotificationJob.DeliveryStatus.UNDELIVERED,NotificationJob.DeliveryStatus.EXPIRED,NotificationJob.DeliveryStatus.BLOCKED,NotificationJob.DeliveryStatus.UNKNOWN,NotificationJob.DeliveryStatus.ERROR],delivery_next_check_at__isnull=True)).count()
+ if failed: status=GroupDispatch.Status.PARTIAL
+ elif dispatch.recipient_count and sent>=dispatch.recipient_count: status=GroupDispatch.Status.SENT
else: status=GroupDispatch.Status.QUEUED
if dispatch.status!=status:
GroupDispatch.objects.filter(pk=dispatch_id).update(status=status,updated_at=timezone.now())
diff --git a/apps/tickets/views.py b/apps/tickets/views.py
index c3e21dc..8f41c20 100644
--- a/apps/tickets/views.py
+++ b/apps/tickets/views.py
@@ -395,8 +395,9 @@ def staff_detail(request,public_id):
mark_messages_read(conversation,request.user)
last=conversation[-1] if conversation else None; TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last})
history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5]
+ sms_jobs=ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")
latest=conversation[-1] if conversation else None
- return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
+ return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
@staff_required
def staff_messages(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
@@ -559,19 +560,59 @@ def staff_dispatch_detail(request, public_id):
if not DispatchPolicy.can_view(request.user, dispatch):
raise PermissionDenied
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "ticket_messages", "notificationjob_set").order_by("customer_id"))
+ delivery_failures = {
+ NotificationJob.DeliveryStatus.UNDELIVERED,
+ NotificationJob.DeliveryStatus.EXPIRED,
+ NotificationJob.DeliveryStatus.BLOCKED,
+ NotificationJob.DeliveryStatus.UNKNOWN,
+ NotificationJob.DeliveryStatus.ERROR,
+ }
+ now = timezone.now()
for ticket in tickets:
- ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS), None)
+ ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS and job.event_type == SMSTemplate.Event.STAFF_TICKET_CREATED), None)
+ ticket.sms_retryable = bool(ticket.sms_job and (ticket.sms_job.status == NotificationJob.Status.FAILED or (ticket.sms_job.delivery_status in delivery_failures and not ticket.sms_job.delivery_next_check_at)))
ticket.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None)
- sms_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, channel=NotificationJob.Channel.SMS)
+ links = list(ticket.magic_links.all())
+ ticket.magic_total_views = sum(link.view_count for link in links)
+ ticket.magic_link_count = len(links)
+ ticket.magic_active_count = sum(not link.revoked_at and link.expires_at > now for link in links)
+ ticket.magic_last_used_at = max((link.used_at for link in links if link.used_at), default=None)
+ job = ticket.sms_job
+ if not job:
+ ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب job پیامک پیدا نشد.", "neutral"
+ elif job.status == NotificationJob.Status.FAILED:
+ ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", f"ارسال خودکار پس از {1 + settings.SMS_MAX_RETRIES} تلاش متوقف شده است.", "danger"
+ elif job.status == NotificationJob.Status.RETRYING:
+ ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "worker پیامک را در موعد بعدی دوباره ارسال میکند.", "warning"
+ elif job.status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING):
+ ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info"
+ elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED:
+ ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل به گوشی تأیید شد", "اپراتور دریافت پیامک توسط مخاطب را تأیید کرده است.", "success"
+ elif job.delivery_status in (NotificationJob.DeliveryStatus.PENDING, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.CHECK_FAILED) and job.delivery_next_check_at:
+ ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست و worker استعلام را ادامه میدهد.", "warning"
+ elif job.delivery_status == NotificationJob.DeliveryStatus.BLOCKED:
+ ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger"
+ elif job.delivery_status == NotificationJob.DeliveryStatus.EXPIRED:
+ ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "مهلت تحویل پایان یافت", "اپراتور پیامک را در بازه مجاز تحویل نداده است.", "danger"
+ elif job.delivery_status in (NotificationJob.DeliveryStatus.ERROR, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED):
+ ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل تأیید نشد", "بازه پیگیری پایان یافته یا درگاه خطای قطعی برگردانده است.", "danger"
+ else:
+ ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "پذیرفتهشده توسط درگاه", "شناسه ارسال ثبت شده و در انتظار نتیجه دلیوری است.", "info"
+ sms_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, channel=NotificationJob.Channel.SMS, event_type=SMSTemplate.Event.STAFF_TICKET_CREATED)
sms_counts = {row["status"]: row["count"] for row in sms_jobs.values("status").annotate(count=Count("id"))}
sent = sms_counts.get(NotificationJob.Status.SENT, 0)
- failed = sms_counts.get(NotificationJob.Status.FAILED, 0)
+ failed = sms_jobs.filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT, delivery_status__in=delivery_failures, delivery_next_check_at__isnull=True)).count()
+ delivered = sms_jobs.filter(delivery_status=NotificationJob.DeliveryStatus.DELIVERED).count()
+ retrying = sms_counts.get(NotificationJob.Status.RETRYING, 0)
+ tracking = sms_jobs.filter(delivery_next_check_at__isnull=False).count()
opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count()
+ total_views = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch).aggregate(total=Sum("view_count"))["total"] or 0
replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count()
- expected = dispatch.recipient_count
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"])
- metrics = {"sent": sent, "failed": failed, "pending": max(expected - sent - failed, 0), "opened": opened, "replied": replied}
- return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "active_staff_section": "dispatches"})
+ pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING))
+ delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0
+ metrics = {"sent": sent, "delivered": delivered, "failed": failed, "retrying": retrying, "tracking": tracking, "pending": pending, "opened": opened, "not_opened": max(dispatch.recipient_count-opened,0), "total_views": total_views, "replied": replied, "delivery_percent": delivery_percent}
+ return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"})
@staff_required
@@ -579,7 +620,20 @@ def staff_dispatch_detail(request, public_id):
def staff_dispatch_retry(request, public_id):
dispatch=get_object_or_404(GroupDispatch,public_id=public_id)
if not DispatchPolicy.can_view(request.user,dispatch) or not DispatchPolicy.can_create(request.user,dispatch.unit,dispatch.operation_type): raise PermissionDenied
- count=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,status=NotificationJob.Status.FAILED).update(status=NotificationJob.Status.PENDING,attempts=0,next_attempt_at=timezone.now(),last_error="")
+ retryable_delivery = [NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.EXPIRED, NotificationJob.DeliveryStatus.BLOCKED, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.ERROR]
+ jobs=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED).filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT,delivery_status__in=retryable_delivery,delivery_next_check_at__isnull=True))
+ job_id=request.POST.get("job_id","").strip()
+ if job_id:
+ if not job_id.isdigit():
+ messages.error(request,"شناسه پیامک برای تلاش مجدد معتبر نیست.")
+ return redirect("staff_tickets:dispatch_detail",public_id=public_id)
+ jobs=jobs.filter(pk=int(job_id))
+ count=jobs.update(
+ status=NotificationJob.Status.PENDING, attempts=0, next_attempt_at=timezone.now(),
+ last_error="", provider_message_id="", provider_status_code="", sent_at=None,
+ delivery_status="", delivery_status_code="", delivery_description="",
+ delivery_checked_at=None, delivery_check_attempts=0, delivery_next_check_at=None,
+ )
refresh_group_dispatch_status(dispatch.pk)
messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.")
return redirect("staff_tickets:dispatch_detail",public_id=public_id)
diff --git a/config/settings/base.py b/config/settings/base.py
index d40bccd..72afe50 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -96,6 +96,10 @@ DEV_OTP_MOBILE = ""
DEV_OTP_CODE = ""
MAGIC_LINK_TTL_DAYS = int(os.getenv("MAGIC_LINK_TTL_DAYS", "7"))
MAX_NOTIFICATION_ATTEMPTS = int(os.getenv("MAX_NOTIFICATION_ATTEMPTS", "5"))
+SMS_MAX_RETRIES = int(os.getenv("SMS_MAX_RETRIES", "3"))
+SMS_RETRY_DELAY_SECONDS = int(os.getenv("SMS_RETRY_DELAY_SECONDS", "180"))
+SMS_DELIVERY_CHECK_DELAY_SECONDS = int(os.getenv("SMS_DELIVERY_CHECK_DELAY_SECONDS", "180"))
+SMS_DELIVERY_MAX_CHECKS = int(os.getenv("SMS_DELIVERY_MAX_CHECKS", "15"))
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://127.0.0.1:8000").rstrip("/")
COMPANY_NAME = os.getenv("COMPANY_NAME", "لوله گستر گلپایگان")
WEBOTP_HOST = os.getenv("WEBOTP_HOST", "")
@@ -110,7 +114,7 @@ BEHIN_SMS_WSDL_URL = os.getenv("BEHIN_SMS_WSDL_URL", "http://panel.behinsms.com/
SMS0098_USERNAME = os.getenv("SMS0098_USERNAME", "")
SMS0098_PASSWORD = os.getenv("SMS0098_PASSWORD", "")
SMS0098_FROM = os.getenv("SMS0098_FROM", "")
-SMS0098_API_URL = os.getenv("SMS0098_API_URL", "http://www.0098sms.com/sendsmslink.aspx")
+SMS0098_WSDL_URL = os.getenv("SMS0098_WSDL_URL", "https://webservice.0098sms.com/service.asmx?WSDL")
SMS_TEST_ALLOWED_MOBILES = ("+989202202129", "+989384999655")
BALE_API_BASE = os.getenv("BALE_API_BASE", "https://tapi.bale.ai")
BALE_BOT_TOKEN = os.getenv("BALE_BOT_TOKEN", "")
diff --git a/design-prototypes/ticket-new-wizard/index.html b/design-prototypes/ticket-new-wizard/index.html
new file mode 100644
index 0000000..19af141
--- /dev/null
+++ b/design-prototypes/ticket-new-wizard/index.html
@@ -0,0 +1,148 @@
+
+
+
+
+
+ نمونه رابط مرحلهای ثبت درخواست
+
+
+
+
+
+
+
+
+
+
+
ثبت درخواست جدید
+
چطور میتوانیم کمک کنیم؟
+
قدمبهقدم پیش میرویم؛ در هر مرحله فقط اطلاعات ضروری همان بخش را میبینید.
+
+ اطلاعات شما در این صفحه حفظ میشود
+
+
+
+ - ۱
موضوعمسیر درخواست
+ - ۲
موقعیتاختیاری
+ - ۳
کارشناسانتخاب مسئول
+ - ۴
جزئیاتمتن و فایل
+ - ۵
بازبینیتأیید نهایی
+
+
+
+
+
+
+
+
diff --git a/design-prototypes/ticket-new-wizard/prototype.js b/design-prototypes/ticket-new-wizard/prototype.js
new file mode 100644
index 0000000..519d5cc
--- /dev/null
+++ b/design-prototypes/ticket-new-wizard/prototype.js
@@ -0,0 +1,379 @@
+const form = document.querySelector('#ticket-wizard');
+const steps = [...document.querySelectorAll('[data-step]')];
+const progress = [...document.querySelectorAll('[data-progress]')];
+const unit = document.querySelector('#unit');
+const category = document.querySelector('#category');
+const subject = document.querySelector('#subject');
+const body = document.querySelector('#body');
+const province = document.querySelector('#province');
+const city = document.querySelector('#city');
+const files = [];
+let current = 1;
+
+const categories = {
+ 'فروش': ['مشاوره خرید', 'موجودی و قیمت', 'پیگیری سفارش'],
+ 'حسابداری': ['فیش واریزی', 'صورتحساب', 'پیگیری پرداخت'],
+ 'بارگیری انبار': ['هماهنگی بار', 'زمان ارسال', 'پیگیری تحویل'],
+};
+const cities = {
+ 'تهران': ['تهران', 'شهریار', 'اسلامشهر'],
+ 'اصفهان': ['اصفهان', 'کاشان', 'گلپایگان'],
+ 'مرکزی': ['اراک', 'ساوه', 'محلات'],
+};
+
+const recorderUi = {
+ root: document.querySelector('#voice-recorder'), idle: document.querySelector('#voice-idle'),
+ live: document.querySelector('#voice-live'), preview: document.querySelector('#voice-preview'),
+ record: document.querySelector('#record'), pause: document.querySelector('#pause'),
+ stop: document.querySelector('#stop'), cancel: document.querySelector('#cancel-recording'),
+ rerecord: document.querySelector('#rerecord'), remove: document.querySelector('#delete-voice'),
+ audio: document.querySelector('#audio'), reviewAudio: document.querySelector('#review-audio'),
+ timer: document.querySelector('#voice-timer'), status: document.querySelector('#voice-status'),
+ meta: document.querySelector('#voice-meta'), error: document.querySelector('#voice-error'),
+};
+
+let recorder = null;
+let microphoneStream = null;
+let audioChunks = [];
+let voiceBlob = null;
+let voiceUrl = '';
+let recordingStartedAt = 0;
+let pausedStartedAt = 0;
+let totalPausedMs = 0;
+let timerId = null;
+let stopResolver = null;
+let discardOnStop = false;
+
+function toPersianDigits(value) {
+ return String(value).replace(/\d/g, digit => '۰۱۲۳۴۵۶۷۸۹'[digit]);
+}
+
+function formatDuration(milliseconds) {
+ const seconds = Math.max(0, Math.floor(milliseconds / 1000));
+ return toPersianDigits(`${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`);
+}
+
+function elapsedRecordingTime() {
+ if (!recordingStartedAt) return 0;
+ const activePause = recorder?.state === 'paused' ? Date.now() - pausedStartedAt : 0;
+ return Date.now() - recordingStartedAt - totalPausedMs - activePause;
+}
+
+function setSelect(select, items, placeholder) {
+ select.replaceChildren(new Option(placeholder, ''), ...items.map(item => new Option(item, item)));
+ select.disabled = !items.length;
+}
+
+function showStep(number) {
+ current = number;
+ steps.forEach(step => {
+ const active = Number(step.dataset.step) === number;
+ step.hidden = !active;
+ step.classList.toggle('active', active);
+ });
+ progress.forEach(item => {
+ const itemNumber = Number(item.dataset.progress);
+ item.classList.toggle('active', itemNumber === number);
+ item.classList.toggle('completed', itemNumber < number);
+ item.querySelector('span').textContent = itemNumber < number ? '✓' : toPersianDigits(itemNumber);
+ });
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+}
+
+function showValidationError(number, message, invalidFields = []) {
+ const step = document.querySelector(`[data-step="${number}"]`);
+ const error = document.querySelector(`[data-error="${number}"]`);
+ step.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid'));
+ invalidFields.forEach(field => field?.setAttribute('aria-invalid', 'true'));
+ error.textContent = message;
+ error.tabIndex = -1;
+ error.classList.add('visible');
+ requestAnimationFrame(() => {
+ error.focus({ preventScroll: true });
+ error.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
+ });
+}
+
+function clearValidationError(number) {
+ const step = document.querySelector(`[data-step="${number}"]`);
+ const error = document.querySelector(`[data-error="${number}"]`);
+ step.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid'));
+ if (error) {
+ error.textContent = '';
+ error.classList.remove('visible');
+ error.removeAttribute('tabindex');
+ }
+}
+
+function validateStep(number) {
+ const error = document.querySelector(`[data-error="${number}"]`);
+ if (number === 1) {
+ const missing = [];
+ const invalidFields = [];
+ if (!unit.value) { missing.push('واحد'); invalidFields.push(unit); }
+ if (!category.value) { missing.push('دستهبندی'); invalidFields.push(category); }
+ if (subject.value.trim().length < 5) { missing.push('موضوع حداقل پنجحرفی'); invalidFields.push(subject); }
+ if (missing.length) {
+ showValidationError(number, `برای ادامه، ${missing.join('، ')} را تکمیل کنید.`, invalidFields);
+ return false;
+ }
+ }
+ if (number === 4 && !body.value.trim() && !files.length && !voiceBlob) {
+ showValidationError(number, 'برای ادامه، توضیح بنویسید یا یک فایل/پیام صوتی اضافه کنید.', [body]);
+ return false;
+ }
+ if (error) clearValidationError(number);
+ return true;
+}
+
+function stopMicrophoneTracks() {
+ microphoneStream?.getTracks().forEach(track => track.stop());
+ microphoneStream = null;
+}
+
+function clearTimer() { clearInterval(timerId); timerId = null; }
+
+function updateRecorderView(mode, statusText) {
+ recorderUi.idle.hidden = mode !== 'idle';
+ recorderUi.live.hidden = mode !== 'live';
+ recorderUi.preview.hidden = mode !== 'preview';
+ recorderUi.root.dataset.state = mode;
+ if (statusText) recorderUi.status.textContent = statusText;
+}
+
+function clearVoice() {
+ clearTimer();
+ stopMicrophoneTracks();
+ if (voiceUrl) URL.revokeObjectURL(voiceUrl);
+ voiceBlob = null;
+ voiceUrl = '';
+ recorderUi.audio.removeAttribute('src');
+ recorderUi.reviewAudio.removeAttribute('src');
+ recorderUi.reviewAudio.hidden = true;
+ recorderUi.timer.textContent = '۰۰:۰۰';
+ recorderUi.pause.textContent = 'مکث';
+ recorderUi.error.hidden = true;
+ updateRecorderView('idle', 'برای توضیح سریعتر، صدای خود را ضبط کنید.');
+}
+
+function completeRecording(blob, duration, automatic) {
+ if (!blob.size) {
+ clearVoice();
+ recorderUi.error.textContent = 'صدایی ثبت نشد؛ لطفاً دوباره تلاش کنید.';
+ recorderUi.error.hidden = false;
+ return;
+ }
+ if (voiceUrl) URL.revokeObjectURL(voiceUrl);
+ voiceBlob = blob;
+ voiceUrl = URL.createObjectURL(blob);
+ recorderUi.audio.src = voiceUrl;
+ recorderUi.reviewAudio.src = voiceUrl;
+ recorderUi.timer.textContent = formatDuration(duration);
+ recorderUi.meta.textContent = `${formatDuration(duration)} · آماده پخش و ارسال`;
+ updateRecorderView('preview', automatic ? 'ضبط هنگام ادامهدادن متوقف و ذخیره شد.' : 'ضبط با موفقیت ذخیره شد؛ آن را بررسی کنید.');
+}
+
+async function startRecording() {
+ recorderUi.error.hidden = true;
+ if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
+ recorderUi.error.textContent = 'مرورگر یا اتصال فعلی امکان ضبط امن صدا را ندارد.';
+ recorderUi.error.hidden = false;
+ return;
+ }
+ try {
+ if (voiceBlob) clearVoice();
+ microphoneStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
+ const mimeType = ['audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/webm']
+ .find(type => MediaRecorder.isTypeSupported?.(type));
+ recorder = new MediaRecorder(microphoneStream, mimeType ? { mimeType } : undefined);
+ audioChunks = [];
+ totalPausedMs = 0;
+ discardOnStop = false;
+ recorder.ondataavailable = event => { if (event.data.size) audioChunks.push(event.data); };
+ recorder.onerror = () => {
+ recorderUi.error.textContent = 'ضبط صدا متوقف شد. دوباره تلاش کنید.';
+ recorderUi.error.hidden = false;
+ clearVoice();
+ };
+ recorder.onstop = () => {
+ const duration = elapsedRecordingTime();
+ clearTimer();
+ stopMicrophoneTracks();
+ if (discardOnStop) clearVoice();
+ else completeRecording(new Blob(audioChunks, { type: recorder.mimeType?.split(';')[0] || 'audio/webm' }), duration, recorderUi.root.dataset.autoStop === 'true');
+ recorderUi.root.dataset.autoStop = 'false';
+ recorder = null;
+ stopResolver?.();
+ stopResolver = null;
+ };
+ recorder.start(500);
+ recordingStartedAt = Date.now();
+ recorderUi.timer.textContent = '۰۰:۰۰';
+ updateRecorderView('live', 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.');
+ timerId = setInterval(() => { recorderUi.timer.textContent = formatDuration(elapsedRecordingTime()); }, 250);
+ } catch (error) {
+ stopMicrophoneTracks();
+ recorderUi.error.textContent = error.name === 'NotAllowedError'
+ ? 'دسترسی میکروفن رد شد؛ آن را از تنظیمات مرورگر فعال کنید.'
+ : 'میکروفن در دسترس نیست یا برنامه دیگری از آن استفاده میکند.';
+ recorderUi.error.hidden = false;
+ }
+}
+
+function stopRecording({ discard = false, automatic = false } = {}) {
+ if (!recorder || recorder.state === 'inactive') return Promise.resolve();
+ discardOnStop = discard;
+ recorderUi.root.dataset.autoStop = String(automatic);
+ if (recorder.state === 'paused') {
+ totalPausedMs += Date.now() - pausedStartedAt;
+ recorder.resume();
+ }
+ return new Promise(resolve => { stopResolver = resolve; recorder.stop(); });
+}
+
+function togglePause() {
+ if (!recorder) return;
+ if (recorder.state === 'recording') {
+ recorder.pause();
+ pausedStartedAt = Date.now();
+ recorderUi.pause.textContent = 'ادامه ضبط';
+ recorderUi.status.textContent = 'ضبط موقتاً متوقف شده است.';
+ recorderUi.root.dataset.state = 'paused';
+ } else if (recorder.state === 'paused') {
+ totalPausedMs += Date.now() - pausedStartedAt;
+ recorder.resume();
+ recorderUi.pause.textContent = 'مکث';
+ recorderUi.status.textContent = 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.';
+ recorderUi.root.dataset.state = 'live';
+ }
+}
+
+function buildReview() {
+ document.querySelector('#review-subject').textContent = subject.value;
+ document.querySelector('#review-unit').textContent = unit.value;
+ document.querySelector('#review-category').textContent = category.value;
+ document.querySelector('#review-location').textContent = province.value ? [province.value, city.value].filter(Boolean).join('، ') : 'ثبت نشده';
+ document.querySelector('#review-expert').textContent = document.querySelector('input[name="expert"]:checked').value;
+ document.querySelector('#review-body').textContent = body.value.trim() || 'شرح متنی ثبت نشده است.';
+ const list = document.querySelector('#review-files');
+ list.replaceChildren();
+ files.forEach(file => {
+ const chip = document.createElement('span');
+ chip.textContent = file.name;
+ list.appendChild(chip);
+ });
+ if (!files.length && !voiceBlob) {
+ const chip = document.createElement('span');
+ chip.textContent = 'بدون پیوست';
+ list.appendChild(chip);
+ }
+ recorderUi.reviewAudio.hidden = !voiceBlob;
+}
+
+document.querySelectorAll('[data-next]').forEach(button => button.addEventListener('click', async () => {
+ button.disabled = true;
+ if (current === 4 && recorder && recorder.state !== 'inactive') await stopRecording({ automatic: true });
+ button.disabled = false;
+ if (!validateStep(current)) return;
+ if (current === 4) buildReview();
+ showStep(Math.min(5, current + 1));
+}));
+document.querySelectorAll('[data-prev]').forEach(button => button.addEventListener('click', async () => {
+ if (current === 4 && recorder && recorder.state !== 'inactive') await stopRecording({ automatic: true });
+ showStep(Math.max(1, current - 1));
+}));
+document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => showStep(Number(button.dataset.edit))));
+document.querySelector('#manual-toggle').addEventListener('click', event => {
+ const target = document.querySelector('#manual-fields');
+ target.hidden = !target.hidden;
+ event.currentTarget.setAttribute('aria-expanded', String(!target.hidden));
+ event.currentTarget.querySelector('span').textContent = target.hidden ? '⌄' : '⌃';
+});
+unit.addEventListener('change', () => setSelect(category, categories[unit.value] || [], 'انتخاب دستهبندی'));
+province.addEventListener('change', () => setSelect(city, cities[province.value] || [], 'انتخاب شهر'));
+document.querySelectorAll('[data-suggest-unit]').forEach(button => button.addEventListener('click', () => {
+ unit.value = button.dataset.suggestUnit;
+ setSelect(category, categories[unit.value] || [], 'انتخاب دستهبندی');
+ category.value = button.dataset.suggestCategory;
+ document.querySelector('#manual-fields').hidden = false;
+ document.querySelector('#manual-toggle').setAttribute('aria-expanded', 'true');
+ document.querySelectorAll('[data-suggest-unit]').forEach(item => item.classList.toggle('chosen', item === button));
+}));
+document.querySelector('#intent').addEventListener('input', event => {
+ const query = event.target.value.trim();
+ const suggestions = document.querySelector('#suggestions');
+ const suggestionButtons = [...suggestions.querySelectorAll('[data-suggest-unit]')];
+ suggestions.hidden = !query;
+ if (!query) {
+ suggestionButtons.forEach(button => { button.hidden = false; });
+ document.querySelector('#suggestion-empty').hidden = true;
+ return;
+ }
+ const normalizedQuery = query.toLocaleLowerCase('fa');
+ let visibleCount = 0;
+ suggestionButtons.forEach(button => {
+ const matches = button.textContent.toLocaleLowerCase('fa').includes(normalizedQuery);
+ button.hidden = !matches;
+ if (matches) visibleCount += 1;
+ });
+ document.querySelector('#suggestion-empty').hidden = visibleCount > 0;
+});
+document.querySelectorAll('.expert input').forEach(input => input.addEventListener('change', () => {
+ document.querySelectorAll('.expert').forEach(card => card.classList.toggle('selected', card.querySelector('input').checked));
+}));
+body.addEventListener('input', () => { document.querySelector('#body-count').textContent = toPersianDigits(body.value.length); });
+
+function renderFiles() {
+ const preview = document.querySelector('#file-preview');
+ preview.replaceChildren();
+ files.forEach((file, index) => {
+ const card = document.createElement('div');
+ card.className = 'file-card';
+ if (file.type.startsWith('image/')) {
+ const image = document.createElement('img');
+ image.src = URL.createObjectURL(file);
+ image.onload = () => URL.revokeObjectURL(image.src);
+ image.alt = `پیشنمایش ${file.name}`;
+ card.appendChild(image);
+ } else {
+ const icon = document.createElement('div');
+ icon.className = 'file-icon';
+ icon.textContent = (file.name.split('.').pop() || 'FILE').toUpperCase();
+ card.appendChild(icon);
+ }
+ const label = document.createElement('small');
+ label.textContent = file.name;
+ card.appendChild(label);
+ const remove = document.createElement('button');
+ remove.type = 'button';
+ remove.textContent = '×';
+ remove.setAttribute('aria-label', `حذف ${file.name}`);
+ remove.addEventListener('click', () => { files.splice(index, 1); renderFiles(); });
+ card.appendChild(remove);
+ preview.appendChild(card);
+ });
+}
+
+document.querySelectorAll('#gallery,#documents').forEach(input => input.addEventListener('change', () => {
+ files.push(...input.files);
+ input.value = '';
+ renderFiles();
+}));
+recorderUi.record.addEventListener('click', startRecording);
+recorderUi.pause.addEventListener('click', togglePause);
+recorderUi.stop.addEventListener('click', () => stopRecording());
+recorderUi.cancel.addEventListener('click', () => stopRecording({ discard: true }));
+recorderUi.remove.addEventListener('click', clearVoice);
+recorderUi.rerecord.addEventListener('click', startRecording);
+window.addEventListener('pagehide', () => { stopMicrophoneTracks(); clearTimer(); });
+form.addEventListener('submit', event => {
+ event.preventDefault();
+ steps.forEach(step => { step.hidden = true; });
+ document.querySelector('#success-page').hidden = false;
+ progress.forEach(item => item.classList.add('completed'));
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+});
+document.querySelector('#restart').addEventListener('click', () => {
+ document.querySelector('#success-page').hidden = true;
+ showStep(1);
+});
diff --git a/design-prototypes/ticket-new-wizard/styles.css b/design-prototypes/ticket-new-wizard/styles.css
new file mode 100644
index 0000000..4306261
--- /dev/null
+++ b/design-prototypes/ticket-new-wizard/styles.css
@@ -0,0 +1,50 @@
+@font-face{font-family:Vazirmatn;src:url("../../static/fonts/vazirmatn-arabic-wght-normal.woff2") format("woff2-variations");font-weight:100 900;font-display:swap}@font-face{font-family:Vazirmatn;src:url("../../static/fonts/vazirmatn-latin-wght-normal.woff2") format("woff2-variations");font-weight:100 900;font-display:swap}
+:root{font-family:Vazirmatn,ui-sans-serif,system-ui,sans-serif;color:#08376b;background:#eee;--ink:#08376b;--ink-soft:#0b4c8c;--sea:#0693e3;--sea-dark:#1977ce;--coral:#fa9f00;--cream:#eee;--soft:#eaeaea;--paper:#fff;--mint:#e5f4ff;--line:rgba(8,55,107,.1);--muted:rgba(8,55,107,.58);--shadow-panel:0 18px 55px -35px rgba(8,55,107,.35);--shadow-lift:0 22px 55px -28px rgba(6,147,227,.42)}
+*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-height:100vh;background:var(--cream);color:var(--ink);-webkit-font-smoothing:antialiased}button,input,textarea,select{font:inherit}button,input,textarea,select,a{outline:0}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,a:focus-visible{box-shadow:0 0 0 4px rgba(6,147,227,.2)}button{cursor:pointer}button:disabled{pointer-events:none;opacity:.5}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
+.prototype-note{display:grid;gap:.05rem;padding:.55rem .8rem;background:#fff3d9;color:#805300;text-align:center;font-size:.68rem}.prototype-note span{font-weight:900}.prototype-note p{margin:0}.shell{width:100%;padding:0 .75rem 6.5rem}.topbar{display:flex;min-height:3.75rem;align-items:center;justify-content:space-between}.back-button,.manual-toggle{border:0;background:transparent;color:var(--sea-dark);font-size:.72rem;font-weight:850}.brand{display:none}.intro{margin:.8rem 0 1.1rem}.eyebrow{color:var(--sea);font-size:.68rem;font-weight:900;letter-spacing:.03em}.intro h1{margin:.3rem 0;color:var(--ink);font-size:1.45rem;line-height:1.45;letter-spacing:-.025em}.intro p{margin:0;color:var(--muted);font-size:.76rem;line-height:1.9}.save-state{display:none}
+.progress{position:relative;display:grid;grid-template-columns:repeat(5,1fr);margin:0 0 .75rem;padding:0;list-style:none}.progress:before{position:absolute;right:10%;left:10%;top:1.05rem;height:2px;background:rgba(8,55,107,.1);content:""}.progress li{position:relative;display:grid;justify-items:center;color:rgba(8,55,107,.35)}.progress li>span{z-index:1;display:grid;width:2.1rem;height:2.1rem;place-items:center;border:2px solid #d5dfe4;border-radius:.7rem;background:var(--cream);font-size:.68rem;font-weight:900}.progress li>div{display:none}.progress li.active>span{border-color:var(--sea);background:var(--sea);color:#fff;box-shadow:0 8px 20px -12px var(--sea)}.progress li.completed>span{border-color:#27a47a;background:#e5f7f0;color:#16815e}
+.wizard{border:1px solid var(--line);border-radius:1.1rem;background:var(--paper);box-shadow:var(--shadow-panel)}.step{padding:1rem}.step-head{display:flex;align-items:flex-start;gap:.65rem;border-bottom:1px solid rgba(8,55,107,.07);padding-bottom:1rem}.step-symbol{display:grid;width:2.4rem;height:2.4rem;flex:none;place-items:center;border-radius:.72rem;background:var(--mint);color:var(--sea-dark);font-size:1.05rem;font-weight:900}.step-symbol.success{background:#e5f8ef;color:#16815e}.step-head small{color:rgba(8,55,107,.42);font-size:.6rem;font-weight:700}.step-head h2{margin:.1rem 0;color:var(--ink);font-size:1rem;line-height:1.55}.step-head p{margin:0;color:var(--muted);font-size:.7rem;line-height:1.85}
+.search-box{display:flex;align-items:center;margin:1rem 0 .6rem;border:1px solid rgba(8,55,107,.14);border-radius:1rem;background:#fff;padding:0 .7rem;box-shadow:0 1px 3px rgba(8,55,107,.06)}.search-box:focus-within{border-color:var(--sea);box-shadow:0 0 0 4px rgba(6,147,227,.1)}.search-box i{color:var(--sea);font-style:normal}.search-box input{width:100%;min-width:0;min-height:3rem;border:0;background:transparent;padding:.65rem;font-size:.76rem;color:var(--ink)}.search-box kbd{display:none}.suggestions{display:grid;gap:.4rem}.suggestions button{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.12rem .5rem;width:100%;border:1px solid rgba(8,55,107,.09);border-radius:.8rem;background:#fff;padding:.7rem;text-align:right;color:var(--ink)}.suggestions button:active,.suggestions button.chosen{border-color:rgba(6,147,227,.45);background:var(--mint)}.suggestions b{font-size:.7rem}.suggestions small{color:var(--muted);font-size:.6rem}.suggestions span{grid-column:2;grid-row:1/3;align-self:center;color:var(--sea-dark);font-size:.6rem;font-weight:900}.manual-toggle{display:flex;width:100%;justify-content:space-between;margin-top:.8rem;border-top:1px dashed rgba(8,55,107,.12);padding:.85rem .1rem .3rem}.manual-fields,.two-columns{display:grid;gap:.7rem;margin-top:.55rem}.manual-fields label,.two-columns label,.full-field{display:grid;gap:.35rem;color:rgba(8,55,107,.78);font-size:.7rem;font-weight:850}.manual-fields em,.full-field em{color:var(--coral);font-style:normal}.manual-fields select,.two-columns select,.full-field input,.full-field textarea{width:100%;min-height:3rem;border:1px solid rgba(8,55,107,.12);border-radius:1rem;background:#fff;padding:.7rem .8rem;color:var(--ink);font-size:.76rem;box-shadow:0 1px 3px rgba(8,55,107,.05)}.manual-fields select:focus,.two-columns select:focus,.full-field input:focus,.full-field textarea:focus{border-color:var(--sea);box-shadow:0 0 0 4px rgba(6,147,227,.1)}.full-field{margin-top:.85rem}.full-field textarea{min-height:9rem;resize:vertical;line-height:1.9}.full-field small{display:flex;justify-content:flex-end;color:rgba(8,55,107,.45);font-size:.6rem;font-weight:500}.validation{min-height:1rem;margin:.45rem 0 0;color:#a32929;font-size:.64rem;font-weight:700}
+.step-actions{position:sticky;z-index:10;bottom:4.65rem;display:grid;grid-template-columns:1fr 1.35fr;gap:.55rem;margin:1rem -1rem -1rem;border-top:1px solid rgba(8,55,107,.07);background:rgba(255,255,255,.96);padding:.7rem 1rem;backdrop-filter:blur(12px)}.step-actions>span{display:none}.primary,.secondary,.voice-box button,.success-page button{display:inline-flex;min-height:3rem;align-items:center;justify-content:center;gap:.45rem;border-radius:1rem;padding:.65rem .9rem;font-size:.72rem;font-weight:900}.primary{border:0;background:var(--sea);color:#fff;box-shadow:0 10px 30px -14px var(--sea)}.primary:active{background:var(--sea-dark)}.secondary{border:1px solid var(--line);background:#fff;color:rgba(8,55,107,.7);box-shadow:none}.step-actions .primary:only-of-type{grid-column:2}.soft-tip{display:flex;align-items:center;gap:.65rem;margin:1rem 0;border-radius:.85rem;background:var(--mint);padding:.75rem;color:rgba(8,55,107,.65)}.soft-tip>span{display:grid;width:1.75rem;height:1.75rem;flex:none;place-items:center;border-radius:.55rem;background:#fff;color:var(--sea-dark);font-weight:900}.soft-tip p{margin:0;font-size:.65rem}.soft-tip b{display:block;color:var(--ink)}
+.expert-list{display:grid;gap:.55rem;margin:1rem 0}.expert{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.7rem;border:1px solid var(--line);border-radius:1rem;background:#fff;padding:.72rem;text-align:right}.expert.selected{border:2px solid var(--sea);background:var(--mint)}.expert input{position:absolute;opacity:0}.expert>i{display:none;width:1.3rem;height:1.3rem;place-items:center;border-radius:50%;background:var(--sea);color:#fff;font-size:.58rem;font-style:normal}.expert.selected>i{display:grid}.avatar{display:grid;width:2.7rem;height:2.7rem;place-items:center;border-radius:.85rem;background:#e4eef8;color:#285b87;font-weight:950}.avatar.queue{background:#e4f6ef;color:#16815e}.avatar.amber{background:#fff1d7;color:#9a6200}.expert b,.expert small{display:block}.expert b{font-size:.72rem}.expert small{margin-top:.12rem;color:var(--muted);font-size:.59rem}
+.attachment-zone{display:grid;gap:.7rem;margin-top:.85rem;border:2px dashed rgba(8,55,107,.14);border-radius:1rem;background:rgba(229,244,255,.45);padding:.85rem}.attachment-head,.voice-box,.voice-box>div{display:flex;align-items:center;gap:.65rem}.attachment-head>span{display:grid;width:2.35rem;height:2.35rem;flex:none;place-items:center;border-radius:.72rem;background:var(--mint);color:var(--sea-dark);font-size:1.05rem;font-weight:900}.attachment-head b,.attachment-head small,.voice-box b,.voice-box small{display:block}.attachment-head b,.voice-box b{font-size:.7rem}.attachment-head small,.voice-box small{margin-top:.1rem;color:var(--muted);font-size:.58rem}.attachment-actions{display:grid;gap:.5rem}.attachment-actions label{display:grid;min-height:3rem;place-items:center;border:1px solid rgba(6,147,227,.38);border-radius:.9rem;background:#fff;color:#0b5f9b;font-size:.7rem;font-weight:900;cursor:pointer}.attachment-actions label:first-child{border-color:var(--sea);background:var(--mint)}.file-preview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.45rem}.file-card{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:.8rem;background:#fff;padding:.4rem}.file-card img,.file-card .file-icon{width:100%;height:76px;border-radius:.6rem}.file-card img{object-fit:cover}.file-card .file-icon{display:grid;place-items:center;background:var(--soft);color:rgba(8,55,107,.62);font-size:.66rem;font-weight:900}.file-card small{display:block;overflow:hidden;margin-top:.3rem;color:rgba(8,55,107,.7);font-size:.54rem;text-overflow:ellipsis;white-space:nowrap}.file-card button{position:absolute;top:.18rem;left:.18rem;display:grid;width:1.6rem;height:1.6rem;place-items:center;border:0;border-radius:.5rem;background:var(--coral);color:var(--ink);font-weight:900}.voice-box{justify-content:space-between;margin-top:.7rem;border:1px solid rgba(6,147,227,.14);border-radius:1rem;background:rgba(229,244,255,.55);padding:.75rem}.voice-box>div>span{color:var(--coral)}.voice-box button{min-height:2.5rem;border:1px solid var(--line);background:#fff;color:var(--ink);font-size:.65rem}
+.review-grid{display:grid;gap:.55rem;margin:1rem 0}.review-grid article{border:1px solid var(--line);border-radius:1rem;background:#fff;padding:.8rem;box-shadow:0 8px 24px -24px rgba(8,55,107,.7)}.review-grid header{display:flex;align-items:center;justify-content:space-between}.review-grid header>b{color:var(--muted);font-size:.63rem}.review-grid header button{border:0;background:transparent;color:var(--sea-dark);font-size:.62rem;font-weight:900}.review-grid h3{margin:.45rem 0 .15rem;color:var(--ink);font-size:.8rem}.review-grid p{display:flex;align-items:center;gap:.35rem;margin:0;color:var(--muted);font-size:.62rem;line-height:1.8}.review-grid p i{font-style:normal}.review-body{color:rgba(8,55,107,.75)!important;white-space:pre-wrap}.review-files{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.55rem}.review-files span{border-radius:999px;background:var(--mint);padding:.28rem .5rem;color:var(--sea-dark);font-size:.58rem}.confirm{display:flex;align-items:flex-start;gap:.65rem;border:1px solid rgba(39,164,122,.25);border-radius:1rem;background:#f3fbf8;padding:.75rem}.confirm input{width:1.1rem;height:1.1rem;accent-color:#16815e}.confirm b,.confirm small{display:block}.confirm b{color:#245d4a;font-size:.68rem}.confirm small{margin-top:.12rem;color:#66877b;font-size:.58rem}.success-page{padding:3rem 1rem;text-align:center}.success-page>span{display:grid;width:3.7rem;height:3.7rem;margin:auto;place-items:center;border-radius:1.1rem;background:#e1f7ed;color:#16815e;font-size:1.7rem;font-weight:900}.success-page h2{margin:.9rem 0 .25rem;font-size:1.1rem}.success-page p{color:var(--muted);font-size:.7rem}.success-page button{margin-top:.8rem;border:0;background:var(--ink);color:#fff}.mobile-dock{position:fixed;z-index:50;right:.75rem;bottom:.75rem;left:.75rem;display:grid;grid-template-columns:repeat(3,1fr);border-radius:1.35rem;background:rgba(8,55,107,.97);padding:.35rem;box-shadow:0 20px 50px -18px rgba(8,55,107,.75);backdrop-filter:blur(14px)}.mobile-dock button{display:flex;min-height:3rem;flex-direction:column;align-items:center;justify-content:center;gap:.05rem;border:0;border-radius:1rem;background:transparent;color:rgba(255,255,255,.6);font-size:.58rem;font-weight:800}.mobile-dock button span{font-size:.9rem}.mobile-dock button.active{background:var(--sea);color:#fff}
+@media(min-width:640px){.shell{width:min(100% - 2.5rem,72rem);padding-bottom:3rem}.topbar{min-height:4.5rem}.brand{display:grid;text-align:left}.brand span{font-size:.9rem;font-weight:900}.brand small{color:var(--muted);font-size:.58rem}.intro{display:flex;align-items:flex-end;justify-content:space-between;gap:2rem;margin:1.1rem 0 1.35rem}.intro h1{font-size:1.9rem}.intro p{font-size:.8rem}.save-state{display:flex;align-items:center;gap:.45rem;color:var(--muted);font-size:.65rem}.save-state i{width:.48rem;height:.48rem;border-radius:50%;background:#19a774;box-shadow:0 0 0 .23rem rgba(25,167,116,.1)}.progress{margin-bottom:1rem}.progress:before{top:1.12rem}.progress li{display:flex;justify-content:center;gap:.5rem}.progress li>span{width:2.3rem;height:2.3rem}.progress li>div{display:block}.progress li b,.progress li small{display:block}.progress li b{font-size:.68rem}.progress li small{margin-top:.08rem;font-size:.55rem}.wizard{border-radius:1.6rem}.step{padding:1.35rem 1.5rem}.step-head{gap:.8rem}.step-symbol{width:2.7rem;height:2.7rem}.step-head h2{font-size:1.1rem}.step-head p{font-size:.74rem}.search-box{margin-top:1.15rem}.search-box kbd{display:inline;border:1px solid var(--line);border-radius:.4rem;background:var(--cream);padding:.2rem .4rem;color:var(--muted);font-size:.56rem}.manual-fields,.two-columns{grid-template-columns:1fr 1fr}.expert-list{grid-template-columns:repeat(3,1fr)}.expert{display:grid;grid-template-columns:1fr;justify-items:center;padding:.9rem;text-align:center}.expert>i{position:absolute;top:.5rem;left:.5rem}.avatar{width:3.15rem;height:3.15rem}.expert b{margin-top:.5rem}.attachment-actions{grid-template-columns:1fr 1fr}.file-preview{grid-template-columns:repeat(3,minmax(0,1fr))}.review-grid{grid-template-columns:1fr 1fr}.review-grid article.wide{grid-column:1/-1}.step-actions{position:static;display:flex;justify-content:flex-end;margin:1.15rem -1.5rem -1.35rem;padding:.8rem 1.5rem}.step-actions>span{display:block;margin-left:auto;color:rgba(8,55,107,.4);font-size:.6rem}.step-actions .primary:only-of-type{margin-right:auto}.primary,.secondary{min-width:8.5rem}.final-actions{justify-content:space-between}.mobile-dock{display:none}}
+@media(min-width:1024px){.shell{width:min(100% - 4rem,65rem)}.intro h1{font-size:2.15rem}.wizard{margin-inline:auto}.step{padding:1.6rem 1.8rem}.step-actions{margin:1.3rem -1.8rem -1.6rem;padding:.85rem 1.8rem}.step-head h2{font-size:1.2rem}.suggestions{grid-template-columns:1fr 1fr}.review-grid{gap:.7rem}.file-preview{grid-template-columns:repeat(4,minmax(0,1fr))}}
+@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{transition:none!important}}
+
+/* Voice recorder: mobile-first states with playback and safe removal. */
+.voice-recorder{margin-top:.8rem;overflow:hidden;border:1px solid rgba(6,147,227,.2);border-radius:1.05rem;background:linear-gradient(145deg,#f7fcff,#edf7ff);box-shadow:0 12px 32px -28px rgba(8,55,107,.75)}
+.voice-head{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.65rem;padding:.8rem}
+.voice-icon{display:grid;width:2.35rem;height:2.35rem;place-items:center;border-radius:.75rem;background:#fff;color:var(--sea);font-size:1rem;box-shadow:0 8px 20px -16px rgba(8,55,107,.8)}
+.voice-head b,.voice-head small,.voice-ready b,.voice-ready small{display:block}.voice-head b{font-size:.72rem}.voice-head small{margin-top:.12rem;color:var(--muted);font-size:.58rem;line-height:1.7}
+.voice-timer{direction:ltr;border:1px solid rgba(6,147,227,.16);border-radius:999px;background:#fff;padding:.28rem .5rem;color:var(--ink);font-size:.64rem;font-weight:900;font-variant-numeric:tabular-nums}
+.voice-idle,.voice-live,.voice-preview{border-top:1px solid rgba(8,55,107,.07);padding:.75rem .8rem}
+.voice-idle{display:grid;gap:.35rem}.voice-idle>small{color:var(--muted);font-size:.56rem;text-align:center}
+.voice-start{display:flex;min-height:3rem;align-items:center;justify-content:center;gap:.45rem;border:0;border-radius:.9rem;background:var(--sea);color:#fff;font-size:.7rem;font-weight:900;box-shadow:0 12px 28px -18px var(--sea)}.voice-start span{color:#fff;font-size:.85rem}
+.voice-wave{display:flex;height:2.8rem;align-items:center;justify-content:center;gap:.24rem}.voice-wave i{width:.22rem;height:.75rem;border-radius:999px;background:var(--sea);animation:voice-wave .9s ease-in-out infinite}.voice-wave i:nth-child(2),.voice-wave i:nth-child(6){height:1.35rem;animation-delay:.12s}.voice-wave i:nth-child(3),.voice-wave i:nth-child(5){height:2rem;animation-delay:.24s}.voice-wave i:nth-child(4){height:2.5rem;animation-delay:.36s}.voice-recorder[data-state="paused"] .voice-wave i{animation-play-state:paused;opacity:.4}
+@keyframes voice-wave{0%,100%{transform:scaleY(.55)}50%{transform:scaleY(1)}}
+.voice-controls{display:grid;grid-template-columns:1fr 1.5fr;gap:.45rem}.voice-controls button,.voice-preview-actions button{min-height:2.65rem;border:1px solid var(--line);border-radius:.8rem;background:#fff;color:var(--ink);font-size:.63rem;font-weight:850}.voice-controls .voice-stop{border-color:var(--sea);background:var(--sea);color:#fff}.voice-controls .voice-cancel{grid-column:1/-1;min-height:2rem;border:0;background:transparent;color:#9c3042}
+.voice-preview{display:grid;gap:.65rem;background:#fff}.voice-ready{display:flex;align-items:center;gap:.55rem}.voice-ready>span{display:grid;width:1.8rem;height:1.8rem;flex:none;place-items:center;border-radius:.55rem;background:#e5f8ef;color:#16815e;font-size:.65rem;font-weight:900}.voice-ready b{font-size:.68rem}.voice-ready small{color:var(--muted);font-size:.56rem}.voice-preview audio,.review-audio{width:100%;height:2.6rem;accent-color:var(--sea)}.voice-preview-actions{display:grid;grid-template-columns:1fr 1fr;gap:.45rem}.voice-preview-actions #delete-voice{border-color:rgba(169,39,60,.18);color:#a9273c}
+.voice-note{display:flex;align-items:flex-start;gap:.35rem;margin:0;border-top:1px dashed rgba(8,55,107,.1);padding:.6rem .8rem;color:var(--muted);font-size:.55rem;line-height:1.7}.voice-note span{color:var(--sea-dark)}.voice-error{margin:0;padding:.55rem .8rem;background:#fff0f1;color:#9c3042;font-size:.6rem;font-weight:800}
+
+/* Review: one calm scan path instead of a dashboard of competing cards. */
+.review-callout{display:flex;align-items:center;gap:.65rem;margin:.85rem 0 .65rem;border-radius:.9rem;background:#f0faf6;padding:.7rem}.review-callout>span{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:.65rem;background:#fff;color:#16815e;font-weight:900}.review-callout b,.review-callout small{display:block}.review-callout b{font-size:.68rem}.review-callout small{margin-top:.1rem;color:#66877b;font-size:.56rem;line-height:1.65}
+.review-list{overflow:hidden;border:1px solid var(--line);border-radius:1rem;background:#fff}.review-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:start;gap:.6rem;padding:.75rem}.review-row+.review-row{border-top:1px solid rgba(8,55,107,.07)}.review-icon{display:grid;width:1.8rem;height:1.8rem;place-items:center;border-radius:.55rem;background:var(--mint);color:var(--sea-dark);font-size:.58rem;font-weight:900}.review-content{min-width:0}.review-content>small{color:var(--muted);font-size:.55rem;font-weight:800}.review-content h3{overflow-wrap:anywhere;margin:.16rem 0 .08rem;color:var(--ink);font-size:.73rem;line-height:1.6}.review-content p{display:flex;flex-wrap:wrap;align-items:center;gap:.3rem;margin:0;color:var(--muted);font-size:.58rem;line-height:1.75}.review-content p i{font-style:normal}.review-row>button{display:flex;min-height:2rem;align-items:center;gap:.25rem;border:0;border-radius:.65rem;background:var(--mint);padding:.35rem .5rem;color:var(--sea-dark);font-size:.56rem;font-weight:900}.review-row>button span{font-size:.7rem}.review-details .review-content{grid-column:2/-1}.review-details>button{grid-column:3;grid-row:1}.review-body{display:block!important;margin-top:.2rem!important;color:rgba(8,55,107,.78)!important;white-space:pre-wrap}.review-files{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.45rem}.review-files span{border-radius:999px;background:var(--mint);padding:.28rem .5rem;color:var(--sea-dark);font-size:.56rem}.review-audio{margin-top:.55rem}
+
+@media(min-width:640px){.voice-recorder{margin-top:1rem}.voice-head{padding:.9rem 1rem}.voice-idle,.voice-live,.voice-preview{padding:.85rem 1rem}.voice-idle{grid-template-columns:auto 1fr;align-items:center}.voice-idle>small{text-align:right}.voice-start{min-width:11rem}.voice-live{grid-template-columns:1fr auto;align-items:center}.voice-controls{grid-template-columns:auto auto auto}.voice-controls .voice-cancel{grid-column:auto;min-height:2.65rem}.voice-controls button{min-width:6.5rem}.voice-note{padding:.65rem 1rem}.review-callout{padding:.8rem 1rem}.review-row{gap:.8rem;padding:.9rem 1rem}.review-row>button{padding:.4rem .65rem}.review-content h3{font-size:.78rem}.review-details .review-content{grid-column:2}.review-details>button{grid-column:3}.review-audio{max-width:28rem}}
+
+@media(prefers-reduced-motion:reduce){.voice-wave i{animation:none}}
+
+/* Readability pass: comfortable mobile text, 16px form controls, and clear hierarchy. */
+body{font-size:16px;line-height:1.75}.prototype-note{font-size:.76rem;line-height:1.65}.back-button,.manual-toggle{font-size:.82rem}.eyebrow{font-size:.76rem}.intro p{font-size:.86rem;line-height:1.85}.step-head small{font-size:.7rem}.step-head h2{font-size:1.08rem;line-height:1.6}.step-head p{font-size:.82rem;line-height:1.85}.search-box input,.manual-fields select,.two-columns select,.full-field input,.full-field textarea{font-size:1rem}.suggestions b{font-size:.84rem}.suggestions small{font-size:.73rem}.suggestions span{font-size:.72rem}.suggestion-empty{grid-column:1/-1;margin:0;border:1px dashed rgba(8,55,107,.14);border-radius:.8rem;background:#fafcfd;padding:.75rem;color:var(--muted);font-size:.78rem;line-height:1.7;text-align:center}.manual-fields label,.two-columns label,.full-field{font-size:.82rem}.full-field small{font-size:.7rem}.validation{font-size:.74rem;line-height:1.7}.primary,.secondary,.success-page button{font-size:.82rem}.soft-tip p{font-size:.76rem;line-height:1.75}.expert b{font-size:.82rem}.expert small{font-size:.7rem;line-height:1.65}.attachment-head b{font-size:.82rem}.attachment-head small{font-size:.7rem;line-height:1.65}.attachment-actions label{font-size:.8rem}.file-card small{font-size:.68rem}.voice-head b{font-size:.84rem}.voice-head small{font-size:.72rem}.voice-timer{font-size:.75rem}.voice-idle>small{font-size:.69rem}.voice-start{font-size:.82rem}.voice-controls button,.voice-preview-actions button{font-size:.75rem}.voice-ready b{font-size:.8rem}.voice-ready small{font-size:.7rem}.voice-note{font-size:.68rem}.voice-error{font-size:.72rem}.review-callout b{font-size:.82rem}.review-callout small{font-size:.7rem}.review-content>small{font-size:.7rem}.review-content h3{font-size:.9rem;line-height:1.7}.review-content p{font-size:.72rem;line-height:1.8}.review-row>button{font-size:.68rem}.review-files span{font-size:.68rem}.confirm b{font-size:.8rem}.confirm small{font-size:.7rem;line-height:1.7}.success-page p{font-size:.8rem}.mobile-dock button{font-size:.68rem}
+#review-subject{font-size:1rem;font-weight:900;line-height:1.8}.review-body{font-size:.88rem!important;font-weight:550;line-height:2!important}
+
+@media(min-width:640px){.brand span{font-size:1rem}.brand small{font-size:.7rem}.intro p{font-size:.9rem}.save-state{font-size:.76rem}.progress li b{font-size:.78rem}.progress li small{font-size:.67rem}.step-head h2{font-size:1.2rem}.step-head p{font-size:.86rem}.review-content h3{font-size:.96rem}#review-subject{font-size:1.08rem}.review-body{font-size:.94rem!important}.review-row>button{font-size:.72rem}}
+
+/* The decisive action stays visible throughout the review step. */
+.review-step{padding-bottom:6.75rem}.final-actions{position:fixed;z-index:40;right:.75rem;bottom:4.85rem;left:.75rem;display:grid;grid-template-columns:minmax(0,.78fr) minmax(0,1.4fr);gap:.5rem;margin:0;border:1px solid rgba(8,55,107,.12);border-radius:1.1rem;background:rgba(255,255,255,.96);padding:.55rem;box-shadow:0 20px 50px -20px rgba(8,55,107,.55);backdrop-filter:blur(14px)}.final-actions .primary,.final-actions .secondary{min-width:0}.final-actions .send{box-shadow:0 12px 28px -14px var(--sea)}
+@media(min-width:640px){.review-step{padding-bottom:6rem}.final-actions{right:auto;bottom:1rem;left:50%;width:min(calc(100% - 2rem),42rem);transform:translateX(-50%);padding:.6rem}.final-actions .primary,.final-actions .secondary{min-height:3.15rem}}
+
+/* Validation feedback is a visible destination, not a quiet line below the form. */
+.validation:empty{display:none}.validation.visible{position:relative;min-height:0;margin:.8rem 0 0;border:1px solid rgba(169,39,60,.2);border-radius:.85rem;background:#fff1f3;padding:.72rem 2.2rem .72rem .75rem;color:#92263a;font-size:.78rem;font-weight:850;line-height:1.8;box-shadow:0 10px 28px -24px rgba(146,38,58,.75)}.validation.visible:before{position:absolute;top:.75rem;right:.75rem;display:grid;width:1.15rem;height:1.15rem;place-items:center;border-radius:50%;background:#a9273c;color:#fff;font-size:.66rem;content:"!"}.validation.visible:focus{box-shadow:0 0 0 4px rgba(169,39,60,.13)}[aria-invalid="true"]{border-color:#b8324a!important;background:#fffafb!important;box-shadow:0 0 0 3px rgba(184,50,74,.1)!important}
diff --git a/design-prototypes/ticket-new-wizard/test_prototype_e2e.py b/design-prototypes/ticket-new-wizard/test_prototype_e2e.py
new file mode 100644
index 0000000..1c94d27
--- /dev/null
+++ b/design-prototypes/ticket-new-wizard/test_prototype_e2e.py
@@ -0,0 +1,163 @@
+import base64
+
+import pytest
+from playwright.sync_api import expect
+
+
+pytest_plugins = ["tests.e2e.conftest"]
+pytestmark = pytest.mark.e2e
+
+
+def test_validation_message_is_focused_and_scrolled_into_view(browser_page):
+ with browser_page() as page:
+ page.set_viewport_size({"width": 390, "height": 620})
+ page.goto(
+ "http://127.0.0.1:4173/design-prototypes/ticket-new-wizard/",
+ wait_until="domcontentloaded",
+ )
+ page.locator('[data-step="1"] [data-next]').click()
+ error = page.locator('[data-error="1"]')
+ expect(error).to_be_visible()
+ expect(error).to_contain_text("واحد، دستهبندی، موضوع")
+ expect(error).to_be_in_viewport()
+ expect(page.locator("#unit")).to_have_attribute("aria-invalid", "true")
+ expect(page.locator("#category")).to_have_attribute("aria-invalid", "true")
+ expect(page.locator("#subject")).to_have_attribute("aria-invalid", "true")
+ assert page.evaluate("document.activeElement === document.querySelector('[data-error=\"1\"]')")
+
+
+def test_ticket_wizard_progressive_flow_preview_edit_and_attachment_removal(browser_page):
+ tiny_png=base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
+ with browser_page() as page:
+ page.set_viewport_size({"width":390,"height":844})
+ page.goto("http://127.0.0.1:4173/design-prototypes/ticket-new-wizard/",wait_until="domcontentloaded")
+ expect(page.locator('[data-step="1"]')).to_be_visible()
+ expect(page.locator('[data-step="2"]')).to_be_hidden()
+ expect(page.locator(".mobile-dock")).to_be_visible()
+ assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
+
+ expect(page.locator("#suggestions")).to_be_hidden()
+ page.locator("#intent").fill("فیش")
+ expect(page.locator("#suggestions")).to_be_visible()
+ expect(page.get_by_role("button",name="ارسال فیش یا رسید پرداخت")).to_be_visible()
+ expect(page.get_by_role("button",name="هماهنگی ارسال یا بارگیری")).to_be_hidden()
+ assert page.locator("#intent").evaluate("element => parseFloat(getComputedStyle(element).fontSize)") >= 16
+ page.get_by_role("button",name="ارسال فیش یا رسید پرداخت").click()
+ expect(page.locator("#unit")).to_have_value("حسابداری")
+ expect(page.locator("#category")).to_have_value("فیش واریزی")
+ expect(page.locator("#subject")).to_have_value("")
+ page.locator("#subject").fill("پیگیری فیش واریزی سفارش")
+ page.locator("#unit").select_option(label="فروش")
+ expect(page.locator("#subject")).to_have_value("پیگیری فیش واریزی سفارش")
+ page.locator("#unit").select_option(label="حسابداری")
+ page.locator("#category").select_option(label="فیش واریزی")
+ page.locator('[data-step="1"] [data-next]').click()
+ expect(page.locator('[data-step="2"]')).to_be_visible()
+
+ page.locator('[data-step="2"] [data-next]').click()
+ expect(page.locator('[data-step="3"]')).to_be_visible()
+ page.locator('[data-step="3"] [data-next]').click()
+ expect(page.locator('[data-step="4"]')).to_be_visible()
+
+ page.locator("#body").fill("فیش واریزی سفارش را برای بررسی ارسال کردهام.")
+ page.locator("#gallery").set_input_files({"name":"receipt.png","mimeType":"image/png","buffer":tiny_png})
+ page.locator("#documents").set_input_files({"name":"invoice.pdf","mimeType":"application/pdf","buffer":b"%PDF-1.4\n%%EOF"})
+ expect(page.locator("#file-preview .file-card")).to_have_count(2)
+ page.get_by_role("button",name="حذف receipt.png").click()
+ expect(page.locator("#file-preview .file-card")).to_have_count(1)
+
+ page.get_by_role("button",name="مشاهده پیشنمایش").click()
+ expect(page.locator('[data-step="5"]')).to_be_visible()
+ expect(page.locator("#review-subject")).to_have_text("پیگیری فیش واریزی سفارش")
+ assert page.locator("#review-subject").evaluate("element => parseFloat(getComputedStyle(element).fontSize)") >= 16
+ assert page.locator("#review-body").evaluate("element => parseFloat(getComputedStyle(element).fontSize)") >= 14
+ expect(page.locator("#review-files")).to_have_text("invoice.pdf")
+ page.locator('[data-edit="4"]').click()
+ expect(page.locator('[data-step="4"]')).to_be_visible()
+ page.get_by_role("button",name="مشاهده پیشنمایش").click()
+ send_button = page.get_by_role("button",name="ثبت و ارسال نهایی")
+ expect(send_button).to_be_visible()
+ expect(send_button).to_be_enabled()
+ send_box = send_button.bounding_box()
+ assert send_box and send_box["y"] + send_box["height"] <= page.viewport_size["height"]
+ send_button.click()
+ expect(page.locator("#success-page")).to_be_visible()
+
+ assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
+
+ page.set_viewport_size({"width":1280,"height":900})
+ expect(page.locator(".mobile-dock")).to_be_hidden()
+ expect(page.locator(".progress li > div").first).to_be_visible()
+ assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
+
+
+def test_voice_recording_auto_stops_on_next_and_can_be_previewed_and_removed(browser_page):
+ with browser_page() as page:
+ page.add_init_script(
+ """
+ window.__microphoneTrackStops = 0;
+ Object.defineProperty(navigator, 'mediaDevices', {
+ configurable: true,
+ value: {
+ getUserMedia: async () => ({
+ getTracks: () => [{ stop: () => { window.__microphoneTrackStops += 1; } }],
+ }),
+ },
+ });
+ class FakeMediaRecorder {
+ constructor(stream, options = {}) {
+ this.stream = stream;
+ this.mimeType = options.mimeType || 'audio/webm';
+ this.state = 'inactive';
+ }
+ start() { this.state = 'recording'; }
+ pause() { this.state = 'paused'; }
+ resume() { this.state = 'recording'; }
+ stop() {
+ this.state = 'inactive';
+ this.ondataavailable?.({ data: new Blob(['recorded voice'], { type: this.mimeType }) });
+ setTimeout(() => this.onstop?.(), 0);
+ }
+ static isTypeSupported() { return true; }
+ }
+ Object.defineProperty(window, 'MediaRecorder', { configurable: true, value: FakeMediaRecorder });
+ """
+ )
+ page.set_viewport_size({"width": 390, "height": 844})
+ page.goto(
+ "http://127.0.0.1:4173/design-prototypes/ticket-new-wizard/",
+ wait_until="domcontentloaded",
+ )
+
+ page.locator("#intent").fill("فیش")
+ page.get_by_role("button", name="ارسال فیش یا رسید پرداخت").click()
+ expect(page.locator("#subject")).to_have_value("")
+ page.locator("#subject").fill("موضوع دستی پیام صوتی")
+ page.locator('[data-step="1"] [data-next]').click()
+ page.locator('[data-step="2"] [data-next]').click()
+ page.locator('[data-step="3"] [data-next]').click()
+ expect(page.locator('[data-step="4"]')).to_be_visible()
+
+ page.get_by_role("button", name="شروع ضبط صدا").click()
+ expect(page.locator("#voice-live")).to_be_visible()
+ page.get_by_role("button", name="مکث").click()
+ expect(page.get_by_role("button", name="ادامه ضبط")).to_be_visible()
+ page.get_by_role("button", name="ادامه ضبط").click()
+
+ page.get_by_role("button", name="مشاهده پیشنمایش").click()
+ expect(page.locator('[data-step="5"]')).to_be_visible()
+ expect(page.locator("#review-audio")).to_be_visible()
+ assert page.evaluate("window.__microphoneTrackStops") == 1
+
+ page.locator('[data-edit="4"]').click()
+ expect(page.locator("#voice-preview")).to_be_visible()
+ assert page.locator("#audio").get_attribute("src").startswith("blob:")
+ page.get_by_role("button", name="حذف پیام صوتی").click()
+ expect(page.locator("#voice-idle")).to_be_visible()
+ expect(page.locator("#voice-preview")).to_be_hidden()
+ page.get_by_role("button", name="مشاهده پیشنمایش").click()
+ error = page.locator('[data-error="4"]')
+ expect(error).to_contain_text("توضیح بنویسید")
+ expect(error).to_be_in_viewport()
+ expect(page.locator("#body")).to_have_attribute("aria-invalid", "true")
+ assert page.evaluate("document.activeElement === document.querySelector('[data-error=\"4\"]')")
diff --git a/docker-compose.yml b/docker-compose.yml
index 3cdf406..259dce9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -33,7 +33,7 @@ x-django-service: &django-service
SMS0098_USERNAME: ${SMS0098_USERNAME:-}
SMS0098_PASSWORD: ${SMS0098_PASSWORD:-}
SMS0098_FROM: ${SMS0098_FROM:-}
- SMS0098_API_URL: ${SMS0098_API_URL:-http://www.0098sms.com/sendsmslink.aspx}
+ SMS0098_WSDL_URL: ${SMS0098_WSDL_URL:-https://webservice.0098sms.com/service.asmx?WSDL}
BEHIN_SMS_USERNAME: ${BEHIN_SMS_USERNAME:-}
BEHIN_SMS_PASSWORD: ${BEHIN_SMS_PASSWORD:-}
BEHIN_SMS_SPECIAL_NUMBER: ${BEHIN_SMS_SPECIAL_NUMBER:-}
@@ -45,6 +45,10 @@ x-django-service: &django-service
BALE_BOT_TOKEN: ${BALE_BOT_TOKEN:-}
INTEGRATION_HTTP_TIMEOUT_SECONDS: ${INTEGRATION_HTTP_TIMEOUT_SECONDS:-10}
MAX_NOTIFICATION_ATTEMPTS: ${MAX_NOTIFICATION_ATTEMPTS:-5}
+ SMS_MAX_RETRIES: ${SMS_MAX_RETRIES:-3}
+ SMS_RETRY_DELAY_SECONDS: ${SMS_RETRY_DELAY_SECONDS:-180}
+ SMS_DELIVERY_CHECK_DELAY_SECONDS: ${SMS_DELIVERY_CHECK_DELAY_SECONDS:-180}
+ SMS_DELIVERY_MAX_CHECKS: ${SMS_DELIVERY_MAX_CHECKS:-15}
SECURE_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-true}
SECURE_HSTS_SECONDS: ${SECURE_HSTS_SECONDS:-31536000}
SECURE_HSTS_INCLUDE_SUBDOMAINS: ${SECURE_HSTS_INCLUDE_SUBDOMAINS:-true}
diff --git a/docs/DEPLOYMENT_COOLIFY.md b/docs/DEPLOYMENT_COOLIFY.md
index 38d6cf4..9c90692 100644
--- a/docs/DEPLOYMENT_COOLIFY.md
+++ b/docs/DEPLOYMENT_COOLIFY.md
@@ -80,7 +80,7 @@ SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider
SMS0098_USERNAME=
SMS0098_PASSWORD=
SMS0098_FROM=
-SMS0098_API_URL=http://www.0098sms.com/sendsmslink.aspx
+SMS0098_WSDL_URL=https://webservice.0098sms.com/service.asmx?WSDL
```
### Behin SMS
@@ -109,11 +109,15 @@ BALE_API_BASE=https://tapi.bale.ai
BALE_BOT_TOKEN=
INTEGRATION_HTTP_TIMEOUT_SECONDS=10
MAX_NOTIFICATION_ATTEMPTS=5
+SMS_MAX_RETRIES=3
+SMS_RETRY_DELAY_SECONDS=180
+SMS_DELIVERY_CHECK_DELAY_SECONDS=180
+SMS_DELIVERY_MAX_CHECKS=15
```
در production نبودن `BALE_BOT_TOKEN` خطای قابلمشاهده ایجاد میکند و job را بیصدا SENT علامت نمیزند. شناسه گروه دیگر یک env سراسری نیست: برای هر واحد از مسیر `x5admin → واحدهای سازمانی`، فیلد «شناسه گروه بله» را جداگانه وارد کنید. خالی بودن این فیلد یعنی اعلانهای گروهی تیکت جدید و پاسخ مشتری برای همان واحد ارسال نمیشود؛ سامانه هرگز به گروه واحد دیگر fallback نمیکند. سه شماره اختیاری سرپرست نیز در همین صفحه تنظیم میشوند و فقط هنگام ایجاد تیکت جدید، پیامک کوتاه دریافت میکنند.
-worker هر پنج ثانیه outbox دیتابیس را بررسی میکند، با قفل `skip_locked` از پردازش همزمان جلوگیری میکند و خطاها را با backoff تا سقف `MAX_NOTIFICATION_ATTEMPTS` دوباره امتحان میکند.
+worker هر پنج ثانیه outbox دیتابیس را بررسی میکند و با قفل `skip_locked` از پردازش همزمان جلوگیری میکند. هر پیامک پس از شکست اولیه، دقیقاً `SMS_MAX_RETRIES=3` بار با فاصله ثابت `SMS_RETRY_DELAY_SECONDS=180` ثانیه دوباره امتحان میشود. `MAX_NOTIFICATION_ATTEMPTS` همچنان سقف تلاش کانالهای دیگر مانند بله است. پیامک دارای شناسه رهگیری پس از `SMS_DELIVERY_CHECK_DELAY_SECONDS` استعلام میشود؛ پاسخهای موقت 0098SMS (کدهای 6 و 8) هر سه دقیقه تا 15 نوبت، یعنی 45 دقیقه، دوباره بررسی میشوند.
سیاست تأخیر پیامک پاسخ کارشناس و اعلان پاسخ مشتری در Bale از مدل `NotificationPolicy` در پنل مدیریت دیتابیس کنترل میشود؛ متغیر قدیمی `STAFF_REPLY_SMS_THROTTLE_SECONDS` مرجع اصلی این رفتار نیست.
diff --git a/static/css/customer-ticket-wizard.css b/static/css/customer-ticket-wizard.css
new file mode 100644
index 0000000..6e23a30
--- /dev/null
+++ b/static/css/customer-ticket-wizard.css
@@ -0,0 +1,16 @@
+.ticket-wizard-page{width:min(100%,65rem);margin-inline:auto;padding:0 0 6.5rem;color:#08376b}
+.ticket-wizard-topbar{display:flex;min-height:3rem;align-items:center;justify-content:space-between;gap:1rem}.ticket-wizard-back{color:#1977ce;font-size:.82rem;font-weight:850}.ticket-wizard-save{display:none;align-items:center;gap:.45rem;color:rgba(8,55,107,.62);font-size:.76rem}.ticket-wizard-save i{width:.48rem;height:.48rem;border-radius:50%;background:#19a774;box-shadow:0 0 0 .23rem rgba(25,167,116,.1)}
+.ticket-wizard-intro{margin:.65rem 0 1.1rem}.ticket-wizard-intro .page-kicker{margin:0;color:#0693e3;font-size:.76rem;font-weight:900}.ticket-wizard-intro h1{margin:.25rem 0;font-size:1.5rem;line-height:1.5}.ticket-wizard-intro>p:last-child{margin:0;color:rgba(8,55,107,.62);font-size:.86rem;line-height:1.9}
+.ticket-wizard-progress{position:relative;display:grid;grid-template-columns:repeat(5,1fr);margin:0 0 .8rem;padding:0;list-style:none}.ticket-wizard-progress:before{position:absolute;top:1.05rem;right:10%;left:10%;height:2px;background:rgba(8,55,107,.1);content:""}.ticket-wizard-progress li{position:relative;display:grid;justify-items:center;color:rgba(8,55,107,.35)}.ticket-wizard-progress li>span{z-index:1;display:grid;width:2.1rem;height:2.1rem;place-items:center;border:2px solid #d5dfe4;border-radius:.7rem;background:#eee;font-size:.72rem;font-weight:900}.ticket-wizard-progress li>div{display:none}.ticket-wizard-progress li.is-active>span{border-color:#0693e3;background:#0693e3;color:#fff;box-shadow:0 8px 20px -12px #0693e3}.ticket-wizard-progress li.is-complete>span{border-color:#27a47a;background:#e5f7f0;color:#16815e}
+.ticket-wizard-card{overflow:visible;border:1px solid rgba(8,55,107,.1);border-radius:1.15rem;background:#fff;box-shadow:0 18px 55px -35px rgba(8,55,107,.35)}.ticket-wizard-step{padding:1rem}.ticket-wizard-card[data-wizard-enhanced] .ticket-wizard-step:not(.is-active){display:none}.ticket-wizard-step-head{display:flex;align-items:flex-start;gap:.7rem;border-bottom:1px solid rgba(8,55,107,.07);padding-bottom:1rem}.ticket-wizard-symbol{display:grid;width:2.45rem;height:2.45rem;flex:none;place-items:center;border-radius:.75rem;background:#e5f4ff;color:#1977ce;font-size:1.05rem;font-weight:900}.ticket-wizard-symbol.is-success{background:#e5f8ef;color:#16815e}.ticket-wizard-step-head small{color:rgba(8,55,107,.48);font-size:.7rem;font-weight:750}.ticket-wizard-step-head h2{margin:.08rem 0;color:#08376b;font-size:1.08rem;line-height:1.6}.ticket-wizard-step-head p{margin:0;color:rgba(8,55,107,.62);font-size:.82rem;line-height:1.85}
+.ticket-intent-search{display:flex;align-items:center;margin:1rem 0 .65rem;border:1px solid rgba(8,55,107,.14);border-radius:1rem;background:#fff;padding:0 .75rem;box-shadow:0 1px 3px rgba(8,55,107,.06)}.ticket-intent-search:focus-within{border-color:#0693e3;box-shadow:0 0 0 4px rgba(6,147,227,.1)}.ticket-intent-search i{color:#0693e3;font-style:normal}.ticket-intent-search input{min-height:3.2rem;border:0!important;background:transparent!important;padding:.7rem!important;font-size:1rem!important;box-shadow:none!important}.ticket-intent-search kbd{display:none}.ticket-intent-results:empty{display:none}.ticket-intent-results{margin-bottom:.65rem}.ticket-intent-results .intent-result{font-size:.84rem}.ticket-intent-results .intent-result span{font-size:.75rem}.ticket-intent-results .intent-result small,.ticket-intent-results .intent-message{font-size:.73rem;line-height:1.7}
+.ticket-manual-routing{margin:.75rem 0!important;border-color:rgba(8,55,107,.1)!important;border-radius:1rem!important}.ticket-manual-routing summary{background:#fafcfd!important;padding:.8rem!important;font-size:.8rem;font-weight:850}.ticket-manual-routing summary small{font-size:.7rem!important}.ticket-manual-body{padding:.8rem!important}.ticket-routing-grid,.ticket-two-columns{display:grid;gap:.7rem}.ticket-wizard-page .field{margin-bottom:.8rem}.ticket-wizard-page label{font-size:.82rem}.ticket-wizard-page input:not([type=checkbox]):not([type=radio]),.ticket-wizard-page textarea,.ticket-wizard-page select{min-height:3.1rem;font-size:1rem}.ticket-subject-field{margin-top:.85rem!important}.ticket-subject-field label em{color:#fa9f00;font-style:normal}.ticket-subject-field>small,.ticket-body-field>small{display:flex;justify-content:flex-end;margin-top:.35rem;color:rgba(8,55,107,.5);font-size:.7rem;font-weight:500}.ticket-body-field textarea{min-height:9rem;line-height:1.9}.ticket-wizard-page .errorlist{margin:.35rem 0 0;padding:.5rem .65rem;border-radius:.7rem;background:#fff1f3;color:#92263a;font-size:.74rem;font-weight:750;list-style:none}
+.ticket-location-fields{margin-top:1rem}.ticket-location-fields .location-hint{margin:.2rem 0;color:rgba(8,55,107,.55);font-size:.72rem}.ticket-soft-tip{display:flex;align-items:center;gap:.65rem;margin:.8rem 0;border-radius:.85rem;background:#e5f4ff;padding:.75rem;color:rgba(8,55,107,.68)}.ticket-soft-tip>span{display:grid;width:1.75rem;height:1.75rem;flex:none;place-items:center;border-radius:.55rem;background:#fff;color:#1977ce;font-weight:900}.ticket-soft-tip p{margin:0;font-size:.76rem;line-height:1.75}.ticket-soft-tip b{color:#08376b}
+.ticket-expert-picker{display:grid!important;grid-template-columns:1fr!important;gap:.55rem!important;margin:1rem 0!important}.ticket-expert-picker .expert-card{display:grid!important;min-height:0!important;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.7rem;border-radius:1rem!important;padding:.72rem!important;text-align:right!important}.ticket-expert-picker .expert-card:has(input:checked){border-color:#0693e3;background:#e5f4ff}.ticket-expert-picker .expert-card img,.ticket-expert-picker .expert-avatar{width:2.8rem!important;height:2.8rem!important;margin:0!important;border-radius:.85rem!important}.ticket-expert-picker .expert-card strong,.ticket-expert-picker .expert-card small{display:block}.ticket-expert-picker .expert-card strong{font-size:.82rem}.ticket-expert-picker .expert-card small{margin-top:.1rem;font-size:.7rem;line-height:1.6}.ticket-expert-picker .expert-card:after{display:none;place-items:center;width:1.3rem;height:1.3rem;border-radius:50%;background:#0693e3;color:#fff;font-size:.6rem;font-weight:900;content:"✓"}.ticket-expert-picker .expert-card:has(input:checked):after{display:grid}
+.ticket-attachment-zone{display:grid;gap:.7rem;margin-top:.8rem!important;border-color:rgba(8,55,107,.14)!important;border-radius:1rem!important;background:rgba(229,244,255,.45)!important;padding:.85rem!important;text-align:right!important}.ticket-attachment-head{display:flex;align-items:center;gap:.65rem}.ticket-attachment-head>span{display:grid;width:2.35rem;height:2.35rem;flex:none;place-items:center;border-radius:.72rem;background:#fff;color:#1977ce;font-size:1.05rem;font-weight:900}.ticket-attachment-head b,.ticket-attachment-head small{display:block}.ticket-attachment-head b{font-size:.82rem}.ticket-attachment-head small{margin-top:.1rem;color:rgba(8,55,107,.58);font-size:.7rem;line-height:1.65}.ticket-attachment-actions{display:grid!important;gap:.5rem!important}.ticket-attachment-actions label{display:grid;min-height:3rem;place-items:center;border:1px solid rgba(6,147,227,.38);border-radius:.9rem;background:#fff;color:#0b5f9b;font-size:.8rem;font-weight:900}.ticket-attachment-actions .gallery-button{background:#e5f4ff}.ticket-attachment-zone .compression-note{font-size:.7rem;line-height:1.65}.ticket-attachment-zone .file-chip{font-size:.72rem}.ticket-attachment-zone .file-chip button{min-width:1.7rem;min-height:1.7rem}.ticket-attachment-zone .canonical-files{display:none}
+.ticket-voice-recorder{margin-top:.8rem;overflow:hidden;border:1px solid rgba(6,147,227,.2);border-radius:1.05rem;background:linear-gradient(145deg,#f7fcff,#edf7ff);box-shadow:0 12px 32px -28px rgba(8,55,107,.75)}.ticket-voice-head{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.65rem;padding:.8rem}.ticket-voice-icon{display:grid;width:2.35rem;height:2.35rem;place-items:center;border-radius:.75rem;background:#fff;color:#0693e3;font-size:1rem}.ticket-voice-head b,.ticket-voice-head small,.ticket-voice-ready b,.ticket-voice-ready small{display:block}.ticket-voice-head b{font-size:.84rem}.ticket-voice-head small{margin-top:.12rem;color:rgba(8,55,107,.58);font-size:.72rem;line-height:1.7}.ticket-voice-timer{direction:ltr;border:1px solid rgba(6,147,227,.16);border-radius:999px;background:#fff;padding:.28rem .5rem;font-size:.75rem;font-weight:900;font-variant-numeric:tabular-nums}.ticket-voice-idle,.ticket-voice-live,.ticket-voice-preview{border-top:1px solid rgba(8,55,107,.07);padding:.75rem .8rem}.ticket-voice-idle{display:grid;gap:.35rem}.ticket-voice-idle>small{color:rgba(8,55,107,.58);font-size:.69rem;text-align:center}.ticket-voice-start{display:flex;min-height:3rem;align-items:center;justify-content:center;gap:.45rem;border:0;border-radius:.9rem;background:#0693e3;color:#fff;font-size:.82rem;font-weight:900}.ticket-voice-wave{display:flex;height:2.8rem;align-items:center;justify-content:center;gap:.24rem}.ticket-voice-wave i{width:.22rem;height:.75rem;border-radius:999px;background:#0693e3;animation:ticket-voice-wave .9s ease-in-out infinite}.ticket-voice-wave i:nth-child(2),.ticket-voice-wave i:nth-child(6){height:1.35rem;animation-delay:.12s}.ticket-voice-wave i:nth-child(3),.ticket-voice-wave i:nth-child(5){height:2rem;animation-delay:.24s}.ticket-voice-wave i:nth-child(4){height:2.5rem;animation-delay:.36s}.ticket-voice-recorder[data-state="paused"] .ticket-voice-wave i{animation-play-state:paused;opacity:.4}@keyframes ticket-voice-wave{0%,100%{transform:scaleY(.55)}50%{transform:scaleY(1)}}.ticket-voice-controls,.ticket-voice-preview-actions{display:grid;grid-template-columns:1fr 1.5fr;gap:.45rem}.ticket-voice-controls button,.ticket-voice-preview-actions button{min-height:2.65rem;border:1px solid rgba(8,55,107,.1);border-radius:.8rem;background:#fff;color:#08376b;font-size:.75rem;font-weight:850}.ticket-voice-controls .ticket-voice-stop{border-color:#0693e3;background:#0693e3;color:#fff}.ticket-voice-controls .ticket-voice-cancel{grid-column:1/-1;min-height:2rem;border:0;background:transparent;color:#9c3042}.ticket-voice-preview{display:grid;gap:.65rem;background:#fff}.ticket-voice-ready{display:flex;align-items:center;gap:.55rem}.ticket-voice-ready>span{display:grid;width:1.8rem;height:1.8rem;flex:none;place-items:center;border-radius:.55rem;background:#e5f8ef;color:#16815e;font-size:.65rem;font-weight:900}.ticket-voice-ready b{font-size:.8rem}.ticket-voice-ready small{color:rgba(8,55,107,.58);font-size:.7rem}.ticket-voice-preview audio,.ticket-review-audio{width:100%;height:2.6rem;accent-color:#0693e3}.ticket-voice-preview-actions{grid-template-columns:1fr 1fr}.ticket-voice-preview-actions [data-voice-delete]{border-color:rgba(169,39,60,.18);color:#a9273c}.ticket-voice-note{display:flex;gap:.35rem;margin:0;border-top:1px dashed rgba(8,55,107,.1);padding:.6rem .8rem;color:rgba(8,55,107,.58);font-size:.68rem;line-height:1.7}.ticket-voice-note span{color:#1977ce}.ticket-voice-error{margin:0;padding:.55rem .8rem;background:#fff0f1;color:#9c3042;font-size:.72rem;font-weight:800}
+.ticket-server-errors:empty{display:none}.ticket-wizard-validation:empty{display:none}.ticket-wizard-validation.is-visible{position:relative;margin:.8rem 0 0;border:1px solid rgba(169,39,60,.2);border-radius:.85rem;background:#fff1f3;padding:.72rem 2.2rem .72rem .75rem;color:#92263a;font-size:.78rem;font-weight:850;line-height:1.8}.ticket-wizard-validation.is-visible:before{position:absolute;top:.75rem;right:.75rem;display:grid;width:1.15rem;height:1.15rem;place-items:center;border-radius:50%;background:#a9273c;color:#fff;font-size:.66rem;content:"!"}.ticket-wizard-validation.is-visible:focus{box-shadow:0 0 0 4px rgba(169,39,60,.13)}.ticket-wizard-page [aria-invalid="true"]{border-color:#b8324a!important;background:#fffafb!important;box-shadow:0 0 0 3px rgba(184,50,74,.1)!important}
+.ticket-wizard-actions{position:sticky;z-index:10;bottom:4.8rem;display:flex;justify-content:flex-end;gap:.55rem;margin:1rem -1rem -1rem;border-top:1px solid rgba(8,55,107,.07);background:rgba(255,255,255,.97);padding:.7rem 1rem;backdrop-filter:blur(12px)}.ticket-wizard-actions>span{display:none}.ticket-wizard-actions button{min-height:3rem;border-radius:1rem;padding:.65rem .9rem;font-size:.82rem;font-weight:900}.ticket-wizard-actions .primary{border:0;background:#0693e3;color:#fff}.ticket-wizard-actions .secondary{border:1px solid rgba(8,55,107,.1);background:#fff;color:rgba(8,55,107,.72)}.ticket-wizard-card:not([data-wizard-enhanced]) [data-wizard-next],.ticket-wizard-card:not([data-wizard-enhanced]) [data-wizard-prev]{display:none}
+.ticket-review-callout{display:flex;align-items:center;gap:.65rem;margin:.85rem 0 .65rem;border-radius:.9rem;background:#f0faf6;padding:.7rem}.ticket-review-callout>span{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:.65rem;background:#fff;color:#16815e;font-weight:900}.ticket-review-callout b,.ticket-review-callout small{display:block}.ticket-review-callout b{font-size:.82rem}.ticket-review-callout small{margin-top:.1rem;color:#66877b;font-size:.7rem;line-height:1.65}.ticket-review-list{overflow:hidden;border:1px solid rgba(8,55,107,.1);border-radius:1rem;background:#fff}.ticket-review-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:start;gap:.6rem;padding:.75rem}.ticket-review-row+.ticket-review-row{border-top:1px solid rgba(8,55,107,.07)}.ticket-review-icon{display:grid;width:1.8rem;height:1.8rem;place-items:center;border-radius:.55rem;background:#e5f4ff;color:#1977ce;font-size:.65rem;font-weight:900}.ticket-review-content{min-width:0}.ticket-review-content>small{color:rgba(8,55,107,.58);font-size:.7rem;font-weight:800}.ticket-review-content h3{overflow-wrap:anywhere;margin:.16rem 0 .08rem;color:#08376b;font-size:.9rem;line-height:1.7}.ticket-review-content [data-review-subject]{font-size:1rem;font-weight:900;line-height:1.8}.ticket-review-content p{display:flex;flex-wrap:wrap;align-items:center;gap:.3rem;margin:0;color:rgba(8,55,107,.62);font-size:.72rem;line-height:1.8}.ticket-review-content p i{font-style:normal}.ticket-review-row>button{display:flex;min-height:2rem;align-items:center;gap:.25rem;border:0;border-radius:.65rem;background:#e5f4ff;padding:.35rem .5rem;color:#1977ce;font-size:.68rem;font-weight:900}.ticket-review-details .ticket-review-content{grid-column:2/-1}.ticket-review-details>button{grid-column:3;grid-row:1}.ticket-review-body{display:block!important;margin-top:.2rem!important;color:rgba(8,55,107,.82)!important;font-size:.88rem!important;font-weight:550;line-height:2!important;white-space:pre-wrap}.ticket-review-files{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.45rem}.ticket-review-files span{border-radius:999px;background:#e5f4ff;padding:.28rem .5rem;color:#1977ce;font-size:.68rem}.ticket-review-audio{margin-top:.55rem}.ticket-review-step{padding-bottom:6.75rem}.ticket-wizard-card[data-wizard-enhanced] .ticket-final-actions{position:fixed;z-index:45;right:.75rem;bottom:5rem;left:.75rem;display:grid;grid-template-columns:minmax(0,.78fr) minmax(0,1.4fr);gap:.5rem;margin:0;border:1px solid rgba(8,55,107,.12);border-radius:1.1rem;background:rgba(255,255,255,.96);padding:.55rem;box-shadow:0 20px 50px -20px rgba(8,55,107,.55);backdrop-filter:blur(14px)}
+@media(min-width:640px){.ticket-wizard-page{padding-bottom:3rem}.ticket-wizard-topbar{min-height:3.8rem}.ticket-wizard-save{display:flex}.ticket-wizard-intro{margin:.8rem 0 1.35rem}.ticket-wizard-intro h1{font-size:2rem}.ticket-wizard-intro>p:last-child{font-size:.9rem}.ticket-wizard-progress{margin-bottom:1rem}.ticket-wizard-progress:before{top:1.12rem}.ticket-wizard-progress li{display:flex;justify-content:center;gap:.5rem}.ticket-wizard-progress li>span{width:2.3rem;height:2.3rem}.ticket-wizard-progress li>div{display:block}.ticket-wizard-progress li b,.ticket-wizard-progress li small{display:block}.ticket-wizard-progress li b{font-size:.78rem}.ticket-wizard-progress li small{font-size:.67rem}.ticket-wizard-card{border-radius:1.6rem}.ticket-wizard-step{padding:1.35rem 1.5rem}.ticket-wizard-step-head h2{font-size:1.2rem}.ticket-intent-search kbd{display:inline;border:1px solid rgba(8,55,107,.1);border-radius:.4rem;background:#eee;padding:.2rem .4rem;color:rgba(8,55,107,.58);font-size:.68rem}.ticket-routing-grid,.ticket-two-columns{grid-template-columns:1fr 1fr}.ticket-manual-body{padding:1rem!important}.ticket-expert-picker{grid-template-columns:repeat(3,1fr)!important}.ticket-expert-picker .expert-card{display:grid!important;grid-template-columns:1fr;justify-items:center;padding:.9rem!important;text-align:center!important}.ticket-expert-picker .expert-card img,.ticket-expert-picker .expert-avatar{width:3.3rem!important;height:3.3rem!important}.ticket-expert-picker .expert-card:after{position:absolute;top:.5rem;left:.5rem}.ticket-attachment-actions{grid-template-columns:1fr 1fr}.ticket-voice-idle{grid-template-columns:auto 1fr;align-items:center}.ticket-voice-idle>small{text-align:right}.ticket-voice-start{min-width:11rem}.ticket-voice-live{display:grid;grid-template-columns:1fr auto;align-items:center}.ticket-voice-controls{grid-template-columns:auto auto auto}.ticket-voice-controls .ticket-voice-cancel{grid-column:auto;min-height:2.65rem}.ticket-wizard-actions{position:static;margin:1.15rem -1.5rem -1.35rem;padding:.8rem 1.5rem}.ticket-wizard-actions>span{display:block;margin-left:auto;color:rgba(8,55,107,.45);font-size:.72rem}.ticket-wizard-actions button{min-width:8.5rem}.ticket-review-row{gap:.8rem;padding:.9rem 1rem}.ticket-review-details .ticket-review-content{grid-column:2}.ticket-review-content h3{font-size:.96rem}.ticket-review-content [data-review-subject]{font-size:1.08rem}.ticket-review-body{font-size:.94rem!important}.ticket-review-step{padding-bottom:6rem}.ticket-wizard-card[data-wizard-enhanced] .ticket-final-actions{position:fixed;right:auto;bottom:1rem;left:50%;width:min(calc(100% - 2rem),42rem);transform:translateX(-50%);padding:.6rem}}
+@media(prefers-reduced-motion:reduce){.ticket-voice-wave i{animation:none}}
diff --git a/static/css/dispatches.css b/static/css/dispatches.css
index c3af99c..3b9ecba 100644
--- a/static/css/dispatches.css
+++ b/static/css/dispatches.css
@@ -1,2 +1,19 @@
.dispatch-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.7rem}.dispatch-metrics article{display:grid;gap:.25rem;border:1px solid #dce7ef;border-radius:1rem;background:#fff;padding:.9rem 1rem;box-shadow:0 12px 30px -28px #08376b}.dispatch-metrics small{color:#718698;font-size:.62rem}.dispatch-metrics b{color:#123e62;font-size:1.25rem}.dispatch-filter{display:grid;gap:.65rem;border-bottom:1px solid #e3ebf1;background:#f8fbfd;padding:.8rem}.dispatch-filter>div{display:grid;grid-template-columns:1fr 1fr;gap:.5rem}.dispatch-filter label{display:grid;gap:.25rem;color:#536d81;font-size:.58rem;font-weight:900}.dispatch-filter input,.dispatch-filter select{width:100%;min-height:2.35rem;border:1px solid #cbd9e5;border-radius:.6rem;background:#fff;padding:.45rem .55rem;color:#173f60;font-size:.62rem}.dispatch-filter footer{display:flex;align-items:center;gap:.5rem}.dispatch-filter button{min-height:2.35rem;flex:1;border:0;border-radius:.6rem;background:#0875aa;color:#fff;font-size:.62rem;font-weight:900}.dispatch-filter footer a{color:#647b8d;font-size:.58rem;font-weight:900}.dispatch-results{display:grid;max-height:34rem;overflow:auto}.operations-pagination{display:flex;align-items:center;justify-content:space-between;border-top:1px solid #e5edf3;padding:.7rem .8rem;color:#667e91;font-size:.6rem}.operations-pagination a{border-radius:.55rem;background:#e8f4fb;padding:.4rem .65rem;color:#0873a9;font-weight:900}
@media(max-width:700px){.dispatch-metrics{display:flex;overflow-x:auto}.dispatch-metrics article{min-width:8.5rem}}
+.dispatch-detail-head{align-items:flex-start}.dispatch-head-actions{display:flex;flex-wrap:wrap;align-items:center;gap:.55rem}.dispatch-head-actions form{margin:0}.button-danger-soft{background:#fff1f2!important;color:#a51d35!important;border:1px solid #fecdd3!important}.dispatch-original-message{display:grid;gap:1rem;border:1px solid #d8e6ef;border-radius:1rem;background:linear-gradient(145deg,#fff,#f5fbff);padding:1.1rem 1.2rem;box-shadow:0 16px 40px -36px #164f75}.dispatch-original-message header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.dispatch-original-message h2{margin:.18rem 0 0;color:#173f60;font-size:1rem}.dispatch-original-message header>span{color:#73889a;font-size:.62rem;direction:ltr}.dispatch-original-message>p{margin:0;border-right:3px solid #1490bb;padding:.7rem .9rem;color:#294c66;line-height:2;white-space:normal}.dispatch-detail-metrics{grid-template-columns:repeat(6,minmax(0,1fr))}.dispatch-detail-metrics small{display:block;margin-top:.25rem;color:#788c9c;font-size:.55rem}.dispatch-sms-state{min-width:15rem}.delivery-badge{display:inline-flex;border-radius:99rem;background:#e8f0f5;padding:.3rem .58rem;color:#435f73;font-size:.58rem}.delivery-delivered{background:#dcfce7;color:#166534}.delivery-undelivered,.delivery-expired,.delivery-blocked,.delivery-error,.delivery-failed{background:#fee2e2;color:#991b1b}.delivery-pending,.delivery-processing,.delivery-retrying{background:#fef3c7;color:#92400e}.delivery-unknown,.delivery-check_failed{background:#f1f5f9;color:#475569}.delivery-detail{max-width:24rem;margin:.45rem 0 0;color:#4b6477;font-size:.58rem;line-height:1.7}.sms-error-detail{max-width:24rem;margin-top:.45rem;border:1px solid #fecaca;border-radius:.55rem;background:#fff7f7;padding:.4rem .5rem;color:#8d1e2d;font-size:.57rem}.sms-error-detail summary{cursor:pointer;font-weight:900}.sms-error-detail p{margin:.35rem 0 0;line-height:1.7}.inline-retry-form{margin-top:.5rem}.inline-retry-form button{border:1px solid #f1b7bf;border-radius:.5rem;background:#fff;color:#a51d35;padding:.38rem .55rem;font:inherit;font-size:.56rem;font-weight:900;cursor:pointer}
+@media(max-width:1100px){.dispatch-detail-metrics{grid-template-columns:repeat(3,minmax(0,1fr))}}
+@media(max-width:700px){.dispatch-detail-head,.dispatch-original-message header{display:grid}.dispatch-head-actions{width:100%}.dispatch-head-actions>*{flex:1}.dispatch-detail-metrics{display:flex;overflow-x:auto}.dispatch-detail-metrics article{min-width:9rem}}
+
+/* Dispatch report: human-readable status first, technical gateway data second. */
+.dispatch-report-hero{display:flex;align-items:flex-start;justify-content:space-between;gap:1.25rem;border:1px solid #d8e6ef;border-radius:1.2rem;background:linear-gradient(135deg,#f8fcff 0%,#fff 55%,#eff9fc 100%);padding:1.35rem;box-shadow:0 20px 50px -42px #0c4269}.dispatch-report-title{display:grid;gap:.7rem}.dispatch-title-line{display:flex;align-items:center;gap:.55rem}.dispatch-report-title h1{margin:0;color:#123b5b;font-size:1.45rem}.report-status{display:inline-flex;border-radius:99rem;background:#e7f0f5;padding:.34rem .7rem;color:#426177;font-size:.58rem;font-weight:900}.report-status.status-sent{background:#dcfce7;color:#166534}.report-status.status-partial{background:#fff0e1;color:#9a4f06}.dispatch-meta-list{display:flex;flex-wrap:wrap;gap:.5rem}.dispatch-meta-list span{display:flex;align-items:center;gap:.35rem;border:1px solid #deebf2;border-radius:.65rem;background:#fff;padding:.42rem .6rem;color:#557084;font-size:.59rem}.dispatch-meta-list b{color:#204c6d}.dispatch-original-message blockquote{margin:0;border:0;border-right:3px solid #1490bb;border-radius:.35rem;background:rgba(255,255,255,.72);padding:.8rem 1rem;color:#294c66;line-height:2}.dispatch-original-message footer{display:flex;flex-wrap:wrap;justify-content:space-between;gap:.5rem;border-top:1px dashed #d9e7ef;padding-top:.75rem;color:#6d8292;font-size:.57rem}.delivery-overview{display:grid;grid-template-columns:auto minmax(12rem,1fr) minmax(14rem,.7fr);align-items:center;gap:1rem;border:1px solid #d8e7ee;border-radius:1rem;background:#fff;padding:1rem 1.15rem}.delivery-progress-copy{min-width:12rem}.delivery-progress-copy strong{display:block;margin:.1rem 0;color:#0878a8;font-size:1.8rem}.delivery-progress-copy p{margin:0;color:#688092;font-size:.58rem}.delivery-progress-track{height:.75rem;overflow:hidden;border-radius:99rem;background:#e9f1f5;box-shadow:inset 0 1px 2px #cfdde6}.delivery-progress-track span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#13a36f,#42c895);transition:width .3s ease}.delivery-overview-note{display:grid;gap:.2rem;border-right:1px solid #e1eaf0;padding-right:1rem}.delivery-overview-note b{color:#294f69;font-size:.66rem}.delivery-overview-note span{color:#728797;font-size:.56rem;line-height:1.7}.dispatch-kpi-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:.7rem}.dispatch-kpi{display:flex;align-items:center;gap:.7rem;border:1px solid #dce8ef;border-radius:.9rem;background:#fff;padding:.85rem;box-shadow:0 14px 34px -32px #123f61}.kpi-icon{display:grid;width:2rem;height:2rem;flex:0 0 auto;place-items:center;border-radius:.65rem;background:#eaf2f7;color:#44657a;font-weight:900}.dispatch-kpi>div{min-width:0}.dispatch-kpi small,.dispatch-kpi p{display:block;margin:0;color:#778b99;font-size:.53rem}.dispatch-kpi strong{display:block;margin:.08rem 0;color:#183f5c;font-size:1.22rem}.kpi-success .kpi-icon{background:#dcfce7;color:#16734c}.kpi-info .kpi-icon{background:#e0f2fe;color:#0878a8}.kpi-danger .kpi-icon{background:#fee2e2;color:#a51d35}.kpi-view .kpi-icon{background:#ede9fe;color:#6d3fc0}.kpi-reply .kpi-icon{background:#fef3c7;color:#96600d}.dispatch-recipient-report{overflow:hidden}.dispatch-detail-table{border-collapse:separate;border-spacing:0;width:100%}.dispatch-detail-table thead th{position:sticky;top:0;z-index:2;background:#f5f9fc;color:#526c80;font-size:.56rem}.dispatch-detail-table tbody tr{transition:background .15s ease}.dispatch-detail-table tbody tr:hover{background:#f9fcfe}.dispatch-detail-table td{vertical-align:top;padding:.9rem .7rem;border-bottom:1px solid #e8eff3}.recipient-cell{display:flex;align-items:center;gap:.55rem;min-width:9rem}.recipient-avatar{display:grid;width:2rem;height:2rem;flex:0 0 auto;place-items:center;border-radius:.7rem;background:linear-gradient(145deg,#dff4fb,#bfe8f5);color:#0875a3;font-weight:900}.recipient-cell div{display:grid;gap:.17rem}.recipient-cell strong{color:#1e4866;font-size:.63rem}.recipient-cell small{color:#738896;font-size:.55rem}.ticket-reference{display:grid;gap:.22rem;text-decoration:none}.ticket-reference strong{color:#176993;font-size:.6rem}.ticket-reference span{color:#7b8d99;font-size:.52rem}.dispatch-sms-state{min-width:18rem}.sms-state-card{display:flex;align-items:flex-start;gap:.55rem;border:1px solid #dfe9ef;border-radius:.75rem;background:#f8fbfd;padding:.65rem}.sms-state-icon{display:grid;width:1.65rem;height:1.65rem;flex:0 0 auto;place-items:center;border-radius:.55rem;background:#e5eef4;color:#3f6075;font-weight:900}.sms-state-card>div{display:grid;gap:.18rem}.sms-state-card strong{color:#254c68;font-size:.61rem}.sms-state-card p,.sms-state-card small{margin:0;color:#718694;font-size:.53rem;line-height:1.65}.tone-success{border-color:#bcebd3;background:#f2fcf7}.tone-success .sms-state-icon{background:#d6f7e6;color:#17734d}.tone-warning{border-color:#f2d7a0;background:#fffaf0}.tone-warning .sms-state-icon{background:#feefc9;color:#93600c}.tone-danger{border-color:#f3c7cd;background:#fff7f8}.tone-danger .sms-state-icon{background:#fee2e2;color:#a51d35}.tone-info{border-color:#bee3f3;background:#f4fbfe}.tone-info .sms-state-icon{background:#dff4fc;color:#0879aa}.sms-technical-details{margin-top:.45rem;border:1px solid #e0e8ed;border-radius:.65rem;background:#fff;color:#607788;font-size:.54rem}.sms-technical-details summary{cursor:pointer;padding:.45rem .55rem;color:#416177;font-weight:900}.sms-technical-details dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.35rem;margin:0;border-top:1px solid #e8eef2;padding:.55rem}.sms-technical-details dl div{display:grid;gap:.1rem}.sms-technical-details dt{color:#8a9aa5}.sms-technical-details dd{margin:0;color:#294e68;font-weight:800;overflow-wrap:anywhere}.gateway-technical-message{margin:0;border-top:1px dashed #e1e9ed;padding:.5rem .55rem;line-height:1.65}.sms-error-panel{margin-top:.45rem;border:1px solid #f3c3ca;border-radius:.65rem;background:#fff5f6;padding:.55rem;color:#912235;font-size:.55rem}.sms-error-panel p{margin:.2rem 0 0;line-height:1.7}.ticket-state-pill{display:inline-flex;white-space:nowrap;border-radius:99rem;background:#e8f1f6;padding:.34rem .6rem;color:#456578;font-size:.55rem;font-weight:900}.engagement-state{display:flex;align-items:flex-start;gap:.45rem;min-width:7.5rem;color:#718493}.engagement-state>span{display:grid;width:1.6rem;height:1.6rem;place-items:center;border-radius:.5rem;background:#eef3f6}.engagement-state div{display:grid;gap:.16rem}.engagement-state strong{color:#536b7b;font-size:.58rem}.engagement-state small{color:#8797a2;font-size:.51rem}.engagement-state.has-engagement>span{background:#e8e3fb;color:#6b3eb9}.engagement-state.has-engagement strong{color:#5c369d}.recipient-reply{display:grid;min-width:10rem;gap:.3rem}.recipient-reply p{margin:0;color:#395a70;font-size:.57rem;line-height:1.75}.recipient-reply small,.no-reply-state{color:#8a9aa5;font-size:.52rem}.inline-retry-form button{width:100%}
+@media(max-width:1100px){.dispatch-kpi-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.delivery-overview{grid-template-columns:auto 1fr}.delivery-overview-note{grid-column:1/-1;border-top:1px solid #e5edf2;border-right:0;padding: .7rem 0 0}}
+@media(max-width:760px){.dispatch-report-hero{display:grid}.dispatch-report-title h1{font-size:1.15rem}.dispatch-head-actions{display:grid}.dispatch-kpi-grid{display:flex;overflow-x:auto;padding-bottom:.25rem}.dispatch-kpi{min-width:10.5rem}.delivery-overview{grid-template-columns:1fr}.delivery-progress-copy{min-width:0}.dispatch-detail-table thead{display:none}.dispatch-detail-table,.dispatch-detail-table tbody,.dispatch-detail-table tr,.dispatch-detail-table td{display:block;width:100%}.dispatch-detail-table tr{border-bottom:8px solid #edf3f6}.dispatch-detail-table td{display:grid;grid-template-columns:6.5rem 1fr;gap:.6rem;border-bottom:1px solid #edf2f5}.dispatch-detail-table td:before{content:attr(data-label);color:#8799a5;font-size:.52rem;font-weight:900}.dispatch-sms-state{min-width:0}.dispatch-detail-table td>*{min-width:0}}
+
+/* Readable report typography and a compact delivery summary. */
+.operations-dispatch-detail{font-size:1rem}.operations-dispatch-detail .eyebrow,.operations-dispatch-detail .panel-index{font-size:.72rem}.operations-dispatch-detail .report-status{font-size:.72rem}.operations-dispatch-detail .dispatch-meta-list span{gap:.45rem;font-size:.76rem}.operations-dispatch-detail .dispatch-original-message header>span,.operations-dispatch-detail .dispatch-original-message footer{font-size:.72rem}.operations-dispatch-detail .dispatch-original-message blockquote{font-size:.86rem}.operations-dispatch-detail .delivery-overview{display:grid;grid-template-columns:minmax(0,1fr) minmax(12rem,.42fr);gap:.7rem 1rem;padding:.75rem 1rem}.operations-dispatch-detail .delivery-progress-copy{display:flex;align-items:center;justify-content:space-between;gap:1rem;min-width:0}.operations-dispatch-detail .delivery-progress-copy>div{display:flex;align-items:center;gap:.55rem}.operations-dispatch-detail .delivery-progress-copy strong{display:inline;margin:0;font-size:1rem}.operations-dispatch-detail .delivery-progress-copy p{font-size:.74rem}.operations-dispatch-detail .delivery-progress-track{grid-column:1;height:.42rem}.operations-dispatch-detail .delivery-overview-note{grid-column:2;grid-row:1/3;align-content:center;border-right:1px solid #e1eaf0;padding-right:1rem}.operations-dispatch-detail .delivery-overview-note b{font-size:.78rem}.operations-dispatch-detail .delivery-overview-note span{font-size:.7rem}.operations-dispatch-detail .dispatch-kpi small,.operations-dispatch-detail .dispatch-kpi p{font-size:.7rem;line-height:1.65}.operations-dispatch-detail .dispatch-detail-table thead th{font-size:.72rem}.operations-dispatch-detail .recipient-cell strong,.operations-dispatch-detail .ticket-reference strong,.operations-dispatch-detail .sms-state-card strong{font-size:.78rem}.operations-dispatch-detail .recipient-cell small,.operations-dispatch-detail .ticket-reference span,.operations-dispatch-detail .sms-state-card p,.operations-dispatch-detail .sms-state-card small{font-size:.7rem}.operations-dispatch-detail .sms-technical-details,.operations-dispatch-detail .sms-error-panel,.operations-dispatch-detail .ticket-state-pill,.operations-dispatch-detail .engagement-state strong,.operations-dispatch-detail .recipient-reply p{font-size:.72rem}.operations-dispatch-detail .engagement-state small,.operations-dispatch-detail .recipient-reply small,.operations-dispatch-detail .no-reply-state{font-size:.7rem}
+@media(max-width:1100px){.operations-dispatch-detail .delivery-overview{grid-template-columns:1fr}.operations-dispatch-detail .delivery-progress-track{grid-column:1}.operations-dispatch-detail .delivery-overview-note{grid-column:1;grid-row:auto;border-top:1px solid #e5edf2;border-right:0;padding:.65rem 0 0}}
+@media(max-width:760px){.operations-dispatch-detail .delivery-progress-copy{align-items:flex-start;flex-direction:column;gap:.35rem}.operations-dispatch-detail .dispatch-detail-table td:before{font-size:.66rem}}
+
+/* Shared attachments for isolated group dispatches. */
+.dispatch-attachment-picker{display:grid;gap:.8rem;margin:0 1rem 1rem;border:1px dashed #8fb7d1;border-radius:.9rem;background:linear-gradient(145deg,#f8fcff,#f2f9fd);padding:1rem}.dispatch-attachment-picker .attachment-picker-copy{display:flex;align-items:center;gap:.75rem}.dispatch-attachment-picker .attachment-icon{display:grid;width:2.45rem;height:2.45rem;flex:none;place-items:center;border-radius:.72rem;background:#dff2ff;color:#1977ce;font-size:1.2rem;font-weight:900}.dispatch-attachment-picker b,.dispatch-attachment-picker small{display:block}.dispatch-attachment-picker b{color:#173f60;font-size:.78rem}.dispatch-attachment-picker .attachment-picker-copy small{margin-top:.15rem;color:#647d91;font-size:.65rem;line-height:1.7}.dispatch-attachment-picker .media-picker-actions{margin:0}.dispatch-attachment-picker .file-preview{margin-top:0}.dispatch-attachment-picker .file-preview:empty{display:none}.dispatch-attachment-picker .file-chip{box-shadow:0 10px 25px -22px #08376b}.dispatch-attachment-picker .compression-note{color:#688095;font-size:.62rem;line-height:1.7}
+@media(max-width:700px){.dispatch-attachment-picker{margin:.8rem;padding:.8rem}.dispatch-attachment-picker .media-picker-actions{display:grid;grid-template-columns:1fr}.dispatch-attachment-picker .media-picker-button{justify-content:center}}
diff --git a/static/js/customer-ticket-wizard.js b/static/js/customer-ticket-wizard.js
new file mode 100644
index 0000000..05999eb
--- /dev/null
+++ b/static/js/customer-ticket-wizard.js
@@ -0,0 +1,320 @@
+(() => {
+ const form = document.querySelector('[data-ticket-wizard]');
+ if (!form) return;
+
+ const steps = [...form.querySelectorAll('[data-wizard-step]')];
+ const progress = [...document.querySelectorAll('[data-wizard-progress-item]')];
+ const unit = form.querySelector('[data-ticket-unit]');
+ const category = form.querySelector('[data-ticket-category]');
+ const subject = form.querySelector('[data-ticket-subject]');
+ const province = form.querySelector('[data-province-input]');
+ const city = form.querySelector('[data-city-input]');
+ const body = form.querySelector('textarea[name="body"]');
+ const canonicalFiles = form.querySelector('input[name="files"]');
+ const voiceRoot = form.querySelector('[data-ticket-voice-recorder]');
+ let currentStep = 1;
+
+ const toPersianDigits = value => String(value).replace(/\d/g, digit => '۰۱۲۳۴۵۶۷۸۹'[digit]);
+
+ function updateProgress(number) {
+ progress.forEach(item => {
+ const itemNumber = Number(item.dataset.wizardProgressItem);
+ item.classList.toggle('is-active', itemNumber === number);
+ item.classList.toggle('is-complete', itemNumber < number);
+ item.querySelector('span').textContent = itemNumber < number ? '✓' : toPersianDigits(itemNumber);
+ });
+ }
+
+ function showStep(number, { scroll = true } = {}) {
+ currentStep = number;
+ steps.forEach(step => {
+ const active = Number(step.dataset.wizardStep) === number;
+ step.hidden = !active;
+ step.classList.toggle('is-active', active);
+ });
+ updateProgress(number);
+ if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' });
+ }
+
+ function clearValidation(number) {
+ const step = form.querySelector(`[data-wizard-step="${number}"]`);
+ const error = form.querySelector(`[data-wizard-error="${number}"]`);
+ step?.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid'));
+ if (error) {
+ error.textContent = '';
+ error.classList.remove('is-visible');
+ error.removeAttribute('tabindex');
+ }
+ }
+
+ function showValidation(number, problem) {
+ const step = form.querySelector(`[data-wizard-step="${number}"]`);
+ const error = form.querySelector(`[data-wizard-error="${number}"]`);
+ if (number === 1 && problem.fields.some(field => field === unit || field === category)) {
+ const manualRouting = form.querySelector('.ticket-manual-routing');
+ if (manualRouting) manualRouting.open = true;
+ }
+ step?.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid'));
+ problem.fields.forEach(field => field?.setAttribute('aria-invalid', 'true'));
+ error.textContent = problem.message;
+ error.tabIndex = -1;
+ error.classList.add('is-visible');
+ requestAnimationFrame(() => {
+ error.focus({ preventScroll: true });
+ error.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
+ });
+ }
+
+ function problemForStep(number) {
+ if (number === 1) {
+ const missing = [];
+ const fields = [];
+ if (!unit?.value) { missing.push('واحد سازمانی'); fields.push(unit); }
+ if (!category?.value) { missing.push('دستهبندی'); fields.push(category); }
+ if ((subject?.value.trim().length || 0) < 5) { missing.push('موضوع دقیق حداقل پنجحرفی'); fields.push(subject); }
+ if (missing.length) return { message: `برای ادامه، ${missing.join('، ')} را تکمیل کنید.`, fields };
+ }
+ if (number === 2) {
+ if (city?.value && !province?.value) return { message: 'برای شهر انتخابشده، استان را نیز مشخص کنید.', fields: [province, city] };
+ }
+ if (number === 4 && !body?.value.trim() && !(canonicalFiles?.files.length)) {
+ return { message: 'برای ادامه، شرح درخواست بنویسید یا یک فایل/پیام صوتی اضافه کنید.', fields: [body] };
+ }
+ return null;
+ }
+
+ function validateStep(number) {
+ const problem = problemForStep(number);
+ if (problem) {
+ showValidation(number, problem);
+ return false;
+ }
+ clearValidation(number);
+ return true;
+ }
+
+ function selectedText(select, fallback) {
+ return select?.selectedOptions?.[0]?.textContent?.trim() || fallback;
+ }
+
+ function buildReview() {
+ form.querySelector('[data-review-subject]').textContent = subject.value.trim();
+ form.querySelector('[data-review-unit]').textContent = selectedText(unit, '—');
+ form.querySelector('[data-review-category]').textContent = selectedText(category, '—');
+ form.querySelector('[data-review-location]').textContent = province?.value
+ ? [province.value, city?.value].filter(Boolean).join('، ')
+ : 'ثبت نشده';
+ const selectedExpert = form.querySelector('input[name="selected_expert"]:checked');
+ form.querySelector('[data-review-expert]').textContent = selectedExpert?.closest('label')?.querySelector('strong')?.textContent?.trim() || 'صف عمومی واحد';
+ form.querySelector('[data-review-body]').textContent = body.value.trim() || 'شرح متنی ثبت نشده است.';
+ const fileList = form.querySelector('[data-review-files]');
+ fileList.replaceChildren();
+ const visibleFiles = [...(canonicalFiles?.files || [])].filter(file => !file.type.startsWith('audio/'));
+ visibleFiles.forEach(file => {
+ const chip = document.createElement('span');
+ chip.textContent = file.name;
+ fileList.append(chip);
+ });
+ const reviewAudio = form.querySelector('[data-review-audio]');
+ const voiceApi = voiceRoot?.ticketVoiceRecorder;
+ reviewAudio.hidden = !voiceApi?.hasVoice();
+ if (voiceApi?.url()) reviewAudio.src = voiceApi.url();
+ if (!visibleFiles.length && !voiceApi?.hasVoice()) {
+ const chip = document.createElement('span');
+ chip.textContent = 'بدون پیوست';
+ fileList.append(chip);
+ }
+ }
+
+ async function leaveCurrentStep(targetStep) {
+ const button = form.querySelector(`[data-wizard-step="${currentStep}"] [data-wizard-next]`);
+ if (button) button.disabled = true;
+ if (currentStep === 4) await voiceRoot?.ticketVoiceRecorder?.stopAndSave({ automatic: true });
+ if (button) button.disabled = false;
+ if (!validateStep(currentStep)) return;
+ if (currentStep === 4) buildReview();
+ showStep(targetStep);
+ }
+
+ form.querySelectorAll('[data-wizard-next]').forEach(button => button.addEventListener('click', () => leaveCurrentStep(Math.min(5, currentStep + 1))));
+ form.querySelectorAll('[data-wizard-prev]').forEach(button => button.addEventListener('click', async () => {
+ if (currentStep === 4) await voiceRoot?.ticketVoiceRecorder?.stopAndSave({ automatic: true });
+ showStep(Math.max(1, currentStep - 1));
+ }));
+ form.querySelectorAll('[data-wizard-edit]').forEach(button => button.addEventListener('click', () => showStep(Number(button.dataset.wizardEdit))));
+
+ form.addEventListener('input', event => {
+ event.target.removeAttribute?.('aria-invalid');
+ if (event.target === body) form.querySelector('[data-body-count]').textContent = toPersianDigits(body.value.length);
+ });
+ form.addEventListener('change', event => event.target.removeAttribute?.('aria-invalid'));
+
+ form.addEventListener('submit', event => {
+ const invalidStep = [1, 2, 3, 4].find(number => problemForStep(number));
+ if (!invalidStep) return;
+ event.preventDefault();
+ form.querySelectorAll('button[type="submit"]').forEach(button => { button.disabled = false; });
+ showStep(invalidStep);
+ requestAnimationFrame(() => validateStep(invalidStep));
+ });
+
+ function setupVoiceRecorder(root) {
+ if (!root) return;
+ const ui = {
+ idle: root.querySelector('[data-voice-idle]'), live: root.querySelector('[data-voice-live]'),
+ preview: root.querySelector('[data-voice-preview]'), record: root.querySelector('[data-voice-record]'),
+ pause: root.querySelector('[data-voice-pause]'), stop: root.querySelector('[data-voice-stop]'),
+ cancel: root.querySelector('[data-voice-cancel]'), rerecord: root.querySelector('[data-voice-rerecord]'),
+ remove: root.querySelector('[data-voice-delete]'), audio: root.querySelector('[data-voice-audio]'),
+ timer: root.querySelector('[data-voice-timer]'), status: root.querySelector('[data-voice-status]'),
+ meta: root.querySelector('[data-voice-meta]'), error: root.querySelector('[data-voice-error]'),
+ };
+ let recorder = null;
+ let stream = null;
+ let chunks = [];
+ let voiceFile = null;
+ let voiceUrl = '';
+ let startedAt = 0;
+ let pausedAt = 0;
+ let pausedMs = 0;
+ let timer = null;
+ let stopResolver = null;
+ let discardOnStop = false;
+ let automaticStop = false;
+
+ const elapsed = () => Math.max(0, Date.now() - startedAt - pausedMs - (recorder?.state === 'paused' ? Date.now() - pausedAt : 0));
+ const formatDuration = milliseconds => {
+ const seconds = Math.floor(milliseconds / 1000);
+ return toPersianDigits(`${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`);
+ };
+ const stopTracks = () => { stream?.getTracks().forEach(track => track.stop()); stream = null; };
+ const clearTimer = () => { clearInterval(timer); timer = null; };
+ const showState = (state, status) => {
+ ui.idle.hidden = state !== 'idle';
+ ui.live.hidden = state !== 'live' && state !== 'paused';
+ ui.preview.hidden = state !== 'preview';
+ root.dataset.state = state;
+ if (status) ui.status.textContent = status;
+ };
+ const removeVoice = ({ notify = true } = {}) => {
+ if (notify && voiceFile) form.dispatchEvent(new CustomEvent('voice-removed', { detail: voiceFile }));
+ voiceFile = null;
+ if (voiceUrl) URL.revokeObjectURL(voiceUrl);
+ voiceUrl = '';
+ ui.audio.removeAttribute('src');
+ ui.timer.textContent = '۰۰:۰۰';
+ ui.pause.textContent = 'مکث';
+ ui.error.hidden = true;
+ showState('idle', 'برای توضیح سریعتر، صدای خود را ضبط کنید.');
+ };
+ const complete = (blob, duration) => {
+ if (!blob.size) {
+ removeVoice();
+ ui.error.textContent = 'صدایی ثبت نشد؛ لطفاً دوباره تلاش کنید.';
+ ui.error.hidden = false;
+ return;
+ }
+ if (voiceUrl) URL.revokeObjectURL(voiceUrl);
+ voiceUrl = URL.createObjectURL(blob);
+ ui.audio.src = voiceUrl;
+ const type = blob.type || 'audio/webm';
+ const extension = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : type.includes('wav') ? 'wav' : 'webm';
+ voiceFile = new File([blob], `voice-${Date.now()}.${extension}`, { type });
+ form.dispatchEvent(new CustomEvent('voice-added', { detail: voiceFile }));
+ ui.timer.textContent = formatDuration(duration);
+ ui.meta.textContent = `${formatDuration(duration)} · آماده پخش و ارسال`;
+ showState('preview', automaticStop ? 'ضبط هنگام ادامهدادن متوقف و ذخیره شد.' : 'ضبط ذخیره شد؛ پیش از ارسال آن را بررسی کنید.');
+ };
+ const start = async () => {
+ ui.error.hidden = true;
+ if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
+ ui.error.textContent = 'مرورگر یا اتصال فعلی امکان ضبط امن صدا را ندارد.';
+ ui.error.hidden = false;
+ return;
+ }
+ try {
+ if (voiceFile) removeVoice();
+ stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
+ const mimeType = ['audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/webm', 'audio/wav'].find(type => MediaRecorder.isTypeSupported?.(type));
+ recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
+ chunks = [];
+ pausedMs = 0;
+ discardOnStop = false;
+ automaticStop = false;
+ recorder.ondataavailable = event => { if (event.data.size) chunks.push(event.data); };
+ recorder.onerror = () => {
+ stopTracks();
+ clearTimer();
+ ui.error.textContent = 'ضبط صدا متوقف شد. دوباره تلاش کنید.';
+ ui.error.hidden = false;
+ showState('idle');
+ };
+ recorder.onstop = () => {
+ const duration = elapsed();
+ clearTimer();
+ stopTracks();
+ if (discardOnStop) removeVoice();
+ else complete(new Blob(chunks, { type: recorder.mimeType?.split(';')[0] || 'audio/webm' }), duration);
+ recorder = null;
+ stopResolver?.();
+ stopResolver = null;
+ };
+ recorder.start(500);
+ startedAt = Date.now();
+ ui.timer.textContent = '۰۰:۰۰';
+ showState('live', 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.');
+ timer = setInterval(() => { ui.timer.textContent = formatDuration(elapsed()); }, 250);
+ } catch (error) {
+ stopTracks();
+ ui.error.textContent = error.name === 'NotAllowedError'
+ ? 'دسترسی میکروفن رد شد؛ آن را از تنظیمات مرورگر فعال کنید.'
+ : 'میکروفن در دسترس نیست یا برنامه دیگری از آن استفاده میکند.';
+ ui.error.hidden = false;
+ }
+ };
+ const stopAndSave = ({ discard = false, automatic = false } = {}) => {
+ if (!recorder || recorder.state === 'inactive') return Promise.resolve();
+ discardOnStop = discard;
+ automaticStop = automatic;
+ if (recorder.state === 'paused') {
+ pausedMs += Date.now() - pausedAt;
+ recorder.resume();
+ }
+ return new Promise(resolve => { stopResolver = resolve; recorder.stop(); });
+ };
+ const togglePause = () => {
+ if (recorder?.state === 'recording') {
+ recorder.pause();
+ pausedAt = Date.now();
+ ui.pause.textContent = 'ادامه ضبط';
+ showState('paused', 'ضبط موقتاً متوقف شده است.');
+ } else if (recorder?.state === 'paused') {
+ pausedMs += Date.now() - pausedAt;
+ recorder.resume();
+ ui.pause.textContent = 'مکث';
+ showState('live', 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.');
+ }
+ };
+
+ ui.record.addEventListener('click', start);
+ ui.pause.addEventListener('click', togglePause);
+ ui.stop.addEventListener('click', () => stopAndSave());
+ ui.cancel.addEventListener('click', () => stopAndSave({ discard: true }));
+ ui.remove.addEventListener('click', removeVoice);
+ ui.rerecord.addEventListener('click', start);
+ root.ticketVoiceRecorder = { stopAndSave, hasVoice: () => Boolean(voiceFile), url: () => voiceUrl };
+ window.addEventListener('pagehide', () => { stopTracks(); clearTimer(); });
+ }
+
+ setupVoiceRecorder(voiceRoot);
+ form.dataset.wizardEnhanced = '';
+
+ const serverErrorStep = steps.find(step => step.querySelector('.errorlist'));
+ const initialStep = serverErrorStep ? Number(serverErrorStep.dataset.wizardStep) : 1;
+ showStep(initialStep, { scroll: false });
+ if (serverErrorStep) {
+ const messages = [...serverErrorStep.querySelectorAll('.errorlist li')].map(item => item.textContent.trim()).filter(Boolean);
+ const number = Number(serverErrorStep.dataset.wizardStep);
+ requestAnimationFrame(() => showValidation(number, { message: messages.join(' '), fields: [] }));
+ }
+})();
diff --git a/templates/tickets/customer_new.html b/templates/tickets/customer_new.html
index 0f02c1a..d5a80e3 100644
--- a/templates/tickets/customer_new.html
+++ b/templates/tickets/customer_new.html
@@ -3,29 +3,144 @@
{% block content %}
-→ بازگشت به درخواستهاثبت درخواست جدید
چه کمکی از ما برمیآید؟
درخواست خود را کامل بنویسید تا همان بار اول پاسخ دقیقتری دریافت کنید.
-