progress in support system

This commit is contained in:
Hesabix 2025-09-21 18:36:15 +03:30
parent 4e07795467
commit d782cbfffc
16 changed files with 2121 additions and 1223 deletions

View file

@ -55,6 +55,9 @@ class MessageRepository(BaseRepository[Message]):
is_internal: bool = False
) -> Message:
"""ایجاد پیام جدید"""
from datetime import datetime
from adapters.db.models.support.ticket import Ticket
message = Message(
ticket_id=ticket_id,
sender_id=sender_id,
@ -64,6 +67,12 @@ class MessageRepository(BaseRepository[Message]):
)
self.db.add(message)
# Update ticket's updated_at field
ticket = self.db.query(Ticket).filter(Ticket.id == ticket_id).first()
if ticket:
ticket.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(message)
return message

View file

@ -73,11 +73,26 @@ class TicketRepository(BaseRepository[Ticket]):
elif filter_item.operator == "=":
query = query.filter(Ticket.title == filter_item.value)
elif filter_item.property == "category.name":
query = query.join(Ticket.category).filter(Ticket.category.has(name=filter_item.value))
query = query.join(Ticket.category)
if filter_item.operator == "in":
from adapters.db.models.support.category import Category
query = query.filter(Category.name.in_(filter_item.value))
else:
query = query.filter(Ticket.category.has(name=filter_item.value))
elif filter_item.property == "priority.name":
query = query.join(Ticket.priority).filter(Ticket.priority.has(name=filter_item.value))
query = query.join(Ticket.priority)
if filter_item.operator == "in":
from adapters.db.models.support.priority import Priority
query = query.filter(Priority.name.in_(filter_item.value))
else:
query = query.filter(Ticket.priority.has(name=filter_item.value))
elif filter_item.property == "status.name":
query = query.join(Ticket.status).filter(Ticket.status.has(name=filter_item.value))
query = query.join(Ticket.status)
if filter_item.operator == "in":
from adapters.db.models.support.status import Status
query = query.filter(Status.name.in_(filter_item.value))
else:
query = query.filter(Ticket.status.has(name=filter_item.value))
elif filter_item.property == "description" and hasattr(Ticket, "description"):
if filter_item.operator == "*":
query = query.filter(Ticket.description.ilike(f"%{filter_item.value}%"))
@ -139,11 +154,26 @@ class TicketRepository(BaseRepository[Ticket]):
elif filter_item.operator == "=":
query = query.filter(Ticket.title == filter_item.value)
elif filter_item.property == "category.name":
query = query.join(Ticket.category).filter(Ticket.category.has(name=filter_item.value))
query = query.join(Ticket.category)
if filter_item.operator == "in":
from adapters.db.models.support.category import Category
query = query.filter(Category.name.in_(filter_item.value))
else:
query = query.filter(Ticket.category.has(name=filter_item.value))
elif filter_item.property == "priority.name":
query = query.join(Ticket.priority).filter(Ticket.priority.has(name=filter_item.value))
query = query.join(Ticket.priority)
if filter_item.operator == "in":
from adapters.db.models.support.priority import Priority
query = query.filter(Priority.name.in_(filter_item.value))
else:
query = query.filter(Ticket.priority.has(name=filter_item.value))
elif filter_item.property == "status.name":
query = query.join(Ticket.status).filter(Ticket.status.has(name=filter_item.value))
query = query.join(Ticket.status)
if filter_item.operator == "in":
from adapters.db.models.support.status import Status
query = query.filter(Status.name.in_(filter_item.value))
else:
query = query.filter(Ticket.status.has(name=filter_item.value))
elif filter_item.property == "description" and hasattr(Ticket, "description"):
if filter_item.operator == "*":
query = query.filter(Ticket.description.ilike(f"%{filter_item.value}%"))
@ -229,4 +259,4 @@ class TicketRepository(BaseRepository[Ticket]):
ticket.assigned_operator_id = operator_id
self.db.commit()
self.db.refresh(ticket)
return ticket
return ticket

View file

@ -243,6 +243,28 @@
"operatorPanel": "Operator Panel",
"allTickets": "All Tickets",
"assignTicket": "Assign Ticket",
"createNewTicket": "Create New Ticket",
"createSupportTicket": "Create Support Ticket",
"ticketTitleLabel": "Ticket Title",
"ticketTitleHint": "Enter a short and clear title for your issue",
"categoryLabel": "Category",
"priorityLabel": "Priority",
"descriptionLabel": "Problem Description",
"descriptionHint": "Please describe your problem or question in detail...",
"submitTicket": "Submit Ticket",
"submittingTicket": "Submitting...",
"ticketTitleRequired": "Ticket title is required",
"ticketTitleMinLength": "Title must be at least 5 characters",
"categoryRequired": "Please select a category",
"priorityRequired": "Please select a priority",
"descriptionRequired": "Problem description is required",
"descriptionMinLength": "Description must be at least 10 characters",
"loadingData": "Loading...",
"dataLoadingError": "Error loading data",
"retry": "Retry",
"ticketCreatedSuccessfully": "Ticket created successfully",
"pleaseSelectCategoryAndPriority": "Please select category and priority",
"cancel": "Cancel",
"changeStatus": "Change Status",
"multiSelectFilter": "Multi-Select Filter",
"selectFilterOptions": "Select Filter Options",
@ -257,6 +279,44 @@
"internalMessage": "Internal Message",
"user": "User",
"operator": "Operator",
"system": "System"
"system": "System",
"ticketNumber": "Ticket #{number}",
"ticketNotFound": "Ticket not found",
"noMessagesFound": "No messages found",
"writeYourMessage": "Write your message...",
"writeYourResponse": "Write your response...",
"sendingMessage": "Sending message...",
"messageSentSuccessfully": "Message sent successfully",
"errorSendingMessage": "Error sending message",
"ticketLoadingError": "Error loading ticket",
"retry": "Retry",
"refresh": "Refresh",
"changeStatus": "Change Status",
"statusUpdatedSuccessfully": "Status updated successfully",
"errorUpdatingStatus": "Error updating status",
"ticketClosed": "Ticket is closed",
"ticketResolved": "Ticket is resolved",
"daysAgo": "{count} days ago",
"hoursAgo": "{count} hours ago",
"minutesAgo": "{count} minutes ago",
"justNow": "Just now",
"ticketDetails": "Ticket Details",
"conversation": "Conversation",
"ticketInfo": "Ticket Information",
"createdBy": "Created by",
"assignedTo": "Assigned to",
"lastUpdated": "Last updated",
"messageCount": "{count} messages",
"replyAsOperator": "Reply as Operator",
"replyAsUser": "Reply as User",
"internalNote": "Internal Note",
"publicMessage": "Public Message",
"markAsInternal": "Mark as Internal",
"markAsPublic": "Mark as Public",
"ticketDetailsDialog": "Ticket Details",
"close": "Close",
"ticketInfo": "Ticket Information",
"conversation": "Conversation",
"messageCount": "{count} messages"
}

View file

@ -242,6 +242,28 @@
"operatorPanel": "پنل اپراتور",
"allTickets": "تمام تیکت‌ها",
"assignTicket": "تخصیص تیکت",
"createNewTicket": "ایجاد تیکت جدید",
"createSupportTicket": "ایجاد تیکت پشتیبانی",
"ticketTitleLabel": "عنوان تیکت",
"ticketTitleHint": "عنوان کوتاه و واضح برای مشکل خود وارد کنید",
"categoryLabel": "دسته‌بندی",
"priorityLabel": "اولویت",
"descriptionLabel": "شرح مشکل",
"descriptionHint": "مشکل یا سوال خود را به تفصیل شرح دهید...",
"submitTicket": "ارسال تیکت",
"submittingTicket": "در حال ارسال...",
"ticketTitleRequired": "عنوان تیکت الزامی است",
"ticketTitleMinLength": "عنوان باید حداقل 5 کاراکتر باشد",
"categoryRequired": "لطفاً دسته‌بندی را انتخاب کنید",
"priorityRequired": "لطفاً اولویت را انتخاب کنید",
"descriptionRequired": "شرح مشکل الزامی است",
"descriptionMinLength": "شرح باید حداقل 10 کاراکتر باشد",
"loadingData": "در حال بارگذاری...",
"dataLoadingError": "خطا در بارگذاری داده‌ها",
"retry": "تلاش مجدد",
"ticketCreatedSuccessfully": "تیکت با موفقیت ایجاد شد",
"pleaseSelectCategoryAndPriority": "لطفاً دسته‌بندی و اولویت را انتخاب کنید",
"cancel": "لغو",
"changeStatus": "تغییر وضعیت",
"multiSelectFilter": "فیلتر چندتایی",
"selectFilterOptions": "انتخاب گزینه‌های فیلتر",
@ -256,6 +278,44 @@
"internalMessage": "پیام داخلی",
"user": "کاربر",
"operator": "اپراتور",
"system": "سیستم"
"system": "سیستم",
"ticketNumber": "تیکت #{number}",
"ticketNotFound": "تیکت یافت نشد",
"noMessagesFound": "هیچ پیامی یافت نشد",
"writeYourMessage": "پیام خود را بنویسید...",
"writeYourResponse": "پاسخ خود را بنویسید...",
"sendingMessage": "در حال ارسال پیام...",
"messageSentSuccessfully": "پیام با موفقیت ارسال شد",
"errorSendingMessage": "خطا در ارسال پیام",
"ticketLoadingError": "خطا در بارگذاری تیکت",
"retry": "تلاش مجدد",
"refresh": "بروزرسانی",
"changeStatus": "تغییر وضعیت",
"statusUpdatedSuccessfully": "وضعیت با موفقیت به‌روزرسانی شد",
"errorUpdatingStatus": "خطا در به‌روزرسانی وضعیت",
"ticketClosed": "تیکت بسته است",
"ticketResolved": "تیکت حل شده است",
"daysAgo": "{count} روز پیش",
"hoursAgo": "{count} ساعت پیش",
"minutesAgo": "{count} دقیقه پیش",
"justNow": "همین الان",
"ticketDetails": "جزئیات تیکت",
"conversation": "مکالمه",
"ticketInfo": "اطلاعات تیکت",
"createdBy": "ایجاد شده توسط",
"assignedTo": "تخصیص یافته به",
"lastUpdated": "آخرین بروزرسانی",
"messageCount": "{count} پیام",
"replyAsOperator": "پاسخ به عنوان اپراتور",
"replyAsUser": "پاسخ به عنوان کاربر",
"internalNote": "یادداشت داخلی",
"publicMessage": "پیام عمومی",
"markAsInternal": "علامت‌گذاری به عنوان داخلی",
"markAsPublic": "علامت‌گذاری به عنوان عمومی",
"ticketDetailsDialog": "جزئیات تیکت",
"close": "بستن",
"ticketInfo": "اطلاعات تیکت",
"conversation": "مکالمه",
"messageCount": "{count} پیام"
}

View file

@ -1319,7 +1319,7 @@ abstract class AppLocalizations {
/// No description provided for @ticketLoadingError.
///
/// In en, this message translates to:
/// **'Error loading tickets'**
/// **'Error loading ticket'**
String get ticketLoadingError;
/// No description provided for @ticketId.
@ -1343,7 +1343,7 @@ abstract class AppLocalizations {
/// No description provided for @assignedTo.
///
/// In en, this message translates to:
/// **'Assigned To'**
/// **'Assigned to'**
String get assignedTo;
/// No description provided for @low.
@ -1442,6 +1442,126 @@ abstract class AppLocalizations {
/// **'Assign Ticket'**
String get assignTicket;
/// No description provided for @createNewTicket.
///
/// In en, this message translates to:
/// **'Create New Ticket'**
String get createNewTicket;
/// No description provided for @createSupportTicket.
///
/// In en, this message translates to:
/// **'Create Support Ticket'**
String get createSupportTicket;
/// No description provided for @ticketTitleLabel.
///
/// In en, this message translates to:
/// **'Ticket Title'**
String get ticketTitleLabel;
/// No description provided for @ticketTitleHint.
///
/// In en, this message translates to:
/// **'Enter a short and clear title for your issue'**
String get ticketTitleHint;
/// No description provided for @categoryLabel.
///
/// In en, this message translates to:
/// **'Category'**
String get categoryLabel;
/// No description provided for @priorityLabel.
///
/// In en, this message translates to:
/// **'Priority'**
String get priorityLabel;
/// No description provided for @descriptionLabel.
///
/// In en, this message translates to:
/// **'Problem Description'**
String get descriptionLabel;
/// No description provided for @descriptionHint.
///
/// In en, this message translates to:
/// **'Please describe your problem or question in detail...'**
String get descriptionHint;
/// No description provided for @submitTicket.
///
/// In en, this message translates to:
/// **'Submit Ticket'**
String get submitTicket;
/// No description provided for @submittingTicket.
///
/// In en, this message translates to:
/// **'Submitting...'**
String get submittingTicket;
/// No description provided for @ticketTitleRequired.
///
/// In en, this message translates to:
/// **'Ticket title is required'**
String get ticketTitleRequired;
/// No description provided for @ticketTitleMinLength.
///
/// In en, this message translates to:
/// **'Title must be at least 5 characters'**
String get ticketTitleMinLength;
/// No description provided for @categoryRequired.
///
/// In en, this message translates to:
/// **'Please select a category'**
String get categoryRequired;
/// No description provided for @priorityRequired.
///
/// In en, this message translates to:
/// **'Please select a priority'**
String get priorityRequired;
/// No description provided for @descriptionRequired.
///
/// In en, this message translates to:
/// **'Problem description is required'**
String get descriptionRequired;
/// No description provided for @descriptionMinLength.
///
/// In en, this message translates to:
/// **'Description must be at least 10 characters'**
String get descriptionMinLength;
/// No description provided for @loadingData.
///
/// In en, this message translates to:
/// **'Loading...'**
String get loadingData;
/// No description provided for @retry.
///
/// In en, this message translates to:
/// **'Retry'**
String get retry;
/// No description provided for @ticketCreatedSuccessfully.
///
/// In en, this message translates to:
/// **'Ticket created successfully'**
String get ticketCreatedSuccessfully;
/// No description provided for @pleaseSelectCategoryAndPriority.
///
/// In en, this message translates to:
/// **'Please select category and priority'**
String get pleaseSelectCategoryAndPriority;
/// No description provided for @changeStatus.
///
/// In en, this message translates to:
@ -1489,6 +1609,180 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Operator'**
String get operator;
/// No description provided for @ticketNumber.
///
/// In en, this message translates to:
/// **'Ticket #{number}'**
String ticketNumber(Object number);
/// No description provided for @ticketNotFound.
///
/// In en, this message translates to:
/// **'Ticket not found'**
String get ticketNotFound;
/// No description provided for @noMessagesFound.
///
/// In en, this message translates to:
/// **'No messages found'**
String get noMessagesFound;
/// No description provided for @writeYourMessage.
///
/// In en, this message translates to:
/// **'Write your message...'**
String get writeYourMessage;
/// No description provided for @writeYourResponse.
///
/// In en, this message translates to:
/// **'Write your response...'**
String get writeYourResponse;
/// No description provided for @sendingMessage.
///
/// In en, this message translates to:
/// **'Sending message...'**
String get sendingMessage;
/// No description provided for @messageSentSuccessfully.
///
/// In en, this message translates to:
/// **'Message sent successfully'**
String get messageSentSuccessfully;
/// No description provided for @errorSendingMessage.
///
/// In en, this message translates to:
/// **'Error sending message'**
String get errorSendingMessage;
/// No description provided for @statusUpdatedSuccessfully.
///
/// In en, this message translates to:
/// **'Status updated successfully'**
String get statusUpdatedSuccessfully;
/// No description provided for @errorUpdatingStatus.
///
/// In en, this message translates to:
/// **'Error updating status'**
String get errorUpdatingStatus;
/// No description provided for @ticketClosed.
///
/// In en, this message translates to:
/// **'Ticket is closed'**
String get ticketClosed;
/// No description provided for @ticketResolved.
///
/// In en, this message translates to:
/// **'Ticket is resolved'**
String get ticketResolved;
/// No description provided for @daysAgo.
///
/// In en, this message translates to:
/// **'{count} days ago'**
String daysAgo(Object count);
/// No description provided for @hoursAgo.
///
/// In en, this message translates to:
/// **'{count} hours ago'**
String hoursAgo(Object count);
/// No description provided for @minutesAgo.
///
/// In en, this message translates to:
/// **'{count} minutes ago'**
String minutesAgo(Object count);
/// No description provided for @justNow.
///
/// In en, this message translates to:
/// **'Just now'**
String get justNow;
/// No description provided for @conversation.
///
/// In en, this message translates to:
/// **'Conversation'**
String get conversation;
/// No description provided for @ticketInfo.
///
/// In en, this message translates to:
/// **'Ticket Information'**
String get ticketInfo;
/// No description provided for @createdBy.
///
/// In en, this message translates to:
/// **'Created by'**
String get createdBy;
/// No description provided for @lastUpdated.
///
/// In en, this message translates to:
/// **'Last updated'**
String get lastUpdated;
/// No description provided for @messageCount.
///
/// In en, this message translates to:
/// **'{count} messages'**
String messageCount(Object count);
/// No description provided for @replyAsOperator.
///
/// In en, this message translates to:
/// **'Reply as Operator'**
String get replyAsOperator;
/// No description provided for @replyAsUser.
///
/// In en, this message translates to:
/// **'Reply as User'**
String get replyAsUser;
/// No description provided for @internalNote.
///
/// In en, this message translates to:
/// **'Internal Note'**
String get internalNote;
/// No description provided for @publicMessage.
///
/// In en, this message translates to:
/// **'Public Message'**
String get publicMessage;
/// No description provided for @markAsInternal.
///
/// In en, this message translates to:
/// **'Mark as Internal'**
String get markAsInternal;
/// No description provided for @markAsPublic.
///
/// In en, this message translates to:
/// **'Mark as Public'**
String get markAsPublic;
/// No description provided for @ticketDetailsDialog.
///
/// In en, this message translates to:
/// **'Ticket Details'**
String get ticketDetailsDialog;
/// No description provided for @close.
///
/// In en, this message translates to:
/// **'Close'**
String get close;
}
class _AppLocalizationsDelegate

View file

@ -632,7 +632,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get ticketUpdatedAt => 'Last Updated';
@override
String get ticketLoadingError => 'Error loading tickets';
String get ticketLoadingError => 'Error loading ticket';
@override
String get ticketId => 'Ticket ID';
@ -644,7 +644,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get updatedAt => 'Updated At';
@override
String get assignedTo => 'Assigned To';
String get assignedTo => 'Assigned to';
@override
String get low => 'Low';
@ -694,6 +694,69 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get assignTicket => 'Assign Ticket';
@override
String get createNewTicket => 'Create New Ticket';
@override
String get createSupportTicket => 'Create Support Ticket';
@override
String get ticketTitleLabel => 'Ticket Title';
@override
String get ticketTitleHint => 'Enter a short and clear title for your issue';
@override
String get categoryLabel => 'Category';
@override
String get priorityLabel => 'Priority';
@override
String get descriptionLabel => 'Problem Description';
@override
String get descriptionHint =>
'Please describe your problem or question in detail...';
@override
String get submitTicket => 'Submit Ticket';
@override
String get submittingTicket => 'Submitting...';
@override
String get ticketTitleRequired => 'Ticket title is required';
@override
String get ticketTitleMinLength => 'Title must be at least 5 characters';
@override
String get categoryRequired => 'Please select a category';
@override
String get priorityRequired => 'Please select a priority';
@override
String get descriptionRequired => 'Problem description is required';
@override
String get descriptionMinLength =>
'Description must be at least 10 characters';
@override
String get loadingData => 'Loading...';
@override
String get retry => 'Retry';
@override
String get ticketCreatedSuccessfully => 'Ticket created successfully';
@override
String get pleaseSelectCategoryAndPriority =>
'Please select category and priority';
@override
String get changeStatus => 'Change Status';
@ -717,4 +780,101 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get operator => 'Operator';
@override
String ticketNumber(Object number) {
return 'Ticket #$number';
}
@override
String get ticketNotFound => 'Ticket not found';
@override
String get noMessagesFound => 'No messages found';
@override
String get writeYourMessage => 'Write your message...';
@override
String get writeYourResponse => 'Write your response...';
@override
String get sendingMessage => 'Sending message...';
@override
String get messageSentSuccessfully => 'Message sent successfully';
@override
String get errorSendingMessage => 'Error sending message';
@override
String get statusUpdatedSuccessfully => 'Status updated successfully';
@override
String get errorUpdatingStatus => 'Error updating status';
@override
String get ticketClosed => 'Ticket is closed';
@override
String get ticketResolved => 'Ticket is resolved';
@override
String daysAgo(Object count) {
return '$count days ago';
}
@override
String hoursAgo(Object count) {
return '$count hours ago';
}
@override
String minutesAgo(Object count) {
return '$count minutes ago';
}
@override
String get justNow => 'Just now';
@override
String get conversation => 'Conversation';
@override
String get ticketInfo => 'Ticket Information';
@override
String get createdBy => 'Created by';
@override
String get lastUpdated => 'Last updated';
@override
String messageCount(Object count) {
return '$count messages';
}
@override
String get replyAsOperator => 'Reply as Operator';
@override
String get replyAsUser => 'Reply as User';
@override
String get internalNote => 'Internal Note';
@override
String get publicMessage => 'Public Message';
@override
String get markAsInternal => 'Mark as Internal';
@override
String get markAsPublic => 'Mark as Public';
@override
String get ticketDetailsDialog => 'Ticket Details';
@override
String get close => 'Close';
}

View file

@ -140,7 +140,7 @@ class AppLocalizationsFa extends AppLocalizations {
String get ok => 'تایید';
@override
String get cancel => 'انصراف';
String get cancel => 'لغو';
@override
String get columnSettings => 'تنظیمات ستون‌ها';
@ -630,7 +630,7 @@ class AppLocalizationsFa extends AppLocalizations {
String get ticketUpdatedAt => 'آخرین بروزرسانی';
@override
String get ticketLoadingError => 'خطا در بارگذاری تیکتها';
String get ticketLoadingError => 'خطا در بارگذاری تیکت';
@override
String get ticketId => 'شماره تیکت';
@ -692,6 +692,67 @@ class AppLocalizationsFa extends AppLocalizations {
@override
String get assignTicket => 'تخصیص تیکت';
@override
String get createNewTicket => 'ایجاد تیکت جدید';
@override
String get createSupportTicket => 'ایجاد تیکت پشتیبانی';
@override
String get ticketTitleLabel => 'عنوان تیکت';
@override
String get ticketTitleHint => 'عنوان کوتاه و واضح برای مشکل خود وارد کنید';
@override
String get categoryLabel => 'دسته‌بندی';
@override
String get priorityLabel => 'اولویت';
@override
String get descriptionLabel => 'شرح مشکل';
@override
String get descriptionHint => 'مشکل یا سوال خود را به تفصیل شرح دهید...';
@override
String get submitTicket => 'ارسال تیکت';
@override
String get submittingTicket => 'در حال ارسال...';
@override
String get ticketTitleRequired => 'عنوان تیکت الزامی است';
@override
String get ticketTitleMinLength => 'عنوان باید حداقل 5 کاراکتر باشد';
@override
String get categoryRequired => 'لطفاً دسته‌بندی را انتخاب کنید';
@override
String get priorityRequired => 'لطفاً اولویت را انتخاب کنید';
@override
String get descriptionRequired => 'شرح مشکل الزامی است';
@override
String get descriptionMinLength => 'شرح باید حداقل 10 کاراکتر باشد';
@override
String get loadingData => 'در حال بارگذاری...';
@override
String get retry => 'تلاش مجدد';
@override
String get ticketCreatedSuccessfully => 'تیکت با موفقیت ایجاد شد';
@override
String get pleaseSelectCategoryAndPriority =>
'لطفاً دسته‌بندی و اولویت را انتخاب کنید';
@override
String get changeStatus => 'تغییر وضعیت';
@ -715,4 +776,101 @@ class AppLocalizationsFa extends AppLocalizations {
@override
String get operator => 'اپراتور';
@override
String ticketNumber(Object number) {
return 'تیکت #$number';
}
@override
String get ticketNotFound => 'تیکت یافت نشد';
@override
String get noMessagesFound => 'هیچ پیامی یافت نشد';
@override
String get writeYourMessage => 'پیام خود را بنویسید...';
@override
String get writeYourResponse => 'پاسخ خود را بنویسید...';
@override
String get sendingMessage => 'در حال ارسال پیام...';
@override
String get messageSentSuccessfully => 'پیام با موفقیت ارسال شد';
@override
String get errorSendingMessage => 'خطا در ارسال پیام';
@override
String get statusUpdatedSuccessfully => 'وضعیت با موفقیت به‌روزرسانی شد';
@override
String get errorUpdatingStatus => 'خطا در به‌روزرسانی وضعیت';
@override
String get ticketClosed => 'تیکت بسته است';
@override
String get ticketResolved => 'تیکت حل شده است';
@override
String daysAgo(Object count) {
return '$count روز پیش';
}
@override
String hoursAgo(Object count) {
return '$count ساعت پیش';
}
@override
String minutesAgo(Object count) {
return '$count دقیقه پیش';
}
@override
String get justNow => 'همین الان';
@override
String get conversation => 'مکالمه';
@override
String get ticketInfo => 'اطلاعات تیکت';
@override
String get createdBy => 'ایجاد شده توسط';
@override
String get lastUpdated => 'آخرین بروزرسانی';
@override
String messageCount(Object count) {
return '$count پیام';
}
@override
String get replyAsOperator => 'پاسخ به عنوان اپراتور';
@override
String get replyAsUser => 'پاسخ به عنوان کاربر';
@override
String get internalNote => 'یادداشت داخلی';
@override
String get publicMessage => 'پیام عمومی';
@override
String get markAsInternal => 'علامت‌گذاری به عنوان داخلی';
@override
String get markAsPublic => 'علامت‌گذاری به عنوان عمومی';
@override
String get ticketDetailsDialog => 'جزئیات تیکت';
@override
String get close => 'بستن';
}

View file

@ -44,6 +44,32 @@ class SupportCategory {
return DateTime.now();
}
}
// Try to parse raw date if formatted is not available
final raw = dateTime['raw'] as String?;
if (raw != null) {
try {
return DateTime.parse(raw);
} catch (e) {
return DateTime.now();
}
}
// Try to parse individual date components
final year = dateTime['year'] as int?;
final month = dateTime['month'] as int?;
final day = dateTime['day'] as int?;
final hour = dateTime['hour'] as int? ?? 0;
final minute = dateTime['minute'] as int? ?? 0;
final second = dateTime['second'] as int? ?? 0;
if (year != null && month != null && day != null) {
try {
return DateTime(year, month, day, hour, minute, second);
} catch (e) {
return DateTime.now();
}
}
}
return DateTime.now();
}

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/services/support_service.dart';
import 'package:hesabix_ui/models/support_models.dart';
@ -66,9 +67,10 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
Future<void> _submitTicket() async {
if (!_formKey.currentState!.validate()) return;
if (_selectedCategory == null || _selectedPriority == null) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('لطفاً دسته‌بندی و اولویت را انتخاب کنید'),
SnackBar(
content: Text(t.pleaseSelectCategoryAndPriority),
backgroundColor: Colors.red,
),
);
@ -91,9 +93,10 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
await _supportService.createTicket(request);
if (mounted) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('تیکت با موفقیت ایجاد شد'),
SnackBar(
content: Text(t.ticketCreatedSuccessfully),
backgroundColor: Colors.green,
),
);
@ -110,236 +113,494 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final t = AppLocalizations.of(context);
final isDesktop = MediaQuery.of(context).size.width > 768;
return Scaffold(
appBar: AppBar(
title: const Text('ایجاد تیکت جدید'),
actions: [
if (_isSubmitting)
const Padding(
padding: EdgeInsets.all(16.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
TextButton(
onPressed: _submitTicket,
child: const Text('ارسال'),
),
],
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
constraints: BoxConstraints(
maxWidth: isDesktop ? 600 : double.infinity,
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header with close button
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary.withOpacity(0.1),
theme.colorScheme.primary.withOpacity(0.05),
],
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.support_agent,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.createNewTicket,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
t.descriptionHint,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.surface,
foregroundColor: theme.colorScheme.onSurface,
),
),
],
),
),
// Form content
Flexible(
child: _buildBody(theme, isDesktop, t),
),
],
),
),
body: _buildBody(theme),
);
}
Widget _buildBody(ThemeData theme) {
Widget _buildBody(ThemeData theme, bool isDesktop, AppLocalizations t) {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(),
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
t.loadingData,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
);
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red.withOpacity(0.6),
),
const SizedBox(height: 16),
Text(
'خطا در بارگذاری داده‌ها',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
_error!,
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.red,
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.error_outline,
size: 48,
color: Colors.red.withOpacity(0.8),
),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadData,
child: const Text('تلاش مجدد'),
),
],
const SizedBox(height: 24),
Text(
t.dataLoadingError,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_error!,
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.red,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _loadData,
icon: const Icon(Icons.refresh),
label: Text(t.retry),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
padding: EdgeInsets.all(isDesktop ? 24.0 : 16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ایجاد تیکت پشتیبانی',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'لطفاً مشکل یا سوال خود را به تفصیل شرح دهید',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: 24),
// Title field
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'عنوان تیکت',
hintText: 'عنوان کوتاه و واضح برای مشکل خود وارد کنید',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'عنوان تیکت الزامی است';
}
if (value.trim().length < 5) {
return 'عنوان باید حداقل 5 کاراکتر باشد';
}
return null;
},
),
const SizedBox(height: 16),
// Category dropdown
DropdownButtonFormField<SupportCategory>(
value: _selectedCategory,
decoration: const InputDecoration(
labelText: 'دسته‌بندی',
border: OutlineInputBorder(),
),
items: _categories.map((category) {
return DropdownMenuItem(
value: category,
child: Text(category.name),
);
}).toList(),
onChanged: (category) {
setState(() {
_selectedCategory = category;
});
},
validator: (value) {
if (value == null) {
return 'لطفاً دسته‌بندی را انتخاب کنید';
}
return null;
},
),
const SizedBox(height: 16),
// Priority dropdown
DropdownButtonFormField<SupportPriority>(
value: _selectedPriority,
decoration: const InputDecoration(
labelText: 'اولویت',
border: OutlineInputBorder(),
),
items: _priorities.map((priority) {
return DropdownMenuItem(
value: priority,
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: priority.color != null
? Color(int.parse(priority.color!.replaceFirst('#', '0xFF')))
: theme.colorScheme.primary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(priority.name),
],
),
);
}).toList(),
onChanged: (priority) {
setState(() {
_selectedPriority = priority;
});
},
validator: (value) {
if (value == null) {
return 'لطفاً اولویت را انتخاب کنید';
}
return null;
},
),
const SizedBox(height: 16),
// Description field
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'شرح مشکل',
hintText: 'مشکل یا سوال خود را به تفصیل شرح دهید...',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: 6,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'شرح مشکل الزامی است';
}
if (value.trim().length < 10) {
return 'شرح باید حداقل 10 کاراکتر باشد';
}
return null;
},
),
const SizedBox(height: 24),
// Submit button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isSubmitting ? null : _submitTicket,
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isSubmitting
? const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
// Form Fields
if (isDesktop) ...[
// Desktop Layout - Title full width, Category and Priority in one row
_buildTitleField(theme, t),
const SizedBox(height: 20),
Row(
children: [
// Category field
Expanded(
child: _buildCategoryField(theme, t),
),
),
SizedBox(width: 8),
Text('در حال ارسال...'),
],
)
: const Text('ارسال تیکت'),
),
),
const SizedBox(width: 20),
// Priority field
Expanded(
child: _buildPriorityField(theme, t),
),
],
),
const SizedBox(height: 20),
] else ...[
// Mobile Layout - Single Column
_buildTitleField(theme, t),
const SizedBox(height: 20),
_buildCategoryField(theme, t),
const SizedBox(height: 20),
_buildPriorityField(theme, t),
const SizedBox(height: 20),
],
// Description Field (Full Width)
_buildDescriptionField(theme, t),
const SizedBox(height: 32),
// Submit Button
_buildSubmitButton(theme, isDesktop, t),
],
),
),
);
}
Widget _buildTitleField(ThemeData theme, AppLocalizations t) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.ticketTitleLabel,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _titleController,
decoration: InputDecoration(
hintText: t.ticketTitleHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.outline.withOpacity(0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
filled: true,
fillColor: theme.colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return t.ticketTitleRequired;
}
if (value.trim().length < 5) {
return t.ticketTitleMinLength;
}
return null;
},
),
],
);
}
Widget _buildCategoryField(ThemeData theme, AppLocalizations t) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.categoryLabel,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<SupportCategory>(
value: _selectedCategory,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.outline.withOpacity(0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
filled: true,
fillColor: theme.colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
items: _categories.map((category) {
return DropdownMenuItem(
value: category,
child: Text(category.name),
);
}).toList(),
onChanged: (category) {
setState(() {
_selectedCategory = category;
});
},
validator: (value) {
if (value == null) {
return t.categoryRequired;
}
return null;
},
),
],
);
}
Widget _buildPriorityField(ThemeData theme, AppLocalizations t) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.priorityLabel,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<SupportPriority>(
value: _selectedPriority,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.outline.withOpacity(0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
filled: true,
fillColor: theme.colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
items: _priorities.map((priority) {
return DropdownMenuItem(
value: priority,
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: priority.color != null
? Color(int.parse(priority.color!.replaceFirst('#', '0xFF')))
: theme.colorScheme.primary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Text(priority.name),
],
),
);
}).toList(),
onChanged: (priority) {
setState(() {
_selectedPriority = priority;
});
},
validator: (value) {
if (value == null) {
return t.priorityRequired;
}
return null;
},
),
],
);
}
Widget _buildDescriptionField(ThemeData theme, AppLocalizations t) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.descriptionLabel,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _descriptionController,
decoration: InputDecoration(
hintText: t.descriptionHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.outline.withOpacity(0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
filled: true,
fillColor: theme.colorScheme.surface,
contentPadding: const EdgeInsets.all(16),
alignLabelWithHint: true,
),
maxLines: 6,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return t.descriptionRequired;
}
if (value.trim().length < 10) {
return t.descriptionMinLength;
}
return null;
},
),
],
);
}
Widget _buildSubmitButton(ThemeData theme, bool isDesktop, AppLocalizations t) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
top: BorderSide(
color: theme.colorScheme.outline.withOpacity(0.2),
width: 1,
),
),
),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isSubmitting ? null : _submitTicket,
icon: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(Icons.send),
label: Text(_isSubmitting ? t.submittingTicket : t.submitTicket),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
elevation: 4,
shadowColor: theme.colorScheme.primary.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(t.cancel),
),
],
),
);
}
}

View file

@ -1,495 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/services/support_service.dart';
import 'package:hesabix_ui/models/support_models.dart';
import 'package:hesabix_ui/widgets/support/message_bubble.dart';
import 'package:hesabix_ui/widgets/support/ticket_status_chip.dart';
import 'package:hesabix_ui/widgets/support/priority_indicator.dart';
class OperatorTicketDetailPage extends StatefulWidget {
final SupportTicket ticket;
const OperatorTicketDetailPage({
super.key,
required this.ticket,
});
@override
State<OperatorTicketDetailPage> createState() => _OperatorTicketDetailPageState();
}
class _OperatorTicketDetailPageState extends State<OperatorTicketDetailPage> {
final _messageController = TextEditingController();
final SupportService _supportService = SupportService(ApiClient());
SupportTicket? _ticket;
List<SupportMessage> _messages = [];
List<SupportStatus> _statuses = [];
List<SupportPriority> _priorities = [];
bool _isLoading = true;
bool _isSending = false;
bool _isUpdating = false;
String? _error;
@override
void initState() {
super.initState();
_ticket = widget.ticket;
_loadTicketDetails();
}
@override
void dispose() {
_messageController.dispose();
super.dispose();
}
Future<void> _loadTicketDetails() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final ticket = await _supportService.getOperatorTicket(_ticket!.id);
final messagesResponse = await _supportService.searchOperatorTicketMessages(
_ticket!.id,
{
'search': '',
'search_fields': ['content'],
'filters': [],
'sort_by': 'created_at',
'sort_desc': false,
'skip': 0,
'take': 100,
},
);
final statuses = await _supportService.getStatuses();
final priorities = await _supportService.getPriorities();
setState(() {
_ticket = ticket;
_messages = messagesResponse.items;
_statuses = statuses;
_priorities = priorities;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _sendMessage() async {
final content = _messageController.text.trim();
if (content.isEmpty) return;
setState(() {
_isSending = true;
});
try {
final request = CreateMessageRequest(content: content);
final message = await _supportService.sendOperatorMessage(_ticket!.id, request);
setState(() {
_messages.add(message);
_messageController.clear();
_isSending = false;
});
} catch (e) {
setState(() {
_isSending = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در ارسال پیام: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _updateStatus(int statusId) async {
setState(() {
_isUpdating = true;
});
try {
final request = UpdateStatusRequest(statusId: statusId);
final ticket = await _supportService.updateTicketStatus(_ticket!.id, request);
setState(() {
_ticket = ticket;
_isUpdating = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('وضعیت تیکت به‌روزرسانی شد'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
setState(() {
_isUpdating = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در به‌روزرسانی وضعیت: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
void _showStatusDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('تغییر وضعیت تیکت'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: _statuses.map((status) {
return ListTile(
title: Text(status.name),
subtitle: Text(status.description ?? ''),
trailing: _ticket?.statusId == status.id
? const Icon(Icons.check, color: Colors.green)
: null,
onTap: () {
Navigator.pop(context);
_updateStatus(status.id);
},
);
}).toList(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('لغو'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text('تیکت #${_ticket?.id ?? ''}'),
actions: [
IconButton(
onPressed: _loadTicketDetails,
icon: const Icon(Icons.refresh),
),
if (_ticket != null && !_ticket!.isClosed && !_ticket!.isResolved)
IconButton(
onPressed: _showStatusDialog,
icon: const Icon(Icons.edit),
tooltip: 'تغییر وضعیت',
),
],
),
body: _buildBody(theme),
);
}
Widget _buildBody(ThemeData theme) {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red.withOpacity(0.6),
),
const SizedBox(height: 16),
Text(
'خطا در بارگذاری تیکت',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
_error!,
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.red,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadTicketDetails,
child: const Text('تلاش مجدد'),
),
],
),
);
}
if (_ticket == null) {
return const Center(
child: Text('تیکت یافت نشد'),
);
}
return Column(
children: [
// Ticket header
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline.withOpacity(0.2),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_ticket!.title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
TicketStatusChip(status: _ticket!.status!),
],
),
const SizedBox(height: 8),
Text(
_ticket!.description,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
if (_ticket!.category != null) ...[
_buildInfoChip(
context,
Icons.category,
_ticket!.category!.name,
theme.colorScheme.primary,
),
const SizedBox(width: 8),
],
if (_ticket!.priority != null) ...[
PriorityIndicator(
priority: _ticket!.priority!,
isSmall: true,
),
const SizedBox(width: 8),
],
const Spacer(),
Text(
_formatDate(_ticket!.createdAt),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
if (_ticket!.user != null) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.person,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
_ticket!.user!.displayName,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
if (_ticket!.assignedOperator != null) ...[
const SizedBox(width: 16),
Icon(
Icons.support_agent,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
_ticket!.assignedOperator!.displayName,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
],
),
],
],
),
),
// Messages
Expanded(
child: _messages.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline,
size: 64,
color: Colors.grey.withOpacity(0.6),
),
const SizedBox(height: 16),
Text(
'هیچ پیامی یافت نشد',
style: theme.textTheme.titleMedium,
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return MessageBubble(
message: message,
isCurrentUser: message.isFromOperator,
);
},
),
),
// Message input
if (!_ticket!.isClosed && !_ticket!.isResolved)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
top: BorderSide(
color: theme.colorScheme.outline.withOpacity(0.2),
),
),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
hintText: 'پاسخ خود را بنویسید...',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
maxLines: 3,
minLines: 1,
enabled: !_isSending,
),
),
const SizedBox(width: 8),
IconButton(
onPressed: _isSending ? null : _sendMessage,
icon: _isSending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
),
),
],
),
),
],
);
}
Widget _buildInfoChip(
BuildContext context,
IconData icon,
String text,
Color color,
) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 12,
color: color,
),
const SizedBox(width: 4),
Text(
text,
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
String _formatDate(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inDays > 0) {
return '${difference.inDays} روز پیش';
} else if (difference.inHours > 0) {
return '${difference.inHours} ساعت پیش';
} else if (difference.inMinutes > 0) {
return '${difference.inMinutes} دقیقه پیش';
} else {
return 'همین الان';
}
}
}

View file

@ -5,7 +5,7 @@ import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/services/support_service.dart';
import 'package:hesabix_ui/models/support_models.dart';
import 'package:hesabix_ui/widgets/data_table/data_table.dart';
import 'operator_ticket_detail_page.dart';
import 'package:hesabix_ui/widgets/support/ticket_details_dialog.dart';
class OperatorTicketsPage extends StatefulWidget {
final CalendarController? calendarController;
@ -17,19 +17,44 @@ class OperatorTicketsPage extends StatefulWidget {
class _OperatorTicketsPageState extends State<OperatorTicketsPage> {
Set<int> _selectedRows = <int>{};
// Support data for filters
final SupportService _supportService = SupportService(ApiClient());
List<SupportStatus> _statuses = [];
List<SupportPriority> _priorities = [];
@override
void initState() {
super.initState();
_loadMetadata();
}
Future<void> _loadMetadata() async {
try {
final statuses = await _supportService.getStatuses();
final priorities = await _supportService.getPriorities();
setState(() {
_statuses = statuses;
_priorities = priorities;
});
} catch (e) {
// Handle error silently for now, filters will just be empty
}
}
void _navigateToTicketDetail(Map<String, dynamic> ticketData) {
final ticket = SupportTicket.fromJson(ticketData);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => OperatorTicketDetailPage(ticket: ticket),
showDialog(
context: context,
builder: (context) => TicketDetailsDialog(
ticket: ticket,
isOperator: true,
onTicketUpdated: () {
// Refresh the data table if needed
setState(() {});
},
),
);
}
@ -98,6 +123,13 @@ class _OperatorTicketsPageState extends State<OperatorTicketsPage> {
sortable: true,
searchable: true,
width: ColumnWidth.small,
filterType: ColumnFilterType.multiSelect,
filterOptions: _priorities.map((priority) => FilterOption(
value: priority.name,
label: priority.name,
description: priority.description,
color: priority.color != null ? Color(int.parse(priority.color!.replaceFirst('#', '0xFF'))) : null,
)).toList(),
),
TextColumn(
'status.name',
@ -105,6 +137,13 @@ class _OperatorTicketsPageState extends State<OperatorTicketsPage> {
sortable: true,
searchable: true,
width: ColumnWidth.small,
filterType: ColumnFilterType.multiSelect,
filterOptions: _statuses.map((status) => FilterOption(
value: status.name,
label: status.name,
description: status.description,
color: status.color != null ? Color(int.parse(status.color!.replaceFirst('#', '0xFF'))) : null,
)).toList(),
),
TextColumn(
'assigned_operator.first_name',

View file

@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/services/support_service.dart';
import 'package:hesabix_ui/models/support_models.dart';
import 'package:hesabix_ui/widgets/data_table/data_table.dart';
import 'ticket_detail_page.dart';
import 'package:hesabix_ui/widgets/support/ticket_details_dialog.dart';
import 'create_ticket_page.dart';
class SupportPage extends StatefulWidget {
@ -16,19 +18,37 @@ class SupportPage extends StatefulWidget {
class _SupportPageState extends State<SupportPage> {
Set<int> _selectedRows = <int>{};
// Support data for filters
final SupportService _supportService = SupportService(ApiClient());
List<SupportStatus> _statuses = [];
List<SupportPriority> _priorities = [];
@override
void initState() {
super.initState();
_loadMetadata();
}
Future<void> _loadMetadata() async {
try {
final statuses = await _supportService.getStatuses();
final priorities = await _supportService.getPriorities();
setState(() {
_statuses = statuses;
_priorities = priorities;
});
} catch (e) {
// Handle error silently for now, filters will just be empty
}
}
void _navigateToCreateTicket() async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CreateTicketPage(),
),
final result = await showDialog<bool>(
context: context,
builder: (context) => const CreateTicketPage(),
);
if (result == true) {
@ -38,10 +58,15 @@ class _SupportPageState extends State<SupportPage> {
void _navigateToTicketDetail(Map<String, dynamic> ticketData) {
final ticket = SupportTicket.fromJson(ticketData);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TicketDetailPage(ticket: ticket),
showDialog(
context: context,
builder: (context) => TicketDetailsDialog(
ticket: ticket,
isOperator: false,
onTicketUpdated: () {
// Refresh the data table if needed
setState(() {});
},
),
);
}
@ -82,6 +107,13 @@ class _SupportPageState extends State<SupportPage> {
sortable: true,
searchable: true,
width: ColumnWidth.small,
filterType: ColumnFilterType.multiSelect,
filterOptions: _priorities.map((priority) => FilterOption(
value: priority.name,
label: priority.name,
description: priority.description,
color: priority.color != null ? Color(int.parse(priority.color!.replaceFirst('#', '0xFF'))) : null,
)).toList(),
),
TextColumn(
'status.name',
@ -89,6 +121,13 @@ class _SupportPageState extends State<SupportPage> {
sortable: true,
searchable: true,
width: ColumnWidth.small,
filterType: ColumnFilterType.multiSelect,
filterOptions: _statuses.map((status) => FilterOption(
value: status.name,
label: status.name,
description: status.description,
color: status.color != null ? Color(int.parse(status.color!.replaceFirst('#', '0xFF'))) : null,
)).toList(),
),
DateColumn(
'created_at',

View file

@ -1,380 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/services/support_service.dart';
import 'package:hesabix_ui/models/support_models.dart';
import 'package:hesabix_ui/widgets/support/message_bubble.dart';
import 'package:hesabix_ui/widgets/support/ticket_status_chip.dart';
import 'package:hesabix_ui/widgets/support/priority_indicator.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
class TicketDetailPage extends StatefulWidget {
final SupportTicket ticket;
const TicketDetailPage({
super.key,
required this.ticket,
});
@override
State<TicketDetailPage> createState() => _TicketDetailPageState();
}
class _TicketDetailPageState extends State<TicketDetailPage> {
final _messageController = TextEditingController();
final SupportService _supportService = SupportService(ApiClient());
SupportTicket? _ticket;
List<SupportMessage> _messages = [];
bool _isLoading = true;
bool _isSending = false;
String? _error;
@override
void initState() {
super.initState();
_ticket = widget.ticket;
_loadTicketDetails();
}
@override
void dispose() {
_messageController.dispose();
super.dispose();
}
Future<void> _loadTicketDetails() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final ticket = await _supportService.getTicket(_ticket!.id);
final messagesResponse = await _supportService.searchTicketMessages(
_ticket!.id,
{
'search': '',
'search_fields': ['content'],
'filters': [],
'sort_by': 'created_at',
'sort_desc': false,
'skip': 0,
'take': 100,
},
);
setState(() {
_ticket = ticket;
_messages = messagesResponse.items;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _sendMessage() async {
final content = _messageController.text.trim();
if (content.isEmpty) return;
setState(() {
_isSending = true;
});
try {
final request = CreateMessageRequest(content: content);
final message = await _supportService.sendMessage(_ticket!.id, request);
setState(() {
_messages.add(message);
_messageController.clear();
_isSending = false;
});
} catch (e) {
setState(() {
_isSending = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در ارسال پیام: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text('تیکت #${_ticket?.id ?? ''}'),
actions: [
IconButton(
onPressed: _loadTicketDetails,
icon: const Icon(Icons.refresh),
),
],
),
body: _buildBody(theme),
);
}
Widget _buildBody(ThemeData theme) {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red.withOpacity(0.6),
),
const SizedBox(height: 16),
Text(
'خطا در بارگذاری تیکت',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
_error!,
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.red,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadTicketDetails,
child: const Text('تلاش مجدد'),
),
],
),
);
}
if (_ticket == null) {
return const Center(
child: Text('تیکت یافت نشد'),
);
}
return Column(
children: [
// Ticket header
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline.withOpacity(0.2),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_ticket!.title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
TicketStatusChip(status: _ticket!.status!),
],
),
const SizedBox(height: 8),
Text(
_ticket!.description,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
if (_ticket!.category != null) ...[
_buildInfoChip(
context,
Icons.category,
_ticket!.category!.name,
theme.colorScheme.primary,
),
const SizedBox(width: 8),
],
if (_ticket!.priority != null) ...[
PriorityIndicator(
priority: _ticket!.priority!,
isSmall: true,
),
const SizedBox(width: 8),
],
const Spacer(),
Text(
_formatDate(_ticket!.createdAt),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
],
),
),
// Messages
Expanded(
child: _messages.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline,
size: 64,
color: Colors.grey.withOpacity(0.6),
),
const SizedBox(height: 16),
Text(
'هیچ پیامی یافت نشد',
style: theme.textTheme.titleMedium,
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return MessageBubble(
message: message,
isCurrentUser: message.isFromUser,
);
},
),
),
// Message input
if (!_ticket!.isClosed && !_ticket!.isResolved)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
top: BorderSide(
color: theme.colorScheme.outline.withOpacity(0.2),
),
),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
hintText: 'پیام خود را بنویسید...',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
maxLines: 3,
minLines: 1,
enabled: !_isSending,
),
),
const SizedBox(width: 8),
IconButton(
onPressed: _isSending ? null : _sendMessage,
icon: _isSending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
),
),
],
),
),
],
);
}
Widget _buildInfoChip(
BuildContext context,
IconData icon,
String text,
Color color,
) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 12,
color: color,
),
const SizedBox(width: 4),
Text(
text,
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
String _formatDate(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inDays > 0) {
return '${difference.inDays} روز پیش';
} else if (difference.inHours > 0) {
return '${difference.inHours} ساعت پیش';
} else if (difference.inMinutes > 0) {
return '${difference.inMinutes} دقیقه پیش';
} else {
return 'همین الان';
}
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/models/support_models.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
class MessageBubble extends StatelessWidget {
final SupportMessage message;
@ -17,9 +18,9 @@ class MessageBubble extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final isUser = message.isFromUser;
final isOperator = message.isFromOperator;
final isSystem = message.isFromSystem;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
@ -84,7 +85,7 @@ class MessageBubble extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.createdAt),
_formatTime(message.createdAt, l10n),
style: TextStyle(
fontSize: 11,
color: _getTimeColor(theme, isUser),
@ -170,18 +171,25 @@ class MessageBubble extends StatelessWidget {
}
}
String _formatTime(DateTime dateTime) {
String _formatTime(DateTime dateTime, AppLocalizations l10n) {
final now = DateTime.now();
final difference = now.difference(dateTime);
// If the difference is negative (future time), show just now
if (difference.isNegative) {
return l10n.justNow;
}
if (difference.inDays > 0) {
return '${difference.inDays} روز پیش';
return l10n.daysAgo(difference.inDays.toString());
} else if (difference.inHours > 0) {
return '${difference.inHours} ساعت پیش';
return l10n.hoursAgo(difference.inHours.toString());
} else if (difference.inMinutes > 0) {
return '${difference.inMinutes} دقیقه پیش';
return l10n.minutesAgo(difference.inMinutes.toString());
} else if (difference.inSeconds > 10) {
return l10n.justNow;
} else {
return 'همین الان';
return l10n.justNow;
}
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/models/support_models.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'ticket_status_chip.dart';
import 'priority_indicator.dart';
@ -22,125 +23,226 @@ class TicketCard extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
ticket.title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
TicketStatusChip(status: ticket.status!),
],
return Container(
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.colorScheme.shadow.withOpacity(0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: theme.colorScheme.outline.withOpacity(0.1),
width: 1,
),
const SizedBox(height: 8),
Text(
ticket.description,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
Row(
children: [
if (ticket.category != null) ...[
_buildInfoChip(
context,
Icons.category,
ticket.category!.name,
theme.colorScheme.primary,
),
const SizedBox(width: 8),
],
if (ticket.priority != null) ...[
PriorityIndicator(
priority: ticket.priority!,
isSmall: true,
),
const SizedBox(width: 8),
],
const Spacer(),
Text(
_formatDate(ticket.createdAt),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
if (showUserInfo && ticket.user != null) ...[
const SizedBox(height: 8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with title and status
Row(
children: [
Icon(
Icons.person,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
ticket.user!.displayName,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
if (ticket.assignedOperator != null) ...[
const SizedBox(width: 16),
Icon(
Icons.support_agent,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
ticket.assignedOperator!.displayName,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
Expanded(
child: Text(
ticket.title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
const SizedBox(width: 12),
TicketStatusChip(status: ticket.status!),
],
),
const SizedBox(height: 12),
// Description
Text(
ticket.description,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.8),
height: 1.4,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 16),
// Tags and metadata row
Row(
children: [
if (ticket.category != null) ...[
_buildInfoChip(
context,
Icons.category_outlined,
ticket.category!.name,
theme.colorScheme.primary,
),
const SizedBox(width: 8),
],
if (ticket.priority != null) ...[
PriorityIndicator(
priority: ticket.priority!,
isSmall: true,
),
const SizedBox(width: 8),
],
const Spacer(),
_buildTimeChip(context, ticket.createdAt),
],
),
if (showUserInfo && ticket.user != null) ...[
const SizedBox(height: 12),
_buildUserInfo(context, ticket),
],
],
],
),
),
),
),
);
}
Widget _buildTimeChip(BuildContext context, DateTime dateTime) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.access_time,
size: 12,
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.7),
),
const SizedBox(width: 4),
Text(
_formatDate(dateTime, l10n),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.8),
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildUserInfo(BuildContext context, SupportTicket ticket) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: theme.colorScheme.primary.withOpacity(0.1),
child: Icon(
Icons.person,
size: 16,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.createdBy,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.7),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
ticket.user!.displayName,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
],
),
),
if (ticket.assignedOperator != null) ...[
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.support_agent,
size: 12,
color: theme.colorScheme.secondary,
),
const SizedBox(width: 4),
Text(
ticket.assignedOperator!.displayName,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.secondary,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
],
),
);
}
Widget _buildInfoChip(
BuildContext context,
IconData icon,
String text,
Color color,
) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.3),
color: color.withOpacity(0.2),
width: 1,
),
),
@ -149,16 +251,16 @@ class TicketCard extends StatelessWidget {
children: [
Icon(
icon,
size: 12,
size: 14,
color: color,
),
const SizedBox(width: 4),
const SizedBox(width: 6),
Text(
text,
style: TextStyle(
fontSize: 11,
fontSize: 12,
color: color,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
),
),
],
@ -166,18 +268,25 @@ class TicketCard extends StatelessWidget {
);
}
String _formatDate(DateTime dateTime) {
String _formatDate(DateTime dateTime, AppLocalizations l10n) {
final now = DateTime.now();
final difference = now.difference(dateTime);
// If the difference is negative (future time), show just now
if (difference.isNegative) {
return l10n.justNow;
}
if (difference.inDays > 0) {
return '${difference.inDays} روز پیش';
return l10n.daysAgo(difference.inDays.toString());
} else if (difference.inHours > 0) {
return '${difference.inHours} ساعت پیش';
return l10n.hoursAgo(difference.inHours.toString());
} else if (difference.inMinutes > 0) {
return '${difference.inMinutes} دقیقه پیش';
return l10n.minutesAgo(difference.inMinutes.toString());
} else if (difference.inSeconds > 10) {
return l10n.justNow;
} else {
return 'همین الان';
return l10n.justNow;
}
}
}

