progress in profile
This commit is contained in:
parent
46902be8af
commit
aa75b9c743
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
1
hesabixAPI/adapters/api/v1/support/__init__.py
Normal file
1
hesabixAPI/adapters/api/v1/support/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Support API endpoints
|
||||
29
hesabixAPI/adapters/api/v1/support/categories.py
Normal file
29
hesabixAPI/adapters/api/v1/support/categories.py
Normal file
|
|
@ -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)
|
||||
292
hesabixAPI/adapters/api/v1/support/operator.py
Normal file
292
hesabixAPI/adapters/api/v1/support/operator.py
Normal file
|
|
@ -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)
|
||||
29
hesabixAPI/adapters/api/v1/support/priorities.py
Normal file
29
hesabixAPI/adapters/api/v1/support/priorities.py
Normal file
|
|
@ -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)
|
||||
134
hesabixAPI/adapters/api/v1/support/schemas.py
Normal file
134
hesabixAPI/adapters/api/v1/support/schemas.py
Normal file
|
|
@ -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
|
||||
29
hesabixAPI/adapters/api/v1/support/statuses.py
Normal file
29
hesabixAPI/adapters/api/v1/support/statuses.py
Normal file
|
|
@ -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)
|
||||
256
hesabixAPI/adapters/api/v1/support/tickets.py
Normal file
256
hesabixAPI/adapters/api/v1/support/tickets.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
8
hesabixAPI/adapters/db/models/support/__init__.py
Normal file
8
hesabixAPI/adapters/db/models/support/__init__.py
Normal file
|
|
@ -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
|
||||
23
hesabixAPI/adapters/db/models/support/category.py
Normal file
23
hesabixAPI/adapters/db/models/support/category.py
Normal file
|
|
@ -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")
|
||||
35
hesabixAPI/adapters/db/models/support/message.py
Normal file
35
hesabixAPI/adapters/db/models/support/message.py
Normal file
|
|
@ -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")
|
||||
24
hesabixAPI/adapters/db/models/support/priority.py
Normal file
24
hesabixAPI/adapters/db/models/support/priority.py
Normal file
|
|
@ -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")
|
||||
24
hesabixAPI/adapters/db/models/support/status.py
Normal file
24
hesabixAPI/adapters/db/models/support/status.py
Normal file
|
|
@ -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")
|
||||
40
hesabixAPI/adapters/db/models/support/ticket.py
Normal file
40
hesabixAPI/adapters/db/models/support/ticket.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -26,4 +26,7 @@ class User(Base):
|
|||
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")
|
||||
|
||||
|
||||
|
|
|
|||
1
hesabixAPI/adapters/db/repositories/support/__init__.py
Normal file
1
hesabixAPI/adapters/db/repositories/support/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Support repositories
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
167
hesabixAPI/adapters/db/repositories/support/ticket_repository.py
Normal file
167
hesabixAPI/adapters/db/repositories/support/ticket_repository.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -263,6 +268,13 @@ def create_app() -> FastAPI:
|
|||
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)
|
||||
|
||||
@application.get("/",
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
24
hesabixAPI/migrations/script.py.mako
Normal file
24
hesabixAPI/migrations/script.py.mako
Normal file
|
|
@ -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"}
|
||||
|
|
@ -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 ###
|
||||
128
hesabixAPI/scripts/grant_operator_permission.py
Normal file
128
hesabixAPI/scripts/grant_operator_permission.py
Normal file
|
|
@ -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 <email>")
|
||||
print(" python grant_operator_permission.py revoke <email>")
|
||||
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)
|
||||
75
hesabixAPI/scripts/seed_support_data.py
Normal file
75
hesabixAPI/scripts/seed_support_data.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -193,6 +193,8 @@ class AuthStore with ChangeNotifier {
|
|||
|
||||
return _appPermissions?[permission] == true;
|
||||
}
|
||||
|
||||
bool get canAccessSupportOperator => hasAppPermission('support_operator');
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "سیستم"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => 'اپراتور';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MyApp> {
|
|||
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<MyApp> {
|
|||
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',
|
||||
|
|
|
|||
446
hesabixUI/hesabix_ui/lib/models/support_models.dart
Normal file
446
hesabixUI/hesabix_ui/lib/models/support_models.dart
Normal file
|
|
@ -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<String, dynamic> 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<String, dynamic>) {
|
||||
// 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
|
||||
return SupportUser(
|
||||
id: json['id'],
|
||||
firstName: json['first_name'],
|
||||
lastName: json['last_name'],
|
||||
email: json['email'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<SupportMessage>? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() {
|
||||
return {
|
||||
'content': content,
|
||||
'is_internal': isInternal,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class UpdateStatusRequest {
|
||||
final int statusId;
|
||||
final int? assignedOperatorId;
|
||||
|
||||
UpdateStatusRequest({
|
||||
required this.statusId,
|
||||
this.assignedOperatorId,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'status_id': statusId,
|
||||
'assigned_operator_id': assignedOperatorId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class AssignTicketRequest {
|
||||
final int operatorId;
|
||||
|
||||
AssignTicketRequest({
|
||||
required this.operatorId,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'operator_id': operatorId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination response
|
||||
class PaginatedResponse<T> {
|
||||
final List<T> 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<String, dynamic> json,
|
||||
T Function(Map<String, dynamic>) fromJsonT,
|
||||
) {
|
||||
return PaginatedResponse<T>(
|
||||
items: (json['items'] as List).map((item) => fromJsonT(item)).toList(),
|
||||
total: json['total'],
|
||||
page: json['page'],
|
||||
limit: json['limit'],
|
||||
totalPages: json['total_pages'],
|
||||
);
|
||||
}
|
||||
}
|
||||
345
hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart
Normal file
345
hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart
Normal file
|
|
@ -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<CreateTicketPage> createState() => _CreateTicketPageState();
|
||||
}
|
||||
|
||||
class _CreateTicketPageState extends State<CreateTicketPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _titleController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
|
||||
final SupportService _supportService = SupportService(ApiClient());
|
||||
|
||||
List<SupportCategory> _categories = [];
|
||||
List<SupportPriority> _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<void> _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<void> _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<SupportCategory>(
|
||||
value: _selectedCategory,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'دستهبندی',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: _categories.map((category) {
|
||||
return DropdownMenuItem(
|
||||
value: category,
|
||||
child: Text(category.name),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (category) {
|
||||
setState(() {
|
||||
_selectedCategory = category;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null) {
|
||||
return 'لطفاً دستهبندی را انتخاب کنید';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Priority dropdown
|
||||
DropdownButtonFormField<SupportPriority>(
|
||||
value: _selectedPriority,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'اولویت',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: _priorities.map((priority) {
|
||||
return DropdownMenuItem(
|
||||
value: priority,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: priority.color != null
|
||||
? Color(int.parse(priority.color!.replaceFirst('#', '0xFF')))
|
||||
: theme.colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(priority.name),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (priority) {
|
||||
setState(() {
|
||||
_selectedPriority = priority;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null) {
|
||||
return 'لطفاً اولویت را انتخاب کنید';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Description field
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'شرح مشکل',
|
||||
hintText: 'مشکل یا سوال خود را به تفصیل شرح دهید...',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: 6,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'شرح مشکل الزامی است';
|
||||
}
|
||||
if (value.trim().length < 10) {
|
||||
return 'شرح باید حداقل 10 کاراکتر باشد';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Submit button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isSubmitting ? null : _submitTicket,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: _isSubmitting
|
||||
? const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text('در حال ارسال...'),
|
||||
],
|
||||
)
|
||||
: const Text('ارسال تیکت'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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<OperatorTicketDetailPage> createState() => _OperatorTicketDetailPageState();
|
||||
}
|
||||
|
||||
class _OperatorTicketDetailPageState extends State<OperatorTicketDetailPage> {
|
||||
final _messageController = TextEditingController();
|
||||
final SupportService _supportService = SupportService(ApiClient());
|
||||
|
||||
SupportTicket? _ticket;
|
||||
List<SupportMessage> _messages = [];
|
||||
List<SupportStatus> _statuses = [];
|
||||
List<SupportPriority> _priorities = [];
|
||||
bool _isLoading = true;
|
||||
bool _isSending = false;
|
||||
bool _isUpdating = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ticket = widget.ticket;
|
||||
_loadTicketDetails();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadTicketDetails() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final ticket = await _supportService.getOperatorTicket(_ticket!.id);
|
||||
final messagesResponse = await _supportService.searchOperatorTicketMessages(
|
||||
_ticket!.id,
|
||||
{
|
||||
'search': '',
|
||||
'search_fields': ['content'],
|
||||
'filters': [],
|
||||
'sort_by': 'created_at',
|
||||
'sort_desc': false,
|
||||
'skip': 0,
|
||||
'take': 100,
|
||||
},
|
||||
);
|
||||
final statuses = await _supportService.getStatuses();
|
||||
final priorities = await _supportService.getPriorities();
|
||||
|
||||
setState(() {
|
||||
_ticket = ticket;
|
||||
_messages = messagesResponse.items;
|
||||
_statuses = statuses;
|
||||
_priorities = priorities;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
final content = _messageController.text.trim();
|
||||
if (content.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_isSending = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final request = CreateMessageRequest(content: content);
|
||||
final message = await _supportService.sendOperatorMessage(_ticket!.id, request);
|
||||
|
||||
setState(() {
|
||||
_messages.add(message);
|
||||
_messageController.clear();
|
||||
_isSending = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isSending = false;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('خطا در ارسال پیام: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateStatus(int statusId) async {
|
||||
setState(() {
|
||||
_isUpdating = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final request = UpdateStatusRequest(statusId: statusId);
|
||||
final ticket = await _supportService.updateTicketStatus(_ticket!.id, request);
|
||||
|
||||
setState(() {
|
||||
_ticket = ticket;
|
||||
_isUpdating = false;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('وضعیت تیکت بهروزرسانی شد'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isUpdating = false;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('خطا در بهروزرسانی وضعیت: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showStatusDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('تغییر وضعیت تیکت'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _statuses.map((status) {
|
||||
return ListTile(
|
||||
title: Text(status.name),
|
||||
subtitle: Text(status.description ?? ''),
|
||||
trailing: _ticket?.statusId == status.id
|
||||
? const Icon(Icons.check, color: Colors.green)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_updateStatus(status.id);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('لغو'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('تیکت #${_ticket?.id ?? ''}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loadTicketDetails,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
if (_ticket != null && !_ticket!.isClosed && !_ticket!.isResolved)
|
||||
IconButton(
|
||||
onPressed: _showStatusDialog,
|
||||
icon: const Icon(Icons.edit),
|
||||
tooltip: 'تغییر وضعیت',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(theme),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(ThemeData theme) {
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.red.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'خطا در بارگذاری تیکت',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.red,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadTicketDetails,
|
||||
child: const Text('تلاش مجدد'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_ticket == null) {
|
||||
return const Center(
|
||||
child: Text('تیکت یافت نشد'),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Ticket header
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_ticket!.title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
TicketStatusChip(status: _ticket!.status!),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_ticket!.description,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
if (_ticket!.category != null) ...[
|
||||
_buildInfoChip(
|
||||
context,
|
||||
Icons.category,
|
||||
_ticket!.category!.name,
|
||||
theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (_ticket!.priority != null) ...[
|
||||
PriorityIndicator(
|
||||
priority: _ticket!.priority!,
|
||||
isSmall: true,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const Spacer(),
|
||||
Text(
|
||||
_formatDate(_ticket!.createdAt),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_ticket!.user != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_ticket!.user!.displayName,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
if (_ticket!.assignedOperator != null) ...[
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Icons.support_agent,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_ticket!.assignedOperator!.displayName,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Messages
|
||||
Expanded(
|
||||
child: _messages.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 64,
|
||||
color: Colors.grey.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'هیچ پیامی یافت نشد',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = _messages[index];
|
||||
return MessageBubble(
|
||||
message: message,
|
||||
isCurrentUser: message.isFromOperator,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Message input
|
||||
if (!_ticket!.isClosed && !_ticket!.isResolved)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: theme.colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'پاسخ خود را بنویسید...',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
enabled: !_isSending,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: _isSending ? null : _sendMessage,
|
||||
icon: _isSending
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoChip(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String text,
|
||||
Color color,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 12,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dateTime) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${difference.inDays} روز پیش';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours} ساعت پیش';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes} دقیقه پیش';
|
||||
} else {
|
||||
return 'همین الان';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<OperatorTicketsPage> createState() => _OperatorTicketsPageState();
|
||||
}
|
||||
|
||||
class _OperatorTicketsPageState extends State<OperatorTicketsPage> {
|
||||
Set<int> _selectedRows = <int>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
|
||||
void _navigateToTicketDetail(Map<String, dynamic> 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<Map<String, dynamic>>(
|
||||
config: DataTableConfig<Map<String, dynamic>>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -58,14 +58,20 @@ class _ProfileShellState extends State<ProfileShell> {
|
|||
_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,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SupportPage> createState() => _SupportPageState();
|
||||
}
|
||||
|
||||
class _SupportPageState extends State<SupportPage> {
|
||||
Set<int> _selectedRows = <int>{};
|
||||
|
||||
@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<String, dynamic> 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<Map<String, dynamic>>(
|
||||
config: DataTableConfig<Map<String, dynamic>>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
380
hesabixUI/hesabix_ui/lib/pages/profile/ticket_detail_page.dart
Normal file
380
hesabixUI/hesabix_ui/lib/pages/profile/ticket_detail_page.dart
Normal file
|
|
@ -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<TicketDetailPage> createState() => _TicketDetailPageState();
|
||||
}
|
||||
|
||||
class _TicketDetailPageState extends State<TicketDetailPage> {
|
||||
final _messageController = TextEditingController();
|
||||
final SupportService _supportService = SupportService(ApiClient());
|
||||
|
||||
SupportTicket? _ticket;
|
||||
List<SupportMessage> _messages = [];
|
||||
bool _isLoading = true;
|
||||
bool _isSending = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ticket = widget.ticket;
|
||||
_loadTicketDetails();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadTicketDetails() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final ticket = await _supportService.getTicket(_ticket!.id);
|
||||
final messagesResponse = await _supportService.searchTicketMessages(
|
||||
_ticket!.id,
|
||||
{
|
||||
'search': '',
|
||||
'search_fields': ['content'],
|
||||
'filters': [],
|
||||
'sort_by': 'created_at',
|
||||
'sort_desc': false,
|
||||
'skip': 0,
|
||||
'take': 100,
|
||||
},
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_ticket = ticket;
|
||||
_messages = messagesResponse.items;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
final content = _messageController.text.trim();
|
||||
if (content.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_isSending = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final request = CreateMessageRequest(content: content);
|
||||
final message = await _supportService.sendMessage(_ticket!.id, request);
|
||||
|
||||
setState(() {
|
||||
_messages.add(message);
|
||||
_messageController.clear();
|
||||
_isSending = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isSending = false;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('خطا در ارسال پیام: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('تیکت #${_ticket?.id ?? ''}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loadTicketDetails,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(theme),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(ThemeData theme) {
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.red.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'خطا در بارگذاری تیکت',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.red,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadTicketDetails,
|
||||
child: const Text('تلاش مجدد'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_ticket == null) {
|
||||
return const Center(
|
||||
child: Text('تیکت یافت نشد'),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Ticket header
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_ticket!.title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
TicketStatusChip(status: _ticket!.status!),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_ticket!.description,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
if (_ticket!.category != null) ...[
|
||||
_buildInfoChip(
|
||||
context,
|
||||
Icons.category,
|
||||
_ticket!.category!.name,
|
||||
theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (_ticket!.priority != null) ...[
|
||||
PriorityIndicator(
|
||||
priority: _ticket!.priority!,
|
||||
isSmall: true,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const Spacer(),
|
||||
Text(
|
||||
_formatDate(_ticket!.createdAt),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Messages
|
||||
Expanded(
|
||||
child: _messages.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 64,
|
||||
color: Colors.grey.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'هیچ پیامی یافت نشد',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = _messages[index];
|
||||
return MessageBubble(
|
||||
message: message,
|
||||
isCurrentUser: message.isFromUser,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Message input
|
||||
if (!_ticket!.isClosed && !_ticket!.isResolved)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: theme.colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'پیام خود را بنویسید...',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
enabled: !_isSending,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: _isSending ? null : _sendMessage,
|
||||
icon: _isSending
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoChip(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String text,
|
||||
Color color,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 12,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dateTime) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${difference.inDays} روز پیش';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours} ساعت پیش';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes} دقیقه پیش';
|
||||
} else {
|
||||
return 'همین الان';
|
||||
}
|
||||
}
|
||||
}
|
||||
196
hesabixUI/hesabix_ui/lib/services/support_service.dart
Normal file
196
hesabixUI/hesabix_ui/lib/services/support_service.dart
Normal file
|
|
@ -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<List<SupportCategory>> getCategories() async {
|
||||
try {
|
||||
final response = await _apiClient.get<Map<String, dynamic>>('/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<List<SupportPriority>> getPriorities() async {
|
||||
try {
|
||||
final response = await _apiClient.get<Map<String, dynamic>>('/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<List<SupportStatus>> getStatuses() async {
|
||||
try {
|
||||
final response = await _apiClient.get<Map<String, dynamic>>('/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<PaginatedResponse<SupportTicket>> searchUserTickets(Map<String, dynamic> queryInfo) async {
|
||||
try {
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/support/search',
|
||||
data: queryInfo,
|
||||
);
|
||||
return PaginatedResponse.fromJson(
|
||||
response.data!['data'],
|
||||
(json) => SupportTicket.fromJson(json),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SupportTicket> createTicket(CreateTicketRequest request) async {
|
||||
try {
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/support',
|
||||
data: request.toJson(),
|
||||
);
|
||||
return SupportTicket.fromJson(response.data!['data']);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SupportTicket> getTicket(int ticketId) async {
|
||||
try {
|
||||
final response = await _apiClient.get<Map<String, dynamic>>('/api/v1/support/$ticketId');
|
||||
return SupportTicket.fromJson(response.data!['data']);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SupportMessage> sendMessage(int ticketId, CreateMessageRequest request) async {
|
||||
try {
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/support/$ticketId/messages',
|
||||
data: request.toJson(),
|
||||
);
|
||||
return SupportMessage.fromJson(response.data!['data']);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PaginatedResponse<SupportMessage>> searchTicketMessages(
|
||||
int ticketId,
|
||||
Map<String, dynamic> queryInfo,
|
||||
) async {
|
||||
try {
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/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<PaginatedResponse<SupportTicket>> searchOperatorTickets(Map<String, dynamic> queryInfo) async {
|
||||
try {
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/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<SupportTicket> getOperatorTicket(int ticketId) async {
|
||||
try {
|
||||
final response = await _apiClient.get<Map<String, dynamic>>('/api/v1/support/operator/tickets/$ticketId');
|
||||
return SupportTicket.fromJson(response.data!['data']);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SupportTicket> updateTicketStatus(int ticketId, UpdateStatusRequest request) async {
|
||||
try {
|
||||
final response = await _apiClient.put<Map<String, dynamic>>(
|
||||
'/api/v1/support/operator/tickets/$ticketId/status',
|
||||
data: request.toJson(),
|
||||
);
|
||||
return SupportTicket.fromJson(response.data!['data']);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SupportTicket> assignTicket(int ticketId, AssignTicketRequest request) async {
|
||||
try {
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/support/operator/tickets/$ticketId/assign',
|
||||
data: request.toJson(),
|
||||
);
|
||||
return SupportTicket.fromJson(response.data!['data']);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SupportMessage> sendOperatorMessage(int ticketId, CreateMessageRequest request) async {
|
||||
try {
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/support/operator/tickets/$ticketId/messages',
|
||||
data: request.toJson(),
|
||||
);
|
||||
return SupportMessage.fromJson(response.data!['data']);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PaginatedResponse<SupportMessage>> searchOperatorTicketMessages(
|
||||
int ticketId,
|
||||
Map<String, dynamic> queryInfo,
|
||||
) async {
|
||||
try {
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/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<String, dynamic> && data.containsKey('detail')) {
|
||||
return Exception(data['detail']);
|
||||
}
|
||||
}
|
||||
return Exception(e.message ?? 'خطای نامشخص');
|
||||
}
|
||||
}
|
||||
|
|
@ -212,6 +212,9 @@ class DataTableConfig<T> {
|
|||
final ColumnSettings? initialColumnSettings;
|
||||
final void Function(ColumnSettings settings)? onColumnSettingsChanged;
|
||||
|
||||
// Custom header actions
|
||||
final List<Widget>? customHeaderActions;
|
||||
|
||||
const DataTableConfig({
|
||||
required this.endpoint,
|
||||
required this.columns,
|
||||
|
|
@ -272,6 +275,7 @@ class DataTableConfig<T> {
|
|||
this.showColumnSettingsButton = true,
|
||||
this.initialColumnSettings,
|
||||
this.onColumnSettingsChanged,
|
||||
this.customHeaderActions,
|
||||
});
|
||||
|
||||
/// Get column width as double
|
||||
|
|
|
|||
|
|
@ -814,6 +814,12 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -212,6 +212,21 @@ class DataTableUtils {
|
|||
/// Get cell value from item
|
||||
static dynamic getCellValue(dynamic item, String key) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
// 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<String, dynamic> && current.containsKey(part)) {
|
||||
current = current[part];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
return item[key];
|
||||
}
|
||||
// For custom objects, try to access property using reflection
|
||||
|
|
|
|||
187
hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart
Normal file
187
hesabixUI/hesabix_ui/lib/widgets/support/message_bubble.dart
Normal file
|
|
@ -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 'همین الان';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
183
hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart
Normal file
183
hesabixUI/hesabix_ui/lib/widgets/support/ticket_card.dart
Normal file
|
|
@ -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 'همین الان';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue