Compare commits

..
2 Commits
Author SHA1 Message Date
javad d72381f0ac fix pwa, wizard focus, supervisor 5 2026-09-16 11:06:38 +03:30
javad ffc1ec0dcf some improve for sms delivery 2026-09-16 10:40:21 +03:30
19 changed files with 233 additions and 36 deletions

No files matched your search

+2 -2
View File
@@ -28,7 +28,7 @@ def customer_reply_bale_policy():
return NotificationPolicy.objects.get_or_create(key=NotificationPolicy.Key.CUSTOMER_REPLY_BALE,defaults={"quiet_period_seconds":600})[0]
def cancel_staff_reply_sms(ticket,reason):
return NotificationJob.objects.filter(ticket=ticket,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.FAILED]).update(status=NotificationJob.Status.CANCELLED,last_error=reason)
return NotificationJob.objects.filter(ticket=ticket,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.RETRYING,NotificationJob.Status.FAILED]).update(status=NotificationJob.Status.CANCELLED,last_error=reason)
def schedule_staff_reply_sms(ticket,message):
"""Debounce a chat burst into one SMS; caller already locks the ticket."""
@@ -46,7 +46,7 @@ def schedule_staff_reply_sms(ticket,message):
return NotificationJob.objects.create(channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",ticket=ticket,customer=ticket.customer,payload=payload,next_attempt_at=due,dedupe_key=f"ticket:{ticket.pk}:staff-reply:{uuid4().hex}"),True
def cancel_staff_reply_sms_if_read(ticket,customer):
jobs=NotificationJob.objects.filter(ticket=ticket,customer=customer,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.FAILED])
jobs=NotificationJob.objects.filter(ticket=ticket,customer=customer,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.RETRYING,NotificationJob.Status.FAILED])
cancelled=0
for job in jobs:
message_id=job.payload.get("latest_message_id")
+2 -2
View File
@@ -15,13 +15,13 @@ class UnitMembershipInline(admin.TabularInline):
class OrganizationalUnitAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "bale_chat_id", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at")
list_filter = ("show_in_customer_form", "accepts_external_tickets", "is_active")
search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4")
search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5")
list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active")
filter_horizontal = ("permissions",)
inlines = (UnitMembershipInline,)
fieldsets = (
("مشخصات واحد", {"fields": ("name", "slug", "description", "search_keywords", "sort_order")}),
("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4")}),
("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5")}),
("نمایش و دسترسی", {"fields": ("permissions", "show_in_customer_form", "accepts_external_tickets", "is_active")}),
)
@@ -0,0 +1,13 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("staff", "0011_organizationalunit_supervisor_mobile_4")]
operations = [
migrations.AddField(
model_name="organizationalunit",
name="supervisor_mobile_5",
field=models.CharField(blank=True, max_length=16, verbose_name="شماره همراه سرپرست پنجم"),
),
]
+4 -2
View File
@@ -37,6 +37,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
supervisor_mobile_2 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست دوم")
supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم")
supervisor_mobile_4 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست چهارم")
supervisor_mobile_5 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست پنجم")
class Meta:
ordering = ["sort_order", "name", "pk"]
@@ -54,6 +55,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
self.supervisor_mobile_2,
self.supervisor_mobile_3,
self.supervisor_mobile_4,
self.supervisor_mobile_5,
))))
def clean(self):
@@ -62,7 +64,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
normalized = []
errors = {}
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4"):
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5"):
value = getattr(self, field_name, "").strip()
if not value:
setattr(self, field_name, "")
@@ -82,7 +84,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
def save(self, *args, **kwargs):
from apps.accounts.models import normalize_mobile
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4"):
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5"):
value = getattr(self, field_name, "").strip()
setattr(self, field_name, normalize_mobile(value) if value else "")
self.bale_chat_id = self.bale_chat_id.strip()
+2 -2
View File
@@ -9,9 +9,9 @@
.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-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-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-page .is-guided-field{position:relative;border-radius:1rem;box-shadow:0 0 0 4px rgba(6,147,227,.12);transition:box-shadow .2s ease}.ticket-wizard-page .is-guided-field:after{position:absolute;z-index:3;top:-.45rem;left:.6rem;border-radius:999px;background:#0679bd;padding:.18rem .48rem;color:#fff;font-size:.64rem;font-weight:850;content:"این بخش را کامل کنید";pointer-events:none}
.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}}
@media(prefers-reduced-motion:reduce){.ticket-voice-wave i{animation:none}.ticket-wizard-page .is-guided-field{transition:none}}
.ticket-wizard-credit{margin-top:-1px;border:1px solid rgba(8,55,107,.1);border-top:1px dashed rgba(8,55,107,.13);border-radius:0 0 1.15rem 1.15rem;background:#f8fbfd;padding:.85rem 1rem;color:rgba(8,55,107,.62);font-size:.74rem;line-height:1.9;text-align:center}.ticket-wizard-credit a{color:#1977ce}.ticket-wizard-card{border-bottom-right-radius:1.15rem;border-bottom-left-radius:1.15rem}@media(min-width:640px){.ticket-wizard-credit{border-radius:0 0 1.6rem 1.6rem}.ticket-wizard-card{border-bottom-right-radius:1.6rem;border-bottom-left-radius:1.6rem}}
+1 -1
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-leading:initial;--tw-font-weight:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-tracking:initial;--tw-border-style:solid;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:Vazirmatn,ui-sans-serif,system-ui;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-500:oklch(63.7% .237 25.331);--color-red-700:oklch(50.5% .213 27.518);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-800:oklch(43.2% .095 166.913);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-400:oklch(70.4% .04 256.788);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--blur-sm:8px;--blur-xl:24px;--blur-2xl:40px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-ink:#08376b;--color-ink-soft:#0b4c8c;--color-cream:#eee;--color-soft:#eaeaea;--color-paper:#fff;--color-sea:#0693e3;--color-sea-dark:#1977ce;--color-blue:#1977ce;--color-mint:#e5f4ff;--color-coral:#fa9f00;--color-sun:#ffc451;--shadow-panel:0 18px 55px -35px #08376b59}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-Line truncated
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-leading:initial;--tw-font-weight:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-tracking:initial;--tw-border-style:solid;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:Vazirmatn,ui-sans-serif,system-ui;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-500:oklch(63.7% .237 25.331);--color-red-700:oklch(50.5% .213 27.518);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-800:oklch(43.2% .095 166.913);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-400:oklch(70.4% .04 256.788);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--blur-sm:8px;--blur-xl:24px;--blur-2xl:40px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-ink:#08376b;--color-ink-soft:#0b4c8c;--color-cream:#eee;--color-soft:#eaeaea;--color-paper:#fff;--color-sea:#0693e3;--color-sea-dark:#1977ce;--color-blue:#1977ce;--color-mint:#e5f4ff;--color-coral:#fa9f00;--color-sun:#ffc451;--shadow-panel:0 18px 55px -35px #08376b59}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-Line truncated
+1 -1
View File
@@ -36,7 +36,7 @@ document.querySelectorAll('[data-conversation-workspace]').forEach(workspace=>{
const replyResult=workspace.dataset.replyResult||new URLSearchParams(location.search).get('reply_result')||'';
const submissionSucceeded=replyResult==='reply'||replyResult==='note';
if(submissionSucceeded){sessionStorage.removeItem(`${baseKey}-reply`);sessionStorage.removeItem(`${baseKey}-note`)}
const setComposerOpen=(open,focus=true)=>{composer?.classList.toggle('is-collapsed',!open);composerToggle?.setAttribute('aria-expanded',String(open));if(open&&focus)setTimeout(()=>input?.focus(),80)};
const setComposerOpen=(open,focus=true)=>{const keepLatestVisible=atBottom();composer?.classList.toggle('is-collapsed',!open);composerToggle?.setAttribute('aria-expanded',String(open));requestAnimationFrame(()=>{if(keepLatestVisible)scrollLatest(false)});if(open&&focus)setTimeout(()=>input?.focus(),80)};
const saveDraft=()=>{if(!input)return;sessionStorage.setItem(draftKey(),input.value);if(status){status.textContent=input.value?'پیش‌نویس ذخیره شد':'';clearTimeout(saveDraft.timer);saveDraft.timer=setTimeout(()=>status.textContent='',1800)}};
const loadDraft=()=>{if(!input)return;input.value=sessionStorage.getItem(draftKey())||'';resize();input.dispatchEvent(new Event('input',{bubbles:true}))};
if(input){input.setAttribute('rows','5');input.setAttribute('placeholder','شرح کامل پاسخ، اطلاعات تکمیلی و نتیجه مورد انتظار خود را بنویسید…');input.addEventListener('input',()=>{resize();saveDraft()});loadDraft()}
+54 -11
View File
@@ -13,6 +13,8 @@
const canonicalFiles = form.querySelector('input[name="files"]');
const voiceRoot = form.querySelector('[data-ticket-voice-recorder]');
let currentStep = 1;
let guidedField = null;
let guideTimer = null;
const toPersianDigits = value => String(value).replace(/\d/g, digit => '۰۱۲۳۴۵۶۷۸۹'[digit]);
@@ -39,14 +41,47 @@
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'));
step?.querySelectorAll('[aria-invalid="true"]').forEach(field => {
field.removeAttribute('aria-invalid');
if (field.getAttribute('aria-describedby') === error?.id) field.removeAttribute('aria-describedby');
});
if (error) {
error.textContent = '';
error.classList.remove('is-visible');
error.removeAttribute('tabindex');
}
}
function clearGuidedField(field = guidedField) {
if (!field) return;
field.closest('.field, .ticket-intent-search, .ticket-location-fields')?.classList.remove('is-guided-field');
if (field === guidedField) guidedField = null;
if (guideTimer) {
window.clearTimeout(guideTimer);
guideTimer = null;
}
}
function guideToField(fields, error) {
const field = fields.find(item => item && !item.disabled && !item.hidden && item.getClientRects().length);
if (!field) {
error?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
return;
}
clearGuidedField();
guidedField = field;
const container = field.closest('.field, .ticket-intent-search, .ticket-location-fields');
container?.classList.add('is-guided-field');
requestAnimationFrame(() => {
field.focus({ preventScroll: true });
field.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'center',
inline: 'nearest',
});
});
guideTimer = window.setTimeout(() => clearGuidedField(field), 2800);
}
function showValidation(number, problem) {
const step = form.querySelector(`[data-wizard-step="${number}"]`);
const error = form.querySelector(`[data-wizard-error="${number}"]`);
@@ -55,14 +90,13 @@
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' });
problem.fields.forEach(field => {
field?.setAttribute('aria-invalid', 'true');
if (field && error?.id) field.setAttribute('aria-describedby', error.id);
});
error.textContent = problem.message;
error.classList.add('is-visible');
guideToField(problem.fields, error);
}
function problemForStep(number) {
@@ -145,9 +179,15 @@
form.addEventListener('input', event => {
event.target.removeAttribute?.('aria-invalid');
if (event.target.getAttribute?.('aria-describedby')?.startsWith('wizard-error-')) event.target.removeAttribute('aria-describedby');
clearGuidedField(event.target);
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('change', event => {
event.target.removeAttribute?.('aria-invalid');
if (event.target.getAttribute?.('aria-describedby')?.startsWith('wizard-error-')) event.target.removeAttribute('aria-describedby');
clearGuidedField(event.target);
});
form.addEventListener('submit', event => {
const invalidStep = [1, 2, 3, 4].find(number => problemForStep(number));
@@ -314,7 +354,10 @@
showStep(initialStep, { scroll: false });
if (serverErrorStep) {
const messages = [...serverErrorStep.querySelectorAll('.errorlist li')].map(item => item.textContent.trim()).filter(Boolean);
const fields = [...serverErrorStep.querySelectorAll('.errorlist')]
.map(list => list.closest('.field, .ticket-server-errors')?.querySelector('input, select, textarea'))
.filter(Boolean);
const number = Number(serverErrorStep.dataset.wizardStep);
requestAnimationFrame(() => showValidation(number, { message: messages.join(' '), fields: [] }));
requestAnimationFrame(() => showValidation(number, { message: messages.join(' '), fields }));
}
})();
+5 -4
View File
@@ -30,6 +30,7 @@ body:has(.ticket-workspace){overflow:hidden}.customer-main:has(.ticket-workspace
.attachment-list{@apply mt-3 grid gap-2}.attachment{margin-top:0}.attachment-image{@apply block max-w-72 overflow-hidden p-1}.attachment-image img{@apply max-h-52 w-full rounded-lg object-cover}.attachment-image span{@apply block truncate px-2 py-1 text-[10px]}.attachment-file{@apply flex max-w-sm items-center gap-3 bg-white/75}.file-icon{@apply grid size-9 shrink-0 place-items-center rounded-lg bg-mint text-lg}.attachment-file b,.attachment-file small{@apply block}.attachment-file small{@apply mt-0.5 text-[9px] font-normal text-ink/40}.attachment-audio{@apply block max-w-md}.attachment-audio audio{@apply my-2 h-10 w-full}.attachment-audio>a{@apply text-[10px]}.message-menu{position:absolute;top:.35rem;left:.35rem}.message.mine .message-menu{left:auto;right:.35rem}.message-menu>summary{@apply grid size-8 cursor-pointer list-none place-items-center rounded-full text-xs tracking-widest text-ink/40 transition hover:bg-white hover:text-sea;box-shadow:none}.message-menu>summary::-webkit-details-marker{display:none}.message-menu[open]>summary{@apply bg-white text-sea;box-shadow:0 5px 15px -10px #08376b}.message-menu-panel{@apply absolute left-0 z-20 mt-1 w-72 overflow-hidden rounded-2xl border border-ink/10 bg-paper shadow-xl}.message.mine .message-menu-panel{right:0;left:auto}.message-menu-head{@apply flex items-center justify-between border-b border-ink/8 bg-ink/[.025] px-4 py-3}.message-menu-head b,.message-menu-head small{@apply block}.message-menu-head b{@apply text-xs text-ink}.message-menu-head small{@apply mt-0.5 text-[9px] font-normal text-ink/45}.message-menu-close{@apply grid size-8 min-h-0 place-items-center rounded-full bg-transparent p-0 text-lg font-normal text-ink/45 shadow-none hover:bg-ink/5 hover:text-ink}.message-edit-form{@apply p-4}.message-edit-form label{@apply mb-1.5 text-[10px] text-ink/65}.message-menu-panel textarea{@apply min-h-24 rounded-xl bg-ink/[.025] p-3 text-xs leading-6 shadow-none}.message-menu-actions{@apply mt-3 grid grid-cols-2 gap-2}.message-menu-actions button{@apply m-0 min-h-10 w-full rounded-xl px-3 py-2 text-xs}.message-edit-save{@apply bg-sea}.message-edit-cancel{@apply border border-ink/10 bg-white text-ink/60 shadow-none hover:bg-ink/5 hover:text-ink}.message-delete-zone{@apply flex items-center justify-between gap-3 border-t border-red-200 bg-red-50/50 px-4 py-3}.message-delete-zone b,.message-delete-zone small{@apply block}.message-delete-zone b{@apply text-[10px] text-red-700}.message-delete-zone small{@apply mt-0.5 text-[9px] text-red-700/60}.message-delete-zone form{@apply m-0}.message-delete-button{@apply m-0 min-h-8 rounded-lg border border-red-200 bg-white px-3 py-1 text-[10px] text-red-700 shadow-none hover:bg-red-500 hover:text-white}@media(max-width:639px){.message-menu-panel{position:fixed;right:.75rem!important;bottom:5rem;left:.75rem!important;top:auto;width:auto;max-height:calc(100dvh - 7rem);overflow:auto}}.conversation-empty{@apply grid h-full min-h-64 place-content-center text-center}.conversation-empty>span{@apply mx-auto mb-3 grid size-12 place-items-center rounded-full bg-mint text-sea}.conversation-empty p{@apply mt-1 text-xs text-ink/45}
.ticket-workspace .composer{position:relative;z-index:15;margin:0;border:0;border-top:1px solid rgba(8,55,107,.08);border-radius:0;padding:.65rem .85rem max(.65rem,env(safe-area-inset-bottom));box-shadow:0 -14px 40px -30px rgba(8,55,107,.55)}.composer-context{@apply mb-2 flex items-start justify-between}.composer-context b,.composer-context small{@apply block}.composer-context b{@apply text-xs}.composer-context small,.composer-context>[data-draft-status]{@apply mt-0.5 text-[9px] text-ink/40}.ticket-workspace .composer textarea{max-height:9rem;min-height:2.75rem;resize:none;border-radius:13px;background:var(--color-cream);padding:.7rem .85rem;line-height:1.7}.ticket-workspace input[type=file]{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}.composer-bar{@apply mt-2 flex items-center gap-2}.composer-tools{@apply flex items-center gap-1}.tool-button{@apply mb-0 inline-flex min-h-10 cursor-pointer items-center gap-1 rounded-xl border border-ink/8 bg-paper px-3 text-xs font-bold text-ink shadow-none hover:border-sea/30 hover:bg-mint}.tool-button span{@apply hidden sm:inline}.composer-hint{@apply hidden flex-1 text-center text-[9px] text-ink/30 lg:block}.send-button{@apply mr-auto min-h-10 rounded-xl bg-sea px-4 py-2}.compact-recorder{@apply my-2 rounded-xl p-3}.compact-recorder[hidden],.composer-extra[hidden]{display:none}.composer-extra{@apply mt-2 rounded-xl border border-ink/8 bg-cream p-3}.composer-extra label{@apply text-xs}.new-message-jump{@apply absolute bottom-36 left-1/2 z-20 min-h-9 -translate-x-1/2 rounded-full bg-ink px-4 py-2 text-[10px] shadow-xl}.new-message-jump[hidden]{display:none}.staff-composer .mode{@apply mb-2}.staff-composer.note-mode{background:var(--theme-note);border-top-color:color-mix(in srgb,var(--color-coral) 45%,transparent)}.staff-composer.note-mode textarea{@apply bg-white}.staff-composer.note-mode .send-button{@apply bg-coral text-ink}.file-preview:empty{display:none}.ticket-workspace .file-preview{@apply mb-2 mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4}.ticket-workspace .file-chip{@apply text-[9px]}
.workspace-side{min-height:0;overflow-y:auto;padding:.75rem;background:var(--color-cream)}.workspace-side .info-card{@apply mb-3 rounded-[14px] p-4 shadow-none}.panel-close{@apply hidden}.ticket-operations form{@apply mt-3 border-t border-ink/6 pt-3}.ticket-operations form button{@apply w-full}.ticket-operations details{@apply mt-3 rounded-xl border border-ink/8 p-3}.ticket-operations summary{@apply cursor-pointer text-xs font-bold}
.sms-delivery-heading{display:flex;align-items:center;justify-content:space-between;gap:.75rem}.sms-delivery-heading h3,.sms-delivery-heading small{display:block}.sms-delivery-heading small{margin-top:.2rem;color:rgba(8,55,107,.48);font-size:.6rem}.sms-delivery-heading>span{display:grid;width:1.75rem;height:1.75rem;flex:none;place-items:center;border-radius:999px;background:var(--color-mint);color:var(--color-sea);font-weight:900}.sms-delivery-list{display:grid;gap:.45rem;margin-top:.75rem}.sms-delivery-item{border:1px solid rgba(8,55,107,.09);border-radius:.75rem;background:var(--color-paper);overflow:hidden}.sms-delivery-item>summary{display:grid;grid-template-columns:minmax(0,1fr) auto 1.6rem;align-items:center;gap:.45rem;min-height:2.8rem;padding:.5rem .6rem;cursor:pointer;list-style:none}.sms-delivery-item>summary::-webkit-details-marker{display:none}.sms-delivery-event{min-width:0;overflow:hidden;color:rgba(8,55,107,.68);font-size:.65rem;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.sms-delivery-item>summary b{font-size:.64rem;color:var(--color-ink)}.sms-delivery-info{display:grid;width:1.55rem;height:1.55rem;place-items:center;border:1px solid rgba(6,147,227,.25);border-radius:999px;background:#eef8ff;color:var(--color-sea);font-size:.68rem;font-weight:900}.sms-delivery-item[open] .sms-delivery-info{background:var(--color-sea);color:#fff}.sms-delivery-timeline{display:grid;gap:.7rem;margin:0;padding:.7rem .75rem .75rem;list-style:none;border-top:1px solid rgba(8,55,107,.07);background:var(--color-cream)}.sms-delivery-timeline li{display:grid;grid-template-columns:.65rem minmax(0,1fr);gap:.55rem;align-items:start;color:rgba(8,55,107,.48)}.sms-delivery-timeline li>span{width:.55rem;height:.55rem;margin-top:.28rem;border:2px solid #aab9c6;border-radius:999px;background:#fff}.sms-delivery-timeline li.is-complete>span{border-color:var(--color-sea);background:var(--color-sea)}.sms-delivery-timeline b,.sms-delivery-timeline time,.sms-delivery-timeline small{display:block}.sms-delivery-timeline b{font-size:.62rem}.sms-delivery-timeline time,.sms-delivery-timeline small{margin-top:.12rem;font-size:.58rem;color:rgba(8,55,107,.58)}.sms-delivery-timeline li.is-complete b{color:var(--color-ink)}.sms-delivery-description,.sms-delivery-error,.sms-delivery-tracking{display:block;margin:0;padding:.55rem .75rem;border-top:1px solid rgba(8,55,107,.07);font-size:.58rem;line-height:1.75}.sms-delivery-description{color:#526b85}.sms-delivery-error{color:#8e2d2d;background:#fff2f2}.sms-delivery-tracking{overflow-wrap:anywhere;color:#647b91;text-align:left}
@media(max-width:1023px){body:has(.ticket-workspace) .mobile-dock,body:has(.ticket-workspace) .staff-mobile-dock{display:none}.ticket-workspace{height:calc(100dvh - 4.25rem)}.staff-conversation-grid,.customer-grid{grid-template-columns:minmax(0,1fr)}.workspace-side{position:fixed;inset:4.25rem 0 0 auto;z-index:60;width:min(88vw,22rem);transform:translateX(105%);border-left:1px solid rgba(8,55,107,.1);box-shadow:-20px 0 60px -35px rgba(8,55,107,.7);transition:transform .25s ease}.workspace-side.panel-open{transform:translateX(0)}.panel-close{@apply mb-3 grid size-10 place-items-center rounded-xl bg-ink text-white}.message-bubble{max-width:86%}}
@media(max-width:639px){.site-header{display:none}.customer-main:has(.ticket-workspace){padding:0}.ticket-workspace{height:100dvh}.ticket-workspace-head{@apply min-h-16}.workspace-grid{height:calc(100% - 4rem)}.ticket-title h1{@apply max-w-[48vw] text-sm}.ticket-head-actions .badge{@apply max-w-24 truncate}.conversation-toolbar{@apply min-h-10}.ticket-workspace .conversation{@apply px-2.5 py-3}.message-avatar{@apply size-7 rounded-lg}.message-bubble{max-width:88%;padding:.6rem .7rem}.message.note .message-bubble{width:96%}.ticket-workspace .composer{@apply px-2.5 pt-2}.composer-context small{max-width:70vw}.composer-hint{display:none}.send-button{@apply px-3}.new-message-jump{bottom:8.5rem}.ticket-head-actions form{display:none}}
@media(prefers-reduced-motion:reduce){.ticket-workspace .conversation{scroll-behavior:auto}.workspace-side{transition:none}}
@@ -41,13 +42,13 @@ body:has(.ticket-workspace){overflow:hidden}.customer-main:has(.ticket-workspace
/* Collapsible reply panel keeps the correspondence history dominant. */
.ticket-workspace .composer.is-collapsed{padding:.55rem .85rem;border-top:1px solid rgba(8,55,107,.1);background:var(--color-paper)}.composer.is-collapsed>form,.composer.is-collapsed>.mode{display:none}.composer-launch{display:none;width:100%;min-height:3.5rem;align-items:center;justify-content:flex-start;gap:.75rem;border:1px solid rgba(8,55,107,.1);border-radius:11px;background:linear-gradient(135deg,var(--color-paper),var(--color-mint));padding:.55rem .75rem;color:var(--color-ink);box-shadow:none;text-align:right}.composer.is-collapsed .composer-launch{display:flex}.composer-launch:hover{transform:none;border-color:color-mix(in srgb,var(--color-sea) 35%,transparent);background:var(--color-mint)}.composer-launch-icon{@apply grid size-9 shrink-0 place-items-center rounded-lg bg-sea text-lg text-white}.composer-launch b,.composer-launch small{display:block}.composer-launch b{@apply text-xs}.composer-launch small{@apply mt-0.5 text-[9px] font-medium text-ink/45}.composer-launch-arrow{@apply mr-auto text-sm text-sea}.composer-close{@apply text-ink/55}.composer:not(.is-collapsed) .composer-launch{display:none}.composer.is-collapsed~*{transition:none}.composer.is-collapsed+.new-message-jump{bottom:5rem}.ticket-workspace .composer.is-collapsed{box-shadow:0 -10px 30px -28px rgba(8,55,107,.65)}
@media(max-width:639px){.ticket-workspace .composer.is-collapsed{@apply px-2.5 py-2}.composer-launch{min-height:3.25rem}.composer-launch small{max-width:65vw}.new-message-jump{bottom:5.5rem}.staff-ticket-workspace .composer:not(.is-collapsed){position:fixed;inset-inline:0;bottom:0;z-index:55;box-sizing:border-box;max-height:calc(100dvh - .5rem);overflow-y:auto;overscroll-behavior:contain;border-radius:1rem 1rem 0 0;padding-bottom:max(1rem,env(safe-area-inset-bottom));box-shadow:0 -18px 55px -25px rgba(8,55,107,.75)}.staff-ticket-workspace .composer:not(.is-collapsed) .composer-bar{position:sticky;bottom:calc(-1 * max(1rem,env(safe-area-inset-bottom)));z-index:2;margin-inline:-.65rem;margin-bottom:calc(-1 * max(1rem,env(safe-area-inset-bottom)));padding:.75rem .65rem max(1rem,env(safe-area-inset-bottom));background:var(--color-paper)}.staff-ticket-workspace .composer:not(.is-collapsed) .composer-tools{display:grid;grid-template-columns:1fr 1fr 1fr;width:100%}.staff-ticket-workspace .composer:not(.is-collapsed) .composer-tools .tool-button{justify-content:center;min-width:0;padding-inline:.4rem}.staff-ticket-workspace .composer:not(.is-collapsed) .composer-close{display:inline-flex;order:initial;color:var(--color-ink)} }
@media(max-width:639px){.ticket-workspace .composer.is-collapsed{@apply px-2.5 py-2}.composer-launch{min-height:3.25rem}.composer-launch small{max-width:65vw}.new-message-jump{bottom:5.5rem}.ticket-workspace .composer{min-width:0;max-height:min(45dvh,28rem);overflow-y:auto;overscroll-behavior:contain}.ticket-workspace .composer-bar{position:sticky;bottom:calc(-1 * max(1rem,env(safe-area-inset-bottom)));z-index:2;margin-inline:-.65rem;margin-bottom:calc(-1 * max(1rem,env(safe-area-inset-bottom)));padding:.75rem .65rem max(1rem,env(safe-area-inset-bottom));background:var(--color-paper)}.staff-ticket-workspace .composer:not(.is-collapsed) .composer-tools{display:grid;grid-template-columns:1fr 1fr 1fr;width:100%}.staff-ticket-workspace .composer:not(.is-collapsed) .composer-tools .tool-button{justify-content:center;min-width:0;padding-inline:.4rem}.staff-ticket-workspace .composer:not(.is-collapsed) .composer-close{display:inline-flex;order:initial;color:var(--color-ink)} }
.conversation-pane:has(.composer.is-collapsed) .new-message-jump{bottom:5rem}.conversation-pane:not(:has(.composer.is-collapsed)) .new-message-jump{bottom:17rem}@media(max-width:639px){.conversation-pane:has(.composer.is-collapsed) .new-message-jump{bottom:5.5rem}.conversation-pane:not(:has(.composer.is-collapsed)) .new-message-jump{bottom:19rem}}
body:has(.ticket-workspace) .flash{position:fixed;top:5rem;left:50%;z-index:100;width:min(calc(100% - 2rem),42rem);margin:0;transform:translateX(-50%);box-shadow:0 18px 50px -25px rgba(8,55,107,.55);animation:flash-in .22s ease-out}.flash{position:relative;padding-left:3rem}.flash-close{position:absolute;left:.5rem;top:50%;min-height:2rem;width:2rem;transform:translateY(-50%);border-radius:.5rem;background:transparent;padding:0;color:currentColor;box-shadow:none}.flash-close:hover{transform:translateY(-50%);background:rgba(255,255,255,.6)}.flash-leaving{opacity:0;transform:translate(-50%,-.5rem)!important;transition:.25s ease}@keyframes flash-in{from{opacity:0;transform:translate(-50%,-.5rem)}to{opacity:1;transform:translate(-50%,0)}}
/* Group context adds a row above the conversation; keep every control inside the viewport. */
.staff-ticket-workspace:has(.ticket-dispatch-context){display:grid;grid-template-rows:auto auto minmax(0,1fr)}.staff-ticket-workspace:has(.ticket-dispatch-context) .workspace-grid{height:auto}.ticket-dispatch-context{margin:.5rem .65rem 0}
@media(max-width:639px){.staff-ticket-workspace{min-width:0}.ticket-workspace-head{min-width:0}.ticket-title{flex:1}.ticket-head-actions{gap:.35rem}.ticket-head-actions .icon-button{width:2.35rem;height:2.35rem}.ticket-dispatch-context{margin:.4rem .45rem 0}.composer-launch{min-width:0;padding-inline:.55rem;gap:.5rem}.composer-launch>span:nth-child(2){min-width:0;flex:1}.composer-launch b,.composer-launch small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:none}.composer-launch-arrow{flex:none;margin-right:0}.staff-ticket-workspace .composer.is-collapsed{position:fixed;right:0;bottom:0;left:0;z-index:45;padding-bottom:max(.5rem,env(safe-area-inset-bottom))}.staff-ticket-workspace .conversation{padding-bottom:5.25rem}}
@media(max-width:639px){.staff-ticket-workspace{min-width:0}.ticket-workspace-head{min-width:0}.ticket-title{flex:1}.ticket-head-actions{gap:.35rem}.ticket-head-actions .icon-button{width:2.35rem;height:2.35rem}.ticket-dispatch-context{margin:.4rem .45rem 0}.composer-launch{min-width:0;padding-inline:.55rem;gap:.5rem}.composer-launch>span:nth-child(2){min-width:0;flex:1}.composer-launch b,.composer-launch small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:none}.composer-launch-arrow{flex:none;margin-right:0}}
.flash.flash-leaving{transform:none!important}body:has(.ticket-workspace) .flash.flash-leaving{transform:translate(-50%,-.5rem)!important}
/* Searchable Iran province/city fields shared by profile and new-ticket forms. */
@@ -90,8 +91,8 @@ body:has(.ticket-workspace) .flash{position:fixed;top:5rem;left:50%;z-index:100;
.ticket-company-logo{display:block;width:3.25rem;height:auto;max-height:1.45rem;flex-shrink:0;object-fit:contain}
.site-footer{display:flex;min-height:2rem;align-items:center;justify-content:center;gap:.35rem;padding:.4rem 1rem;color:#60758b;font-size:.68rem;font-weight:500;text-align:center}
.staff-main>.site-footer{border-top:1px solid rgba(8,55,107,.06)}
body:has(.ticket-workspace) .ticket-workspace{height:calc(100dvh - 4.25rem - 2rem)}
@media(max-width:639px){.logo-copy{font-size:.82rem}.header-company-logo{width:4.75rem;max-height:2.1rem}.auth-brand{gap:.5rem}.auth-brand img{width:7rem;max-height:3.1rem}.auth-brand>div{min-width:0}.auth-footer{font-size:.6rem}.ticket-company-logo{width:2.5rem;max-height:1.1rem}.site-footer{padding-inline:3.5rem;font-size:.62rem;line-height:1.6}body:has(.ticket-workspace) .ticket-workspace{height:calc(100dvh - 2rem)}}
body:has(.ticket-workspace) .site-footer{display:none}body:has(.ticket-workspace) .ticket-workspace{height:calc(100dvh - 4.25rem)}body:has(.staff-ticket-workspace) .ticket-workspace{height:calc(100dvh - 4.5rem)}
@media(max-width:639px){.logo-copy{font-size:.82rem}.header-company-logo{width:4.75rem;max-height:2.1rem}.auth-brand{gap:.5rem}.auth-brand img{width:7rem;max-height:3.1rem}.auth-brand>div{min-width:0}.auth-footer{font-size:.6rem}.ticket-company-logo{width:2.5rem;max-height:1.1rem}.site-footer{padding-inline:3.5rem;font-size:.62rem;line-height:1.6}body:has(.ticket-workspace) .ticket-workspace{height:100dvh}body:has(.staff-ticket-workspace) .ticket-workspace{height:calc(100dvh - 4.5rem)}}
/* Session actions, staff-created tickets and install prompt */
.desktop-nav form,.staff-topbar-actions form{margin:0}.logout-button{min-height:auto;border:0;background:transparent;padding:.5rem .75rem;color:var(--color-coral);box-shadow:none}.logout-button:hover{transform:none;background:#fff;color:#b45309;box-shadow:none}
+1 -1
View File
@@ -61,7 +61,7 @@
{% elif user.is_authenticated %}
<nav class="mobile-dock customer-mobile-dock" aria-label="دسترسی سریع مشتری"><a class="{% if request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name != 'new' %}active{% endif %}" href="{% url 'tickets:list' %}" {% if request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name != 'new' %}aria-current="page"{% endif %}><span class="dock-icon">◎</span>درخواست‌ها</a><a class="{% if request.resolver_match.url_name == 'new' %}active{% endif %}" href="{% url 'tickets:new' %}" {% if request.resolver_match.url_name == 'new' %}aria-current="page"{% endif %}><span class="dock-icon">+</span>درخواست جدید</a><a class="{% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' %}active{% endif %}" href="{% url 'accounts:profile' %}" {% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' %}aria-current="page"{% endif %}><span class="dock-icon">○</span>پروفایل</a><form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button type="submit"><span class="dock-icon">←</span>خروج</button></form></nav>
{% endif %}
{% if user.is_authenticated %}<aside class="pwa-install" data-pwa-install hidden><img class="pwa-install-icon" src="{{ pwa_icon_url }}?v={{ static_asset_version }}-{{ branding_version }}" alt="" width="64" height="64"><div class="pwa-install-copy"><b>{{ site_branding.pwa_install_label }}</b><small data-pwa-help role="status">{{ site_branding.pwa_install_help }}</small></div><div class="pwa-install-actions"><button type="button" data-pwa-dismiss>بعداً</button><button type="button" class="primary" data-pwa-accept>نصب</button></div></aside>{% endif %}
{% block pwa_install_prompt %}{% endblock %}
<script src="{% static 'js/voice-recorder.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/ui.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/chat-coach.js' %}?v={{ static_asset_version }}"></script>
{% if user.is_authenticated %}<script defer src="{% static 'js/pwa.js' %}?v={{ static_asset_version }}"></script>{% endif %}
{% block extra_scripts %}{% endblock %}
+1
View File
@@ -26,3 +26,4 @@
{% endfor %}
</div>
{% endblock %}
{% block pwa_install_prompt %}<aside class="pwa-install" data-pwa-install hidden><img class="pwa-install-icon" src="{{ pwa_icon_url }}?v={{ static_asset_version }}-{{ branding_version }}" alt="" width="64" height="64"><div class="pwa-install-copy"><b>{{ site_branding.pwa_install_label }}</b><small data-pwa-help role="status">{{ site_branding.pwa_install_help }}</small></div><div class="pwa-install-actions"><button type="button" data-pwa-dismiss>بعداً</button><button type="button" class="primary" data-pwa-accept>نصب</button></div></aside>{% endblock %}
+5 -5
View File
@@ -62,7 +62,7 @@
{{ form.subject }}{{ form.subject.errors }}
<small>این عنوان فقط توسط شما نوشته می‌شود و با تغییر واحد یا دسته‌بندی عوض نخواهد شد.</small>
</div>
<p class="ticket-wizard-validation" data-wizard-error="1" role="alert"></p>
<p class="ticket-wizard-validation" id="wizard-error-1" data-wizard-error="1" role="alert"></p>
<footer class="ticket-wizard-actions"><span>حدود یک دقیقه تا پایان</span><button class="primary" type="button" data-wizard-next>ادامه <b>←</b></button></footer>
</section>
@@ -76,7 +76,7 @@
<p class="location-hint" data-location-hint>نام استان را جست‌وجو و انتخاب کنید.</p>
</section>
<div class="ticket-soft-tip"><span>i</span><p><b>نیازی به انتخاب ندارید</b> این بخش کاملاً اختیاری است و می‌توانید مستقیماً ادامه دهید.</p></div>
<p class="ticket-wizard-validation" data-wizard-error="2" role="alert"></p>
<p class="ticket-wizard-validation" id="wizard-error-2" data-wizard-error="2" role="alert"></p>
<footer class="ticket-wizard-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="button" data-wizard-next>ادامه <b>←</b></button></footer>
</section>
@@ -91,7 +91,7 @@
{% for expert in form.fields.selected_expert.queryset %}<label class="expert-card"><input type="radio" name="selected_expert" value="{{ expert.pk }}" {% if form.selected_expert.value|stringformat:'s' == expert.pk|stringformat:'s' %}checked{% endif %}><img src="{% url 'tickets:expert_avatar' expert.user.public_id %}?v={{ expert.updated_at|date:'U' }}" alt="تصویر {{ expert.display_name }}" width="56" height="56" loading="lazy"><strong>{{ expert.display_name }}</strong><small>{{ expert.job_title|default:'کارشناس واحد' }}</small></label>{% empty %}<p class="muted expert-empty">کارشناس قابل انتخابی ثبت نشده؛ درخواست به صف عمومی می‌رود.</p>{% endfor %}
</fieldset>
{{ form.selected_expert.errors }}
<p class="ticket-wizard-validation" data-wizard-error="3" role="alert"></p>
<p class="ticket-wizard-validation" id="wizard-error-3" data-wizard-error="3" role="alert"></p>
<footer class="ticket-wizard-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="button" data-wizard-next>ادامه <b>←</b></button></footer>
</section>
@@ -120,7 +120,7 @@
</section>
<div class="ticket-server-errors" data-step-server-errors>{% if form.non_field_errors %}{{ form.non_field_errors }}{% endif %}</div>
<p class="ticket-wizard-validation" data-wizard-error="4" role="alert"></p>
<p class="ticket-wizard-validation" id="wizard-error-4" data-wizard-error="4" role="alert"></p>
<footer class="ticket-wizard-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="button" data-wizard-next>مشاهده پیش‌نمایش <b>←</b></button></footer>
</section>
@@ -133,7 +133,7 @@
<section class="ticket-review-row"><span class="ticket-review-icon">۳</span><div class="ticket-review-content"><small>نحوه ارجاع</small><h3 data-review-expert>صف عمومی</h3><p>در همان واحد سازمانی</p></div><button type="button" data-wizard-edit="3"><span>✎</span> ویرایش</button></section>
<section class="ticket-review-row ticket-review-details"><span class="ticket-review-icon">۴</span><div class="ticket-review-content"><small>شرح و پیوست‌ها</small><p class="ticket-review-body" data-review-body>—</p><div class="ticket-review-files" data-review-files></div><audio class="ticket-review-audio" controls hidden data-review-audio></audio></div><button type="button" data-wizard-edit="4"><span>✎</span> ویرایش</button></section>
</div>
<p class="ticket-wizard-validation" data-wizard-error="5" role="alert"></p>
<p class="ticket-wizard-validation" id="wizard-error-5" data-wizard-error="5" role="alert"></p>
<footer class="ticket-wizard-actions ticket-final-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="submit" data-final-submit>ثبت و ارسال نهایی <b>←</b></button></footer>
</section>
</form>
+1 -1
View File
@@ -17,7 +17,7 @@
<aside class="workspace-side actions-side" id="ticket-actions" data-workspace-panel>
<button type="button" class="panel-close" data-close-panel>×</button>
<section class="info-card"><p class="panel-index">وضعیت تیکت</p><div class="info-list"><div class="info-row"><span>وضعیت</span><b>{{ ticket.get_status_display }}</b></div><div class="info-row"><span>اولویت</span><b>{{ ticket.get_priority_display }}</b></div><div class="info-row"><span>دسته</span><b>{{ ticket.category }}</b></div><div class="info-row"><span>مالک</span><b>{{ ticket.assigned_expert_name }}</b></div>{% if ticket.first_response_due_at %}<div class="info-row"><span>مهلت پاسخ</span><b>{{ ticket.first_response_due_at|jalali_datetime }}</b></div>{% endif %}</div></section>
{% if sms_jobs %}<section class="info-card"><h3>وضعیت پیامک‌های مشتری</h3><div class="info-list">{% for sms_job in sms_jobs %}<div class="info-row"><span>{% if sms_job.event_type == 'STAFF_TICKET_CREATED' %}ثبت تیکت توسط کارشناس{% elif sms_job.event_type == 'CUSTOMER_TICKET_CREATED' %}ثبت تیکت مشتری{% elif sms_job.event_type == 'STAFF_REPLY' %}اعلان پاسخ کارشناس{% else %}پیامک سیستمی{% endif %}</span><b>{% if sms_job.delivery_status %}{{ sms_job.get_delivery_status_display }}{% else %}{{ sms_job.get_status_display }}{% endif %}</b></div>{% if sms_job.delivery_description %}<p class="muted">{{ sms_job.delivery_description }}</p>{% endif %}{% if sms_job.last_error %}<div class="create-form-errors">علت دقیق: {{ sms_job.last_error }}</div>{% endif %}{% endfor %}</div></section>{% endif %}
{% if sms_jobs %}<section class="info-card sms-delivery-card"><div class="sms-delivery-heading"><div><h3>وضعیت پیامک‌های مشتری</h3><small>خلاصه ارسال و تحویل</small></div><span aria-hidden="true">ⓘ</span></div><div class="sms-delivery-list">{% for sms_job in sms_jobs %}<details class="sms-delivery-item"><summary><span class="sms-delivery-event">{% if sms_job.event_type == 'STAFF_TICKET_CREATED' %}ثبت تیکت توسط کارشناس{% elif sms_job.event_type == 'CUSTOMER_TICKET_CREATED' %}ثبت تیکت مشتری{% elif sms_job.event_type == 'STAFF_REPLY' %}اعلان پاسخ کارشناس{% else %}پیامک سیستمی{% endif %}</span><b>{% if sms_job.delivery_status %}{{ sms_job.get_delivery_status_display }}{% else %}{{ sms_job.get_status_display }}{% endif %}</b><span class="sms-delivery-info" aria-label="نمایش جزئیات زمان‌بندی پیامک" title="نمایش جزئیات">i</span></summary><ol class="sms-delivery-timeline"><li class="is-complete"><span></span><div><b>ثبت درخواست ارسال</b><time datetime="{{ sms_job.created_at|date:'c' }}">{{ sms_job.created_at|jalali_datetime }}</time></div></li><li class="{% if sms_job.sent_at %}is-complete{% endif %}"><span></span><div><b>پذیرش توسط درگاه پیامک</b>{% if sms_job.sent_at %}<time datetime="{{ sms_job.sent_at|date:'c' }}">{{ sms_job.sent_at|jalali_datetime }}</time>{% else %}<small>{% if sms_job.status == 'FAILED' %}ناموفق{% elif sms_job.status == 'CANCELLED' %}لغوشده پیش از ارسال{% else %}در انتظار ارسال{% endif %}</small>{% endif %}</div></li><li class="{% if sms_job.delivery_status == 'DELIVERED' %}is-complete{% endif %}"><span></span><div><b>تأیید رسیدن به گوشی</b>{% if sms_job.delivery_status == 'DELIVERED' and sms_job.delivery_checked_at %}<time datetime="{{ sms_job.delivery_checked_at|date:'c' }}">{{ sms_job.delivery_checked_at|jalali_datetime }}</time>{% elif sms_job.delivery_checked_at %}<small>آخرین استعلام: {{ sms_job.delivery_checked_at|jalali_datetime }}</small>{% else %}<small>{% if sms_job.sent_at %}در انتظار نتیجه اپراتور{% else %}هنوز ارسال نشده{% endif %}</small>{% endif %}</div></li></ol>{% if sms_job.delivery_description %}<p class="sms-delivery-description">{{ sms_job.delivery_description }}</p>{% endif %}{% if sms_job.last_error %}<p class="sms-delivery-error">علت دقیق: {{ sms_job.last_error }}</p>{% endif %}{% if sms_job.provider_message_id %}<small class="sms-delivery-tracking" dir="ltr">شناسه رهگیری: {{ sms_job.provider_message_id }}</small>{% endif %}</details>{% endfor %}</div></section>{% endif %}
<section class="info-card ticket-operations"><h3>عملیات تیکت</h3>{% if perms.tickets.change_ticket %}<form method="post" action="{% url 'staff_tickets:status' ticket.public_id %}">{% csrf_token %}<label>وضعیت<select name="status">{% for value,label in statuses %}<option value="{{ value }}" {% if ticket.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button>ثبت وضعیت</button></form>{% endif %}{% if perms.tickets.change_ticket_priority %}<form method="post" action="{% url 'staff_tickets:priority' ticket.public_id %}">{% csrf_token %}<label>اولویت<select name="priority">{% for value,label in priorities %}<option value="{{ value }}" {% if ticket.priority == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="secondary">ثبت اولویت</button></form>{% endif %}{% if can_transfer %}<details><summary>انتقال تیکت</summary><form method="post" action="{% url 'staff_tickets:transfer' ticket.public_id %}">{% csrf_token %}{{ transfer_form.as_p }}<button>انتقال</button></form></details>{% endif %}{% if can_share %}<details><summary>اشتراک با کارشناس</summary><form method="post" action="{% url 'staff_tickets:share' ticket.public_id %}">{% csrf_token %}{{ share_form.as_p }}<button>ایجاد دسترسی</button></form>{% for grant in grants %}<div class="grant"><span>{{ grant.user.staff_profile.display_name }}</span><form method="post" action="{% url 'staff_tickets:revoke' ticket.public_id grant.user.public_id %}">{% csrf_token %}<button class="danger-button">لغو</button></form></div>{% endfor %}</details>{% endif %}</section>
{% if can_delete %}<section class="info-card danger-zone"><h3>حذف کامل تیکت</h3><p class="muted">تمام پیام‌ها، فایل‌ها و سوابق تیکت برای همیشه حذف می‌شوند.</p><form method="post" action="{% url 'staff_tickets:delete' ticket.public_id %}" onsubmit="return confirm('آیا از حذف کامل و غیرقابل‌بازگشت این تیکت مطمئن هستید؟')">{% csrf_token %}<button type="submit" class="danger-button">حذف کامل تیکت</button></form></section>{% endif %}
</aside>
+15
View File
@@ -18,6 +18,21 @@ class TicketConversationPage(BasePage):
assert "reply_result=reply" in self.page.url
expect(self.page.locator("[data-conversation]")).to_contain_text(body)
def open_composer(self):
launch = self.page.locator("[data-composer-toggle]")
expect(launch).to_be_visible()
launch.click()
expect(self.page.locator("[data-composer-shell]:not(.is-collapsed)")).to_be_visible()
def expect_latest_message_above_composer(self):
latest = self.page.locator("[data-message]").last
expect(latest).to_be_visible()
latest.scroll_into_view_if_needed()
message_box = latest.bounding_box()
composer_box = self.page.locator("[data-composer-shell]").bounding_box()
assert message_box and composer_box
assert message_box["y"] + message_box["height"] <= composer_box["y"] + 1
@property
def chat_guidance(self):
return self.page.locator(".chat-coach-floating")
+30
View File
@@ -3,6 +3,7 @@ from django.urls import reverse
from apps.accounts.models import User
from apps.customers.models import CustomerProfile
from apps.messaging.models import Message
from apps.staff.models import StaffProfile
from apps.tickets.models import TicketCategory
from apps.tickets.services import create_ticket
@@ -76,3 +77,32 @@ def test_staff_sees_chat_guidance_after_two_rapid_replies(live_server, browser_p
conversation.send_reply("پاسخ سوم کارشناس")
conversation.expect_guidance_visible()
conversation.dismiss_guidance()
def test_customer_mobile_composer_never_covers_latest_message(live_server, browser_page):
category = TicketCategory.objects.create(title="مکاتبات موبایل", slug="e2e-customer-mobile-composer")
customer = User.objects.create_user(
username="e2e-mobile-customer",
mobile="09120009021",
password=PASSWORD,
account_type=User.AccountType.CUSTOMER,
)
CustomerProfile.objects.create(user=customer, first_name="مشتری", last_name="موبایل")
staff = User.objects.create_user(
username="e2e-mobile-staff",
mobile="09120009022",
account_type=User.AccountType.STAFF,
)
StaffProfile.objects.create(user=staff, display_name="کارشناس موبایل")
ticket = create_ticket(customer=customer, category=category, subject="کنترل composer مشتری", body="پیام آغازین")
for index in range(24):
Message.objects.create(ticket=ticket, author=staff, body=f"پاسخ طولانی کارشناس شماره {index + 1}")
with browser_page() as page:
page.set_viewport_size({"width": 390, "height": 844})
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
conversation = TicketConversationPage(page, live_server.url)
conversation.goto(reverse("tickets:detail", args=[ticket.public_id]))
conversation.expect_latest_message_above_composer()
conversation.open_composer()
conversation.expect_latest_message_above_composer()
@@ -180,10 +180,14 @@ def test_e2e_customer_wizard_validation_preview_edit_and_floating_submit(
expect(error).to_be_in_viewport()
expect(page.locator('[data-ticket-subject]')).to_have_attribute("aria-invalid", "true")
expect(form.category).to_be_visible()
assert page.evaluate("document.activeElement === document.querySelector('[data-wizard-error=\"1\"]')")
assert page.evaluate("document.activeElement.matches('[aria-invalid=\"true\"]:not(:disabled)')")
expect(page.locator(".is-guided-field")).to_have_count(1)
form.select_search_result("عمومی فروش", "عمومی فروش")
expect(page.locator('[data-ticket-subject]')).to_have_value("")
page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
assert page.evaluate("document.activeElement === document.querySelector('[data-ticket-subject]')")
expect(page.locator('.ticket-subject-field')).to_have_class(re.compile(r"is-guided-field"))
page.locator('[data-ticket-subject]').fill(subject)
expect(page.locator('[data-ticket-subject]')).to_have_value(subject)
page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
+7
View File
@@ -9,11 +9,13 @@ from playwright.sync_api import expect
from apps.accounts.models import TicketMagicLink, User
from apps.customers.models import ContactGroup, CustomerProfile
from apps.core.models import PageTitle, SiteBranding
from apps.messaging.models import Message
from apps.notifications.models import NotificationJob, SMSTemplate
from apps.staff.models import OrganizationalUnit, StaffProfile
from apps.tickets.models import TicketCategory
from apps.tickets.services import create_ticket, dispatch_group_tickets
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.tickets import TicketConversationPage
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
@@ -153,6 +155,8 @@ def test_dispatch_report_presents_delivery_and_engagement_professionally(live_se
delivery_description="0098SMS [کد 5]: پیامک به گوشی گیرنده رسیده است.",
delivery_check_attempts=2,delivery_checked_at=timezone.now(),delivery_next_check_at=None,
)
for index in range(24):
Message.objects.create(ticket=ticket,author=staff,body=f"مکاتبه طولانی شماره {index + 1}")
links=[TicketMagicLink.issue_with_record(ticket,created_by=staff)[1] for _ in range(4)]
links[0].view_count=2; links[0].used_at=timezone.now(); links[0].save(update_fields=["view_count","used_at"])
@@ -190,6 +194,8 @@ def test_dispatch_report_presents_delivery_and_engagement_professionally(live_se
assert box["x"] >= 0 and box["x"] + box["width"] <= 390
assert box["y"] >= 0 and box["y"] + box["height"] <= 844
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
conversation=TicketConversationPage(page,live_server.url)
conversation.expect_latest_message_above_composer()
launch.click()
close_composer = page.get_by_role("button", name="بستن", exact=True)
expect(close_composer).to_be_visible()
@@ -197,5 +203,6 @@ def test_dispatch_report_presents_delivery_and_engagement_professionally(live_se
assert close_box is not None
assert close_box["x"] >= 0 and close_box["x"] + close_box["width"] <= 390
assert close_box["y"] >= 0 and close_box["y"] + close_box["height"] <= 844
conversation.expect_latest_message_above_composer()
dismiss_context.click()
expect(page.locator(".ticket-dispatch-context")).to_have_count(0)
+34 -2
View File
@@ -8,6 +8,7 @@ from django.utils import timezone
from django.core.files.uploadedfile import SimpleUploadedFile
from apps.attachments.models import Attachment, AttachmentPolicy
from apps.accounts.models import User, normalize_mobile
from apps.messaging.models import MessageReadReceipt
from apps.notifications.models import NotificationJob
from apps.staff.models import OrganizationalUnit, StaffProfile
from apps.tickets.models import Ticket, TicketCategory
@@ -141,10 +142,11 @@ def test_unit_notification_destinations_normalize_and_reject_duplicate_superviso
supervisor_mobile_1="0912 111 2233",
supervisor_mobile_2="0935 222 3344",
supervisor_mobile_4="0990 444 5566",
supervisor_mobile_5="0921 555 6677",
)
unit.full_clean()
unit.save()
assert unit.supervisor_mobiles == ("+989121112233", "+989352223344", "+989904445566")
assert unit.supervisor_mobiles == ("+989121112233", "+989352223344", "+989904445566", "+989215556677")
unit.supervisor_mobile_3 = "09121112233"
with pytest.raises(ValidationError, match="قبلاً"):
@@ -160,6 +162,7 @@ def test_new_ticket_sms_is_queued_for_each_unit_supervisor_only_on_creation(cate
unit.supervisor_mobile_2 = "09352223344"
unit.supervisor_mobile_3 = "09903334455"
unit.supervisor_mobile_4 = "09214445566"
unit.supervisor_mobile_5 = "09195556677"
unit.save()
ticket = create_ticket(customer=customer, category=category, subject=" پیگیری بارگیری ", body="متن")
jobs = list(NotificationJob.objects.filter(ticket=ticket, event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("id"))
@@ -499,7 +502,11 @@ def test_due_unread_staff_reply_is_sent_once(category,customer,monkeypatch):
from apps.integrations.sms.base import SMSResult
from apps.notifications.management.commands import process_notifications
a=make_staff("a"); ticket=create_ticket(customer=customer,category=category,subject="پاسخ خوانده نشده",selected_expert=a,body="متن")
reply_ticket(ticket_id=ticket.pk,user=a,body="پاسخ کارشناس")
message=reply_ticket(ticket_id=ticket.pk,user=a,body="پاسخ کارشناس")
observer=make_staff("observer")
other_customer=User.objects.create_user(username="other-sms-customer",mobile="09123334444",account_type="CUSTOMER")
MessageReadReceipt.objects.create(message=message,user=observer)
MessageReadReceipt.objects.create(message=message,user=other_customer)
job=NotificationJob.objects.get(ticket=ticket,event_type="STAFF_REPLY")
NotificationJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now()-timedelta(seconds=1))
ticket.last_activity_at=timezone.now()-timedelta(hours=1); ticket.save(update_fields=["last_activity_at"])
@@ -514,6 +521,31 @@ def test_due_unread_staff_reply_is_sent_once(category,customer,monkeypatch):
assert len(sent)==1
@pytest.mark.django_db
def test_due_staff_reply_is_cancelled_only_when_ticket_customer_has_seen_it(category,customer,monkeypatch):
from django.core.management import call_command
from apps.notifications.management.commands import process_notifications
staff=make_staff("a")
ticket=create_ticket(customer=customer,category=category,subject="پاسخ دیده شده",selected_expert=staff,body="متن")
message=reply_ticket(ticket_id=ticket.pk,user=staff,body="پاسخ کارشناس")
MessageReadReceipt.objects.create(message=message,user=customer)
job=NotificationJob.objects.get(ticket=ticket,event_type="STAFF_REPLY")
NotificationJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now()-timedelta(seconds=1))
ticket.last_activity_at=timezone.now()-timedelta(hours=1)
ticket.save(update_fields=["last_activity_at"])
class Provider:
def send_message(self,mobile,message):
raise AssertionError("پیام دیده‌شده توسط خود مشتری نباید به درگاه ارسال شود")
monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:Provider())
call_command("process_notifications",once=True,job_id=job.pk)
job.refresh_from_db()
assert job.status==NotificationJob.Status.CANCELLED
assert job.last_error=="customer_read"
@pytest.mark.django_db
def test_every_failed_sms_gets_exactly_three_retries_three_minutes_apart(category,customer,monkeypatch,settings):
from django.core.management import call_command
+50 -1
View File
@@ -252,6 +252,42 @@ def test_staff_reply_feedback_and_new_reply_action_remain_available(client):
assert 'composer staff-composer is-collapsed' in content
assert 'accept="image/*"' in content and "data-gallery-input" in content and "data-document-input" in content
@pytest.mark.django_db
def test_staff_ticket_sms_status_is_compact_expandable_and_time_precise(client):
from apps.core.templatetags.jalali import jalali_datetime
from apps.notifications.models import NotificationJob
category=TicketCategory.objects.create(title="رهگیری پیامک",slug="sms-timeline")
customer=User.objects.create_user(username="sms-timeline-customer",mobile="09120000031",account_type="CUSTOMER")
expert=User.objects.create_user(username="sms-timeline-expert",mobile="09120000032",account_type="STAFF")
StaffProfile.objects.create(user=expert,display_name="کارشناس رهگیری")
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
ticket=create_ticket(customer=customer,category=category,subject="نمایش دقیق وضعیت پیامک",selected_expert=expert,body="شرح")
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
sent_at=timezone.now()-timedelta(minutes=4)
delivered_at=timezone.now()-timedelta(minutes=1)
NotificationJob.objects.filter(pk=job.pk).update(
status=NotificationJob.Status.SENT,
sent_at=sent_at,
provider_message_id="123456789",
delivery_status=NotificationJob.DeliveryStatus.DELIVERED,
delivery_status_code="5",
delivery_description="پیامک به گوشی گیرنده رسیده است.",
delivery_checked_at=delivered_at,
delivery_next_check_at=None,
)
client.force_login(expert)
content=client.get(reverse("staff_tickets:detail",args=[ticket.public_id])).content.decode()
assert 'class="sms-delivery-item"' in content
assert 'aria-label="نمایش جزئیات زمان‌بندی پیامک"' in content
for label in ("ثبت درخواست ارسال","پذیرش توسط درگاه پیامک","تأیید رسیدن به گوشی"):
assert label in content
assert jalali_datetime(sent_at) in content
assert jalali_datetime(delivered_at) in content
assert "123456789" in content
@pytest.mark.django_db
def test_staff_sidebar_hides_admin_link_and_shows_developer_credit(client):
expert=User.objects.create_superuser(username="sidebar-admin",mobile="09384999655",password="test-pass",account_type="STAFF")
@@ -392,7 +428,7 @@ def test_ticket_polling_is_versioned_incremental_and_permission_safe(client):
def test_ticket_polling_javascript_keeps_background_sync_and_recovers_reliably():
script=Path("static/js/conversation.js").read_text(encoding="utf-8")
for feature in ("visibilitychange","document.visibilityState==='visible'","AbortController","pollRunning","nextPollAt","pollHeartbeat","setInterval(startPolling,1000)","cache:'no-store'","const restartPolling=()=>{stopPolling();startPolling()}","window.addEventListener('pageshow',restartPolling)","window.addEventListener('online',restartPolling)","data-scroll-to-submit","scrollIntoView","submit-attention"):
for feature in ("visibilitychange","document.visibilityState==='visible'","AbortController","pollRunning","nextPollAt","pollHeartbeat","setInterval(startPolling,1000)","cache:'no-store'","const restartPolling=()=>{stopPolling();startPolling()}","window.addEventListener('pageshow',restartPolling)","window.addEventListener('online',restartPolling)","data-scroll-to-submit","scrollIntoView","submit-attention","keepLatestVisible","requestAnimationFrame"):
assert feature in script
assert "pollUrl&&navigator.onLine" not in script
assert "window.addEventListener('offline',stopPolling)" not in script
@@ -403,6 +439,10 @@ def test_ticket_polling_javascript_keeps_background_sync_and_recovers_reliably()
assert "scroller.scrollTo({top,behavior:'smooth'})" in script
assert "data-message-id" in Path("templates/tickets/_messages.html").read_text(encoding="utf-8")
assert "acknowledgeVisibleMessages" in script and "document.visibilityState!=='visible'" in script
source_css=Path("static/src/tailwind.css").read_text(encoding="utf-8")
assert ".staff-ticket-workspace .composer.is-collapsed{position:fixed" not in source_css
assert ".staff-ticket-workspace .composer:not(.is-collapsed){position:fixed" not in source_css
assert "max-height:min(45dvh,28rem)" in source_css
for menu_feature in ("data-message-menu","data-message-menu-close","closeMessageMenu","event.key==='Escape'","!menu.contains(event.target)"):
assert menu_feature in script or menu_feature in Path("templates/tickets/_messages.html").read_text(encoding="utf-8")
@@ -489,6 +529,15 @@ def test_pwa_prompt_is_mobile_only_and_uses_square_app_icon(client):
assert "mobileDevice" in script and "banner&&mobileDevice" in script
assert all(icon["src"].endswith("pwa-icon.png") for icon in manifest["icons"])
@pytest.mark.django_db
def test_pwa_install_prompt_is_only_rendered_on_customer_ticket_list(client):
customer=User.objects.create_user(username="pwa-list-only",mobile="09120000123",account_type="CUSTOMER")
client.force_login(customer)
assert 'data-pwa-install' in client.get(reverse("tickets:list")).content.decode()
assert 'data-pwa-install' not in client.get(reverse("tickets:new")).content.decode()
assert 'data-pwa-install' not in Path("templates/base.html").read_text(encoding="utf-8")
@pytest.mark.django_db
def test_customer_list_recommends_profile_completion_without_blocking_ticket_creation(client):
customer=User.objects.create_user(username="incomplete-profile",mobile="09202202129",account_type="CUSTOMER")