From aa75b9c743df8fd3c146b4a19153b5ed5cc4089c Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Sat, 20 Sep 2025 22:46:06 +0330 Subject: [PATCH] progress in profile --- hesabixAPI/adapters/api/v1/schemas.py | 25 +- .../adapters/api/v1/support/__init__.py | 1 + .../adapters/api/v1/support/categories.py | 29 + .../adapters/api/v1/support/operator.py | 292 +++++ .../adapters/api/v1/support/priorities.py | 29 + hesabixAPI/adapters/api/v1/support/schemas.py | 134 +++ .../adapters/api/v1/support/statuses.py | 29 + hesabixAPI/adapters/api/v1/support/tickets.py | 256 ++++ hesabixAPI/adapters/db/models/__init__.py | 3 + .../adapters/db/models/support/__init__.py | 8 + .../adapters/db/models/support/category.py | 23 + .../adapters/db/models/support/message.py | 35 + .../adapters/db/models/support/priority.py | 24 + .../adapters/db/models/support/status.py | 24 + .../adapters/db/models/support/ticket.py | 40 + hesabixAPI/adapters/db/models/user.py | 5 +- .../db/repositories/support/__init__.py | 1 + .../support/category_repository.py | 19 + .../support/message_repository.py | 69 ++ .../support/priority_repository.py | 18 + .../repositories/support/status_repository.py | 25 + .../repositories/support/ticket_repository.py | 167 +++ hesabixAPI/app/core/auth_dependency.py | 4 + hesabixAPI/app/core/permissions.py | 15 +- hesabixAPI/app/core/responses.py | 2 +- hesabixAPI/app/main.py | 25 +- hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 20 + hesabixAPI/migrations/script.py.mako | 24 + .../5553f8745c6e_add_support_tables.py | 132 ++ .../scripts/grant_operator_permission.py | 128 ++ hesabixAPI/scripts/seed_support_data.py | 75 ++ hesabixUI/hesabix_ui/lib/core/auth_store.dart | 2 + hesabixUI/hesabix_ui/lib/l10n/app_en.arb | 92 +- hesabixUI/hesabix_ui/lib/l10n/app_fa.arb | 92 +- .../lib/l10n/app_localizations.dart | 506 +++++++- .../lib/l10n/app_localizations_en.dart | 255 +++- .../lib/l10n/app_localizations_fa.dart | 256 +++- hesabixUI/hesabix_ui/lib/main.dart | 18 +- .../hesabix_ui/lib/models/support_models.dart | 446 +++++++ .../lib/pages/profile/create_ticket_page.dart | 345 ++++++ .../lib/pages/profile/new_business_page.dart | 1059 ++++++++++++----- .../operator/operator_ticket_detail_page.dart | 495 ++++++++ .../operator/operator_tickets_page.dart | 177 +++ .../lib/pages/profile/profile_shell.dart | 8 +- .../lib/pages/profile/support_page.dart | 165 ++- .../lib/pages/profile/ticket_detail_page.dart | 380 ++++++ .../lib/services/support_service.dart | 196 +++ .../widgets/data_table/data_table_config.dart | 4 + .../widgets/data_table/data_table_widget.dart | 6 + .../data_table/helpers/data_table_utils.dart | 15 + .../lib/widgets/support/message_bubble.dart | 187 +++ .../widgets/support/priority_indicator.dart | 93 ++ .../lib/widgets/support/ticket_card.dart | 183 +++ .../widgets/support/ticket_status_chip.dart | 84 ++ 54 files changed, 6392 insertions(+), 353 deletions(-) create mode 100644 hesabixAPI/adapters/api/v1/support/__init__.py create mode 100644 hesabixAPI/adapters/api/v1/support/categories.py create mode 100644 hesabixAPI/adapters/api/v1/support/operator.py create mode 100644 hesabixAPI/adapters/api/v1/support/priorities.py create mode 100644 hesabixAPI/adapters/api/v1/support/schemas.py create mode 100644 hesabixAPI/adapters/api/v1/support/statuses.py create mode 100644 hesabixAPI/adapters/api/v1/support/tickets.py create mode 100644 hesabixAPI/adapters/db/models/support/__init__.py create mode 100644 hesabixAPI/adapters/db/models/support/category.py create mode 100644 hesabixAPI/adapters/db/models/support/message.py create mode 100644 hesabixAPI/adapters/db/models/support/priority.py create mode 100644 hesabixAPI/adapters/db/models/support/status.py create mode 100644 hesabixAPI/adapters/db/models/support/ticket.py create mode 100644 hesabixAPI/adapters/db/repositories/support/__init__.py create mode 100644 hesabixAPI/adapters/db/repositories/support/category_repository.py create mode 100644 hesabixAPI/adapters/db/repositories/support/message_repository.py create mode 100644 hesabixAPI/adapters/db/repositories/support/priority_repository.py create mode 100644 hesabixAPI/adapters/db/repositories/support/status_repository.py create mode 100644 hesabixAPI/adapters/db/repositories/support/ticket_repository.py create mode 100644 hesabixAPI/migrations/script.py.mako create mode 100644 hesabixAPI/migrations/versions/5553f8745c6e_add_support_tables.py create mode 100644 hesabixAPI/scripts/grant_operator_permission.py create mode 100644 hesabixAPI/scripts/seed_support_data.py create mode 100644 hesabixUI/hesabix_ui/lib/models/support_models.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_ticket_detail_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_tickets_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/profile/ticket_detail_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/support_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/support/priority_indicator.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/support/ticket_status_chip.dart diff --git a/hesabixAPI/adapters/api/v1/schemas.py b/hesabixAPI/adapters/api/v1/schemas.py index dd5964b..48d74e6 100644 --- a/hesabixAPI/adapters/api/v1/schemas.py +++ b/hesabixAPI/adapters/api/v1/schemas.py @@ -1,7 +1,9 @@ -from typing import Any, List, Optional, Union +from typing import Any, List, Optional, Union, Generic, TypeVar from pydantic import BaseModel, EmailStr, Field from enum import Enum +T = TypeVar('T') + class FilterItem(BaseModel): property: str = Field(..., description="نام فیلد مورد نظر برای اعمال فیلتر") @@ -224,3 +226,24 @@ class BusinessSummaryResponse(BaseModel): by_field: dict = Field(..., description="تعداد بر اساس زمینه فعالیت") +class PaginatedResponse(BaseModel, Generic[T]): + """پاسخ صفحه‌بندی شده برای لیست‌ها""" + items: List[T] = Field(..., description="آیتم‌های صفحه") + total: int = Field(..., description="تعداد کل آیتم‌ها") + page: int = Field(..., description="شماره صفحه فعلی") + limit: int = Field(..., description="تعداد آیتم در هر صفحه") + total_pages: int = Field(..., description="تعداد کل صفحات") + + @classmethod + def create(cls, items: List[T], total: int, page: int, limit: int) -> 'PaginatedResponse[T]': + """ایجاد پاسخ صفحه‌بندی شده""" + total_pages = (total + limit - 1) // limit + return cls( + items=items, + total=total, + page=page, + limit=limit, + total_pages=total_pages + ) + + diff --git a/hesabixAPI/adapters/api/v1/support/__init__.py b/hesabixAPI/adapters/api/v1/support/__init__.py new file mode 100644 index 0000000..3c6872d --- /dev/null +++ b/hesabixAPI/adapters/api/v1/support/__init__.py @@ -0,0 +1 @@ +# Support API endpoints diff --git a/hesabixAPI/adapters/api/v1/support/categories.py b/hesabixAPI/adapters/api/v1/support/categories.py new file mode 100644 index 0000000..47d4dcb --- /dev/null +++ b/hesabixAPI/adapters/api/v1/support/categories.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import List +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.support.category_repository import CategoryRepository +from adapters.api.v1.support.schemas import CategoryResponse +from adapters.api.v1.schemas import SuccessResponse +from app.core.responses import success_response, format_datetime_fields + +router = APIRouter() + + +@router.get("", response_model=SuccessResponse) +async def get_categories( + request: Request, + db: Session = Depends(get_db) +): + """دریافت لیست دسته‌بندی‌های فعال""" + category_repo = CategoryRepository(db) + categories = category_repo.get_active_categories() + + # Convert to dict and format datetime fields + categories_data = [CategoryResponse.from_orm(category).dict() for category in categories] + formatted_data = format_datetime_fields(categories_data, request) + + return success_response(formatted_data, request) diff --git a/hesabixAPI/adapters/api/v1/support/operator.py b/hesabixAPI/adapters/api/v1/support/operator.py new file mode 100644 index 0000000..1fda7ff --- /dev/null +++ b/hesabixAPI/adapters/api/v1/support/operator.py @@ -0,0 +1,292 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status, Request, Body +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.support.ticket_repository import TicketRepository +from adapters.db.repositories.support.message_repository import MessageRepository +from adapters.api.v1.schemas import QueryInfo, PaginatedResponse, SuccessResponse +from adapters.api.v1.support.schemas import ( + CreateMessageRequest, + UpdateStatusRequest, + AssignTicketRequest, + TicketResponse, + MessageResponse +) +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_app_permission +from app.core.responses import success_response, format_datetime_fields + +router = APIRouter() + + +@router.post("/tickets/search", response_model=SuccessResponse) +@require_app_permission("support_operator") +async def search_operator_tickets( + query_info: QueryInfo = Body(...), + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + request: Request = None +): + """جستجو در تمام تیکت‌ها برای اپراتور""" + ticket_repo = TicketRepository(db) + + # تنظیم فیلدهای قابل جستجو + if not query_info.search_fields: + query_info.search_fields = ["title", "description", "user_email", "user_name"] + + tickets, total = ticket_repo.get_operator_tickets(query_info) + + # تبدیل به dict + ticket_dicts = [] + for ticket in tickets: + ticket_dict = { + "id": ticket.id, + "title": ticket.title, + "description": ticket.description, + "user_id": ticket.user_id, + "category_id": ticket.category_id, + "priority_id": ticket.priority_id, + "status_id": ticket.status_id, + "assigned_operator_id": ticket.assigned_operator_id, + "is_internal": ticket.is_internal, + "closed_at": ticket.closed_at, + "created_at": ticket.created_at, + "updated_at": ticket.updated_at, + "user": { + "id": ticket.user.id, + "first_name": ticket.user.first_name, + "last_name": ticket.user.last_name, + "email": ticket.user.email + } if ticket.user else None, + "assigned_operator": { + "id": ticket.assigned_operator.id, + "first_name": ticket.assigned_operator.first_name, + "last_name": ticket.assigned_operator.last_name, + "email": ticket.assigned_operator.email + } if ticket.assigned_operator else None, + "category": { + "id": ticket.category.id, + "name": ticket.category.name, + "description": ticket.category.description, + "is_active": ticket.category.is_active, + "created_at": ticket.category.created_at, + "updated_at": ticket.category.updated_at + } if ticket.category else None, + "priority": { + "id": ticket.priority.id, + "name": ticket.priority.name, + "description": ticket.priority.description, + "color": ticket.priority.color, + "order": ticket.priority.order, + "created_at": ticket.priority.created_at, + "updated_at": ticket.priority.updated_at + } if ticket.priority else None, + "status": { + "id": ticket.status.id, + "name": ticket.status.name, + "description": ticket.status.description, + "color": ticket.status.color, + "is_final": ticket.status.is_final, + "created_at": ticket.status.created_at, + "updated_at": ticket.status.updated_at + } if ticket.status else None + } + ticket_dicts.append(ticket_dict) + + paginated_data = PaginatedResponse.create( + items=ticket_dicts, + total=total, + page=(query_info.skip // query_info.take) + 1, + limit=query_info.take + ) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(paginated_data.dict(), request) + + return success_response(formatted_data, request) + + +@router.get("/tickets/{ticket_id}", response_model=SuccessResponse) +@require_app_permission("support_operator") +async def get_operator_ticket( + ticket_id: int, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + request: Request = None +): + """مشاهده تیکت برای اپراتور""" + ticket_repo = TicketRepository(db) + + ticket = ticket_repo.get_operator_ticket_with_details(ticket_id) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # Format datetime fields based on calendar type + ticket_data = TicketResponse.from_orm(ticket).dict() + formatted_data = format_datetime_fields(ticket_data, request) + + return success_response(formatted_data, request) + + +@router.put("/tickets/{ticket_id}/status", response_model=SuccessResponse) +@require_app_permission("support_operator") +async def update_ticket_status( + ticket_id: int, + status_request: UpdateStatusRequest, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + request: Request = None +): + """تغییر وضعیت تیکت""" + ticket_repo = TicketRepository(db) + + ticket = ticket_repo.update_ticket_status( + ticket_id=ticket_id, + status_id=status_request.status_id, + operator_id=status_request.assigned_operator_id or current_user.get_user_id() + ) + + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # دریافت تیکت با جزئیات + ticket_with_details = ticket_repo.get_operator_ticket_with_details(ticket_id) + + # Format datetime fields based on calendar type + ticket_data = TicketResponse.from_orm(ticket_with_details).dict() + formatted_data = format_datetime_fields(ticket_data, request) + + return success_response(formatted_data, request) + + +@router.post("/tickets/{ticket_id}/assign", response_model=SuccessResponse) +@require_app_permission("support_operator") +async def assign_ticket( + ticket_id: int, + assign_request: AssignTicketRequest, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + request: Request = None +): + """تخصیص تیکت به اپراتور""" + ticket_repo = TicketRepository(db) + + ticket = ticket_repo.assign_ticket(ticket_id, assign_request.operator_id) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # دریافت تیکت با جزئیات + ticket_with_details = ticket_repo.get_operator_ticket_with_details(ticket_id) + + # Format datetime fields based on calendar type + ticket_data = TicketResponse.from_orm(ticket_with_details).dict() + formatted_data = format_datetime_fields(ticket_data, request) + + return success_response(formatted_data, request) + + +@router.post("/tickets/{ticket_id}/messages", response_model=SuccessResponse) +@require_app_permission("support_operator") +async def send_operator_message( + ticket_id: int, + message_request: CreateMessageRequest, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + request: Request = None +): + """ارسال پیام اپراتور به تیکت""" + ticket_repo = TicketRepository(db) + message_repo = MessageRepository(db) + + # بررسی وجود تیکت + ticket = ticket_repo.get_operator_ticket_with_details(ticket_id) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # ایجاد پیام + message = message_repo.create_message( + ticket_id=ticket_id, + sender_id=current_user.get_user_id(), + sender_type="operator", + content=message_request.content, + is_internal=message_request.is_internal + ) + + # Format datetime fields based on calendar type + message_data = MessageResponse.from_orm(message).dict() + formatted_data = format_datetime_fields(message_data, request) + + return success_response(formatted_data, request) + + +@router.post("/tickets/{ticket_id}/messages/search", response_model=SuccessResponse) +@require_app_permission("support_operator") +async def search_operator_ticket_messages( + ticket_id: int, + query_info: QueryInfo = Body(...), + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + request: Request = None +): + """جستجو در پیام‌های تیکت برای اپراتور""" + ticket_repo = TicketRepository(db) + message_repo = MessageRepository(db) + + # بررسی وجود تیکت + ticket = ticket_repo.get_operator_ticket_with_details(ticket_id) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # تنظیم فیلدهای قابل جستجو + if not query_info.search_fields: + query_info.search_fields = ["content"] + + messages, total = message_repo.get_ticket_messages(ticket_id, query_info) + + # تبدیل به dict + message_dicts = [] + for message in messages: + message_dict = { + "id": message.id, + "ticket_id": message.ticket_id, + "sender_id": message.sender_id, + "sender_type": message.sender_type, + "content": message.content, + "is_internal": message.is_internal, + "created_at": message.created_at, + "sender": { + "id": message.sender.id, + "first_name": message.sender.first_name, + "last_name": message.sender.last_name, + "email": message.sender.email + } if message.sender else None + } + message_dicts.append(message_dict) + + paginated_data = PaginatedResponse.create( + items=message_dicts, + total=total, + page=(query_info.skip // query_info.take) + 1, + limit=query_info.take + ) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(paginated_data.dict(), request) + + return success_response(formatted_data, request) diff --git a/hesabixAPI/adapters/api/v1/support/priorities.py b/hesabixAPI/adapters/api/v1/support/priorities.py new file mode 100644 index 0000000..47442c7 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/support/priorities.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import List +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.support.priority_repository import PriorityRepository +from adapters.api.v1.support.schemas import PriorityResponse +from adapters.api.v1.schemas import SuccessResponse +from app.core.responses import success_response, format_datetime_fields + +router = APIRouter() + + +@router.get("", response_model=SuccessResponse) +async def get_priorities( + request: Request, + db: Session = Depends(get_db) +): + """دریافت لیست اولویت‌ها""" + priority_repo = PriorityRepository(db) + priorities = priority_repo.get_priorities_ordered() + + # Convert to dict and format datetime fields + priorities_data = [PriorityResponse.from_orm(priority).dict() for priority in priorities] + formatted_data = format_datetime_fields(priorities_data, request) + + return success_response(formatted_data, request) diff --git a/hesabixAPI/adapters/api/v1/support/schemas.py b/hesabixAPI/adapters/api/v1/support/schemas.py new file mode 100644 index 0000000..6cda665 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/support/schemas.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + +from adapters.db.models.support.message import SenderType +from adapters.api.v1.schemas import PaginatedResponse + + +# Base schemas +class CategoryBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = None + is_active: bool = True + + +class PriorityBase(BaseModel): + name: str = Field(..., min_length=1, max_length=50) + description: Optional[str] = None + color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') + order: int = 0 + + +class StatusBase(BaseModel): + name: str = Field(..., min_length=1, max_length=50) + description: Optional[str] = None + color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') + is_final: bool = False + + +class TicketBase(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + description: str = Field(..., min_length=1) + category_id: int + priority_id: int + + +class MessageBase(BaseModel): + content: str = Field(..., min_length=1) + is_internal: bool = False + + +# Response schemas +class CategoryResponse(CategoryBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class PriorityResponse(PriorityBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class StatusResponse(StatusBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class UserInfo(BaseModel): + id: int + first_name: Optional[str] = None + last_name: Optional[str] = None + email: Optional[str] = None + + class Config: + from_attributes = True + + +class MessageResponse(MessageBase): + id: int + ticket_id: int + sender_id: int + sender_type: SenderType + sender: Optional[UserInfo] = None + created_at: datetime + + class Config: + from_attributes = True + + +class TicketResponse(TicketBase): + id: int + user_id: int + status_id: int + assigned_operator_id: Optional[int] = None + is_internal: bool = False + closed_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + # Related objects + user: Optional[UserInfo] = None + assigned_operator: Optional[UserInfo] = None + category: Optional[CategoryResponse] = None + priority: Optional[PriorityResponse] = None + status: Optional[StatusResponse] = None + messages: Optional[List[MessageResponse]] = None + + class Config: + from_attributes = True + + +# Request schemas +class CreateTicketRequest(TicketBase): + pass + + +class CreateMessageRequest(MessageBase): + pass + + +class UpdateStatusRequest(BaseModel): + status_id: int + assigned_operator_id: Optional[int] = None + + +class AssignTicketRequest(BaseModel): + operator_id: int + + +# PaginatedResponse is now imported from adapters.api.v1.schemas diff --git a/hesabixAPI/adapters/api/v1/support/statuses.py b/hesabixAPI/adapters/api/v1/support/statuses.py new file mode 100644 index 0000000..e9a292b --- /dev/null +++ b/hesabixAPI/adapters/api/v1/support/statuses.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import List +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.support.status_repository import StatusRepository +from adapters.api.v1.support.schemas import StatusResponse +from adapters.api.v1.schemas import SuccessResponse +from app.core.responses import success_response, format_datetime_fields + +router = APIRouter() + + +@router.get("", response_model=SuccessResponse) +async def get_statuses( + request: Request, + db: Session = Depends(get_db) +): + """دریافت لیست وضعیت‌ها""" + status_repo = StatusRepository(db) + statuses = status_repo.get_all_statuses() + + # Convert to dict and format datetime fields + statuses_data = [StatusResponse.from_orm(status).dict() for status in statuses] + formatted_data = format_datetime_fields(statuses_data, request) + + return success_response(formatted_data, request) diff --git a/hesabixAPI/adapters/api/v1/support/tickets.py b/hesabixAPI/adapters/api/v1/support/tickets.py new file mode 100644 index 0000000..0670c26 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/support/tickets.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.support.ticket_repository import TicketRepository +from adapters.db.repositories.support.message_repository import MessageRepository +from adapters.api.v1.schemas import QueryInfo, PaginatedResponse, SuccessResponse +from adapters.api.v1.support.schemas import ( + CreateTicketRequest, + CreateMessageRequest, + TicketResponse, + MessageResponse +) +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.responses import success_response, format_datetime_fields + +router = APIRouter() + + +@router.post("/search", response_model=SuccessResponse) +async def search_user_tickets( + query_info: QueryInfo, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + request: Request = None +): + """جستجو در تیکت‌های کاربر""" + ticket_repo = TicketRepository(db) + + # تنظیم فیلدهای قابل جستجو + if not query_info.search_fields: + query_info.search_fields = ["title", "description"] + + tickets, total = ticket_repo.get_user_tickets(current_user.get_user_id(), query_info) + + # تبدیل به dict + ticket_dicts = [] + for ticket in tickets: + ticket_dict = { + "id": ticket.id, + "title": ticket.title, + "description": ticket.description, + "user_id": ticket.user_id, + "category_id": ticket.category_id, + "priority_id": ticket.priority_id, + "status_id": ticket.status_id, + "assigned_operator_id": ticket.assigned_operator_id, + "is_internal": ticket.is_internal, + "closed_at": ticket.closed_at, + "created_at": ticket.created_at, + "updated_at": ticket.updated_at, + "category": { + "id": ticket.category.id, + "name": ticket.category.name, + "description": ticket.category.description, + "is_active": ticket.category.is_active, + "created_at": ticket.category.created_at, + "updated_at": ticket.category.updated_at + } if ticket.category else None, + "priority": { + "id": ticket.priority.id, + "name": ticket.priority.name, + "description": ticket.priority.description, + "color": ticket.priority.color, + "order": ticket.priority.order, + "created_at": ticket.priority.created_at, + "updated_at": ticket.priority.updated_at + } if ticket.priority else None, + "status": { + "id": ticket.status.id, + "name": ticket.status.name, + "description": ticket.status.description, + "color": ticket.status.color, + "is_final": ticket.status.is_final, + "created_at": ticket.status.created_at, + "updated_at": ticket.status.updated_at + } if ticket.status else None + } + ticket_dicts.append(ticket_dict) + + paginated_data = PaginatedResponse.create( + items=ticket_dicts, + total=total, + page=(query_info.skip // query_info.take) + 1, + limit=query_info.take + ) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(paginated_data.dict(), request) + + return success_response(formatted_data, request) + + +@router.post("", response_model=SuccessResponse) +async def create_ticket( + ticket_request: CreateTicketRequest, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + request: Request = None +): + """ایجاد تیکت جدید""" + ticket_repo = TicketRepository(db) + + # ایجاد تیکت + ticket_data = { + "title": ticket_request.title, + "description": ticket_request.description, + "user_id": current_user.get_user_id(), + "category_id": ticket_request.category_id, + "priority_id": ticket_request.priority_id, + "status_id": 1, # وضعیت پیش‌فرض: باز + "is_internal": False + } + + ticket = ticket_repo.create(ticket_data) + + # ایجاد پیام اولیه + message_repo = MessageRepository(db) + message_repo.create_message( + ticket_id=ticket.id, + sender_id=current_user.get_user_id(), + sender_type="user", + content=ticket_request.description, + is_internal=False + ) + + # دریافت تیکت با جزئیات + ticket_with_details = ticket_repo.get_ticket_with_details(ticket.id, current_user.get_user_id()) + + # Format datetime fields based on calendar type + ticket_data = TicketResponse.from_orm(ticket_with_details).dict() + formatted_data = format_datetime_fields(ticket_data, request) + + return success_response(formatted_data, request) + + +@router.get("/{ticket_id}", response_model=SuccessResponse) +async def get_ticket( + ticket_id: int, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + request: Request = None +): + """مشاهده تیکت""" + ticket_repo = TicketRepository(db) + + ticket = ticket_repo.get_ticket_with_details(ticket_id, current_user.get_user_id()) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # Format datetime fields based on calendar type + ticket_data = TicketResponse.from_orm(ticket).dict() + formatted_data = format_datetime_fields(ticket_data, request) + + return success_response(formatted_data, request) + + +@router.post("/{ticket_id}/messages", response_model=SuccessResponse) +async def send_message( + ticket_id: int, + message_request: CreateMessageRequest, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + request: Request = None +): + """ارسال پیام به تیکت""" + ticket_repo = TicketRepository(db) + message_repo = MessageRepository(db) + + # بررسی وجود تیکت + ticket = ticket_repo.get_ticket_with_details(ticket_id, current_user.get_user_id()) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # ایجاد پیام + message = message_repo.create_message( + ticket_id=ticket_id, + sender_id=current_user.get_user_id(), + sender_type="user", + content=message_request.content, + is_internal=message_request.is_internal + ) + + # Format datetime fields based on calendar type + message_data = MessageResponse.from_orm(message).dict() + formatted_data = format_datetime_fields(message_data, request) + + return success_response(formatted_data, request) + + +@router.post("/{ticket_id}/messages/search", response_model=SuccessResponse) +async def search_ticket_messages( + ticket_id: int, + query_info: QueryInfo, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + request: Request = None +): + """جستجو در پیام‌های تیکت""" + ticket_repo = TicketRepository(db) + message_repo = MessageRepository(db) + + # بررسی وجود تیکت + ticket = ticket_repo.get_ticket_with_details(ticket_id, current_user.get_user_id()) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # تنظیم فیلدهای قابل جستجو + if not query_info.search_fields: + query_info.search_fields = ["content"] + + messages, total = message_repo.get_ticket_messages(ticket_id, query_info) + + # تبدیل به dict + message_dicts = [] + for message in messages: + message_dict = { + "id": message.id, + "ticket_id": message.ticket_id, + "sender_id": message.sender_id, + "sender_type": message.sender_type, + "content": message.content, + "is_internal": message.is_internal, + "created_at": message.created_at, + "sender": { + "id": message.sender.id, + "first_name": message.sender.first_name, + "last_name": message.sender.last_name, + "email": message.sender.email + } if message.sender else None + } + message_dicts.append(message_dict) + + paginated_data = PaginatedResponse.create( + items=message_dicts, + total=total, + page=(query_info.skip // query_info.take) + 1, + limit=query_info.take + ) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(paginated_data.dict(), request) + + return success_response(formatted_data, request) diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py index 51715b5..4eb887f 100644 --- a/hesabixAPI/adapters/db/models/__init__.py +++ b/hesabixAPI/adapters/db/models/__init__.py @@ -8,4 +8,7 @@ from .password_reset import PasswordReset # noqa: F401 from .business import Business # noqa: F401 from .business_permission import BusinessPermission # noqa: F401 +# Import support models +from .support import * # noqa: F401, F403 + diff --git a/hesabixAPI/adapters/db/models/support/__init__.py b/hesabixAPI/adapters/db/models/support/__init__.py new file mode 100644 index 0000000..d73b820 --- /dev/null +++ b/hesabixAPI/adapters/db/models/support/__init__.py @@ -0,0 +1,8 @@ +from adapters.db.session import Base # re-export Base for Alembic + +# Import support models to register with SQLAlchemy metadata +from .category import Category # noqa: F401 +from .priority import Priority # noqa: F401 +from .status import Status # noqa: F401 +from .ticket import Ticket # noqa: F401 +from .message import Message # noqa: F401 diff --git a/hesabixAPI/adapters/db/models/support/category.py b/hesabixAPI/adapters/db/models/support/category.py new file mode 100644 index 0000000..ee1c947 --- /dev/null +++ b/hesabixAPI/adapters/db/models/support/category.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, Boolean, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class Category(Base): + """دسته‌بندی تیکت‌های پشتیبانی""" + __tablename__ = "support_categories" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + tickets = relationship("Ticket", back_populates="category") diff --git a/hesabixAPI/adapters/db/models/support/message.py b/hesabixAPI/adapters/db/models/support/message.py new file mode 100644 index 0000000..799b09a --- /dev/null +++ b/hesabixAPI/adapters/db/models/support/message.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from sqlalchemy import String, DateTime, Integer, ForeignKey, Text, Boolean, Enum as SQLEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class SenderType(str, Enum): + """نوع فرستنده پیام""" + USER = "user" + OPERATOR = "operator" + SYSTEM = "system" + + +class Message(Base): + """پیام‌های تیکت‌های پشتیبانی""" + __tablename__ = "support_messages" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_tickets.id", ondelete="CASCADE"), nullable=False, index=True) + sender_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + sender_type: Mapped[SenderType] = mapped_column(SQLEnum(SenderType), nullable=False, index=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + is_internal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # آیا پیام داخلی است؟ + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + ticket = relationship("Ticket", back_populates="messages") + sender = relationship("User") diff --git a/hesabixAPI/adapters/db/models/support/priority.py b/hesabixAPI/adapters/db/models/support/priority.py new file mode 100644 index 0000000..cbb0e7b --- /dev/null +++ b/hesabixAPI/adapters/db/models/support/priority.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class Priority(Base): + """اولویت تیکت‌های پشتیبانی""" + __tablename__ = "support_priorities" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + color: Mapped[str | None] = mapped_column(String(7), nullable=True) # Hex color code + order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + tickets = relationship("Ticket", back_populates="priority") diff --git a/hesabixAPI/adapters/db/models/support/status.py b/hesabixAPI/adapters/db/models/support/status.py new file mode 100644 index 0000000..d216749 --- /dev/null +++ b/hesabixAPI/adapters/db/models/support/status.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, Boolean, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class Status(Base): + """وضعیت تیکت‌های پشتیبانی""" + __tablename__ = "support_statuses" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + color: Mapped[str | None] = mapped_column(String(7), nullable=True) # Hex color code + is_final: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # آیا وضعیت نهایی است؟ + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + tickets = relationship("Ticket", back_populates="status") diff --git a/hesabixAPI/adapters/db/models/support/ticket.py b/hesabixAPI/adapters/db/models/support/ticket.py new file mode 100644 index 0000000..70155e9 --- /dev/null +++ b/hesabixAPI/adapters/db/models/support/ticket.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, Integer, ForeignKey, Text, Boolean +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class Ticket(Base): + """تیکت‌های پشتیبانی""" + __tablename__ = "support_tickets" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + title: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + description: Mapped[str] = mapped_column(Text, nullable=False) + + # Foreign Keys + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + category_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_categories.id", ondelete="RESTRICT"), nullable=False, index=True) + priority_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_priorities.id", ondelete="RESTRICT"), nullable=False, index=True) + status_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_statuses.id", ondelete="RESTRICT"), nullable=False, index=True) + assigned_operator_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + # Additional fields + is_internal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # آیا تیکت داخلی است؟ + closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + user = relationship("User", foreign_keys=[user_id], back_populates="tickets") + assigned_operator = relationship("User", foreign_keys=[assigned_operator_id]) + category = relationship("Category", back_populates="tickets") + priority = relationship("Priority", back_populates="tickets") + status = relationship("Status", back_populates="tickets") + messages = relationship("Message", back_populates="ticket", cascade="all, delete-orphan") diff --git a/hesabixAPI/adapters/db/models/user.py b/hesabixAPI/adapters/db/models/user.py index 8c1082f..90dbc49 100644 --- a/hesabixAPI/adapters/db/models/user.py +++ b/hesabixAPI/adapters/db/models/user.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from sqlalchemy import String, DateTime, Boolean, Integer, ForeignKey, JSON -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from adapters.db.session import Base @@ -25,5 +25,8 @@ class User(Base): app_permissions: Mapped[dict | None] = mapped_column(JSON, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Support relationships + tickets = relationship("Ticket", foreign_keys="Ticket.user_id", back_populates="user") diff --git a/hesabixAPI/adapters/db/repositories/support/__init__.py b/hesabixAPI/adapters/db/repositories/support/__init__.py new file mode 100644 index 0000000..b496a54 --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/support/__init__.py @@ -0,0 +1 @@ +# Support repositories diff --git a/hesabixAPI/adapters/db/repositories/support/category_repository.py b/hesabixAPI/adapters/db/repositories/support/category_repository.py new file mode 100644 index 0000000..0b43003 --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/support/category_repository.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import List +from sqlalchemy.orm import Session + +from adapters.db.repositories.base_repo import BaseRepository +from adapters.db.models.support.category import Category + + +class CategoryRepository(BaseRepository[Category]): + def __init__(self, db: Session): + super().__init__(db, Category) + + def get_active_categories(self) -> List[Category]: + """دریافت دسته‌بندی‌های فعال""" + return self.db.query(Category)\ + .filter(Category.is_active == True)\ + .order_by(Category.name)\ + .all() diff --git a/hesabixAPI/adapters/db/repositories/support/message_repository.py b/hesabixAPI/adapters/db/repositories/support/message_repository.py new file mode 100644 index 0000000..73a537d --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/support/message_repository.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Optional, List +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import select, func, and_, or_ + +from adapters.db.repositories.base_repo import BaseRepository +from adapters.db.models.support.message import Message, SenderType +from adapters.api.v1.schemas import QueryInfo + + +class MessageRepository(BaseRepository[Message]): + def __init__(self, db: Session): + super().__init__(db, Message) + + def get_ticket_messages(self, ticket_id: int, query_info: QueryInfo) -> tuple[List[Message], int]: + """دریافت پیام‌های تیکت با فیلتر و صفحه‌بندی""" + query = self.db.query(Message)\ + .options(joinedload(Message.sender))\ + .filter(Message.ticket_id == ticket_id) + + # اعمال جستجو + if query_info.search and query_info.search_fields: + search_conditions = [] + for field in query_info.search_fields: + if hasattr(Message, field): + search_conditions.append(getattr(Message, field).ilike(f"%{query_info.search}%")) + if search_conditions: + query = query.filter(or_(*search_conditions)) + + # شمارش کل + total = query.count() + + # اعمال مرتب‌سازی + if query_info.sort_by and hasattr(Message, query_info.sort_by): + sort_column = getattr(Message, query_info.sort_by) + if query_info.sort_desc: + query = query.order_by(sort_column.desc()) + else: + query = query.order_by(sort_column.asc()) + else: + query = query.order_by(Message.created_at.asc()) + + # اعمال صفحه‌بندی + query = query.offset(query_info.skip).limit(query_info.take) + + return query.all(), total + + def create_message( + self, + ticket_id: int, + sender_id: int, + sender_type: SenderType, + content: str, + is_internal: bool = False + ) -> Message: + """ایجاد پیام جدید""" + message = Message( + ticket_id=ticket_id, + sender_id=sender_id, + sender_type=sender_type, + content=content, + is_internal=is_internal + ) + + self.db.add(message) + self.db.commit() + self.db.refresh(message) + return message diff --git a/hesabixAPI/adapters/db/repositories/support/priority_repository.py b/hesabixAPI/adapters/db/repositories/support/priority_repository.py new file mode 100644 index 0000000..12d7a15 --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/support/priority_repository.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import List +from sqlalchemy.orm import Session + +from adapters.db.repositories.base_repo import BaseRepository +from adapters.db.models.support.priority import Priority + + +class PriorityRepository(BaseRepository[Priority]): + def __init__(self, db: Session): + super().__init__(db, Priority) + + def get_priorities_ordered(self) -> List[Priority]: + """دریافت اولویت‌ها به ترتیب""" + return self.db.query(Priority)\ + .order_by(Priority.order, Priority.name)\ + .all() diff --git a/hesabixAPI/adapters/db/repositories/support/status_repository.py b/hesabixAPI/adapters/db/repositories/support/status_repository.py new file mode 100644 index 0000000..32e75c7 --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/support/status_repository.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import List +from sqlalchemy.orm import Session + +from adapters.db.repositories.base_repo import BaseRepository +from adapters.db.models.support.status import Status + + +class StatusRepository(BaseRepository[Status]): + def __init__(self, db: Session): + super().__init__(db, Status) + + def get_all_statuses(self) -> List[Status]: + """دریافت تمام وضعیت‌ها""" + return self.db.query(Status)\ + .order_by(Status.name)\ + .all() + + def get_final_statuses(self) -> List[Status]: + """دریافت وضعیت‌های نهایی""" + return self.db.query(Status)\ + .filter(Status.is_final == True)\ + .order_by(Status.name)\ + .all() diff --git a/hesabixAPI/adapters/db/repositories/support/ticket_repository.py b/hesabixAPI/adapters/db/repositories/support/ticket_repository.py new file mode 100644 index 0000000..215a566 --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/support/ticket_repository.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from typing import Optional, List, Dict, Any +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import select, func, and_, or_ + +from adapters.db.repositories.base_repo import BaseRepository +from adapters.db.models.support.ticket import Ticket +from adapters.db.models.support.message import Message +from adapters.api.v1.schemas import QueryInfo + + +class TicketRepository(BaseRepository[Ticket]): + def __init__(self, db: Session): + super().__init__(db, Ticket) + + def create(self, ticket_data: Dict[str, Any]) -> Ticket: + """ایجاد تیکت جدید""" + ticket = Ticket(**ticket_data) + self.db.add(ticket) + self.db.commit() + self.db.refresh(ticket) + return ticket + + def get_ticket_with_details(self, ticket_id: int, user_id: int) -> Optional[Ticket]: + """دریافت تیکت با جزئیات کامل""" + return self.db.query(Ticket)\ + .options( + joinedload(Ticket.user), + joinedload(Ticket.assigned_operator), + joinedload(Ticket.category), + joinedload(Ticket.priority), + joinedload(Ticket.status), + joinedload(Ticket.messages).joinedload(Message.sender) + )\ + .filter(Ticket.id == ticket_id, Ticket.user_id == user_id)\ + .first() + + def get_operator_ticket_with_details(self, ticket_id: int) -> Optional[Ticket]: + """دریافت تیکت برای اپراتور با جزئیات کامل""" + return self.db.query(Ticket)\ + .options( + joinedload(Ticket.user), + joinedload(Ticket.assigned_operator), + joinedload(Ticket.category), + joinedload(Ticket.priority), + joinedload(Ticket.status), + joinedload(Ticket.messages).joinedload(Message.sender) + )\ + .filter(Ticket.id == ticket_id)\ + .first() + + def get_user_tickets(self, user_id: int, query_info: QueryInfo) -> tuple[List[Ticket], int]: + """دریافت تیکت‌های کاربر با فیلتر و صفحه‌بندی""" + query = self.db.query(Ticket)\ + .options( + joinedload(Ticket.category), + joinedload(Ticket.priority), + joinedload(Ticket.status) + )\ + .filter(Ticket.user_id == user_id) + + # اعمال جستجو + if query_info.search and query_info.search_fields: + search_conditions = [] + for field in query_info.search_fields: + if hasattr(Ticket, field): + search_conditions.append(getattr(Ticket, field).ilike(f"%{query_info.search}%")) + if search_conditions: + query = query.filter(or_(*search_conditions)) + + # شمارش کل + total = query.count() + + # اعمال مرتب‌سازی + if query_info.sort_by and hasattr(Ticket, query_info.sort_by): + sort_column = getattr(Ticket, query_info.sort_by) + if query_info.sort_desc: + query = query.order_by(sort_column.desc()) + else: + query = query.order_by(sort_column.asc()) + else: + query = query.order_by(Ticket.created_at.desc()) + + # اعمال صفحه‌بندی + query = query.offset(query_info.skip).limit(query_info.take) + + return query.all(), total + + def get_operator_tickets(self, query_info: QueryInfo) -> tuple[List[Ticket], int]: + """دریافت تمام تیکت‌ها برای اپراتور با فیلتر و صفحه‌بندی""" + query = self.db.query(Ticket)\ + .options( + joinedload(Ticket.user), + joinedload(Ticket.assigned_operator), + joinedload(Ticket.category), + joinedload(Ticket.priority), + joinedload(Ticket.status) + ) + + # اعمال جستجو + if query_info.search and query_info.search_fields: + search_conditions = [] + for field in query_info.search_fields: + if hasattr(Ticket, field): + search_conditions.append(getattr(Ticket, field).ilike(f"%{query_info.search}%")) + elif field == "user_email" and hasattr(Ticket.user, "email"): + search_conditions.append(Ticket.user.email.ilike(f"%{query_info.search}%")) + elif field == "user_name": + search_conditions.append( + or_( + Ticket.user.first_name.ilike(f"%{query_info.search}%"), + Ticket.user.last_name.ilike(f"%{query_info.search}%") + ) + ) + if search_conditions: + query = query.filter(or_(*search_conditions)) + + # شمارش کل + total = query.count() + + # اعمال مرتب‌سازی + if query_info.sort_by and hasattr(Ticket, query_info.sort_by): + sort_column = getattr(Ticket, query_info.sort_by) + if query_info.sort_desc: + query = query.order_by(sort_column.desc()) + else: + query = query.order_by(sort_column.asc()) + else: + query = query.order_by(Ticket.created_at.desc()) + + # اعمال صفحه‌بندی + query = query.offset(query_info.skip).limit(query_info.take) + + return query.all(), total + + def update_ticket_status(self, ticket_id: int, status_id: int, operator_id: Optional[int] = None) -> Optional[Ticket]: + """تغییر وضعیت تیکت""" + ticket = self.get_by_id(ticket_id) + if not ticket: + return None + + ticket.status_id = status_id + if operator_id: + ticket.assigned_operator_id = operator_id + + # اگر وضعیت نهایی است، تاریخ بسته شدن را تنظیم کن + from adapters.db.models.support.status import Status + status = self.db.query(Status).filter(Status.id == status_id).first() + if status and status.is_final: + from datetime import datetime + ticket.closed_at = datetime.utcnow() + + self.db.commit() + self.db.refresh(ticket) + return ticket + + def assign_ticket(self, ticket_id: int, operator_id: int) -> Optional[Ticket]: + """تخصیص تیکت به اپراتور""" + ticket = self.get_by_id(ticket_id) + if not ticket: + return None + + ticket.assigned_operator_id = operator_id + self.db.commit() + self.db.refresh(ticket) + return ticket diff --git a/hesabixAPI/app/core/auth_dependency.py b/hesabixAPI/app/core/auth_dependency.py index a360d23..cd91eda 100644 --- a/hesabixAPI/app/core/auth_dependency.py +++ b/hesabixAPI/app/core/auth_dependency.py @@ -117,6 +117,10 @@ class AuthContext: """بررسی دسترسی به تنظیمات سیستم""" return self.has_app_permission("system_settings") + def can_access_support_operator(self) -> bool: + """بررسی دسترسی به پنل اپراتور پشتیبانی""" + return self.has_app_permission("support_operator") + def is_business_owner(self) -> bool: """بررسی اینکه آیا کاربر مالک کسب و کار است یا نه""" if not self.business_id or not self.db: diff --git a/hesabixAPI/app/core/permissions.py b/hesabixAPI/app/core/permissions.py index a9d749e..32c938d 100644 --- a/hesabixAPI/app/core/permissions.py +++ b/hesabixAPI/app/core/permissions.py @@ -12,11 +12,20 @@ def require_app_permission(permission: str): """Decorator برای بررسی دسترسی در سطح اپلیکیشن""" def decorator(func: Callable) -> Callable: @wraps(func) - def wrapper(*args, **kwargs) -> Any: - ctx = get_current_user() + async def wrapper(*args, **kwargs) -> Any: + # پیدا کردن AuthContext در kwargs + ctx = None + for key, value in kwargs.items(): + if isinstance(value, AuthContext): + ctx = value + break + + if not ctx: + raise ApiError("UNAUTHORIZED", "Authentication required", http_status=401) + if not ctx.has_app_permission(permission): raise ApiError("FORBIDDEN", f"Missing app permission: {permission}", http_status=403) - return func(*args, **kwargs) + return await func(*args, **kwargs) return wrapper return decorator diff --git a/hesabixAPI/app/core/responses.py b/hesabixAPI/app/core/responses.py index c8752fc..63aeeec 100644 --- a/hesabixAPI/app/core/responses.py +++ b/hesabixAPI/app/core/responses.py @@ -19,7 +19,7 @@ def success_response(data: Any, request: Request = None) -> dict[str, Any]: def format_datetime_fields(data: Any, request: Request) -> Any: """Recursively format datetime fields based on calendar type""" - if not hasattr(request.state, 'calendar_type'): + if not request or not hasattr(request.state, 'calendar_type'): return data calendar_type = request.state.calendar_type diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index 7835ca2..4037c65 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -7,6 +7,11 @@ from adapters.api.v1.health import router as health_router from adapters.api.v1.auth import router as auth_router from adapters.api.v1.users import router as users_router from adapters.api.v1.businesses import router as businesses_router +from adapters.api.v1.support.tickets import router as support_tickets_router +from adapters.api.v1.support.operator import router as support_operator_router +from adapters.api.v1.support.categories import router as support_categories_router +from adapters.api.v1.support.priorities import router as support_priorities_router +from adapters.api.v1.support.statuses import router as support_statuses_router from app.core.i18n import negotiate_locale, Translator from app.core.error_handlers import register_error_handlers from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig @@ -262,6 +267,13 @@ def create_app() -> FastAPI: application.include_router(auth_router, prefix=settings.api_v1_prefix) application.include_router(users_router, prefix=settings.api_v1_prefix) application.include_router(businesses_router, prefix=settings.api_v1_prefix) + + # Support endpoints + application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support") + application.include_router(support_operator_router, prefix=f"{settings.api_v1_prefix}/support/operator") + application.include_router(support_categories_router, prefix=f"{settings.api_v1_prefix}/metadata/categories") + application.include_router(support_priorities_router, prefix=f"{settings.api_v1_prefix}/metadata/priorities") + application.include_router(support_statuses_router, prefix=f"{settings.api_v1_prefix}/metadata/statuses") register_error_handlers(application) @@ -289,11 +301,10 @@ def create_app() -> FastAPI: # اضافه کردن security schemes openapi_schema["components"]["securitySchemes"] = { - "BearerAuth": { + "ApiKeyAuth": { "type": "http", - "scheme": "bearer", - "bearerFormat": "API Key", - "description": "کلید API برای احراز هویت. فرمت: Bearer sk_your_api_key_here" + "scheme": "ApiKey", + "description": "کلید API برای احراز هویت. فرمت: ApiKey sk_your_api_key_here" } } @@ -301,9 +312,9 @@ def create_app() -> FastAPI: for path, methods in openapi_schema["paths"].items(): for method, details in methods.items(): if method in ["get", "post", "put", "delete", "patch"]: - # تمام endpoint های auth و users نیاز به احراز هویت دارند - if "/auth/" in path or "/users" in path: - details["security"] = [{"BearerAuth": []}] + # تمام endpoint های auth، users و support نیاز به احراز هویت دارند + if "/auth/" in path or "/users" in path or "/support" in path: + details["security"] = [{"ApiKeyAuth": []}] application.openapi_schema = openapi_schema return application.openapi_schema diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index 28df8eb..5c9edde 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -8,6 +8,13 @@ adapters/api/v1/businesses.py adapters/api/v1/health.py adapters/api/v1/schemas.py adapters/api/v1/users.py +adapters/api/v1/support/__init__.py +adapters/api/v1/support/categories.py +adapters/api/v1/support/operator.py +adapters/api/v1/support/priorities.py +adapters/api/v1/support/schemas.py +adapters/api/v1/support/statuses.py +adapters/api/v1/support/tickets.py adapters/db/__init__.py adapters/db/session.py adapters/db/models/__init__.py @@ -17,12 +24,24 @@ adapters/db/models/business_permission.py adapters/db/models/captcha.py adapters/db/models/password_reset.py adapters/db/models/user.py +adapters/db/models/support/__init__.py +adapters/db/models/support/category.py +adapters/db/models/support/message.py +adapters/db/models/support/priority.py +adapters/db/models/support/status.py +adapters/db/models/support/ticket.py adapters/db/repositories/api_key_repo.py adapters/db/repositories/base_repo.py adapters/db/repositories/business_permission_repo.py adapters/db/repositories/business_repo.py adapters/db/repositories/password_reset_repo.py adapters/db/repositories/user_repo.py +adapters/db/repositories/support/__init__.py +adapters/db/repositories/support/category_repository.py +adapters/db/repositories/support/message_repository.py +adapters/db/repositories/support/priority_repository.py +adapters/db/repositories/support/status_repository.py +adapters/db/repositories/support/ticket_repository.py app/__init__.py app/main.py app/core/__init__.py @@ -61,6 +80,7 @@ migrations/versions/20250117_000006_add_app_permissions_to_users.py migrations/versions/20250117_000007_create_business_permissions_table.py migrations/versions/20250915_000001_init_auth_tables.py migrations/versions/20250916_000002_add_referral_fields.py +migrations/versions/5553f8745c6e_add_support_tables.py tests/__init__.py tests/test_health.py tests/test_permissions.py \ No newline at end of file diff --git a/hesabixAPI/migrations/script.py.mako b/hesabixAPI/migrations/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/hesabixAPI/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/hesabixAPI/migrations/versions/5553f8745c6e_add_support_tables.py b/hesabixAPI/migrations/versions/5553f8745c6e_add_support_tables.py new file mode 100644 index 0000000..c47f75f --- /dev/null +++ b/hesabixAPI/migrations/versions/5553f8745c6e_add_support_tables.py @@ -0,0 +1,132 @@ +"""add_support_tables + +Revision ID: 5553f8745c6e +Revises: 20250117_000007 +Create Date: 2025-09-20 14:02:19.543853 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '5553f8745c6e' +down_revision = '20250117_000007' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('support_categories', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False) + op.create_table('support_priorities', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('order', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False) + op.create_table('support_statuses', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('is_final', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False) + op.create_table('support_tickets', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('category_id', sa.Integer(), nullable=False), + sa.Column('priority_id', sa.Integer(), nullable=False), + sa.Column('status_id', sa.Integer(), nullable=False), + sa.Column('assigned_operator_id', sa.Integer(), nullable=True), + sa.Column('is_internal', sa.Boolean(), nullable=False), + sa.Column('closed_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['assigned_operator_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['category_id'], ['support_categories.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['priority_id'], ['support_priorities.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['status_id'], ['support_statuses.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_tickets_assigned_operator_id'), 'support_tickets', ['assigned_operator_id'], unique=False) + op.create_index(op.f('ix_support_tickets_category_id'), 'support_tickets', ['category_id'], unique=False) + op.create_index(op.f('ix_support_tickets_priority_id'), 'support_tickets', ['priority_id'], unique=False) + op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False) + op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False) + op.create_index(op.f('ix_support_tickets_user_id'), 'support_tickets', ['user_id'], unique=False) + op.create_table('support_messages', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('ticket_id', sa.Integer(), nullable=False), + sa.Column('sender_id', sa.Integer(), nullable=False), + sa.Column('sender_type', sa.Enum('USER', 'OPERATOR', 'SYSTEM', name='sendertype'), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('is_internal', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_messages_sender_id'), 'support_messages', ['sender_id'], unique=False) + op.create_index(op.f('ix_support_messages_sender_type'), 'support_messages', ['sender_type'], unique=False) + op.create_index(op.f('ix_support_messages_ticket_id'), 'support_messages', ['ticket_id'], unique=False) + op.alter_column('businesses', 'business_type', + existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'), + type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'), + existing_nullable=False) + op.alter_column('businesses', 'business_field', + existing_type=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'), + type_=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('businesses', 'business_field', + existing_type=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'), + type_=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'), + existing_nullable=False) + op.alter_column('businesses', 'business_type', + existing_type=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'), + type_=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'), + existing_nullable=False) + op.drop_index(op.f('ix_support_messages_ticket_id'), table_name='support_messages') + op.drop_index(op.f('ix_support_messages_sender_type'), table_name='support_messages') + op.drop_index(op.f('ix_support_messages_sender_id'), table_name='support_messages') + op.drop_table('support_messages') + op.drop_index(op.f('ix_support_tickets_user_id'), table_name='support_tickets') + op.drop_index(op.f('ix_support_tickets_title'), table_name='support_tickets') + op.drop_index(op.f('ix_support_tickets_status_id'), table_name='support_tickets') + op.drop_index(op.f('ix_support_tickets_priority_id'), table_name='support_tickets') + op.drop_index(op.f('ix_support_tickets_category_id'), table_name='support_tickets') + op.drop_index(op.f('ix_support_tickets_assigned_operator_id'), table_name='support_tickets') + op.drop_table('support_tickets') + op.drop_index(op.f('ix_support_statuses_name'), table_name='support_statuses') + op.drop_table('support_statuses') + op.drop_index(op.f('ix_support_priorities_name'), table_name='support_priorities') + op.drop_table('support_priorities') + op.drop_index(op.f('ix_support_categories_name'), table_name='support_categories') + op.drop_table('support_categories') + # ### end Alembic commands ### diff --git a/hesabixAPI/scripts/grant_operator_permission.py b/hesabixAPI/scripts/grant_operator_permission.py new file mode 100644 index 0000000..88f7a96 --- /dev/null +++ b/hesabixAPI/scripts/grant_operator_permission.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Script برای اعطای مجوز اپراتور پشتیبانی به کاربران +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy.orm import Session +from adapters.db.session import get_db +from adapters.db.models.user import User + + +def grant_operator_permission(user_email: str): + """اعطای مجوز اپراتور پشتیبانی به کاربر""" + db = next(get_db()) + + try: + # پیدا کردن کاربر + user = db.query(User).filter(User.email == user_email).first() + if not user: + print(f"❌ کاربر با ایمیل {user_email} یافت نشد") + return False + + # دریافت مجوزهای موجود + app_permissions = user.app_permissions or {} + + # اضافه کردن مجوز اپراتور + app_permissions['support_operator'] = True + + # به‌روزرسانی مجوزها + user.app_permissions = app_permissions + db.commit() + + print(f"✅ مجوز اپراتور پشتیبانی به {user_email} اعطا شد") + return True + + except Exception as e: + db.rollback() + print(f"❌ خطا در اعطای مجوز: {e}") + return False + finally: + db.close() + + +def revoke_operator_permission(user_email: str): + """لغو مجوز اپراتور پشتیبانی از کاربر""" + db = next(get_db()) + + try: + # پیدا کردن کاربر + user = db.query(User).filter(User.email == user_email).first() + if not user: + print(f"❌ کاربر با ایمیل {user_email} یافت نشد") + return False + + # دریافت مجوزهای موجود + app_permissions = user.app_permissions or {} + + # حذف مجوز اپراتور + app_permissions['support_operator'] = False + + # به‌روزرسانی مجوزها + user.app_permissions = app_permissions + db.commit() + + print(f"✅ مجوز اپراتور پشتیبانی از {user_email} لغو شد") + return True + + except Exception as e: + db.rollback() + print(f"❌ خطا در لغو مجوز: {e}") + return False + finally: + db.close() + + +def list_operators(): + """لیست اپراتورهای پشتیبانی""" + db = next(get_db()) + + try: + operators = db.query(User).filter( + User.app_permissions['support_operator'].astext == 'true' + ).all() + + if not operators: + print("هیچ اپراتور پشتیبانی یافت نشد") + return + + print("اپراتورهای پشتیبانی:") + for operator in operators: + print(f"- {operator.email} ({operator.first_name} {operator.last_name})") + + except Exception as e: + print(f"❌ خطا در دریافت لیست اپراتورها: {e}") + finally: + db.close() + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("استفاده:") + print(" python grant_operator_permission.py grant ") + print(" python grant_operator_permission.py revoke ") + print(" python grant_operator_permission.py list") + sys.exit(1) + + command = sys.argv[1] + + if command == "grant": + if len(sys.argv) < 3: + print("❌ ایمیل کاربر را وارد کنید") + sys.exit(1) + email = sys.argv[2] + grant_operator_permission(email) + elif command == "revoke": + if len(sys.argv) < 3: + print("❌ ایمیل کاربر را وارد کنید") + sys.exit(1) + email = sys.argv[2] + revoke_operator_permission(email) + elif command == "list": + list_operators() + else: + print("❌ دستور نامعتبر") + sys.exit(1) diff --git a/hesabixAPI/scripts/seed_support_data.py b/hesabixAPI/scripts/seed_support_data.py new file mode 100644 index 0000000..a0f0407 --- /dev/null +++ b/hesabixAPI/scripts/seed_support_data.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +""" +Script برای اضافه کردن داده‌های اولیه سیستم پشتیبانی +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy.orm import Session +from adapters.db.session import get_db +from adapters.db.models.support.category import Category +from adapters.db.models.support.priority import Priority +from adapters.db.models.support.status import Status + + +def seed_support_data(): + """اضافه کردن داده‌های اولیه سیستم پشتیبانی""" + db = next(get_db()) + + try: + # اضافه کردن دسته‌بندی‌ها + categories = [ + Category(name="مشکل فنی", description="مشکلات فنی و باگ‌ها", is_active=True), + Category(name="درخواست ویژگی", description="درخواست ویژگی‌های جدید", is_active=True), + Category(name="سوال", description="سوالات عمومی", is_active=True), + Category(name="شکایت", description="شکایات و انتقادات", is_active=True), + Category(name="سایر", description="سایر موارد", is_active=True), + ] + + for category in categories: + existing = db.query(Category).filter(Category.name == category.name).first() + if not existing: + db.add(category) + + # اضافه کردن اولویت‌ها + priorities = [ + Priority(name="کم", description="اولویت کم", color="#28a745", order=1), + Priority(name="متوسط", description="اولویت متوسط", color="#ffc107", order=2), + Priority(name="بالا", description="اولویت بالا", color="#fd7e14", order=3), + Priority(name="فوری", description="اولویت فوری", color="#dc3545", order=4), + ] + + for priority in priorities: + existing = db.query(Priority).filter(Priority.name == priority.name).first() + if not existing: + db.add(priority) + + # اضافه کردن وضعیت‌ها + statuses = [ + Status(name="باز", description="تیکت باز و در انتظار پاسخ", color="#007bff", is_final=False), + Status(name="در حال پیگیری", description="تیکت در حال بررسی", color="#6f42c1", is_final=False), + Status(name="در انتظار کاربر", description="در انتظار پاسخ کاربر", color="#17a2b8", is_final=False), + Status(name="بسته", description="تیکت بسته شده", color="#6c757d", is_final=True), + Status(name="حل شده", description="مشکل حل شده", color="#28a745", is_final=True), + ] + + for status in statuses: + existing = db.query(Status).filter(Status.name == status.name).first() + if not existing: + db.add(status) + + db.commit() + print("✅ داده‌های اولیه سیستم پشتیبانی با موفقیت اضافه شدند") + + except Exception as e: + db.rollback() + print(f"❌ خطا در اضافه کردن داده‌های اولیه: {e}") + raise + finally: + db.close() + + +if __name__ == "__main__": + seed_support_data() diff --git a/hesabixUI/hesabix_ui/lib/core/auth_store.dart b/hesabixUI/hesabix_ui/lib/core/auth_store.dart index 054a1f1..188b4cc 100644 --- a/hesabixUI/hesabix_ui/lib/core/auth_store.dart +++ b/hesabixUI/hesabix_ui/lib/core/auth_store.dart @@ -193,6 +193,8 @@ class AuthStore with ChangeNotifier { return _appPermissions?[permission] == true; } + + bool get canAccessSupportOperator => hasAppPermission('support_operator'); } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index 2d95e95..7b09c06 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -157,6 +157,96 @@ "contains": "contains", "starts_with": "starts with", "ends_with": "ends with", - "in_list": "in list" + "in_list": "in list", + "businessBasicInfo": "Basic Business Information", + "businessContactInfo": "Contact Information", + "businessLegalInfo": "Legal Information", + "businessGeographicInfo": "Geographic Information", + "businessConfirmation": "Confirmation", + "businessName": "Business Name", + "businessType": "Business Type", + "businessField": "Business Field", + "address": "Address", + "phone": "Phone", + "mobile": "Mobile", + "postalCode": "Postal Code", + "nationalId": "National ID", + "registrationNumber": "Registration Number", + "economicId": "Economic ID", + "country": "Country", + "province": "Province", + "city": "City", + "step": "Step", + "ofText": "of", + "previous": "Previous", + "next": "Next", + "createBusiness": "Create Business", + "confirmInfo": "Confirm Information", + "confirmInfoMessage": "Are you sure about the entered information?", + "businessCreatedSuccessfully": "Business created successfully", + "businessCreationFailed": "Failed to create business", + "pleaseFillRequiredFields": "Please fill all required fields", + "required": "required", + "example": "Example", + "phoneExample": "02112345678", + "mobileExample": "09123456789", + "nationalIdExample": "1234567890", + "company": "Company", + "shop": "Shop", + "store": "Store", + "union": "Union", + "club": "Club", + "institute": "Institute", + "individual": "Individual", + "manufacturing": "Manufacturing", + "trading": "Trading", + "service": "Service", + "other": "Other", + "support": "Support", + "newTicket": "New Ticket", + "ticketTitle": "Ticket Title", + "ticketDescription": "Problem Description", + "category": "Category", + "priority": "Priority", + "status": "Status", + "messages": "Messages", + "sendMessage": "Send Message", + "messageHint": "Type your message...", + "createTicket": "Create Ticket", + "ticketCreated": "Ticket created successfully", + "messageSent": "Message sent", + "loadingTickets": "Loading tickets...", + "noTickets": "No tickets found", + "ticketDetails": "Ticket Details", + "supportTickets": "Support Tickets", + "ticketCreatedAt": "Created At", + "ticketUpdatedAt": "Last Updated", + "ticketLoadingError": "Error loading tickets", + "ticketId": "Ticket ID", + "createdAt": "Created At", + "updatedAt": "Updated At", + "assignedTo": "Assigned To", + "low": "Low", + "medium": "Medium", + "high": "High", + "urgent": "Urgent", + "open": "Open", + "inProgress": "In Progress", + "waitingForUser": "Waiting for User", + "closed": "Closed", + "resolved": "Resolved", + "technicalIssue": "Technical Issue", + "featureRequest": "Feature Request", + "question": "Question", + "complaint": "Complaint", + "other": "Other", + "operatorPanel": "Operator Panel", + "allTickets": "All Tickets", + "assignTicket": "Assign Ticket", + "changeStatus": "Change Status", + "internalMessage": "Internal Message", + "user": "User", + "operator": "Operator", + "system": "System" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index c3a4e0e..ef983f5 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -156,6 +156,96 @@ "contains": "شامل", "starts_with": "شروع با", "ends_with": "پایان با", - "in_list": "در لیست" + "in_list": "در لیست", + "businessBasicInfo": "اطلاعات پایه کسب و کار", + "businessContactInfo": "اطلاعات تماس", + "businessLegalInfo": "اطلاعات قانونی", + "businessGeographicInfo": "اطلاعات جغرافیایی", + "businessConfirmation": "تأیید", + "businessName": "نام کسب و کار", + "businessType": "نوع کسب و کار", + "businessField": "زمینه فعالیت", + "address": "آدرس", + "phone": "تلفن ثابت", + "mobile": "موبایل", + "postalCode": "کد پستی", + "nationalId": "کد ملی", + "registrationNumber": "شماره ثبت", + "economicId": "شناسه اقتصادی", + "country": "کشور", + "province": "استان", + "city": "شهر", + "step": "مرحله", + "ofText": "از", + "previous": "قبلی", + "next": "بعدی", + "createBusiness": "ایجاد کسب و کار", + "confirmInfo": "تأیید اطلاعات", + "confirmInfoMessage": "آیا از صحت اطلاعات وارد شده اطمینان دارید؟", + "businessCreatedSuccessfully": "کسب و کار با موفقیت ایجاد شد", + "businessCreationFailed": "خطا در ایجاد کسب و کار", + "pleaseFillRequiredFields": "لطفاً تمام فیلدهای اجباری را پر کنید", + "required": "اجباری است", + "example": "مثال", + "phoneExample": "02112345678", + "mobileExample": "09123456789", + "nationalIdExample": "1234567890", + "company": "شرکت", + "shop": "مغازه", + "store": "فروشگاه", + "union": "اتحادیه", + "club": "باشگاه", + "institute": "موسسه", + "individual": "شخصی", + "manufacturing": "تولیدی", + "trading": "بازرگانی", + "service": "خدماتی", + "other": "سایر", + "support": "پشتیبانی", + "newTicket": "تیکت جدید", + "ticketTitle": "عنوان تیکت", + "ticketDescription": "شرح مشکل", + "category": "دسته‌بندی", + "priority": "اولویت", + "status": "وضعیت", + "messages": "پیام‌ها", + "sendMessage": "ارسال پیام", + "messageHint": "پیام خود را بنویسید...", + "createTicket": "ایجاد تیکت", + "ticketCreated": "تیکت با موفقیت ایجاد شد", + "messageSent": "پیام ارسال شد", + "loadingTickets": "در حال بارگذاری تیکت‌ها...", + "noTickets": "هیچ تیکتی یافت نشد", + "ticketDetails": "جزئیات تیکت", + "supportTickets": "تیکت‌های پشتیبانی", + "ticketCreatedAt": "تاریخ ایجاد", + "ticketUpdatedAt": "آخرین بروزرسانی", + "ticketLoadingError": "خطا در بارگذاری تیکت‌ها", + "ticketId": "شماره تیکت", + "createdAt": "تاریخ ایجاد", + "updatedAt": "تاریخ به‌روزرسانی", + "assignedTo": "تخصیص یافته به", + "low": "کم", + "medium": "متوسط", + "high": "بالا", + "urgent": "فوری", + "open": "باز", + "inProgress": "در حال پیگیری", + "waitingForUser": "در انتظار کاربر", + "closed": "بسته", + "resolved": "حل شده", + "technicalIssue": "مشکل فنی", + "featureRequest": "درخواست ویژگی", + "question": "سوال", + "complaint": "شکایت", + "other": "سایر", + "operatorPanel": "پنل اپراتور", + "allTickets": "تمام تیکت‌ها", + "assignTicket": "تخصیص تیکت", + "changeStatus": "تغییر وضعیت", + "internalMessage": "پیام داخلی", + "user": "کاربر", + "operator": "اپراتور", + "system": "سیستم" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index 48f50d4..fff9143 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -185,7 +185,7 @@ abstract class AppLocalizations { /// No description provided for @mobile. /// /// In en, this message translates to: - /// **'Mobile number'** + /// **'Mobile'** String get mobile; /// No description provided for @registerSuccess. @@ -955,6 +955,510 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'in list'** String get in_list; + + /// No description provided for @businessBasicInfo. + /// + /// In en, this message translates to: + /// **'Basic Business Information'** + String get businessBasicInfo; + + /// No description provided for @businessContactInfo. + /// + /// In en, this message translates to: + /// **'Contact Information'** + String get businessContactInfo; + + /// No description provided for @businessLegalInfo. + /// + /// In en, this message translates to: + /// **'Legal Information'** + String get businessLegalInfo; + + /// No description provided for @businessGeographicInfo. + /// + /// In en, this message translates to: + /// **'Geographic Information'** + String get businessGeographicInfo; + + /// No description provided for @businessConfirmation. + /// + /// In en, this message translates to: + /// **'Confirmation'** + String get businessConfirmation; + + /// No description provided for @businessName. + /// + /// In en, this message translates to: + /// **'Business Name'** + String get businessName; + + /// No description provided for @businessType. + /// + /// In en, this message translates to: + /// **'Business Type'** + String get businessType; + + /// No description provided for @businessField. + /// + /// In en, this message translates to: + /// **'Business Field'** + String get businessField; + + /// No description provided for @address. + /// + /// In en, this message translates to: + /// **'Address'** + String get address; + + /// No description provided for @phone. + /// + /// In en, this message translates to: + /// **'Phone'** + String get phone; + + /// No description provided for @postalCode. + /// + /// In en, this message translates to: + /// **'Postal Code'** + String get postalCode; + + /// No description provided for @nationalId. + /// + /// In en, this message translates to: + /// **'National ID'** + String get nationalId; + + /// No description provided for @registrationNumber. + /// + /// In en, this message translates to: + /// **'Registration Number'** + String get registrationNumber; + + /// No description provided for @economicId. + /// + /// In en, this message translates to: + /// **'Economic ID'** + String get economicId; + + /// No description provided for @country. + /// + /// In en, this message translates to: + /// **'Country'** + String get country; + + /// No description provided for @province. + /// + /// In en, this message translates to: + /// **'Province'** + String get province; + + /// No description provided for @city. + /// + /// In en, this message translates to: + /// **'City'** + String get city; + + /// No description provided for @step. + /// + /// In en, this message translates to: + /// **'Step'** + String get step; + + /// No description provided for @previous. + /// + /// In en, this message translates to: + /// **'Previous'** + String get previous; + + /// No description provided for @next. + /// + /// In en, this message translates to: + /// **'Next'** + String get next; + + /// No description provided for @createBusiness. + /// + /// In en, this message translates to: + /// **'Create Business'** + String get createBusiness; + + /// No description provided for @confirmInfo. + /// + /// In en, this message translates to: + /// **'Confirm Information'** + String get confirmInfo; + + /// No description provided for @confirmInfoMessage. + /// + /// In en, this message translates to: + /// **'Are you sure about the entered information?'** + String get confirmInfoMessage; + + /// No description provided for @businessCreatedSuccessfully. + /// + /// In en, this message translates to: + /// **'Business created successfully'** + String get businessCreatedSuccessfully; + + /// No description provided for @businessCreationFailed. + /// + /// In en, this message translates to: + /// **'Failed to create business'** + String get businessCreationFailed; + + /// No description provided for @pleaseFillRequiredFields. + /// + /// In en, this message translates to: + /// **'Please fill all required fields'** + String get pleaseFillRequiredFields; + + /// No description provided for @required. + /// + /// In en, this message translates to: + /// **'required'** + String get required; + + /// No description provided for @example. + /// + /// In en, this message translates to: + /// **'Example'** + String get example; + + /// No description provided for @phoneExample. + /// + /// In en, this message translates to: + /// **'02112345678'** + String get phoneExample; + + /// No description provided for @mobileExample. + /// + /// In en, this message translates to: + /// **'09123456789'** + String get mobileExample; + + /// No description provided for @nationalIdExample. + /// + /// In en, this message translates to: + /// **'1234567890'** + String get nationalIdExample; + + /// No description provided for @company. + /// + /// In en, this message translates to: + /// **'Company'** + String get company; + + /// No description provided for @shop. + /// + /// In en, this message translates to: + /// **'Shop'** + String get shop; + + /// No description provided for @store. + /// + /// In en, this message translates to: + /// **'Store'** + String get store; + + /// No description provided for @union. + /// + /// In en, this message translates to: + /// **'Union'** + String get union; + + /// No description provided for @club. + /// + /// In en, this message translates to: + /// **'Club'** + String get club; + + /// No description provided for @institute. + /// + /// In en, this message translates to: + /// **'Institute'** + String get institute; + + /// No description provided for @individual. + /// + /// In en, this message translates to: + /// **'Individual'** + String get individual; + + /// No description provided for @manufacturing. + /// + /// In en, this message translates to: + /// **'Manufacturing'** + String get manufacturing; + + /// No description provided for @trading. + /// + /// In en, this message translates to: + /// **'Trading'** + String get trading; + + /// No description provided for @service. + /// + /// In en, this message translates to: + /// **'Service'** + String get service; + + /// No description provided for @other. + /// + /// In en, this message translates to: + /// **'Other'** + String get other; + + /// No description provided for @newTicket. + /// + /// In en, this message translates to: + /// **'New Ticket'** + String get newTicket; + + /// No description provided for @ticketTitle. + /// + /// In en, this message translates to: + /// **'Ticket Title'** + String get ticketTitle; + + /// No description provided for @ticketDescription. + /// + /// In en, this message translates to: + /// **'Problem Description'** + String get ticketDescription; + + /// No description provided for @category. + /// + /// In en, this message translates to: + /// **'Category'** + String get category; + + /// No description provided for @priority. + /// + /// In en, this message translates to: + /// **'Priority'** + String get priority; + + /// No description provided for @status. + /// + /// In en, this message translates to: + /// **'Status'** + String get status; + + /// No description provided for @messages. + /// + /// In en, this message translates to: + /// **'Messages'** + String get messages; + + /// No description provided for @sendMessage. + /// + /// In en, this message translates to: + /// **'Send Message'** + String get sendMessage; + + /// No description provided for @messageHint. + /// + /// In en, this message translates to: + /// **'Type your message...'** + String get messageHint; + + /// No description provided for @createTicket. + /// + /// In en, this message translates to: + /// **'Create Ticket'** + String get createTicket; + + /// No description provided for @ticketCreated. + /// + /// In en, this message translates to: + /// **'Ticket created successfully'** + String get ticketCreated; + + /// No description provided for @messageSent. + /// + /// In en, this message translates to: + /// **'Message sent'** + String get messageSent; + + /// No description provided for @loadingTickets. + /// + /// In en, this message translates to: + /// **'Loading tickets...'** + String get loadingTickets; + + /// No description provided for @noTickets. + /// + /// In en, this message translates to: + /// **'No tickets found'** + String get noTickets; + + /// No description provided for @ticketDetails. + /// + /// In en, this message translates to: + /// **'Ticket Details'** + String get ticketDetails; + + /// No description provided for @supportTickets. + /// + /// In en, this message translates to: + /// **'Support Tickets'** + String get supportTickets; + + /// No description provided for @ticketCreatedAt. + /// + /// In en, this message translates to: + /// **'Created At'** + String get ticketCreatedAt; + + /// No description provided for @ticketUpdatedAt. + /// + /// In en, this message translates to: + /// **'Last Updated'** + String get ticketUpdatedAt; + + /// No description provided for @ticketLoadingError. + /// + /// In en, this message translates to: + /// **'Error loading tickets'** + String get ticketLoadingError; + + /// No description provided for @ticketId. + /// + /// In en, this message translates to: + /// **'Ticket ID'** + String get ticketId; + + /// No description provided for @createdAt. + /// + /// In en, this message translates to: + /// **'Created At'** + String get createdAt; + + /// No description provided for @updatedAt. + /// + /// In en, this message translates to: + /// **'Updated At'** + String get updatedAt; + + /// No description provided for @assignedTo. + /// + /// In en, this message translates to: + /// **'Assigned To'** + String get assignedTo; + + /// No description provided for @low. + /// + /// In en, this message translates to: + /// **'Low'** + String get low; + + /// No description provided for @medium. + /// + /// In en, this message translates to: + /// **'Medium'** + String get medium; + + /// No description provided for @high. + /// + /// In en, this message translates to: + /// **'High'** + String get high; + + /// No description provided for @urgent. + /// + /// In en, this message translates to: + /// **'Urgent'** + String get urgent; + + /// No description provided for @open. + /// + /// In en, this message translates to: + /// **'Open'** + String get open; + + /// No description provided for @inProgress. + /// + /// In en, this message translates to: + /// **'In Progress'** + String get inProgress; + + /// No description provided for @waitingForUser. + /// + /// In en, this message translates to: + /// **'Waiting for User'** + String get waitingForUser; + + /// No description provided for @closed. + /// + /// In en, this message translates to: + /// **'Closed'** + String get closed; + + /// No description provided for @resolved. + /// + /// In en, this message translates to: + /// **'Resolved'** + String get resolved; + + /// No description provided for @technicalIssue. + /// + /// In en, this message translates to: + /// **'Technical Issue'** + String get technicalIssue; + + /// No description provided for @featureRequest. + /// + /// In en, this message translates to: + /// **'Feature Request'** + String get featureRequest; + + /// No description provided for @question. + /// + /// In en, this message translates to: + /// **'Question'** + String get question; + + /// No description provided for @complaint. + /// + /// In en, this message translates to: + /// **'Complaint'** + String get complaint; + + /// No description provided for @operatorPanel. + /// + /// In en, this message translates to: + /// **'Operator Panel'** + String get operatorPanel; + + /// No description provided for @allTickets. + /// + /// In en, this message translates to: + /// **'All Tickets'** + String get allTickets; + + /// No description provided for @assignTicket. + /// + /// In en, this message translates to: + /// **'Assign Ticket'** + String get assignTicket; + + /// No description provided for @changeStatus. + /// + /// In en, this message translates to: + /// **'Change Status'** + String get changeStatus; + + /// No description provided for @internalMessage. + /// + /// In en, this message translates to: + /// **'Internal Message'** + String get internalMessage; + + /// No description provided for @operator. + /// + /// In en, this message translates to: + /// **'Operator'** + String get operator; } 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 403c84a..56952d8 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -51,7 +51,7 @@ class AppLocalizationsEn extends AppLocalizations { String get email => 'Email'; @override - String get mobile => 'Mobile number'; + String get mobile => 'Mobile'; @override String get registerSuccess => 'Registration successful.'; @@ -449,4 +449,257 @@ class AppLocalizationsEn extends AppLocalizations { @override String get in_list => 'in list'; + + @override + String get businessBasicInfo => 'Basic Business Information'; + + @override + String get businessContactInfo => 'Contact Information'; + + @override + String get businessLegalInfo => 'Legal Information'; + + @override + String get businessGeographicInfo => 'Geographic Information'; + + @override + String get businessConfirmation => 'Confirmation'; + + @override + String get businessName => 'Business Name'; + + @override + String get businessType => 'Business Type'; + + @override + String get businessField => 'Business Field'; + + @override + String get address => 'Address'; + + @override + String get phone => 'Phone'; + + @override + String get postalCode => 'Postal Code'; + + @override + String get nationalId => 'National ID'; + + @override + String get registrationNumber => 'Registration Number'; + + @override + String get economicId => 'Economic ID'; + + @override + String get country => 'Country'; + + @override + String get province => 'Province'; + + @override + String get city => 'City'; + + @override + String get step => 'Step'; + + @override + String get previous => 'Previous'; + + @override + String get next => 'Next'; + + @override + String get createBusiness => 'Create Business'; + + @override + String get confirmInfo => 'Confirm Information'; + + @override + String get confirmInfoMessage => + 'Are you sure about the entered information?'; + + @override + String get businessCreatedSuccessfully => 'Business created successfully'; + + @override + String get businessCreationFailed => 'Failed to create business'; + + @override + String get pleaseFillRequiredFields => 'Please fill all required fields'; + + @override + String get required => 'required'; + + @override + String get example => 'Example'; + + @override + String get phoneExample => '02112345678'; + + @override + String get mobileExample => '09123456789'; + + @override + String get nationalIdExample => '1234567890'; + + @override + String get company => 'Company'; + + @override + String get shop => 'Shop'; + + @override + String get store => 'Store'; + + @override + String get union => 'Union'; + + @override + String get club => 'Club'; + + @override + String get institute => 'Institute'; + + @override + String get individual => 'Individual'; + + @override + String get manufacturing => 'Manufacturing'; + + @override + String get trading => 'Trading'; + + @override + String get service => 'Service'; + + @override + String get other => 'Other'; + + @override + String get newTicket => 'New Ticket'; + + @override + String get ticketTitle => 'Ticket Title'; + + @override + String get ticketDescription => 'Problem Description'; + + @override + String get category => 'Category'; + + @override + String get priority => 'Priority'; + + @override + String get status => 'Status'; + + @override + String get messages => 'Messages'; + + @override + String get sendMessage => 'Send Message'; + + @override + String get messageHint => 'Type your message...'; + + @override + String get createTicket => 'Create Ticket'; + + @override + String get ticketCreated => 'Ticket created successfully'; + + @override + String get messageSent => 'Message sent'; + + @override + String get loadingTickets => 'Loading tickets...'; + + @override + String get noTickets => 'No tickets found'; + + @override + String get ticketDetails => 'Ticket Details'; + + @override + String get supportTickets => 'Support Tickets'; + + @override + String get ticketCreatedAt => 'Created At'; + + @override + String get ticketUpdatedAt => 'Last Updated'; + + @override + String get ticketLoadingError => 'Error loading tickets'; + + @override + String get ticketId => 'Ticket ID'; + + @override + String get createdAt => 'Created At'; + + @override + String get updatedAt => 'Updated At'; + + @override + String get assignedTo => 'Assigned To'; + + @override + String get low => 'Low'; + + @override + String get medium => 'Medium'; + + @override + String get high => 'High'; + + @override + String get urgent => 'Urgent'; + + @override + String get open => 'Open'; + + @override + String get inProgress => 'In Progress'; + + @override + String get waitingForUser => 'Waiting for User'; + + @override + String get closed => 'Closed'; + + @override + String get resolved => 'Resolved'; + + @override + String get technicalIssue => 'Technical Issue'; + + @override + String get featureRequest => 'Feature Request'; + + @override + String get question => 'Question'; + + @override + String get complaint => 'Complaint'; + + @override + String get operatorPanel => 'Operator Panel'; + + @override + String get allTickets => 'All Tickets'; + + @override + String get assignTicket => 'Assign Ticket'; + + @override + String get changeStatus => 'Change Status'; + + @override + String get internalMessage => 'Internal Message'; + + @override + String get operator => 'Operator'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index feb0d78..ae69310 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -51,7 +51,7 @@ class AppLocalizationsFa extends AppLocalizations { String get email => 'ایمیل'; @override - String get mobile => 'شماره موبایل'; + String get mobile => 'موبایل'; @override String get registerSuccess => 'عضویت با موفقیت انجام شد.'; @@ -66,7 +66,7 @@ class AppLocalizationsFa extends AppLocalizations { String get theme => 'تم'; @override - String get system => 'سیستمی'; + String get system => 'سیستم'; @override String get light => 'روشن'; @@ -448,4 +448,256 @@ class AppLocalizationsFa extends AppLocalizations { @override String get in_list => 'در لیست'; + + @override + String get businessBasicInfo => 'اطلاعات پایه کسب و کار'; + + @override + String get businessContactInfo => 'اطلاعات تماس'; + + @override + String get businessLegalInfo => 'اطلاعات قانونی'; + + @override + String get businessGeographicInfo => 'اطلاعات جغرافیایی'; + + @override + String get businessConfirmation => 'تأیید'; + + @override + String get businessName => 'نام کسب و کار'; + + @override + String get businessType => 'نوع کسب و کار'; + + @override + String get businessField => 'زمینه فعالیت'; + + @override + String get address => 'آدرس'; + + @override + String get phone => 'تلفن ثابت'; + + @override + String get postalCode => 'کد پستی'; + + @override + String get nationalId => 'کد ملی'; + + @override + String get registrationNumber => 'شماره ثبت'; + + @override + String get economicId => 'شناسه اقتصادی'; + + @override + String get country => 'کشور'; + + @override + String get province => 'استان'; + + @override + String get city => 'شهر'; + + @override + String get step => 'مرحله'; + + @override + String get previous => 'قبلی'; + + @override + String get next => 'بعدی'; + + @override + String get createBusiness => 'ایجاد کسب و کار'; + + @override + String get confirmInfo => 'تأیید اطلاعات'; + + @override + String get confirmInfoMessage => 'آیا از صحت اطلاعات وارد شده اطمینان دارید؟'; + + @override + String get businessCreatedSuccessfully => 'کسب و کار با موفقیت ایجاد شد'; + + @override + String get businessCreationFailed => 'خطا در ایجاد کسب و کار'; + + @override + String get pleaseFillRequiredFields => 'لطفاً تمام فیلدهای اجباری را پر کنید'; + + @override + String get required => 'اجباری است'; + + @override + String get example => 'مثال'; + + @override + String get phoneExample => '02112345678'; + + @override + String get mobileExample => '09123456789'; + + @override + String get nationalIdExample => '1234567890'; + + @override + String get company => 'شرکت'; + + @override + String get shop => 'مغازه'; + + @override + String get store => 'فروشگاه'; + + @override + String get union => 'اتحادیه'; + + @override + String get club => 'باشگاه'; + + @override + String get institute => 'موسسه'; + + @override + String get individual => 'شخصی'; + + @override + String get manufacturing => 'تولیدی'; + + @override + String get trading => 'بازرگانی'; + + @override + String get service => 'خدماتی'; + + @override + String get other => 'سایر'; + + @override + String get newTicket => 'تیکت جدید'; + + @override + String get ticketTitle => 'عنوان تیکت'; + + @override + String get ticketDescription => 'شرح مشکل'; + + @override + String get category => 'دسته‌بندی'; + + @override + String get priority => 'اولویت'; + + @override + String get status => 'وضعیت'; + + @override + String get messages => 'پیام‌ها'; + + @override + String get sendMessage => 'ارسال پیام'; + + @override + String get messageHint => 'پیام خود را بنویسید...'; + + @override + String get createTicket => 'ایجاد تیکت'; + + @override + String get ticketCreated => 'تیکت با موفقیت ایجاد شد'; + + @override + String get messageSent => 'پیام ارسال شد'; + + @override + String get loadingTickets => 'در حال بارگذاری تیکت‌ها...'; + + @override + String get noTickets => 'هیچ تیکتی یافت نشد'; + + @override + String get ticketDetails => 'جزئیات تیکت'; + + @override + String get supportTickets => 'تیکت‌های پشتیبانی'; + + @override + String get ticketCreatedAt => 'تاریخ ایجاد'; + + @override + String get ticketUpdatedAt => 'آخرین بروزرسانی'; + + @override + String get ticketLoadingError => 'خطا در بارگذاری تیکت‌ها'; + + @override + String get ticketId => 'شماره تیکت'; + + @override + String get createdAt => 'تاریخ ایجاد'; + + @override + String get updatedAt => 'تاریخ به‌روزرسانی'; + + @override + String get assignedTo => 'تخصیص یافته به'; + + @override + String get low => 'کم'; + + @override + String get medium => 'متوسط'; + + @override + String get high => 'بالا'; + + @override + String get urgent => 'فوری'; + + @override + String get open => 'باز'; + + @override + String get inProgress => 'در حال پیگیری'; + + @override + String get waitingForUser => 'در انتظار کاربر'; + + @override + String get closed => 'بسته'; + + @override + String get resolved => 'حل شده'; + + @override + String get technicalIssue => 'مشکل فنی'; + + @override + String get featureRequest => 'درخواست ویژگی'; + + @override + String get question => 'سوال'; + + @override + String get complaint => 'شکایت'; + + @override + String get operatorPanel => 'پنل اپراتور'; + + @override + String get allTickets => 'تمام تیکت‌ها'; + + @override + String get assignTicket => 'تخصیص تیکت'; + + @override + String get changeStatus => 'تغییر وضعیت'; + + @override + String get internalMessage => 'پیام داخلی'; + + @override + String get operator => 'اپراتور'; } diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index f1382d9..d5770ef 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -11,6 +11,7 @@ import 'pages/profile/businesses_page.dart'; import 'pages/profile/support_page.dart'; import 'pages/profile/change_password_page.dart'; import 'pages/profile/marketing_page.dart'; +import 'pages/profile/operator/operator_tickets_page.dart'; import 'pages/system_settings_page.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'core/locale_controller.dart'; @@ -344,7 +345,7 @@ class _MyAppState extends State { GoRoute( path: '/user/profile/support', name: 'profile_support', - builder: (context, state) => const SupportPage(), + builder: (context, state) => SupportPage(calendarController: _calendarController), ), GoRoute( path: '/user/profile/marketing', @@ -356,6 +357,21 @@ class _MyAppState extends State { name: 'profile_change_password', builder: (context, state) => const ChangePasswordPage(), ), + GoRoute( + path: '/user/profile/operator', + name: 'profile_operator', + builder: (context, state) { + // بررسی دسترسی اپراتور پشتیبانی + if (_authStore == null) { + return PermissionGuard.buildAccessDeniedPage(); + } + + if (!_authStore!.canAccessSupportOperator) { + return PermissionGuard.buildAccessDeniedPage(); + } + return OperatorTicketsPage(calendarController: _calendarController); + }, + ), GoRoute( path: '/user/profile/system-settings', name: 'profile_system_settings', diff --git a/hesabixUI/hesabix_ui/lib/models/support_models.dart b/hesabixUI/hesabix_ui/lib/models/support_models.dart new file mode 100644 index 0000000..10f157c --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/support_models.dart @@ -0,0 +1,446 @@ +class SupportCategory { + final int id; + final String name; + final String? description; + final bool isActive; + final DateTime createdAt; + final DateTime updatedAt; + + SupportCategory({ + required this.id, + required this.name, + this.description, + required this.isActive, + required this.createdAt, + required this.updatedAt, + }); + + factory SupportCategory.fromJson(Map json) { + return SupportCategory( + id: json['id'], + name: json['name'], + description: json['description'], + isActive: json['is_active'], + createdAt: _parseDateTime(json['created_at']), + updatedAt: _parseDateTime(json['updated_at']), + ); + } + + static DateTime _parseDateTime(dynamic dateTime) { + if (dateTime is String) { + try { + return DateTime.parse(dateTime); + } catch (e) { + // If parsing fails, return current time + return DateTime.now(); + } + } else if (dateTime is Map) { + // Handle formatted date from backend + final formatted = dateTime['formatted'] as String?; + if (formatted != null) { + try { + return DateTime.parse(formatted); + } catch (e) { + return DateTime.now(); + } + } + } + return DateTime.now(); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'is_active': isActive, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } +} + +class SupportPriority { + final int id; + final String name; + final String? description; + final String? color; + final int order; + final DateTime createdAt; + final DateTime updatedAt; + + SupportPriority({ + required this.id, + required this.name, + this.description, + this.color, + required this.order, + required this.createdAt, + required this.updatedAt, + }); + + factory SupportPriority.fromJson(Map json) { + return SupportPriority( + id: json['id'], + name: json['name'], + description: json['description'], + color: json['color'], + order: json['order'], + createdAt: SupportCategory._parseDateTime(json['created_at']), + updatedAt: SupportCategory._parseDateTime(json['updated_at']), + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'color': color, + 'order': order, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } +} + +class SupportStatus { + final int id; + final String name; + final String? description; + final String? color; + final bool isFinal; + final DateTime createdAt; + final DateTime updatedAt; + + SupportStatus({ + required this.id, + required this.name, + this.description, + this.color, + required this.isFinal, + required this.createdAt, + required this.updatedAt, + }); + + factory SupportStatus.fromJson(Map json) { + return SupportStatus( + id: json['id'], + name: json['name'], + description: json['description'], + color: json['color'], + isFinal: json['is_final'], + createdAt: SupportCategory._parseDateTime(json['created_at']), + updatedAt: SupportCategory._parseDateTime(json['updated_at']), + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'color': color, + 'is_final': isFinal, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } +} + +class SupportUser { + final int id; + final String? firstName; + final String? lastName; + final String? email; + + SupportUser({ + required this.id, + this.firstName, + this.lastName, + this.email, + }); + + factory SupportUser.fromJson(Map json) { + return SupportUser( + id: json['id'], + firstName: json['first_name'], + lastName: json['last_name'], + email: json['email'], + ); + } + + Map toJson() { + return { + 'id': id, + 'first_name': firstName, + 'last_name': lastName, + 'email': email, + }; + } + + String get displayName { + if (firstName != null && lastName != null) { + return '$firstName $lastName'; + } else if (firstName != null) { + return firstName!; + } else if (lastName != null) { + return lastName!; + } else if (email != null) { + return email!; + } + return 'کاربر $id'; + } +} + +class SupportMessage { + final int id; + final int ticketId; + final int senderId; + final String senderType; // 'user', 'operator', 'system' + final String content; + final bool isInternal; + final DateTime createdAt; + final SupportUser? sender; + + SupportMessage({ + required this.id, + required this.ticketId, + required this.senderId, + required this.senderType, + required this.content, + required this.isInternal, + required this.createdAt, + this.sender, + }); + + factory SupportMessage.fromJson(Map json) { + return SupportMessage( + id: json['id'], + ticketId: json['ticket_id'], + senderId: json['sender_id'], + senderType: json['sender_type'], + content: json['content'], + isInternal: json['is_internal'], + createdAt: SupportCategory._parseDateTime(json['created_at']), + sender: json['sender'] != null ? SupportUser.fromJson(json['sender']) : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'ticket_id': ticketId, + 'sender_id': senderId, + 'sender_type': senderType, + 'content': content, + 'is_internal': isInternal, + 'created_at': createdAt.toIso8601String(), + 'sender': sender?.toJson(), + }; + } + + bool get isFromUser => senderType == 'user'; + bool get isFromOperator => senderType == 'operator'; + bool get isFromSystem => senderType == 'system'; +} + +class SupportTicket { + final int id; + final String title; + final String description; + final int userId; + final int categoryId; + final int priorityId; + final int statusId; + final int? assignedOperatorId; + final bool isInternal; + final DateTime? closedAt; + final DateTime createdAt; + final DateTime updatedAt; + + // Related objects + final SupportUser? user; + final SupportUser? assignedOperator; + final SupportCategory? category; + final SupportPriority? priority; + final SupportStatus? status; + final List? messages; + + SupportTicket({ + required this.id, + required this.title, + required this.description, + required this.userId, + required this.categoryId, + required this.priorityId, + required this.statusId, + this.assignedOperatorId, + required this.isInternal, + this.closedAt, + required this.createdAt, + required this.updatedAt, + this.user, + this.assignedOperator, + this.category, + this.priority, + this.status, + this.messages, + }); + + factory SupportTicket.fromJson(Map json) { + return SupportTicket( + id: json['id'], + title: json['title'], + description: json['description'], + userId: json['user_id'], + categoryId: json['category_id'], + priorityId: json['priority_id'], + statusId: json['status_id'], + assignedOperatorId: json['assigned_operator_id'], + isInternal: json['is_internal'], + closedAt: json['closed_at'] != null ? SupportCategory._parseDateTime(json['closed_at']) : null, + createdAt: SupportCategory._parseDateTime(json['created_at']), + updatedAt: SupportCategory._parseDateTime(json['updated_at']), + user: json['user'] != null ? SupportUser.fromJson(json['user']) : null, + assignedOperator: json['assigned_operator'] != null ? SupportUser.fromJson(json['assigned_operator']) : null, + category: json['category'] != null ? SupportCategory.fromJson(json['category']) : null, + priority: json['priority'] != null ? SupportPriority.fromJson(json['priority']) : null, + status: json['status'] != null ? SupportStatus.fromJson(json['status']) : null, + messages: json['messages'] != null + ? (json['messages'] as List).map((m) => SupportMessage.fromJson(m)).toList() + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'user_id': userId, + 'category_id': categoryId, + 'priority_id': priorityId, + 'status_id': statusId, + 'assigned_operator_id': assignedOperatorId, + 'is_internal': isInternal, + 'closed_at': closedAt?.toIso8601String(), + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + 'user': user?.toJson(), + 'assigned_operator': assignedOperator?.toJson(), + 'category': category?.toJson(), + 'priority': priority?.toJson(), + 'status': status?.toJson(), + 'messages': messages?.map((m) => m.toJson()).toList(), + }; + } + + bool get isOpen => statusId == 1; // وضعیت "باز" + bool get isInProgress => statusId == 2; // وضعیت "در حال پیگیری" + bool get isWaitingForUser => statusId == 3; // وضعیت "در انتظار کاربر" + bool get isClosed => statusId == 4; // وضعیت "بسته" + bool get isResolved => statusId == 5; // وضعیت "حل شده" +} + +// Request models +class CreateTicketRequest { + final String title; + final String description; + final int categoryId; + final int priorityId; + + CreateTicketRequest({ + required this.title, + required this.description, + required this.categoryId, + required this.priorityId, + }); + + Map toJson() { + return { + 'title': title, + 'description': description, + 'category_id': categoryId, + 'priority_id': priorityId, + }; + } +} + +class CreateMessageRequest { + final String content; + final bool isInternal; + + CreateMessageRequest({ + required this.content, + this.isInternal = false, + }); + + Map toJson() { + return { + 'content': content, + 'is_internal': isInternal, + }; + } +} + +class UpdateStatusRequest { + final int statusId; + final int? assignedOperatorId; + + UpdateStatusRequest({ + required this.statusId, + this.assignedOperatorId, + }); + + Map toJson() { + return { + 'status_id': statusId, + 'assigned_operator_id': assignedOperatorId, + }; + } +} + +class AssignTicketRequest { + final int operatorId; + + AssignTicketRequest({ + required this.operatorId, + }); + + Map toJson() { + return { + 'operator_id': operatorId, + }; + } +} + +// Pagination response +class PaginatedResponse { + final List items; + final int total; + final int page; + final int limit; + final int totalPages; + + PaginatedResponse({ + required this.items, + required this.total, + required this.page, + required this.limit, + required this.totalPages, + }); + + factory PaginatedResponse.fromJson( + Map json, + T Function(Map) fromJsonT, + ) { + return PaginatedResponse( + items: (json['items'] as List).map((item) => fromJsonT(item)).toList(), + total: json['total'], + page: json['page'], + limit: json['limit'], + totalPages: json['total_pages'], + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart new file mode 100644 index 0000000..66bdd03 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart @@ -0,0 +1,345 @@ +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'; + +class CreateTicketPage extends StatefulWidget { + const CreateTicketPage({super.key}); + + @override + State createState() => _CreateTicketPageState(); +} + +class _CreateTicketPageState extends State { + final _formKey = GlobalKey(); + final _titleController = TextEditingController(); + final _descriptionController = TextEditingController(); + + final SupportService _supportService = SupportService(ApiClient()); + + List _categories = []; + List _priorities = []; + + SupportCategory? _selectedCategory; + SupportPriority? _selectedPriority; + + bool _isLoading = false; + bool _isSubmitting = false; + String? _error; + + @override + void initState() { + super.initState(); + _loadData(); + } + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _loadData() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final categories = await _supportService.getCategories(); + final priorities = await _supportService.getPriorities(); + + setState(() { + _categories = categories; + _priorities = priorities; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + Future _submitTicket() async { + if (!_formKey.currentState!.validate()) return; + if (_selectedCategory == null || _selectedPriority == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('لطفاً دسته‌بندی و اولویت را انتخاب کنید'), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() { + _isSubmitting = true; + _error = null; + }); + + try { + final request = CreateTicketRequest( + title: _titleController.text.trim(), + description: _descriptionController.text.trim(), + categoryId: _selectedCategory!.id, + priorityId: _selectedPriority!.id, + ); + + await _supportService.createTicket(request); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('تیکت با موفقیت ایجاد شد'), + backgroundColor: Colors.green, + ), + ); + Navigator.pop(context, true); + } + } catch (e) { + setState(() { + _error = e.toString(); + _isSubmitting = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + 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('ارسال'), + ), + ], + ), + 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: _loadData, + child: const Text('تلاش مجدد'), + ), + ], + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(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), + ), + ), + SizedBox(width: 8), + Text('در حال ارسال...'), + ], + ) + : const Text('ارسال تیکت'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart index 5a205b4..50995e0 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart @@ -23,7 +23,7 @@ class _NewBusinessPageState extends State { } void _nextStep() { - if (_currentStep < 4) { + if (_currentStep < 3) { setState(() { _currentStep++; }); @@ -65,18 +65,36 @@ class _NewBusinessPageState extends State { return _businessData.isStep2Valid(); case 2: return _businessData.isStep3Valid(); - case 3: - return _businessData.isStep4Valid(); default: return false; } } + bool _isMobile(BuildContext context) { + return MediaQuery.of(context).size.width < 768; + } + + String _getCurrentStepTitle(AppLocalizations t) { + switch (_currentStep) { + case 0: + return t.businessBasicInfo; + case 1: + return t.businessContactInfo; + case 2: + return t.businessLegalInfo; + case 3: + return t.businessConfirmation; + default: + return ''; + } + } + Future _submitBusiness() async { + final t = Localizations.of(context, AppLocalizations)!; if (!_businessData.isFormValid()) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('لطفاً تمام فیلدهای اجباری را پر کنید'), + SnackBar( + content: Text(t.pleaseFillRequiredFields), backgroundColor: Colors.red, ), ); @@ -92,8 +110,8 @@ class _NewBusinessPageState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('کسب و کار با موفقیت ایجاد شد'), + SnackBar( + content: Text(t.businessCreatedSuccessfully), backgroundColor: Colors.green, ), ); @@ -103,7 +121,7 @@ class _NewBusinessPageState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('خطا در ایجاد کسب و کار: $e'), + content: Text('${t.businessCreationFailed}: $e'), backgroundColor: Colors.red, ), ); @@ -119,40 +137,49 @@ class _NewBusinessPageState extends State { @override Widget build(BuildContext context) { - final t = AppLocalizations.of(context); + final t = Localizations.of(context, AppLocalizations)!; + final isMobile = _isMobile(context); return Scaffold( - appBar: AppBar( + appBar: isMobile ? AppBar( title: Text(t.newBusiness), centerTitle: true, - ), + elevation: 0, + ) : null, body: Column( children: [ // Progress indicator Container( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + padding: EdgeInsets.fromLTRB( + isMobile ? 16 : 24, + isMobile ? 8 : 16, + isMobile ? 16 : 24, + isMobile ? 8 : 8 + ), child: Column( children: [ // Progress bar Row( - children: List.generate(5, (index) { + children: List.generate(4, (index) { final isActive = index <= _currentStep; final isCurrent = index == _currentStep; return Expanded( child: AnimatedContainer( duration: const Duration(milliseconds: 300), - margin: const EdgeInsets.symmetric(horizontal: 2), - height: 6, + margin: EdgeInsets.symmetric(horizontal: isMobile ? 1 : 2), + height: isMobile ? 4 : 6, decoration: BoxDecoration( color: isActive - ? Theme.of(context).primaryColor - : Colors.grey[300], - borderRadius: BorderRadius.circular(3), + ? Theme.of(context).colorScheme.primary + : Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.surfaceContainerHighest + : Theme.of(context).colorScheme.outline.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(isMobile ? 2 : 3), boxShadow: isCurrent ? [ BoxShadow( - color: Theme.of(context).primaryColor.withOpacity(0.3), + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.4), blurRadius: 4, spreadRadius: 1, ), @@ -166,40 +193,84 @@ class _NewBusinessPageState extends State { const SizedBox(height: 8), // Progress text Text( - 'مرحله ${_currentStep + 1} از 5', + '${t.step} ${_currentStep + 1} ${t.ofText} 4', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w600, ), ), ], ), ), - // Step indicator - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildStepIndicator(0, 'اطلاعات پایه'), - _buildStepIndicator(1, 'اطلاعات تماس'), - _buildStepIndicator(2, 'اطلاعات قانونی'), - _buildStepIndicator(3, 'اطلاعات جغرافیایی'), - _buildStepIndicator(4, 'تأیید'), - ], + // Step indicator - فقط برای دسکتاپ + if (!isMobile) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildStepIndicator(0, t.businessBasicInfo), + _buildStepIndicator(1, t.businessContactInfo), + _buildStepIndicator(2, t.businessLegalInfo), + _buildStepIndicator(3, t.businessConfirmation), + ], + ), ), - ), - // Form content with scroll + // Current step title for mobile + if (isMobile) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + width: 1, + ), + ), + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${_currentStep + 1}', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _getCurrentStepTitle(t), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + + // Form content Expanded( child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: MediaQuery.of(context).size.height - - kToolbarHeight - - 200, // برای progress indicator، step indicator و navigation buttons - ), + child: SizedBox( + height: MediaQuery.of(context).size.height - 200, // ارتفاع مناسب برای اسکرول child: PageView( controller: _pageController, physics: const NeverScrollableScrollPhysics(), @@ -213,7 +284,6 @@ class _NewBusinessPageState extends State { _buildStep2(), _buildStep3(), _buildStep4(), - _buildStep5(), ], ), ), @@ -222,47 +292,85 @@ class _NewBusinessPageState extends State { // Navigation buttons Container( - padding: const EdgeInsets.all(24), + padding: EdgeInsets.all(isMobile ? 16 : 24), decoration: BoxDecoration( color: Theme.of(context).scaffoldBackgroundColor, border: Border( top: BorderSide( - color: Theme.of(context).dividerColor.withOpacity(0.1), + color: Theme.of(context).dividerColor.withValues(alpha: 0.2), width: 1, ), ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildNavigationButton( - text: 'قبلی', - icon: Icons.arrow_back_ios, - onPressed: _currentStep > 0 ? _previousStep : null, - isPrimary: false, - ), - Row( - children: [ - if (_currentStep < 4) ...[ - _buildNavigationButton( - text: 'بعدی', - icon: Icons.arrow_forward_ios, - onPressed: _canGoToNextStep() ? _nextStep : null, - isPrimary: true, - ), - ] else ...[ - _buildNavigationButton( - text: 'ایجاد کسب و کار', - icon: Icons.check, - onPressed: _isLoading ? null : _submitBusiness, - isPrimary: true, - isLoading: _isLoading, - ), - ], - ], + boxShadow: [ + BoxShadow( + color: Theme.of(context).shadowColor.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, -2), ), ], ), + child: isMobile + ? Column( + children: [ + // Next/Submit button - full width on mobile + SizedBox( + width: double.infinity, + child: _buildNavigationButton( + text: _currentStep < 3 ? t.next : t.createBusiness, + icon: _currentStep < 3 ? Icons.arrow_forward_ios : Icons.check, + onPressed: _currentStep < 3 + ? (_canGoToNextStep() ? _nextStep : null) + : (_isLoading ? null : _submitBusiness), + isPrimary: true, + isLoading: _isLoading, + ), + ), + // Previous button - full width on mobile + if (_currentStep > 0) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: _buildNavigationButton( + text: t.previous, + icon: Icons.arrow_back_ios, + onPressed: _previousStep, + isPrimary: false, + ), + ), + ], + ], + ) + : Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildNavigationButton( + text: t.previous, + icon: Icons.arrow_back_ios, + onPressed: _currentStep > 0 ? _previousStep : null, + isPrimary: false, + ), + Row( + children: [ + if (_currentStep < 3) ...[ + _buildNavigationButton( + text: t.next, + icon: Icons.arrow_forward_ios, + onPressed: _canGoToNextStep() ? _nextStep : null, + isPrimary: true, + ), + ] else ...[ + _buildNavigationButton( + text: t.createBusiness, + icon: Icons.check, + onPressed: _isLoading ? null : _submitBusiness, + isPrimary: true, + isLoading: _isLoading, + ), + ], + ], + ), + ], + ), ), ], ), @@ -280,14 +388,14 @@ class _NewBusinessPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: isActive - ? Theme.of(context).primaryColor.withOpacity(0.1) - : Colors.transparent, + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), border: Border.all( color: isActive - ? Theme.of(context).primaryColor - : Colors.grey[300]!, - width: 1, + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outlineVariant, + width: 1.5, ), ), child: Row( @@ -299,18 +407,26 @@ class _NewBusinessPageState extends State { height: 24, decoration: BoxDecoration( color: isActive - ? Theme.of(context).primaryColor - : Colors.grey[300], + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline, shape: BoxShape.circle, boxShadow: isCurrent ? [ BoxShadow( - color: Theme.of(context).primaryColor.withOpacity(0.3), + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.4), blurRadius: 6, spreadRadius: 2, ), ] - : null, + : isActive + ? [ + BoxShadow( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), + blurRadius: 3, + spreadRadius: 1, + ), + ] + : null, ), child: Center( child: isActive @@ -322,7 +438,9 @@ class _NewBusinessPageState extends State { : Text( '${step + 1}', style: TextStyle( - color: Colors.grey[600], + color: isActive + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurface, fontSize: 12, fontWeight: FontWeight.bold, ), @@ -334,10 +452,10 @@ class _NewBusinessPageState extends State { title, style: TextStyle( color: isActive - ? Theme.of(context).primaryColor - : Colors.grey[600], + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurface, fontSize: 12, - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, ), ), ], @@ -368,14 +486,14 @@ class _NewBusinessPageState extends State { : Theme.of(context).colorScheme.onSurface, elevation: isPrimary ? 2 : 0, shadowColor: isPrimary - ? Theme.of(context).primaryColor.withOpacity(0.3) + ? Theme.of(context).primaryColor.withValues(alpha: 0.3) : null, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: isPrimary ? BorderSide.none : BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), width: 1, ), ), @@ -435,26 +553,41 @@ class _NewBusinessPageState extends State { } Widget _buildStep1() { - return SingleChildScrollView( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + final t = Localizations.of(context, AppLocalizations)!; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - 'اطلاعات پایه کسب و کار', + t.businessBasicInfo, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 24), // نام کسب و کار TextFormField( - decoration: const InputDecoration( - labelText: 'نام کسب و کار *', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: '${t.businessName} *', + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), ), onChanged: (value) { setState(() { @@ -463,7 +596,7 @@ class _NewBusinessPageState extends State { }, validator: (value) { if (value == null || value.isEmpty) { - return 'نام کسب و کار اجباری است'; + return '${t.businessName} ${t.required}'; } return null; }, @@ -472,9 +605,24 @@ class _NewBusinessPageState extends State { // نوع کسب و کار DropdownButtonFormField( - decoration: const InputDecoration( - labelText: 'نوع کسب و کار *', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: '${t.businessType} *', + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), ), value: _businessData.businessType, items: BusinessType.values.map((type) { @@ -490,7 +638,7 @@ class _NewBusinessPageState extends State { }, validator: (value) { if (value == null) { - return 'نوع کسب و کار اجباری است'; + return '${t.businessType} ${t.required}'; } return null; }, @@ -499,9 +647,24 @@ class _NewBusinessPageState extends State { // زمینه فعالیت DropdownButtonFormField( - decoration: const InputDecoration( - labelText: 'زمینه فعالیت *', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: '${t.businessField} *', + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), ), value: _businessData.businessField, items: BusinessField.values.map((field) { @@ -517,13 +680,12 @@ class _NewBusinessPageState extends State { }, validator: (value) { if (value == null) { - return 'زمینه فعالیت اجباری است'; + return '${t.businessField} ${t.required}'; } return null; }, ), - ], - ), + ], ), ), ), @@ -531,26 +693,41 @@ class _NewBusinessPageState extends State { } Widget _buildStep2() { - return SingleChildScrollView( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 800), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + final t = Localizations.of(context, AppLocalizations)!; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - 'اطلاعات تماس', + t.businessContactInfo, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 24), // آدرس - تمام عرض TextFormField( - decoration: const InputDecoration( - labelText: 'آدرس', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: t.address, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), ), maxLines: 3, onChanged: (value) { @@ -570,10 +747,25 @@ class _NewBusinessPageState extends State { Expanded( child: TextFormField( decoration: InputDecoration( - labelText: 'تلفن ثابت', - border: const OutlineInputBorder(), + labelText: t.phone, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), errorText: _businessData.getValidationError('phone'), - helperText: 'مثال: 02112345678', + helperText: '${t.example}: ${t.phoneExample}', ), keyboardType: TextInputType.phone, onChanged: (value) { @@ -587,10 +779,25 @@ class _NewBusinessPageState extends State { Expanded( child: TextFormField( decoration: InputDecoration( - labelText: 'موبایل', - border: const OutlineInputBorder(), + labelText: t.mobile, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), errorText: _businessData.getValidationError('mobile'), - helperText: 'مثال: 09123456789', + helperText: '${t.example}: ${t.mobileExample}', ), keyboardType: TextInputType.phone, onChanged: (value) { @@ -607,10 +814,25 @@ class _NewBusinessPageState extends State { children: [ TextFormField( decoration: InputDecoration( - labelText: 'تلفن ثابت', - border: const OutlineInputBorder(), + labelText: t.phone, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), errorText: _businessData.getValidationError('phone'), - helperText: 'مثال: 02112345678', + helperText: '${t.example}: ${t.phoneExample}', ), keyboardType: TextInputType.phone, onChanged: (value) { @@ -622,10 +844,25 @@ class _NewBusinessPageState extends State { const SizedBox(height: 16), TextFormField( decoration: InputDecoration( - labelText: 'موبایل', - border: const OutlineInputBorder(), + labelText: t.mobile, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), errorText: _businessData.getValidationError('mobile'), - helperText: 'مثال: 09123456789', + helperText: '${t.example}: ${t.mobileExample}', ), keyboardType: TextInputType.phone, onChanged: (value) { @@ -643,9 +880,24 @@ class _NewBusinessPageState extends State { // کد پستی TextFormField( - decoration: const InputDecoration( - labelText: 'کد پستی', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: t.postalCode, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), ), keyboardType: TextInputType.number, onChanged: (value) { @@ -654,8 +906,203 @@ class _NewBusinessPageState extends State { }); }, ), - ], - ), + const SizedBox(height: 24), + + // فیلدهای جغرافیایی + Text( + t.businessGeographicInfo, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 16), + + // فیلدهای جغرافیایی در دو ستون + LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth > 600) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + decoration: InputDecoration( + labelText: t.country, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), + ), + onChanged: (value) { + setState(() { + _businessData.country = value; + }); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + decoration: InputDecoration( + labelText: t.province, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), + ), + onChanged: (value) { + setState(() { + _businessData.province = value; + }); + }, + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + decoration: InputDecoration( + labelText: t.city, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), + ), + onChanged: (value) { + setState(() { + _businessData.city = value; + }); + }, + ), + ], + ); + } else { + return Column( + children: [ + TextFormField( + decoration: InputDecoration( + labelText: t.country, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), + ), + onChanged: (value) { + setState(() { + _businessData.country = value; + }); + }, + ), + const SizedBox(height: 16), + TextFormField( + decoration: InputDecoration( + labelText: t.province, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), + ), + onChanged: (value) { + setState(() { + _businessData.province = value; + }); + }, + ), + const SizedBox(height: 16), + TextFormField( + decoration: InputDecoration( + labelText: t.city, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), + ), + onChanged: (value) { + setState(() { + _businessData.city = value; + }); + }, + ), + ], + ); + } + }, + ), + ], ), ), ), @@ -663,17 +1110,17 @@ class _NewBusinessPageState extends State { } Widget _buildStep3() { - return SingleChildScrollView( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 800), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + final t = Localizations.of(context, AppLocalizations)!; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - 'اطلاعات قانونی', + t.businessLegalInfo, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 24), @@ -689,10 +1136,25 @@ class _NewBusinessPageState extends State { Expanded( child: TextFormField( decoration: InputDecoration( - labelText: 'کد ملی', - border: const OutlineInputBorder(), + labelText: t.nationalId, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), errorText: _businessData.getValidationError('nationalId'), - helperText: 'مثال: 1234567890', + helperText: '${t.example}: ${t.nationalIdExample}', ), keyboardType: TextInputType.number, onChanged: (value) { @@ -705,9 +1167,24 @@ class _NewBusinessPageState extends State { const SizedBox(width: 16), Expanded( child: TextFormField( - decoration: const InputDecoration( - labelText: 'شماره ثبت', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: t.registrationNumber, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), ), keyboardType: TextInputType.text, onChanged: (value) { @@ -721,8 +1198,8 @@ class _NewBusinessPageState extends State { ), const SizedBox(height: 16), TextFormField( - decoration: const InputDecoration( - labelText: 'شناسه اقتصادی', + decoration: InputDecoration( + labelText: t.economicId, border: OutlineInputBorder(), ), keyboardType: TextInputType.text, @@ -739,10 +1216,25 @@ class _NewBusinessPageState extends State { children: [ TextFormField( decoration: InputDecoration( - labelText: 'کد ملی', - border: const OutlineInputBorder(), + labelText: t.nationalId, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), errorText: _businessData.getValidationError('nationalId'), - helperText: 'مثال: 1234567890', + helperText: '${t.example}: ${t.nationalIdExample}', ), keyboardType: TextInputType.number, onChanged: (value) { @@ -753,9 +1245,24 @@ class _NewBusinessPageState extends State { ), const SizedBox(height: 16), TextFormField( - decoration: const InputDecoration( - labelText: 'شماره ثبت', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: t.registrationNumber, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), ), keyboardType: TextInputType.text, onChanged: (value) { @@ -766,9 +1273,24 @@ class _NewBusinessPageState extends State { ), const SizedBox(height: 16), TextFormField( - decoration: const InputDecoration( - labelText: 'شناسه اقتصادی', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: t.economicId, + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), ), keyboardType: TextInputType.text, onChanged: (value) { @@ -782,144 +1304,26 @@ class _NewBusinessPageState extends State { } }, ), - ], - ), + ], ), ), ), ); } + Widget _buildStep4() { - return SingleChildScrollView( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 800), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + final t = Localizations.of(context, AppLocalizations)!; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - 'اطلاعات جغرافیایی', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 24), - - // فیلدهای جغرافیایی در دو ستون - LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth > 600) { - return Column( - children: [ - Row( - children: [ - Expanded( - child: TextFormField( - decoration: const InputDecoration( - labelText: 'کشور', - border: OutlineInputBorder(), - ), - onChanged: (value) { - setState(() { - _businessData.country = value; - }); - }, - ), - ), - const SizedBox(width: 16), - Expanded( - child: TextFormField( - decoration: const InputDecoration( - labelText: 'استان', - border: OutlineInputBorder(), - ), - onChanged: (value) { - setState(() { - _businessData.province = value; - }); - }, - ), - ), - ], - ), - const SizedBox(height: 16), - TextFormField( - decoration: const InputDecoration( - labelText: 'شهر', - border: OutlineInputBorder(), - ), - onChanged: (value) { - setState(() { - _businessData.city = value; - }); - }, - ), - ], - ); - } else { - return Column( - children: [ - TextFormField( - decoration: const InputDecoration( - labelText: 'کشور', - border: OutlineInputBorder(), - ), - onChanged: (value) { - setState(() { - _businessData.country = value; - }); - }, - ), - const SizedBox(height: 16), - TextFormField( - decoration: const InputDecoration( - labelText: 'استان', - border: OutlineInputBorder(), - ), - onChanged: (value) { - setState(() { - _businessData.province = value; - }); - }, - ), - const SizedBox(height: 16), - TextFormField( - decoration: const InputDecoration( - labelText: 'شهر', - border: OutlineInputBorder(), - ), - onChanged: (value) { - setState(() { - _businessData.city = value; - }); - }, - ), - ], - ); - } - }, - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildStep5() { - return SingleChildScrollView( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'تأیید اطلاعات', + t.confirmInfo, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 24), @@ -931,35 +1335,42 @@ class _NewBusinessPageState extends State { color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), border: Border.all( - color: Theme.of(context).dividerColor.withOpacity(0.2), + color: Theme.of(context).dividerColor.withValues(alpha: 0.3), ), + boxShadow: [ + BoxShadow( + color: Theme.of(context).shadowColor.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSummaryItem('نام کسب و کار', _businessData.name), - _buildSummaryItem('نوع کسب و کار', _businessData.businessType?.displayName ?? ''), - _buildSummaryItem('زمینه فعالیت', _businessData.businessField?.displayName ?? ''), + _buildSummaryItem(t.businessName, _businessData.name), + _buildSummaryItem(t.businessType, _businessData.businessType?.displayName ?? ''), + _buildSummaryItem(t.businessField, _businessData.businessField?.displayName ?? ''), if (_businessData.address?.isNotEmpty == true) - _buildSummaryItem('آدرس', _businessData.address!), + _buildSummaryItem(t.address, _businessData.address!), if (_businessData.phone?.isNotEmpty == true) - _buildSummaryItem('تلفن ثابت', _businessData.phone!), + _buildSummaryItem(t.phone, _businessData.phone!), if (_businessData.mobile?.isNotEmpty == true) - _buildSummaryItem('موبایل', _businessData.mobile!), + _buildSummaryItem(t.mobile, _businessData.mobile!), if (_businessData.nationalId?.isNotEmpty == true) - _buildSummaryItem('کد ملی', _businessData.nationalId!), + _buildSummaryItem(t.nationalId, _businessData.nationalId!), if (_businessData.registrationNumber?.isNotEmpty == true) - _buildSummaryItem('شماره ثبت', _businessData.registrationNumber!), + _buildSummaryItem(t.registrationNumber, _businessData.registrationNumber!), if (_businessData.economicId?.isNotEmpty == true) - _buildSummaryItem('شناسه اقتصادی', _businessData.economicId!), + _buildSummaryItem(t.economicId, _businessData.economicId!), if (_businessData.country?.isNotEmpty == true) - _buildSummaryItem('کشور', _businessData.country!), + _buildSummaryItem(t.country, _businessData.country!), if (_businessData.province?.isNotEmpty == true) - _buildSummaryItem('استان', _businessData.province!), + _buildSummaryItem(t.province, _businessData.province!), if (_businessData.city?.isNotEmpty == true) - _buildSummaryItem('شهر', _businessData.city!), + _buildSummaryItem(t.city, _businessData.city!), if (_businessData.postalCode?.isNotEmpty == true) - _buildSummaryItem('کد پستی', _businessData.postalCode!), + _buildSummaryItem(t.postalCode, _businessData.postalCode!), ], ), ), @@ -974,16 +1385,19 @@ class _NewBusinessPageState extends State { size: 20, ), const SizedBox(width: 8), - const Expanded( + Expanded( child: Text( - 'آیا از صحت اطلاعات وارد شده اطمینان دارید؟', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + t.confirmInfoMessage, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), ), ), ], ), - ], - ), + ], ), ), ), @@ -1002,14 +1416,17 @@ class _NewBusinessPageState extends State { '$label:', style: TextStyle( fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), ), ), ), Expanded( child: Text( value, - style: const TextStyle(fontWeight: FontWeight.w400), + style: TextStyle( + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurface, + ), ), ), ], 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 new file mode 100644 index 0000000..b63c0a2 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_ticket_detail_page.dart @@ -0,0 +1,495 @@ +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 new file mode 100644 index 0000000..ca9f86a --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_tickets_page.dart @@ -0,0 +1,177 @@ +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/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'; + +class OperatorTicketsPage extends StatefulWidget { + final CalendarController? calendarController; + const OperatorTicketsPage({super.key, this.calendarController}); + + @override + State createState() => _OperatorTicketsPageState(); +} + +class _OperatorTicketsPageState extends State { + Set _selectedRows = {}; + + @override + void initState() { + super.initState(); + } + + + void _navigateToTicketDetail(Map ticketData) { + final ticket = SupportTicket.fromJson(ticketData); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => OperatorTicketDetailPage(ticket: ticket), + ), + ); + } + + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final theme = Theme.of(context); + + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + t.operatorPanel, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + ], + ), + const SizedBox(height: 16), + Expanded( + child: DataTableWidget>( + config: DataTableConfig>( + title: 'لیست تیکت‌های پشتیبانی - پنل اپراتور', + endpoint: '/api/v1/support/operator/tickets/search', + columns: [ + TextColumn( + 'title', + 'عنوان', + sortable: true, + searchable: true, + width: ColumnWidth.large, + ), + TextColumn( + 'user.first_name', + 'نام کاربر', + sortable: true, + searchable: true, + width: ColumnWidth.medium, + ), + TextColumn( + 'user.email', + 'ایمیل کاربر', + sortable: true, + searchable: true, + width: ColumnWidth.large, + ), + TextColumn( + 'category.name', + 'دسته‌بندی', + sortable: true, + searchable: true, + width: ColumnWidth.medium, + ), + TextColumn( + 'priority.name', + 'اولویت', + sortable: true, + searchable: true, + width: ColumnWidth.small, + ), + TextColumn( + 'status.name', + 'وضعیت', + sortable: true, + searchable: true, + width: ColumnWidth.small, + ), + TextColumn( + 'assigned_operator.first_name', + 'اپراتور مسئول', + sortable: true, + searchable: true, + width: ColumnWidth.medium, + ), + DateColumn( + 'created_at', + 'تاریخ ایجاد', + sortable: true, + searchable: true, + width: ColumnWidth.medium, + showTime: false, + ), + DateColumn( + 'updated_at', + 'آخرین بروزرسانی', + sortable: true, + searchable: true, + width: ColumnWidth.medium, + showTime: false, + ), + ], + searchFields: ['title', 'description', 'user.first_name', 'user.last_name', 'user.email'], + filterFields: ['title', 'user.first_name', 'user.email', 'category.name', 'priority.name', 'status.name', 'assigned_operator.first_name', 'created_at'], + dateRangeField: 'created_at', + showSearch: true, + showFilters: true, + showColumnSearch: true, + showPagination: true, + showActiveFilters: true, + enableSorting: true, + enableGlobalSearch: true, + enableDateRangeFilter: true, + showRowNumbers: true, + enableRowSelection: true, + enableMultiRowSelection: true, + selectedRows: _selectedRows, + onRowSelectionChanged: (selectedRows) { + setState(() { + _selectedRows = selectedRows; + }); + }, + defaultPageSize: 20, + pageSizeOptions: const [10, 20, 50, 100], + showRefreshButton: true, + showClearFiltersButton: true, + emptyStateMessage: 'هیچ تیکتی یافت نشد', + loadingMessage: 'در حال بارگذاری تیکت‌ها...', + errorMessage: 'خطا در بارگذاری تیکت‌ها', + enableHorizontalScroll: true, + minTableWidth: 1000, + showBorder: true, + borderRadius: BorderRadius.circular(8), + padding: const EdgeInsets.all(16), + onRowTap: (ticketData) => _navigateToTicketDetail(ticketData), + ), + fromJson: (json) => json, + calendarController: widget.calendarController, + ), + ), + ], + ), + ), + ); + } + +} diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/profile_shell.dart b/hesabixUI/hesabix_ui/lib/pages/profile/profile_shell.dart index 6819c07..d4dab00 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/profile_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/profile_shell.dart @@ -58,14 +58,20 @@ class _ProfileShellState extends State { _Dest(t.changePassword, Icons.password, Icons.password, '/user/profile/change-password'), ]; + // اضافه کردن منوی اپراتور پشتیبانی + final operatorDestinations = <_Dest>[ + _Dest(t.operatorPanel, Icons.support_agent, Icons.support_agent, '/user/profile/operator'), + ]; + // اضافه کردن منوی تنظیمات سیستم برای ادمین‌ها final adminDestinations = <_Dest>[ _Dest(t.systemSettings, Icons.admin_panel_settings, Icons.admin_panel_settings, '/user/profile/system-settings'), ]; - // ترکیب منوهای عادی و ادمین + // ترکیب منوهای عادی، اپراتور و ادمین final allDestinations = <_Dest>[ ...destinations, + if (widget.authStore.canAccessSupportOperator) ...operatorDestinations, if (widget.authStore.isSuperAdmin) ...adminDestinations, ]; diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/support_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/support_page.dart index 30355c3..34bec0e 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/support_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/support_page.dart @@ -1,24 +1,167 @@ 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/models/support_models.dart'; +import 'package:hesabix_ui/widgets/data_table/data_table.dart'; +import 'ticket_detail_page.dart'; +import 'create_ticket_page.dart'; -class SupportPage extends StatelessWidget { - const SupportPage({super.key}); +class SupportPage extends StatefulWidget { + final CalendarController? calendarController; + const SupportPage({super.key, this.calendarController}); + + @override + State createState() => _SupportPageState(); +} + +class _SupportPageState extends State { + Set _selectedRows = {}; + + @override + void initState() { + super.initState(); + } + + + void _navigateToCreateTicket() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CreateTicketPage(), + ), + ); + + if (result == true) { + // Refresh will be handled by DataTableWidget + } + } + + void _navigateToTicketDetail(Map ticketData) { + final ticket = SupportTicket.fromJson(ticketData); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TicketDetailPage(ticket: ticket), + ), + ); + } @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(t.support, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 8), - Text('${t.support} - sample page'), - ], + + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: DataTableWidget>( + config: DataTableConfig>( + title: t.supportTickets, + endpoint: '/api/v1/support/search', + columns: [ + TextColumn( + 'title', + t.ticketTitle, + sortable: true, + searchable: true, + width: ColumnWidth.large, + ), + TextColumn( + 'category.name', + t.category, + sortable: true, + searchable: true, + width: ColumnWidth.medium, + ), + TextColumn( + 'priority.name', + t.priority, + sortable: true, + searchable: true, + width: ColumnWidth.small, + ), + TextColumn( + 'status.name', + t.status, + sortable: true, + searchable: true, + width: ColumnWidth.small, + ), + DateColumn( + 'created_at', + t.ticketCreatedAt, + sortable: true, + searchable: true, + width: ColumnWidth.medium, + showTime: false, + ), + DateColumn( + 'updated_at', + t.ticketUpdatedAt, + sortable: true, + searchable: true, + width: ColumnWidth.medium, + showTime: false, + ), + ], + searchFields: ['title', 'description'], + filterFields: ['title', 'category.name', 'priority.name', 'status.name', 'created_at'], + dateRangeField: 'created_at', + showSearch: true, + showFilters: true, + showColumnSearch: true, + showPagination: true, + showActiveFilters: true, + enableSorting: true, + enableGlobalSearch: true, + enableDateRangeFilter: true, + showRowNumbers: true, + enableRowSelection: true, + enableMultiRowSelection: true, + selectedRows: _selectedRows, + onRowSelectionChanged: (selectedRows) { + setState(() { + _selectedRows = selectedRows; + }); + }, + defaultPageSize: 20, + pageSizeOptions: const [10, 20, 50, 100], + showRefreshButton: true, + showClearFiltersButton: true, + emptyStateMessage: t.noTickets, + loadingMessage: t.loadingTickets, + errorMessage: t.ticketLoadingError, + enableHorizontalScroll: true, + minTableWidth: 800, + showBorder: true, + borderRadius: BorderRadius.circular(8), + padding: const EdgeInsets.all(8), + onRowTap: (ticketData) => _navigateToTicketDetail(ticketData), + customHeaderActions: [ + // دکمه ایجاد تیکت جدید + Tooltip( + message: t.newTicket, + child: IconButton( + onPressed: _navigateToCreateTicket, + icon: const Icon(Icons.add), + tooltip: t.newTicket, + ), + ), + ], + ), + fromJson: (json) => json, + calendarController: widget.calendarController, + ), + ), + ], + ), ), ); } + } diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/ticket_detail_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/ticket_detail_page.dart new file mode 100644 index 0000000..0e479a9 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/profile/ticket_detail_page.dart @@ -0,0 +1,380 @@ +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/services/support_service.dart b/hesabixUI/hesabix_ui/lib/services/support_service.dart new file mode 100644 index 0000000..535f104 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/support_service.dart @@ -0,0 +1,196 @@ +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/models/support_models.dart'; +import 'package:dio/dio.dart'; + +class SupportService { + final ApiClient _apiClient; + + SupportService(this._apiClient); + + // Categories + Future> getCategories() async { + try { + final response = await _apiClient.get>('/api/v1/metadata/categories'); + return (response.data!['data'] as List).map((json) => SupportCategory.fromJson(json)).toList(); + } on DioException catch (e) { + throw _handleError(e); + } + } + + // Priorities + Future> getPriorities() async { + try { + final response = await _apiClient.get>('/api/v1/metadata/priorities'); + return (response.data!['data'] as List).map((json) => SupportPriority.fromJson(json)).toList(); + } on DioException catch (e) { + throw _handleError(e); + } + } + + // Statuses + Future> getStatuses() async { + try { + final response = await _apiClient.get>('/api/v1/metadata/statuses'); + return (response.data!['data'] as List).map((json) => SupportStatus.fromJson(json)).toList(); + } on DioException catch (e) { + throw _handleError(e); + } + } + + // User tickets + Future> searchUserTickets(Map queryInfo) async { + try { + final response = await _apiClient.post>( + '/api/v1/support/search', + data: queryInfo, + ); + return PaginatedResponse.fromJson( + response.data!['data'], + (json) => SupportTicket.fromJson(json), + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + Future createTicket(CreateTicketRequest request) async { + try { + final response = await _apiClient.post>( + '/api/v1/support', + data: request.toJson(), + ); + return SupportTicket.fromJson(response.data!['data']); + } on DioException catch (e) { + throw _handleError(e); + } + } + + Future getTicket(int ticketId) async { + try { + final response = await _apiClient.get>('/api/v1/support/$ticketId'); + return SupportTicket.fromJson(response.data!['data']); + } on DioException catch (e) { + throw _handleError(e); + } + } + + Future sendMessage(int ticketId, CreateMessageRequest request) async { + try { + final response = await _apiClient.post>( + '/api/v1/support/$ticketId/messages', + data: request.toJson(), + ); + return SupportMessage.fromJson(response.data!['data']); + } on DioException catch (e) { + throw _handleError(e); + } + } + + Future> searchTicketMessages( + int ticketId, + Map queryInfo, + ) async { + try { + final response = await _apiClient.post>( + '/api/v1/support/$ticketId/messages/search', + data: queryInfo, + ); + return PaginatedResponse.fromJson( + response.data!['data'], + (json) => SupportMessage.fromJson(json), + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + // Operator tickets + Future> searchOperatorTickets(Map queryInfo) async { + try { + final response = await _apiClient.post>( + '/api/v1/support/operator/tickets/search', + data: queryInfo, + ); + return PaginatedResponse.fromJson( + response.data!['data'], + (json) => SupportTicket.fromJson(json), + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + Future getOperatorTicket(int ticketId) async { + try { + final response = await _apiClient.get>('/api/v1/support/operator/tickets/$ticketId'); + return SupportTicket.fromJson(response.data!['data']); + } on DioException catch (e) { + throw _handleError(e); + } + } + + Future updateTicketStatus(int ticketId, UpdateStatusRequest request) async { + try { + final response = await _apiClient.put>( + '/api/v1/support/operator/tickets/$ticketId/status', + data: request.toJson(), + ); + return SupportTicket.fromJson(response.data!['data']); + } on DioException catch (e) { + throw _handleError(e); + } + } + + Future assignTicket(int ticketId, AssignTicketRequest request) async { + try { + final response = await _apiClient.post>( + '/api/v1/support/operator/tickets/$ticketId/assign', + data: request.toJson(), + ); + return SupportTicket.fromJson(response.data!['data']); + } on DioException catch (e) { + throw _handleError(e); + } + } + + Future sendOperatorMessage(int ticketId, CreateMessageRequest request) async { + try { + final response = await _apiClient.post>( + '/api/v1/support/operator/tickets/$ticketId/messages', + data: request.toJson(), + ); + return SupportMessage.fromJson(response.data!['data']); + } on DioException catch (e) { + throw _handleError(e); + } + } + + Future> searchOperatorTicketMessages( + int ticketId, + Map queryInfo, + ) async { + try { + final response = await _apiClient.post>( + '/api/v1/support/operator/tickets/$ticketId/messages/search', + data: queryInfo, + ); + return PaginatedResponse.fromJson( + response.data!['data'], + (json) => SupportMessage.fromJson(json), + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + + // Error handling + Exception _handleError(DioException e) { + if (e.response != null) { + final data = e.response!.data; + if (data is Map && data.containsKey('detail')) { + return Exception(data['detail']); + } + } + return Exception(e.message ?? 'خطای نامشخص'); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart index accd7ed..71d71b0 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart @@ -211,6 +211,9 @@ class DataTableConfig { final bool showColumnSettingsButton; final ColumnSettings? initialColumnSettings; final void Function(ColumnSettings settings)? onColumnSettingsChanged; + + // Custom header actions + final List? customHeaderActions; const DataTableConfig({ required this.endpoint, @@ -272,6 +275,7 @@ class DataTableConfig { this.showColumnSettingsButton = true, this.initialColumnSettings, this.onColumnSettingsChanged, + this.customHeaderActions, }); /// Get column width as double diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart index 1b8584c..39c5a02 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart @@ -814,6 +814,12 @@ class _DataTableWidgetState extends State> { tooltip: t.refresh, ), + // Custom header actions + if (widget.config.customHeaderActions != null) ...[ + const SizedBox(width: 8), + ...widget.config.customHeaderActions!, + ], + // Column settings button (moved after refresh button) if (widget.config.showColumnSettingsButton && widget.config.enableColumnSettings) ...[ const SizedBox(width: 4), diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/data_table_utils.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/data_table_utils.dart index ae332b5..e0141e4 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/data_table_utils.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/data_table_utils.dart @@ -212,6 +212,21 @@ class DataTableUtils { /// Get cell value from item static dynamic getCellValue(dynamic item, String key) { if (item is Map) { + // Handle nested properties like 'priority.name' or 'status.name' + if (key.contains('.')) { + final parts = key.split('.'); + dynamic current = item; + + for (final part in parts) { + if (current is Map && current.containsKey(part)) { + current = current[part]; + } else { + return null; + } + } + return current; + } + return item[key]; } // For custom objects, try to access property using reflection diff --git a/hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart b/hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart new file mode 100644 index 0000000..9654be8 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/models/support_models.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; + +class MessageBubble extends StatelessWidget { + final SupportMessage message; + final CalendarController? calendarController; + final bool isCurrentUser; + + const MessageBubble({ + super.key, + required this.message, + this.calendarController, + this.isCurrentUser = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isUser = message.isFromUser; + final isOperator = message.isFromOperator; + final isSystem = message.isFromSystem; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Row( + mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isUser) ...[ + CircleAvatar( + radius: 16, + backgroundColor: _getSenderColor(theme), + child: Icon( + isOperator ? Icons.support_agent : Icons.settings, + size: 16, + color: Colors.white, + ), + ), + const SizedBox(width: 8), + ], + Flexible( + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.7, + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: _getBubbleColor(theme, isUser), + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(isUser ? 16 : 4), + bottomRight: Radius.circular(isUser ? 4 : 16), + ), + border: Border.all( + color: _getBorderColor(theme, isUser), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isUser && message.sender != null) ...[ + Text( + message.sender!.displayName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _getSenderTextColor(theme, isUser), + ), + ), + const SizedBox(height: 4), + ], + Text( + message.content, + style: TextStyle( + fontSize: 14, + color: _getTextColor(theme, isUser), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatTime(message.createdAt), + style: TextStyle( + fontSize: 11, + color: _getTimeColor(theme, isUser), + ), + ), + if (message.isInternal) ...[ + const SizedBox(width: 4), + Icon( + Icons.lock, + size: 12, + color: _getTimeColor(theme, isUser), + ), + ], + ], + ), + ], + ), + ), + ), + if (isUser) ...[ + const SizedBox(width: 8), + CircleAvatar( + radius: 16, + backgroundColor: theme.colorScheme.primary, + child: const Icon( + Icons.person, + size: 16, + color: Colors.white, + ), + ), + ], + ], + ), + ); + } + + Color _getSenderColor(ThemeData theme) { + if (message.isFromOperator) { + return Colors.blue; + } else if (message.isFromSystem) { + return Colors.grey; + } + return theme.colorScheme.primary; + } + + Color _getBubbleColor(ThemeData theme, bool isUser) { + if (isUser) { + return theme.colorScheme.primary; + } else { + return theme.colorScheme.surface; + } + } + + Color _getBorderColor(ThemeData theme, bool isUser) { + if (isUser) { + return theme.colorScheme.primary.withOpacity(0.3); + } else { + return theme.colorScheme.outline.withOpacity(0.3); + } + } + + Color _getTextColor(ThemeData theme, bool isUser) { + if (isUser) { + return Colors.white; + } else { + return theme.colorScheme.onSurface; + } + } + + Color _getSenderTextColor(ThemeData theme, bool isUser) { + if (isUser) { + return Colors.white.withOpacity(0.8); + } else { + return theme.colorScheme.primary; + } + } + + Color _getTimeColor(ThemeData theme, bool isUser) { + if (isUser) { + return Colors.white.withOpacity(0.7); + } else { + return theme.colorScheme.onSurface.withOpacity(0.6); + } + } + + String _formatTime(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/priority_indicator.dart b/hesabixUI/hesabix_ui/lib/widgets/support/priority_indicator.dart new file mode 100644 index 0000000..5e4b633 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/support/priority_indicator.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/models/support_models.dart'; + +class PriorityIndicator extends StatelessWidget { + final SupportPriority priority; + final bool isSmall; + final bool showText; + + const PriorityIndicator({ + super.key, + required this.priority, + this.isSmall = false, + this.showText = true, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = _getPriorityColor(theme); + + if (!showText) { + return Container( + width: isSmall ? 12 : 16, + height: isSmall ? 12 : 16, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ); + } + + return Container( + padding: EdgeInsets.symmetric( + horizontal: isSmall ? 8 : 12, + vertical: isSmall ? 4 : 6, + ), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(isSmall ? 12 : 16), + border: Border.all( + color: color.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: isSmall ? 6 : 8, + height: isSmall ? 6 : 8, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + priority.name, + style: TextStyle( + color: color, + fontSize: isSmall ? 11 : 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Color _getPriorityColor(ThemeData theme) { + if (priority.color != null) { + try { + return Color(int.parse(priority.color!.replaceFirst('#', '0xFF'))); + } catch (e) { + // Fallback to default colors + } + } + + // Default colors based on priority name + switch (priority.name.toLowerCase()) { + case 'کم': + return Colors.green; + case 'متوسط': + return Colors.orange; + case 'بالا': + return Colors.red; + case 'فوری': + return Colors.red.shade800; + default: + return theme.colorScheme.primary; + } + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart new file mode 100644 index 0000000..6cfcb8c --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/models/support_models.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'ticket_status_chip.dart'; +import 'priority_indicator.dart'; + +class TicketCard extends StatelessWidget { + final SupportTicket ticket; + final CalendarController? calendarController; + final VoidCallback? onTap; + final bool showUserInfo; + + const TicketCard({ + super.key, + required this.ticket, + this.calendarController, + this.onTap, + this.showUserInfo = false, + }); + + @override + 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!), + ], + ), + 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), + 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), + ), + ), + ], + ], + ), + ], + ], + ), + ), + ), + ); + } + + 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), + 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/ticket_status_chip.dart b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_status_chip.dart new file mode 100644 index 0000000..f930fcf --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_status_chip.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/models/support_models.dart'; + +class TicketStatusChip extends StatelessWidget { + final SupportStatus status; + final bool isSmall; + + const TicketStatusChip({ + super.key, + required this.status, + this.isSmall = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = _getStatusColor(theme); + + return Container( + padding: EdgeInsets.symmetric( + horizontal: isSmall ? 8 : 12, + vertical: isSmall ? 4 : 6, + ), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(isSmall ? 12 : 16), + border: Border.all( + color: color.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isSmall) ...[ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + ], + Text( + status.name, + style: TextStyle( + color: color, + fontSize: isSmall ? 11 : 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Color _getStatusColor(ThemeData theme) { + if (status.color != null) { + try { + return Color(int.parse(status.color!.replaceFirst('#', '0xFF'))); + } catch (e) { + // Fallback to default colors + } + } + + // Default colors based on status name + switch (status.name.toLowerCase()) { + case 'باز': + return Colors.blue; + case 'در حال پیگیری': + return Colors.purple; + case 'در انتظار کاربر': + return Colors.cyan; + case 'بسته': + return Colors.grey; + case 'حل شده': + return Colors.green; + default: + return theme.colorScheme.primary; + } + } +}