View file

@ -0,0 +1,520 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/models/support_models.dart';
import 'package:hesabix_ui/services/support_service.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/widgets/support/message_bubble.dart';
class TicketDetailsDialog extends StatefulWidget {
final SupportTicket ticket;
final bool isOperator;
final VoidCallback? onTicketUpdated;
const TicketDetailsDialog({
super.key,
required this.ticket,
this.isOperator = false,
this.onTicketUpdated,
});
@override
State<TicketDetailsDialog> createState() => _TicketDetailsDialogState();
}
class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
late SupportTicket _ticket;
List<SupportMessage> _messages = [];
bool _isLoading = false;
bool _isSending = false;
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_ticket = widget.ticket;
_messages = _ticket.messages ?? [];
_loadMessages();
}
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _loadMessages() async {
if (_isLoading) return;
setState(() {
_isLoading = true;
});
try {
final supportService = SupportService(ApiClient());
final queryInfo = {
'take': 1000, // Get all messages
'skip': 0,
'sort_by': 'created_at',
'sort_desc': false,
};
final response = await supportService.searchTicketMessages(_ticket.id, queryInfo);
final messages = response.items;
setState(() {
_messages = messages;
_isLoading = false;
});
// Scroll to bottom
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
} catch (e) {
setState(() {
_isLoading = false;
});
if (mounted) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.ticketLoadingError),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
}
}
Future<void> _sendMessage() async {
final content = _messageController.text.trim();
if (content.isEmpty) return;
setState(() {
_isSending = true;
});
try {
final supportService = SupportService(ApiClient());
final request = CreateMessageRequest(content: content);
SupportMessage message;
if (widget.isOperator) {
message = await supportService.sendOperatorMessage(_ticket.id, request);
// Refresh ticket to get updated last_updated time
final updatedTicket = await supportService.getOperatorTicket(_ticket.id);
setState(() {
_ticket = updatedTicket;
});
} else {
message = await supportService.sendMessage(_ticket.id, request);
// Refresh ticket to get updated last_updated time
final updatedTicket = await supportService.getTicket(_ticket.id);
setState(() {
_ticket = updatedTicket;
});
}
setState(() {
_messages.add(message);
_messageController.clear();
_isSending = false;
});
// Scroll to bottom
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
if (mounted) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.messageSentSuccessfully),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
// Notify parent about ticket update
widget.onTicketUpdated?.call();
} catch (e) {
setState(() {
_isSending = false;
});
if (mounted) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.errorSendingMessage),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
}
}
String _formatDate(DateTime dateTime, AppLocalizations l10n) {
final now = DateTime.now();
final difference = now.difference(dateTime);
// If the difference is negative (future time), show just now
if (difference.isNegative) {
return l10n.justNow;
}
if (difference.inDays > 0) {
return l10n.daysAgo(difference.inDays.toString());
} else if (difference.inHours > 0) {
return l10n.hoursAgo(difference.inHours.toString());
} else if (difference.inMinutes > 0) {
return l10n.minutesAgo(difference.inMinutes.toString());
} else if (difference.inSeconds > 10) {
return l10n.justNow;
} else {
return l10n.justNow;
}
}
Widget _buildInfoChip(String label, String value, IconData icon) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.grey[300]!),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: Colors.grey[600]),
const SizedBox(width: 6),
Text(
'$label: $value',
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
fontWeight: FontWeight.w500,
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
height: MediaQuery.of(context).size.height * 0.9,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Colors.white,
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.primaryColor.withOpacity(0.1),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Row(
children: [
Icon(
Icons.support_agent,
color: theme.primaryColor,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.ticketNumber(_ticket.id.toString()),
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.primaryColor,
),
),
Text(
_ticket.title,
style: theme.textTheme.titleMedium?.copyWith(
color: Colors.grey[700],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: Colors.grey[200],
foregroundColor: Colors.grey[600],
),
),
],
),
),
// Messages Section (Main Focus)
Expanded(
child: Container(
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[200]!),
),
child: Column(
children: [
// Messages Header with Ticket Info
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
border: Border(
bottom: BorderSide(color: Colors.grey[200]!),
),
),
child: Column(
children: [
// Conversation Title
Row(
children: [
Icon(
Icons.chat_bubble_outline,
color: theme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
Text(
l10n.conversation,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.primaryColor,
),
),
const Spacer(),
Text(
l10n.messageCount(_messages.length.toString()),
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
const SizedBox(height: 12),
// Ticket Info Chips
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildInfoChip(
l10n.status,
_ticket.status?.name ?? '',
Icons.info_outline,
),
_buildInfoChip(
l10n.category,
_ticket.category?.name ?? '',
Icons.category_outlined,
),
_buildInfoChip(
l10n.priority,
_ticket.priority?.name ?? '',
Icons.priority_high,
),
_buildInfoChip(
l10n.createdAt,
_formatDate(_ticket.createdAt, l10n),
Icons.schedule,
),
if (widget.isOperator && _ticket.user != null)
_buildInfoChip(
l10n.createdBy,
_ticket.user!.displayName,
Icons.person,
),
if (widget.isOperator && _ticket.assignedOperator != null)
_buildInfoChip(
l10n.assignedTo,
_ticket.assignedOperator!.displayName,
Icons.person_outline,
),
],
),
],
),
),
// Messages List
Expanded(
child: _isLoading
? const Center(
child: CircularProgressIndicator(),
)
: _messages.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
l10n.noMessagesFound,
style: theme.textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
),
],
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MessageBubble(
message: message,
),
);
},
),
),
// Message Input
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
border: Border(
top: BorderSide(color: Colors.grey[200]!),
),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: widget.isOperator
? l10n.writeYourResponse
: l10n.writeYourMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(color: Colors.grey[300]!),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(color: Colors.grey[300]!),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(color: theme.primaryColor),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
filled: true,
fillColor: Colors.grey[50],
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 12),
Container(
decoration: BoxDecoration(
color: theme.primaryColor,
borderRadius: BorderRadius.circular(25),
),
child: IconButton(
onPressed: _isSending ? null : _sendMessage,
icon: _isSending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(
Icons.send,
color: Colors.white,
),
),
),
],
),
),
],
),
),
),
],
),
),
);
}
}