From d782cbfffc477e2037c028fe44841ac311322db2 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Sun, 21 Sep 2025 18:36:15 +0330 Subject: [PATCH] progress in support system --- .../support/message_repository.py | 9 + .../repositories/support/ticket_repository.py | 44 +- hesabixUI/hesabix_ui/lib/l10n/app_en.arb | 62 +- hesabixUI/hesabix_ui/lib/l10n/app_fa.arb | 62 +- .../lib/l10n/app_localizations.dart | 298 +++++++- .../lib/l10n/app_localizations_en.dart | 164 ++++- .../lib/l10n/app_localizations_fa.dart | 162 ++++- .../hesabix_ui/lib/models/support_models.dart | 26 + .../lib/pages/profile/create_ticket_page.dart | 677 ++++++++++++------ .../operator/operator_ticket_detail_page.dart | 495 ------------- .../operator/operator_tickets_page.dart | 49 +- .../lib/pages/profile/support_page.dart | 59 +- .../lib/pages/profile/ticket_detail_page.dart | 380 ---------- .../lib/widgets/support/message_bubble.dart | 22 +- .../lib/widgets/support/ticket_card.dart | 315 +++++--- .../support/ticket_details_dialog.dart | 520 ++++++++++++++ 16 files changed, 2121 insertions(+), 1223 deletions(-) delete mode 100644 hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_ticket_detail_page.dart delete mode 100644 hesabixUI/hesabix_ui/lib/pages/profile/ticket_detail_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/support/ticket_details_dialog.dart diff --git a/hesabixAPI/adapters/db/repositories/support/message_repository.py b/hesabixAPI/adapters/db/repositories/support/message_repository.py index 73a537d..11c7e82 100644 --- a/hesabixAPI/adapters/db/repositories/support/message_repository.py +++ b/hesabixAPI/adapters/db/repositories/support/message_repository.py @@ -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 diff --git a/hesabixAPI/adapters/db/repositories/support/ticket_repository.py b/hesabixAPI/adapters/db/repositories/support/ticket_repository.py index b00badc..a44601b 100644 --- a/hesabixAPI/adapters/db/repositories/support/ticket_repository.py +++ b/hesabixAPI/adapters/db/repositories/support/ticket_repository.py @@ -73,11 +73,26 @@ class TicketRepository(BaseRepository[Ticket]): elif filter_item.operator == "=": query = query.filter(Ticket.title == filter_item.value) elif filter_item.property == "category.name": - query = query.join(Ticket.category).filter(Ticket.category.has(name=filter_item.value)) + query = query.join(Ticket.category) + if filter_item.operator == "in": + from adapters.db.models.support.category import Category + query = query.filter(Category.name.in_(filter_item.value)) + else: + query = query.filter(Ticket.category.has(name=filter_item.value)) elif filter_item.property == "priority.name": - query = query.join(Ticket.priority).filter(Ticket.priority.has(name=filter_item.value)) + query = query.join(Ticket.priority) + if filter_item.operator == "in": + from adapters.db.models.support.priority import Priority + query = query.filter(Priority.name.in_(filter_item.value)) + else: + query = query.filter(Ticket.priority.has(name=filter_item.value)) elif filter_item.property == "status.name": - query = query.join(Ticket.status).filter(Ticket.status.has(name=filter_item.value)) + query = query.join(Ticket.status) + if filter_item.operator == "in": + from adapters.db.models.support.status import Status + query = query.filter(Status.name.in_(filter_item.value)) + else: + query = query.filter(Ticket.status.has(name=filter_item.value)) elif filter_item.property == "description" and hasattr(Ticket, "description"): if filter_item.operator == "*": query = query.filter(Ticket.description.ilike(f"%{filter_item.value}%")) @@ -139,11 +154,26 @@ class TicketRepository(BaseRepository[Ticket]): elif filter_item.operator == "=": query = query.filter(Ticket.title == filter_item.value) elif filter_item.property == "category.name": - query = query.join(Ticket.category).filter(Ticket.category.has(name=filter_item.value)) + query = query.join(Ticket.category) + if filter_item.operator == "in": + from adapters.db.models.support.category import Category + query = query.filter(Category.name.in_(filter_item.value)) + else: + query = query.filter(Ticket.category.has(name=filter_item.value)) elif filter_item.property == "priority.name": - query = query.join(Ticket.priority).filter(Ticket.priority.has(name=filter_item.value)) + query = query.join(Ticket.priority) + if filter_item.operator == "in": + from adapters.db.models.support.priority import Priority + query = query.filter(Priority.name.in_(filter_item.value)) + else: + query = query.filter(Ticket.priority.has(name=filter_item.value)) elif filter_item.property == "status.name": - query = query.join(Ticket.status).filter(Ticket.status.has(name=filter_item.value)) + query = query.join(Ticket.status) + if filter_item.operator == "in": + from adapters.db.models.support.status import Status + query = query.filter(Status.name.in_(filter_item.value)) + else: + query = query.filter(Ticket.status.has(name=filter_item.value)) elif filter_item.property == "description" and hasattr(Ticket, "description"): if filter_item.operator == "*": query = query.filter(Ticket.description.ilike(f"%{filter_item.value}%")) @@ -229,4 +259,4 @@ class TicketRepository(BaseRepository[Ticket]): ticket.assigned_operator_id = operator_id self.db.commit() self.db.refresh(ticket) - return ticket + return ticket \ No newline at end of file diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index 9024837..8ae3974 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -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" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index 98711be..39608fb 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -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} پیام" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index 6dc0308..8ca258a 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -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 diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart index ac5cf50..42623c7 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index 8bdf30f..d23da8c 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -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 => 'بستن'; } diff --git a/hesabixUI/hesabix_ui/lib/models/support_models.dart b/hesabixUI/hesabix_ui/lib/models/support_models.dart index 10f157c..efdcc67 100644 --- a/hesabixUI/hesabix_ui/lib/models/support_models.dart +++ b/hesabixUI/hesabix_ui/lib/models/support_models.dart @@ -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(); } diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart index 66bdd03..a04c6ec 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart @@ -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 { Future _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 { await _supportService.createTicket(request); if (mounted) { + final t = AppLocalizations.of(context); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('تیکت با موفقیت ایجاد شد'), + SnackBar( + content: Text(t.ticketCreatedSuccessfully), backgroundColor: Colors.green, ), ); @@ -110,236 +113,494 @@ class _CreateTicketPageState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final t = AppLocalizations.of(context); + final isDesktop = MediaQuery.of(context).size.width > 768; - return Scaffold( - appBar: AppBar( - title: const Text('ایجاد تیکت جدید'), - actions: [ - if (_isSubmitting) - const Padding( - padding: EdgeInsets.all(16.0), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - else - TextButton( - onPressed: _submitTicket, - child: const Text('ارسال'), - ), - ], + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + constraints: BoxConstraints( + maxWidth: isDesktop ? 600 : double.infinity, + maxHeight: MediaQuery.of(context).size.height * 0.9, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with close button + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + theme.colorScheme.primary.withOpacity(0.1), + theme.colorScheme.primary.withOpacity(0.05), + ], + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.support_agent, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.createNewTicket, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 4), + Text( + t.descriptionHint, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.surface, + foregroundColor: theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + // Form content + Flexible( + child: _buildBody(theme, isDesktop, t), + ), + ], + ), ), - body: _buildBody(theme), ); } - Widget _buildBody(ThemeData theme) { + Widget _buildBody(ThemeData theme, bool isDesktop, AppLocalizations t) { if (_isLoading) { - return const Center( - child: CircularProgressIndicator(), + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: theme.colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + t.loadingData, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + ), + ], + ), ); } if (_error != null) { return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 64, - color: Colors.red.withOpacity(0.6), - ), - const SizedBox(height: 16), - Text( - 'خطا در بارگذاری داده‌ها', - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - _error!, - style: theme.textTheme.bodyMedium?.copyWith( - color: Colors.red, + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.error_outline, + size: 48, + color: Colors.red.withOpacity(0.8), + ), ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadData, - child: const Text('تلاش مجدد'), - ), - ], + const SizedBox(height: 24), + Text( + t.dataLoadingError, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + _error!, + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.red, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _loadData, + icon: const Icon(Icons.refresh), + label: Text(t.retry), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ), ), ); } return SingleChildScrollView( - padding: const EdgeInsets.all(16.0), + padding: EdgeInsets.all(isDesktop ? 24.0 : 16.0), child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'ایجاد تیکت پشتیبانی', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'لطفاً مشکل یا سوال خود را به تفصیل شرح دهید', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.7), - ), - ), - const SizedBox(height: 24), - - // Title field - TextFormField( - controller: _titleController, - decoration: const InputDecoration( - labelText: 'عنوان تیکت', - hintText: 'عنوان کوتاه و واضح برای مشکل خود وارد کنید', - border: OutlineInputBorder(), - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'عنوان تیکت الزامی است'; - } - if (value.trim().length < 5) { - return 'عنوان باید حداقل 5 کاراکتر باشد'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Category dropdown - DropdownButtonFormField( - value: _selectedCategory, - decoration: const InputDecoration( - labelText: 'دسته‌بندی', - border: OutlineInputBorder(), - ), - items: _categories.map((category) { - return DropdownMenuItem( - value: category, - child: Text(category.name), - ); - }).toList(), - onChanged: (category) { - setState(() { - _selectedCategory = category; - }); - }, - validator: (value) { - if (value == null) { - return 'لطفاً دسته‌بندی را انتخاب کنید'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Priority dropdown - DropdownButtonFormField( - value: _selectedPriority, - decoration: const InputDecoration( - labelText: 'اولویت', - border: OutlineInputBorder(), - ), - items: _priorities.map((priority) { - return DropdownMenuItem( - value: priority, - child: Row( - children: [ - Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: priority.color != null - ? Color(int.parse(priority.color!.replaceFirst('#', '0xFF'))) - : theme.colorScheme.primary, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text(priority.name), - ], - ), - ); - }).toList(), - onChanged: (priority) { - setState(() { - _selectedPriority = priority; - }); - }, - validator: (value) { - if (value == null) { - return 'لطفاً اولویت را انتخاب کنید'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Description field - TextFormField( - controller: _descriptionController, - decoration: const InputDecoration( - labelText: 'شرح مشکل', - hintText: 'مشکل یا سوال خود را به تفصیل شرح دهید...', - border: OutlineInputBorder(), - alignLabelWithHint: true, - ), - maxLines: 6, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'شرح مشکل الزامی است'; - } - if (value.trim().length < 10) { - return 'شرح باید حداقل 10 کاراکتر باشد'; - } - return null; - }, - ), - const SizedBox(height: 24), - - // Submit button - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _isSubmitting ? null : _submitTicket, - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: _isSubmitting - ? const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), + + // Form Fields + if (isDesktop) ...[ + // Desktop Layout - Title full width, Category and Priority in one row + _buildTitleField(theme, t), + const SizedBox(height: 20), + Row( + children: [ + // Category field + Expanded( + child: _buildCategoryField(theme, t), ), - ), - SizedBox(width: 8), - Text('در حال ارسال...'), - ], - ) - : const Text('ارسال تیکت'), - ), - ), + const SizedBox(width: 20), + // Priority field + Expanded( + child: _buildPriorityField(theme, t), + ), + ], + ), + const SizedBox(height: 20), + ] else ...[ + // Mobile Layout - Single Column + _buildTitleField(theme, t), + const SizedBox(height: 20), + _buildCategoryField(theme, t), + const SizedBox(height: 20), + _buildPriorityField(theme, t), + const SizedBox(height: 20), + ], + + // Description Field (Full Width) + _buildDescriptionField(theme, t), + const SizedBox(height: 32), + + // Submit Button + _buildSubmitButton(theme, isDesktop, t), ], ), ), ); } + + Widget _buildTitleField(ThemeData theme, AppLocalizations t) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.ticketTitleLabel, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _titleController, + decoration: InputDecoration( + hintText: t.ticketTitleHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.outline.withOpacity(0.3), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: theme.colorScheme.surface, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return t.ticketTitleRequired; + } + if (value.trim().length < 5) { + return t.ticketTitleMinLength; + } + return null; + }, + ), + ], + ); + } + + Widget _buildCategoryField(ThemeData theme, AppLocalizations t) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.categoryLabel, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedCategory, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.outline.withOpacity(0.3), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: theme.colorScheme.surface, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + items: _categories.map((category) { + return DropdownMenuItem( + value: category, + child: Text(category.name), + ); + }).toList(), + onChanged: (category) { + setState(() { + _selectedCategory = category; + }); + }, + validator: (value) { + if (value == null) { + return t.categoryRequired; + } + return null; + }, + ), + ], + ); + } + + Widget _buildPriorityField(ThemeData theme, AppLocalizations t) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.priorityLabel, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedPriority, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.outline.withOpacity(0.3), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: theme.colorScheme.surface, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + items: _priorities.map((priority) { + return DropdownMenuItem( + value: priority, + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: priority.color != null + ? Color(int.parse(priority.color!.replaceFirst('#', '0xFF'))) + : theme.colorScheme.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Text(priority.name), + ], + ), + ); + }).toList(), + onChanged: (priority) { + setState(() { + _selectedPriority = priority; + }); + }, + validator: (value) { + if (value == null) { + return t.priorityRequired; + } + return null; + }, + ), + ], + ); + } + + Widget _buildDescriptionField(ThemeData theme, AppLocalizations t) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.descriptionLabel, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _descriptionController, + decoration: InputDecoration( + hintText: t.descriptionHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.outline.withOpacity(0.3), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: theme.colorScheme.surface, + contentPadding: const EdgeInsets.all(16), + alignLabelWithHint: true, + ), + maxLines: 6, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return t.descriptionRequired; + } + if (value.trim().length < 10) { + return t.descriptionMinLength; + } + return null; + }, + ), + ], + ); + } + + Widget _buildSubmitButton(ThemeData theme, bool isDesktop, AppLocalizations t) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide( + color: theme.colorScheme.outline.withOpacity(0.2), + width: 1, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _isSubmitting ? null : _submitTicket, + icon: _isSubmitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Icon(Icons.send), + label: Text(_isSubmitting ? t.submittingTicket : t.submitTicket), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + elevation: 4, + shadowColor: theme.colorScheme.primary.withOpacity(0.3), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text(t.cancel), + ), + ], + ), + ); + } } diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_ticket_detail_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_ticket_detail_page.dart deleted file mode 100644 index b63c0a2..0000000 --- a/hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_ticket_detail_page.dart +++ /dev/null @@ -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 createState() => _OperatorTicketDetailPageState(); -} - -class _OperatorTicketDetailPageState extends State { - final _messageController = TextEditingController(); - final SupportService _supportService = SupportService(ApiClient()); - - SupportTicket? _ticket; - List _messages = []; - List _statuses = []; - List _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 _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 _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 _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 'همین الان'; - } - } -} diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_tickets_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_tickets_page.dart index ca9f86a..ce23a29 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_tickets_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_tickets_page.dart @@ -5,7 +5,7 @@ import 'package:hesabix_ui/core/calendar_controller.dart'; import 'package:hesabix_ui/services/support_service.dart'; import 'package:hesabix_ui/models/support_models.dart'; import 'package:hesabix_ui/widgets/data_table/data_table.dart'; -import 'operator_ticket_detail_page.dart'; +import 'package:hesabix_ui/widgets/support/ticket_details_dialog.dart'; class OperatorTicketsPage extends StatefulWidget { final CalendarController? calendarController; @@ -17,19 +17,44 @@ class OperatorTicketsPage extends StatefulWidget { class _OperatorTicketsPageState extends State { Set _selectedRows = {}; + + // Support data for filters + final SupportService _supportService = SupportService(ApiClient()); + List _statuses = []; + List _priorities = []; @override void initState() { super.initState(); + _loadMetadata(); + } + + Future _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 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 { 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 { 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', diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/support_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/support_page.dart index 448f177..9bf6f52 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/support_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/support_page.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/services/support_service.dart'; import 'package:hesabix_ui/models/support_models.dart'; import 'package:hesabix_ui/widgets/data_table/data_table.dart'; -import 'ticket_detail_page.dart'; +import 'package:hesabix_ui/widgets/support/ticket_details_dialog.dart'; import 'create_ticket_page.dart'; class SupportPage extends StatefulWidget { @@ -16,19 +18,37 @@ class SupportPage extends StatefulWidget { class _SupportPageState extends State { Set _selectedRows = {}; + + // Support data for filters + final SupportService _supportService = SupportService(ApiClient()); + List _statuses = []; + List _priorities = []; @override void initState() { super.initState(); + _loadMetadata(); + } + + Future _loadMetadata() async { + try { + final statuses = await _supportService.getStatuses(); + final priorities = await _supportService.getPriorities(); + + setState(() { + _statuses = statuses; + _priorities = priorities; + }); + } catch (e) { + // Handle error silently for now, filters will just be empty + } } void _navigateToCreateTicket() async { - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const CreateTicketPage(), - ), + final result = await showDialog( + context: context, + builder: (context) => const CreateTicketPage(), ); if (result == true) { @@ -38,10 +58,15 @@ class _SupportPageState extends State { void _navigateToTicketDetail(Map 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 { 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 { 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', diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/ticket_detail_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/ticket_detail_page.dart deleted file mode 100644 index 0e479a9..0000000 --- a/hesabixUI/hesabix_ui/lib/pages/profile/ticket_detail_page.dart +++ /dev/null @@ -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 createState() => _TicketDetailPageState(); -} - -class _TicketDetailPageState extends State { - final _messageController = TextEditingController(); - final SupportService _supportService = SupportService(ApiClient()); - - SupportTicket? _ticket; - List _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 _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 _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 'همین الان'; - } - } -} diff --git a/hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart b/hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart index 9654be8..9502603 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart @@ -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; } } } diff --git a/hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart index 6cfcb8c..8c3a3a2 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hesabix_ui/models/support_models.dart'; import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'ticket_status_chip.dart'; import 'priority_indicator.dart'; @@ -22,125 +23,226 @@ class TicketCard extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return Card( - margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - ticket.title, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - TicketStatusChip(status: ticket.status!), - ], + return Container( + margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: theme.colorScheme.shadow.withOpacity(0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: theme.colorScheme.outline.withOpacity(0.1), + width: 1, ), - const SizedBox(height: 8), - Text( - ticket.description, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.7), - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 12), - Row( - children: [ - if (ticket.category != null) ...[ - _buildInfoChip( - context, - Icons.category, - ticket.category!.name, - theme.colorScheme.primary, - ), - const SizedBox(width: 8), - ], - if (ticket.priority != null) ...[ - PriorityIndicator( - priority: ticket.priority!, - isSmall: true, - ), - const SizedBox(width: 8), - ], - const Spacer(), - Text( - _formatDate(ticket.createdAt), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.6), - ), - ), - ], - ), - if (showUserInfo && ticket.user != null) ...[ - const SizedBox(height: 8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with title and status Row( children: [ - Icon( - Icons.person, - size: 16, - color: theme.colorScheme.onSurface.withOpacity(0.6), - ), - const SizedBox(width: 4), - Text( - ticket.user!.displayName, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.6), - ), - ), - if (ticket.assignedOperator != null) ...[ - const SizedBox(width: 16), - Icon( - Icons.support_agent, - size: 16, - color: theme.colorScheme.onSurface.withOpacity(0.6), - ), - const SizedBox(width: 4), - Text( - ticket.assignedOperator!.displayName, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(0.6), + Expanded( + child: Text( + ticket.title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - ], + ), + const SizedBox(width: 12), + TicketStatusChip(status: ticket.status!), ], ), + + const SizedBox(height: 12), + + // Description + Text( + ticket.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.8), + height: 1.4, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 16), + // Tags and metadata row + Row( + children: [ + if (ticket.category != null) ...[ + _buildInfoChip( + context, + Icons.category_outlined, + ticket.category!.name, + theme.colorScheme.primary, + ), + const SizedBox(width: 8), + ], + if (ticket.priority != null) ...[ + PriorityIndicator( + priority: ticket.priority!, + isSmall: true, + ), + const SizedBox(width: 8), + ], + const Spacer(), + _buildTimeChip(context, ticket.createdAt), + ], + ), + + if (showUserInfo && ticket.user != null) ...[ + const SizedBox(height: 12), + _buildUserInfo(context, ticket), + ], ], - ], + ), ), ), ), ); } + Widget _buildTimeChip(BuildContext context, DateTime dateTime) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceVariant.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.access_time, + size: 12, + color: theme.colorScheme.onSurfaceVariant.withOpacity(0.7), + ), + const SizedBox(width: 4), + Text( + _formatDate(dateTime, l10n), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withOpacity(0.8), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildUserInfo(BuildContext context, SupportTicket ticket) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceVariant.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + CircleAvatar( + radius: 16, + backgroundColor: theme.colorScheme.primary.withOpacity(0.1), + child: Icon( + Icons.person, + size: 16, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.createdBy, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withOpacity(0.7), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + ticket.user!.displayName, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + if (ticket.assignedOperator != null) ...[ + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.secondary.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.support_agent, + size: 12, + color: theme.colorScheme.secondary, + ), + const SizedBox(width: 4), + Text( + ticket.assignedOperator!.displayName, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.secondary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + Widget _buildInfoChip( BuildContext context, IconData icon, String text, Color color, ) { - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(12), border: Border.all( - color: color.withOpacity(0.3), + color: color.withOpacity(0.2), width: 1, ), ), @@ -149,16 +251,16 @@ class TicketCard extends StatelessWidget { children: [ Icon( icon, - size: 12, + size: 14, color: color, ), - const SizedBox(width: 4), + const SizedBox(width: 6), Text( text, style: TextStyle( - fontSize: 11, + fontSize: 12, color: color, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, ), ), ], @@ -166,18 +268,25 @@ class TicketCard extends StatelessWidget { ); } - String _formatDate(DateTime dateTime) { + String _formatDate(DateTime dateTime, AppLocalizations l10n) { final now = DateTime.now(); final difference = now.difference(dateTime); + // If the difference is negative (future time), show just now + if (difference.isNegative) { + return l10n.justNow; + } + if (difference.inDays > 0) { - return '${difference.inDays} روز پیش'; + return l10n.daysAgo(difference.inDays.toString()); } else if (difference.inHours > 0) { - return '${difference.inHours} ساعت پیش'; + return l10n.hoursAgo(difference.inHours.toString()); } else if (difference.inMinutes > 0) { - return '${difference.inMinutes} دقیقه پیش'; + return l10n.minutesAgo(difference.inMinutes.toString()); + } else if (difference.inSeconds > 10) { + return l10n.justNow; } else { - return 'همین الان'; + return l10n.justNow; } } } diff --git a/hesabixUI/hesabix_ui/lib/widgets/support/ticket_details_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_details_dialog.dart new file mode 100644 index 0000000..fbc5668 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_details_dialog.dart @@ -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 createState() => _TicketDetailsDialogState(); +} + +class _TicketDetailsDialogState extends State { + late SupportTicket _ticket; + List _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 _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 _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(Colors.white), + ), + ) + : const Icon( + Icons.send, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +}