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 pydantic import BaseModel, EmailStr, Field
from enum import Enum from enum import Enum
T = TypeVar('T')
class FilterItem(BaseModel): class FilterItem(BaseModel):
property: str = Field(..., description="نام فیلد مورد نظر برای اعمال فیلتر") property: str = Field(..., description="نام فیلد مورد نظر برای اعمال فیلتر")
@ -224,3 +226,24 @@ class BusinessSummaryResponse(BaseModel):
by_field: dict = Field(..., description="تعداد بر اساس زمینه فعالیت") 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 import Business # noqa: F401
from .business_permission import BusinessPermission # 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 datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Integer, ForeignKey, JSON 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 from adapters.db.session import Base
@ -25,5 +25,8 @@ class User(Base):
app_permissions: Mapped[dict | None] = mapped_column(JSON, nullable=True) app_permissions: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, 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) 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") 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: def is_business_owner(self) -> bool:
"""بررسی اینکه آیا کاربر مالک کسب و کار است یا نه""" """بررسی اینکه آیا کاربر مالک کسب و کار است یا نه"""
if not self.business_id or not self.db: if not self.business_id or not self.db:

View file

@ -12,11 +12,20 @@ def require_app_permission(permission: str):
"""Decorator برای بررسی دسترسی در سطح اپلیکیشن""" """Decorator برای بررسی دسترسی در سطح اپلیکیشن"""
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs) -> Any: async def wrapper(*args, **kwargs) -> Any:
ctx = get_current_user() # پیدا کردن 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): if not ctx.has_app_permission(permission):
raise ApiError("FORBIDDEN", f"Missing app permission: {permission}", http_status=403) raise ApiError("FORBIDDEN", f"Missing app permission: {permission}", http_status=403)
return func(*args, **kwargs) return await func(*args, **kwargs)
return wrapper return wrapper
return decorator 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: def format_datetime_fields(data: Any, request: Request) -> Any:
"""Recursively format datetime fields based on calendar type""" """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 return data
calendar_type = request.state.calendar_type 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.auth import router as auth_router
from adapters.api.v1.users import router as users_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.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.i18n import negotiate_locale, Translator
from app.core.error_handlers import register_error_handlers from app.core.error_handlers import register_error_handlers
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig 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(auth_router, prefix=settings.api_v1_prefix)
application.include_router(users_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) 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) register_error_handlers(application)
@ -289,11 +301,10 @@ def create_app() -> FastAPI:
# اضافه کردن security schemes # اضافه کردن security schemes
openapi_schema["components"]["securitySchemes"] = { openapi_schema["components"]["securitySchemes"] = {
"BearerAuth": { "ApiKeyAuth": {
"type": "http", "type": "http",
"scheme": "bearer", "scheme": "ApiKey",
"bearerFormat": "API Key", "description": "کلید API برای احراز هویت. فرمت: ApiKey sk_your_api_key_here"
"description": "کلید API برای احراز هویت. فرمت: Bearer sk_your_api_key_here"
} }
} }
@ -301,9 +312,9 @@ def create_app() -> FastAPI:
for path, methods in openapi_schema["paths"].items(): for path, methods in openapi_schema["paths"].items():
for method, details in methods.items(): for method, details in methods.items():
if method in ["get", "post", "put", "delete", "patch"]: if method in ["get", "post", "put", "delete", "patch"]:
# تمام endpoint های auth و users نیاز به احراز هویت دارند # تمام endpoint های auth، users و support نیاز به احراز هویت دارند
if "/auth/" in path or "/users" in path: if "/auth/" in path or "/users" in path or "/support" in path:
details["security"] = [{"BearerAuth": []}] details["security"] = [{"ApiKeyAuth": []}]
application.openapi_schema = openapi_schema application.openapi_schema = openapi_schema
return application.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/health.py
adapters/api/v1/schemas.py adapters/api/v1/schemas.py
adapters/api/v1/users.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/__init__.py
adapters/db/session.py adapters/db/session.py
adapters/db/models/__init__.py adapters/db/models/__init__.py
@ -17,12 +24,24 @@ adapters/db/models/business_permission.py
adapters/db/models/captcha.py adapters/db/models/captcha.py
adapters/db/models/password_reset.py adapters/db/models/password_reset.py
adapters/db/models/user.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/api_key_repo.py
adapters/db/repositories/base_repo.py adapters/db/repositories/base_repo.py
adapters/db/repositories/business_permission_repo.py adapters/db/repositories/business_permission_repo.py
adapters/db/repositories/business_repo.py adapters/db/repositories/business_repo.py
adapters/db/repositories/password_reset_repo.py adapters/db/repositories/password_reset_repo.py
adapters/db/repositories/user_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/__init__.py
app/main.py app/main.py
app/core/__init__.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/20250117_000007_create_business_permissions_table.py
migrations/versions/20250915_000001_init_auth_tables.py migrations/versions/20250915_000001_init_auth_tables.py
migrations/versions/20250916_000002_add_referral_fields.py migrations/versions/20250916_000002_add_referral_fields.py
migrations/versions/5553f8745c6e_add_support_tables.py
tests/__init__.py tests/__init__.py
tests/test_health.py tests/test_health.py
tests/test_permissions.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; return _appPermissions?[permission] == true;
} }
bool get canAccessSupportOperator => hasAppPermission('support_operator');
} }

View file

@ -157,6 +157,96 @@
"contains": "contains", "contains": "contains",
"starts_with": "starts with", "starts_with": "starts with",
"ends_with": "ends 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": "شامل", "contains": "شامل",
"starts_with": "شروع با", "starts_with": "شروع با",
"ends_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. /// No description provided for @mobile.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Mobile number'** /// **'Mobile'**
String get mobile; String get mobile;
/// No description provided for @registerSuccess. /// No description provided for @registerSuccess.
@ -955,6 +955,510 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'in list'** /// **'in list'**
String get 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 class _AppLocalizationsDelegate

View file

@ -51,7 +51,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get email => 'Email'; String get email => 'Email';
@override @override
String get mobile => 'Mobile number'; String get mobile => 'Mobile';
@override @override
String get registerSuccess => 'Registration successful.'; String get registerSuccess => 'Registration successful.';
@ -449,4 +449,257 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get in_list => 'in list'; 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 => 'ایمیل'; String get email => 'ایمیل';
@override @override
String get mobile => 'شماره موبایل'; String get mobile => 'موبایل';
@override @override
String get registerSuccess => 'عضویت با موفقیت انجام شد.'; String get registerSuccess => 'عضویت با موفقیت انجام شد.';
@ -66,7 +66,7 @@ class AppLocalizationsFa extends AppLocalizations {
String get theme => 'تم'; String get theme => 'تم';
@override @override
String get system => 'سیستمی'; String get system => 'سیستم';
@override @override
String get light => 'روشن'; String get light => 'روشن';
@ -448,4 +448,256 @@ class AppLocalizationsFa extends AppLocalizations {
@override @override
String get in_list => 'در لیست'; 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/support_page.dart';
import 'pages/profile/change_password_page.dart'; import 'pages/profile/change_password_page.dart';
import 'pages/profile/marketing_page.dart'; import 'pages/profile/marketing_page.dart';
import 'pages/profile/operator/operator_tickets_page.dart';
import 'pages/system_settings_page.dart'; import 'pages/system_settings_page.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'core/locale_controller.dart'; import 'core/locale_controller.dart';
@ -344,7 +345,7 @@ class _MyAppState extends State<MyApp> {
GoRoute( GoRoute(
path: '/user/profile/support', path: '/user/profile/support',
name: 'profile_support', name: 'profile_support',
builder: (context, state) => const SupportPage(), builder: (context, state) => SupportPage(calendarController: _calendarController),
), ),
GoRoute( GoRoute(
path: '/user/profile/marketing', path: '/user/profile/marketing',
@ -356,6 +357,21 @@ class _MyAppState extends State<MyApp> {
name: 'profile_change_password', name: 'profile_change_password',
builder: (context, state) => const ChangePasswordPage(), 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( GoRoute(
path: '/user/profile/system-settings', path: '/user/profile/system-settings',
name: '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'), _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>[ final adminDestinations = <_Dest>[
_Dest(t.systemSettings, Icons.admin_panel_settings, Icons.admin_panel_settings, '/user/profile/system-settings'), _Dest(t.systemSettings, Icons.admin_panel_settings, Icons.admin_panel_settings, '/user/profile/system-settings'),
]; ];
// ترکیب منوهای عادی و ادمین // ترکیب منوهای عادی، اپراتور و ادمین
final allDestinations = <_Dest>[ final allDestinations = <_Dest>[
...destinations, ...destinations,
if (widget.authStore.canAccessSupportOperator) ...operatorDestinations,
if (widget.authStore.isSuperAdmin) ...adminDestinations, if (widget.authStore.isSuperAdmin) ...adminDestinations,
]; ];

View file

@ -1,24 +1,167 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.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 { class SupportPage extends StatefulWidget {
const SupportPage({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.all(16.0), return Scaffold(
child: Column( body: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(8.0),
children: [ child: Column(
Text(t.support, style: Theme.of(context).textTheme.titleLarge), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 8), children: [
Text('${t.support} - sample page'), 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 bool showColumnSettingsButton;
final ColumnSettings? initialColumnSettings; final ColumnSettings? initialColumnSettings;
final void Function(ColumnSettings settings)? onColumnSettingsChanged; final void Function(ColumnSettings settings)? onColumnSettingsChanged;
// Custom header actions
final List<Widget>? customHeaderActions;
const DataTableConfig({ const DataTableConfig({
required this.endpoint, required this.endpoint,
@ -272,6 +275,7 @@ class DataTableConfig<T> {
this.showColumnSettingsButton = true, this.showColumnSettingsButton = true,
this.initialColumnSettings, this.initialColumnSettings,
this.onColumnSettingsChanged, this.onColumnSettingsChanged,
this.customHeaderActions,
}); });
/// Get column width as double /// Get column width as double

View file

@ -814,6 +814,12 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
tooltip: t.refresh, 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) // Column settings button (moved after refresh button)
if (widget.config.showColumnSettingsButton && widget.config.enableColumnSettings) ...[ if (widget.config.showColumnSettingsButton && widget.config.enableColumnSettings) ...[
const SizedBox(width: 4), const SizedBox(width: 4),

View file

@ -212,6 +212,21 @@ class DataTableUtils {
/// Get cell value from item /// Get cell value from item
static dynamic getCellValue(dynamic item, String key) { static dynamic getCellValue(dynamic item, String key) {
if (item is Map<String, dynamic>) { 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]; return item[key];
} }
// For custom objects, try to access property using reflection // 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;
}
}
}