progress in profile

This commit is contained in:
Hesabix 2025-09-20 22:46:06 +03:30
parent 46902be8af
commit aa75b9c743
54 changed files with 6392 additions and 353 deletions

View file

@ -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
)

View file

@ -0,0 +1 @@
# Support API endpoints

View 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)

View 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)

View 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)

View 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

View 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)

View 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)

View file

@ -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

View 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

View 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")

View 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")

View 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")

View 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")

View 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")

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Integer, ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
@ -25,5 +25,8 @@ class User(Base):
app_permissions: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Support relationships
tickets = relationship("Ticket", foreign_keys="Ticket.user_id", back_populates="user")

View file

@ -0,0 +1 @@
# Support repositories

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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()

View 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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -7,6 +7,11 @@ from adapters.api.v1.health import router as health_router
from adapters.api.v1.auth import router as auth_router
from adapters.api.v1.users import router as users_router
from adapters.api.v1.businesses import router as businesses_router
from adapters.api.v1.support.tickets import router as support_tickets_router
from adapters.api.v1.support.operator import router as support_operator_router
from adapters.api.v1.support.categories import router as support_categories_router
from adapters.api.v1.support.priorities import router as support_priorities_router
from adapters.api.v1.support.statuses import router as support_statuses_router
from app.core.i18n import negotiate_locale, Translator
from app.core.error_handlers import register_error_handlers
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
@ -262,6 +267,13 @@ def create_app() -> FastAPI:
application.include_router(auth_router, prefix=settings.api_v1_prefix)
application.include_router(users_router, prefix=settings.api_v1_prefix)
application.include_router(businesses_router, prefix=settings.api_v1_prefix)
# Support endpoints
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
application.include_router(support_operator_router, prefix=f"{settings.api_v1_prefix}/support/operator")
application.include_router(support_categories_router, prefix=f"{settings.api_v1_prefix}/metadata/categories")
application.include_router(support_priorities_router, prefix=f"{settings.api_v1_prefix}/metadata/priorities")
application.include_router(support_statuses_router, prefix=f"{settings.api_v1_prefix}/metadata/statuses")
register_error_handlers(application)
@ -289,11 +301,10 @@ def create_app() -> FastAPI:
# اضافه کردن security schemes
openapi_schema["components"]["securitySchemes"] = {
"BearerAuth": {
"ApiKeyAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "API Key",
"description": "کلید API برای احراز هویت. فرمت: Bearer sk_your_api_key_here"
"scheme": "ApiKey",
"description": "کلید API برای احراز هویت. فرمت: ApiKey sk_your_api_key_here"
}
}
@ -301,9 +312,9 @@ def create_app() -> FastAPI:
for path, methods in openapi_schema["paths"].items():
for method, details in methods.items():
if method in ["get", "post", "put", "delete", "patch"]:
# تمام endpoint های auth و users نیاز به احراز هویت دارند
if "/auth/" in path or "/users" in path:
details["security"] = [{"BearerAuth": []}]
# تمام endpoint های auth، users و support نیاز به احراز هویت دارند
if "/auth/" in path or "/users" in path or "/support" in path:
details["security"] = [{"ApiKeyAuth": []}]
application.openapi_schema = openapi_schema
return application.openapi_schema

View file

@ -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

View 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"}

View file

@ -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 ###

View 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)

View 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()

View file

@ -193,6 +193,8 @@ class AuthStore with ChangeNotifier {
return _appPermissions?[permission] == true;
}
bool get canAccessSupportOperator => hasAppPermission('support_operator');
}

View file

@ -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"
}

View file

@ -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": "سیستم"
}

View file

@ -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

View file

@ -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';
}

View file

@ -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 => 'اپراتور';
}

View file

@ -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',

View 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'],
);
}
}

View 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

View file

@ -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 'همین الان';
}
}
}

View file

@ -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,
),
),
],
),
),
);
}
}

View file

@ -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,
];

View file

@ -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,
),
),
],
),
),
);
}
}

View 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 'همین الان';
}
}
}

View 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 ?? 'خطای نامشخص');
}
}

View file

@ -211,6 +211,9 @@ class DataTableConfig<T> {
final bool showColumnSettingsButton;
final ColumnSettings? initialColumnSettings;
final void Function(ColumnSettings settings)? onColumnSettingsChanged;
// Custom header actions
final List<Widget>? customHeaderActions;
const DataTableConfig({
required this.endpoint,
@ -272,6 +275,7 @@ class DataTableConfig<T> {
this.showColumnSettingsButton = true,
this.initialColumnSettings,
this.onColumnSettingsChanged,
this.customHeaderActions,
});
/// Get column width as double

View file

@ -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),

View file

@ -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

View 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 'همین الان';
}
}
}

View file

@ -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;
}
}
}

View 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 'همین الان';
}
}
}

View file

@ -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;
}
}
}