progress in support system
This commit is contained in:
parent
4e07795467
commit
d782cbfffc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}%"))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} پیام"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => 'بستن';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,52 +113,139 @@ 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),
|
||||
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),
|
||||
),
|
||||
)
|
||||
else
|
||||
TextButton(
|
||||
onPressed: _submitTicket,
|
||||
child: const Text('ارسال'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(theme),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.red.withOpacity(0.6),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.red.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'خطا در بارگذاری دادهها',
|
||||
style: theme.textTheme.titleMedium,
|
||||
t.dataLoadingError,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
|
|
@ -165,64 +255,154 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
|
|||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _loadData,
|
||||
child: const Text('تلاش مجدد'),
|
||||
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: [
|
||||
|
||||
// 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),
|
||||
),
|
||||
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(
|
||||
'ایجاد تیکت پشتیبانی',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
t.ticketTitleLabel,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
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(),
|
||||
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 'عنوان تیکت الزامی است';
|
||||
return t.ticketTitleRequired;
|
||||
}
|
||||
if (value.trim().length < 5) {
|
||||
return 'عنوان باید حداقل 5 کاراکتر باشد';
|
||||
return t.ticketTitleMinLength;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Category dropdown
|
||||
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: const InputDecoration(
|
||||
labelText: 'دستهبندی',
|
||||
border: OutlineInputBorder(),
|
||||
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(
|
||||
|
|
@ -237,19 +417,48 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
|
|||
},
|
||||
validator: (value) {
|
||||
if (value == null) {
|
||||
return 'لطفاً دستهبندی را انتخاب کنید';
|
||||
return t.categoryRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Priority dropdown
|
||||
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: const InputDecoration(
|
||||
labelText: 'اولویت',
|
||||
border: OutlineInputBorder(),
|
||||
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(
|
||||
|
|
@ -266,7 +475,7 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
|
|||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 12),
|
||||
Text(priority.name),
|
||||
],
|
||||
),
|
||||
|
|
@ -279,67 +488,119 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
|
|||
},
|
||||
validator: (value) {
|
||||
if (value == null) {
|
||||
return 'لطفاً اولویت را انتخاب کنید';
|
||||
return t.priorityRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Description field
|
||||
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: const InputDecoration(
|
||||
labelText: 'شرح مشکل',
|
||||
hintText: 'مشکل یا سوال خود را به تفصیل شرح دهید...',
|
||||
border: OutlineInputBorder(),
|
||||
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 'شرح مشکل الزامی است';
|
||||
return t.descriptionRequired;
|
||||
}
|
||||
if (value.trim().length < 10) {
|
||||
return 'شرح باید حداقل 10 کاراکتر باشد';
|
||||
return t.descriptionMinLength;
|
||||
}
|
||||
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),
|
||||
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: _isSubmitting
|
||||
? const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
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),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text('در حال ارسال...'),
|
||||
],
|
||||
)
|
||||
: const Text('ارسال تیکت'),
|
||||
: 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 'همین الان';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -18,18 +18,43 @@ 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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -17,18 +19,36 @@ 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(
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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 'همین الان';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,48 +23,76 @@ class TicketCard extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
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(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
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,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header with title and status
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
ticket.title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 12),
|
||||
TicketStatusChip(status: ticket.status!),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Description
|
||||
Text(
|
||||
ticket.description,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.8),
|
||||
height: 1.4,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
// Tags and metadata row
|
||||
Row(
|
||||
children: [
|
||||
if (ticket.category != null) ...[
|
||||
_buildInfoChip(
|
||||
context,
|
||||
Icons.category,
|
||||
Icons.category_outlined,
|
||||
ticket.category!.name,
|
||||
theme.colorScheme.primary,
|
||||
),
|
||||
|
|
@ -77,52 +106,127 @@ class TicketCard extends StatelessWidget {
|
|||
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(ticket.createdAt),
|
||||
_formatDate(dateTime, l10n),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.8),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showUserInfo && ticket.user != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
);
|
||||
}
|
||||
|
||||
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: [
|
||||
Icon(
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: theme.colorScheme.primary.withOpacity(0.1),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
),
|
||||
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.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (ticket.assignedOperator != null) ...[
|
||||
const SizedBox(width: 16),
|
||||
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: 16,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
size: 12,
|
||||
color: theme.colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
ticket.assignedOperator!.displayName,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
color: theme.colorScheme.secondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -132,15 +236,13 @@ class TicketCard extends StatelessWidget {
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue