progress in file service

This commit is contained in:
Hesabix 2025-09-21 19:53:21 +03:30
parent d782cbfffc
commit b690e115d4
35 changed files with 4287 additions and 256 deletions

View file

@ -0,0 +1,402 @@
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user
from app.core.permissions import require_permission
from app.core.responses import success_response
from app.core.error_handlers import ApiError
from app.core.i18n import locale_dependency
from app.services.file_storage_service import FileStorageService
from adapters.db.repositories.file_storage_repository import StorageConfigRepository, FileStorageRepository
from adapters.db.models.user import User
from adapters.api.v1.schemas.file_storage import (
StorageConfigCreateRequest,
StorageConfigUpdateRequest,
FileUploadRequest,
FileVerificationRequest,
FileInfo,
FileUploadResponse,
StorageConfigResponse,
FileStatisticsResponse,
CleanupResponse
)
router = APIRouter(prefix="/admin/files", tags=["Admin File Management"])
@router.get("/", response_model=dict)
async def list_all_files(
request: Request,
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=100),
module_context: Optional[str] = Query(None),
is_temporary: Optional[bool] = Query(None),
is_verified: Optional[bool] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.file.view")),
translator = Depends(locale_dependency)
):
"""لیست تمام فایل‌ها با فیلتر"""
try:
file_service = FileStorageService(db)
# TODO: پیاده‌سازی pagination و فیلترها
statistics = await file_service.get_storage_statistics()
data = {
"statistics": statistics,
"message": translator.t("FILE_LIST_NOT_IMPLEMENTED", "File list endpoint - to be implemented")
}
return success_response(data, request)
except Exception as e:
raise ApiError(
code="FILE_LIST_ERROR",
message=translator.t("FILE_LIST_ERROR", f"خطا در دریافت لیست فایل‌ها: {str(e)}"),
http_status=500,
translator=translator
)
@router.get("/unverified", response_model=dict)
async def get_unverified_files(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.file.view")),
translator = Depends(locale_dependency)
):
"""فایل‌های تایید نشده"""
try:
file_service = FileStorageService(db)
unverified_files = await file_service.file_repo.get_unverified_temporary_files()
data = {
"unverified_files": [
{
"file_id": str(file.id),
"original_name": file.original_name,
"file_size": file.file_size,
"module_context": file.module_context,
"created_at": file.created_at.isoformat(),
"expires_at": file.expires_at.isoformat() if file.expires_at else None
}
for file in unverified_files
],
"count": len(unverified_files)
}
return success_response(data, request)
except Exception as e:
raise ApiError(
code="UNVERIFIED_FILES_ERROR",
message=translator.t("UNVERIFIED_FILES_ERROR", f"خطا در دریافت فایل‌های تایید نشده: {str(e)}"),
http_status=500,
translator=translator
)
@router.post("/cleanup-temporary", response_model=dict)
async def cleanup_temporary_files(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.file.cleanup")),
translator = Depends(locale_dependency)
):
"""پاکسازی فایل‌های موقت"""
try:
file_service = FileStorageService(db)
cleanup_result = await file_service.cleanup_unverified_files()
data = {
"message": translator.t("CLEANUP_COMPLETED", "Temporary files cleanup completed"),
"result": cleanup_result
}
return success_response(data, request)
except Exception as e:
raise ApiError(
code="CLEANUP_ERROR",
message=translator.t("CLEANUP_ERROR", f"خطا در پاکسازی فایل‌های موقت: {str(e)}"),
http_status=500,
translator=translator
)
@router.delete("/{file_id}", response_model=dict)
async def force_delete_file(
file_id: UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.file.delete")),
translator = Depends(locale_dependency)
):
"""حذف اجباری فایل"""
try:
file_service = FileStorageService(db)
success = await file_service.delete_file(file_id)
if not success:
raise ApiError(
code="FILE_NOT_FOUND",
message=translator.t("FILE_NOT_FOUND", "فایل یافت نشد"),
http_status=404,
translator=translator
)
data = {"message": translator.t("FILE_DELETED_SUCCESS", "File deleted successfully")}
return success_response(data, request)
except ApiError:
raise
except Exception as e:
raise ApiError(
code="DELETE_FILE_ERROR",
message=translator.t("DELETE_FILE_ERROR", f"خطا در حذف فایل: {str(e)}"),
http_status=500,
translator=translator
)
@router.put("/{file_id}/restore", response_model=dict)
async def restore_file(
file_id: UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.file.restore")),
translator = Depends(locale_dependency)
):
"""بازیابی فایل حذف شده"""
try:
file_repo = FileStorageRepository(db)
success = await file_repo.restore_file(file_id)
if not success:
raise ApiError(
code="FILE_NOT_FOUND",
message=translator.t("FILE_NOT_FOUND", "فایل یافت نشد"),
http_status=404,
translator=translator
)
data = {"message": translator.t("FILE_RESTORED_SUCCESS", "File restored successfully")}
return success_response(data, request)
except ApiError:
raise
except Exception as e:
raise ApiError(
code="RESTORE_FILE_ERROR",
message=translator.t("RESTORE_FILE_ERROR", f"خطا در بازیابی فایل: {str(e)}"),
http_status=500,
translator=translator
)
@router.get("/statistics", response_model=dict)
async def get_file_statistics(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.file.view")),
translator = Depends(locale_dependency)
):
"""آمار استفاده از فضای ذخیره‌سازی"""
try:
file_service = FileStorageService(db)
statistics = await file_service.get_storage_statistics()
return success_response(statistics, request)
except Exception as e:
raise ApiError(
code="STATISTICS_ERROR",
message=translator.t("STATISTICS_ERROR", f"خطا در دریافت آمار: {str(e)}"),
http_status=500,
translator=translator
)
# Storage Configuration Management
@router.get("/storage-configs/", response_model=dict)
async def get_storage_configs(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.storage.view")),
translator = Depends(locale_dependency)
):
"""لیست تنظیمات ذخیره‌سازی"""
try:
config_repo = StorageConfigRepository(db)
configs = await config_repo.get_all_configs()
data = {
"configs": [
{
"id": str(config.id),
"name": config.name,
"storage_type": config.storage_type,
"is_default": config.is_default,
"is_active": config.is_active,
"created_at": config.created_at.isoformat()
}
for config in configs
]
}
return success_response(data, request)
except Exception as e:
raise ApiError(
code="STORAGE_CONFIGS_ERROR",
message=translator.t("STORAGE_CONFIGS_ERROR", f"خطا در دریافت تنظیمات ذخیره‌سازی: {str(e)}"),
http_status=500,
translator=translator
)
@router.post("/storage-configs/", response_model=dict)
async def create_storage_config(
request: Request,
config_request: StorageConfigCreateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.storage.create")),
translator = Depends(locale_dependency)
):
"""ایجاد تنظیمات ذخیره‌سازی جدید"""
try:
config_repo = StorageConfigRepository(db)
config = await config_repo.create_config(
name=config_request.name,
storage_type=config_request.storage_type,
config_data=config_request.config_data,
created_by=current_user.id,
is_default=config_request.is_default
)
data = {
"message": translator.t("STORAGE_CONFIG_CREATED", "Storage configuration created successfully"),
"config_id": str(config.id)
}
return success_response(data, request)
except Exception as e:
raise ApiError(
code="CREATE_STORAGE_CONFIG_ERROR",
message=translator.t("CREATE_STORAGE_CONFIG_ERROR", f"خطا در ایجاد تنظیمات ذخیره‌سازی: {str(e)}"),
http_status=400,
translator=translator
)
@router.put("/storage-configs/{config_id}", response_model=dict)
async def update_storage_config(
config_id: UUID,
request: Request,
config_request: StorageConfigUpdateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.storage.update")),
translator = Depends(locale_dependency)
):
"""بروزرسانی تنظیمات ذخیره‌سازی"""
try:
config_repo = StorageConfigRepository(db)
# TODO: پیاده‌سازی بروزرسانی
data = {"message": translator.t("STORAGE_CONFIG_UPDATE_NOT_IMPLEMENTED", "Storage configuration update - to be implemented")}
return success_response(data, request)
except Exception as e:
raise ApiError(
code="UPDATE_STORAGE_CONFIG_ERROR",
message=translator.t("UPDATE_STORAGE_CONFIG_ERROR", f"خطا در بروزرسانی تنظیمات ذخیره‌سازی: {str(e)}"),
http_status=500,
translator=translator
)
@router.put("/storage-configs/{config_id}/set-default", response_model=dict)
async def set_default_storage_config(
config_id: UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.storage.update")),
translator = Depends(locale_dependency)
):
"""تنظیم به عنوان پیش‌فرض"""
try:
config_repo = StorageConfigRepository(db)
success = await config_repo.set_default_config(config_id)
if not success:
raise ApiError(
code="STORAGE_CONFIG_NOT_FOUND",
message=translator.t("STORAGE_CONFIG_NOT_FOUND", "تنظیمات ذخیره‌سازی یافت نشد"),
http_status=404,
translator=translator
)
data = {"message": translator.t("DEFAULT_STORAGE_CONFIG_UPDATED", "Default storage configuration updated successfully")}
return success_response(data, request)
except ApiError:
raise
except Exception as e:
raise ApiError(
code="SET_DEFAULT_STORAGE_CONFIG_ERROR",
message=translator.t("SET_DEFAULT_STORAGE_CONFIG_ERROR", f"خطا در تنظیم پیش‌فرض: {str(e)}"),
http_status=500,
translator=translator
)
@router.delete("/storage-configs/{config_id}", response_model=dict)
async def delete_storage_config(
config_id: UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.storage.delete")),
translator = Depends(locale_dependency)
):
"""حذف تنظیمات ذخیره‌سازی"""
try:
config_repo = StorageConfigRepository(db)
success = await config_repo.delete_config(config_id)
if not success:
raise ApiError(
code="STORAGE_CONFIG_NOT_FOUND",
message=translator.t("STORAGE_CONFIG_NOT_FOUND", "تنظیمات ذخیره‌سازی یافت نشد"),
http_status=404,
translator=translator
)
data = {"message": translator.t("STORAGE_CONFIG_DELETED", "Storage configuration deleted successfully")}
return success_response(data, request)
except ApiError:
raise
except Exception as e:
raise ApiError(
code="DELETE_STORAGE_CONFIG_ERROR",
message=translator.t("DELETE_STORAGE_CONFIG_ERROR", f"خطا در حذف تنظیمات ذخیره‌سازی: {str(e)}"),
http_status=500,
translator=translator
)
@router.post("/storage-configs/{config_id}/test", response_model=dict)
async def test_storage_config(
config_id: UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("admin.storage.test")),
translator = Depends(locale_dependency)
):
"""تست اتصال به storage"""
try:
# TODO: پیاده‌سازی تست اتصال
data = {"message": translator.t("STORAGE_CONNECTION_TEST_NOT_IMPLEMENTED", "Storage connection test - to be implemented")}
return success_response(data, request)
except Exception as e:
raise ApiError(
code="TEST_STORAGE_CONFIG_ERROR",
message=translator.t("TEST_STORAGE_CONFIG_ERROR", f"خطا در تست اتصال: {str(e)}"),
http_status=500,
translator=translator
)

View file

@ -0,0 +1,2 @@
# Import all schemas
from .file_storage import *

View file

@ -0,0 +1,79 @@
from typing import Optional, Dict, Any, List
from uuid import UUID
from pydantic import BaseModel, Field
from datetime import datetime
# Request Models
class StorageConfigCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="نام پیکربندی")
storage_type: str = Field(..., description="نوع ذخیره‌سازی")
config_data: Dict[str, Any] = Field(..., description="داده‌های پیکربندی")
is_default: bool = Field(default=False, description="آیا پیش‌فرض است")
class StorageConfigUpdateRequest(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=100, description="نام پیکربندی")
config_data: Optional[Dict[str, Any]] = Field(default=None, description="داده‌های پیکربندی")
is_active: Optional[bool] = Field(default=None, description="آیا فعال است")
class FileUploadRequest(BaseModel):
module_context: str = Field(..., description="زمینه ماژول")
context_id: Optional[UUID] = Field(default=None, description="شناسه زمینه")
developer_data: Optional[Dict[str, Any]] = Field(default=None, description="داده‌های توسعه‌دهنده")
is_temporary: bool = Field(default=False, description="آیا فایل موقت است")
expires_in_days: int = Field(default=30, ge=1, le=365, description="تعداد روزهای انقضا")
class FileVerificationRequest(BaseModel):
verification_data: Dict[str, Any] = Field(..., description="داده‌های تایید")
# Response Models
class FileInfo(BaseModel):
file_id: str = Field(..., description="شناسه فایل")
original_name: str = Field(..., description="نام اصلی فایل")
file_size: int = Field(..., description="حجم فایل")
mime_type: str = Field(..., description="نوع فایل")
is_temporary: bool = Field(..., description="آیا موقت است")
is_verified: bool = Field(..., description="آیا تایید شده است")
created_at: str = Field(..., description="تاریخ ایجاد")
expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا")
class Config:
from_attributes = True
class FileUploadResponse(BaseModel):
file_id: str = Field(..., description="شناسه فایل")
original_name: str = Field(..., description="نام اصلی فایل")
file_size: int = Field(..., description="حجم فایل")
mime_type: str = Field(..., description="نوع فایل")
is_temporary: bool = Field(..., description="آیا موقت است")
verification_token: Optional[str] = Field(default=None, description="توکن تایید")
expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا")
class StorageConfigResponse(BaseModel):
id: str = Field(..., description="شناسه پیکربندی")
name: str = Field(..., description="نام پیکربندی")
storage_type: str = Field(..., description="نوع ذخیره‌سازی")
is_default: bool = Field(..., description="آیا پیش‌فرض است")
is_active: bool = Field(..., description="آیا فعال است")
created_at: str = Field(..., description="تاریخ ایجاد")
class Config:
from_attributes = True
class FileStatisticsResponse(BaseModel):
total_files: int = Field(..., description="کل فایل‌ها")
total_size: int = Field(..., description="حجم کل")
temporary_files: int = Field(..., description="فایل‌های موقت")
unverified_files: int = Field(..., description="فایل‌های تایید نشده")
class CleanupResponse(BaseModel):
cleaned_files: int = Field(..., description="تعداد فایل‌های پاکسازی شده")
total_unverified: int = Field(..., description="کل فایل‌های تایید نشده")

View file

@ -225,6 +225,10 @@ async def send_operator_message(
is_internal=message_request.is_internal is_internal=message_request.is_internal
) )
# اگر تیکت هنوز به اپراتور تخصیص نشده، آن را تخصیص ده
if not ticket.assigned_operator_id:
ticket_repo.assign_ticket(ticket_id, current_user.get_user_id())
# Format datetime fields based on calendar type # Format datetime fields based on calendar type
message_data = MessageResponse.from_orm(message).dict() message_data = MessageResponse.from_orm(message).dict()
formatted_data = format_datetime_fields(message_data, request) formatted_data = format_datetime_fields(message_data, request)

View file

@ -11,4 +11,7 @@ from .business_permission import BusinessPermission # noqa: F401
# Import support models # Import support models
from .support import * # noqa: F401, F403 from .support import * # noqa: F401, F403
# Import file storage models
from .file_storage import * # noqa: F401, F403

View file

@ -0,0 +1,72 @@
from sqlalchemy import Column, String, Integer, DateTime, Boolean, Text, ForeignKey, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from adapters.db.session import Base
class FileStorage(Base):
__tablename__ = "file_storage"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
original_name = Column(String(255), nullable=False)
stored_name = Column(String(255), nullable=False)
file_path = Column(String(500), nullable=False)
file_size = Column(Integer, nullable=False)
mime_type = Column(String(100), nullable=False)
storage_type = Column(String(20), nullable=False) # local, ftp
storage_config_id = Column(UUID(as_uuid=True), ForeignKey("storage_configs.id"), nullable=True)
uploaded_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
module_context = Column(String(50), nullable=False) # tickets, accounting, business_logo, etc.
context_id = Column(UUID(as_uuid=True), nullable=True) # ticket_id, document_id, etc.
developer_data = Column(JSON, nullable=True)
checksum = Column(String(64), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
is_temporary = Column(Boolean, default=False, nullable=False)
is_verified = Column(Boolean, default=False, nullable=False)
verification_token = Column(String(100), nullable=True)
last_verified_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
deleted_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
uploader = relationship("User", foreign_keys=[uploaded_by])
storage_config = relationship("StorageConfig", foreign_keys=[storage_config_id])
class StorageConfig(Base):
__tablename__ = "storage_configs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), nullable=False)
storage_type = Column(String(20), nullable=False) # local, ftp
is_default = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
config_data = Column(JSON, nullable=False)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
creator = relationship("User", foreign_keys=[created_by])
class FileVerification(Base):
__tablename__ = "file_verifications"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
file_id = Column(UUID(as_uuid=True), ForeignKey("file_storage.id"), nullable=False)
module_name = Column(String(50), nullable=False)
verification_token = Column(String(100), nullable=False)
verified_at = Column(DateTime(timezone=True), nullable=True)
verified_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
verification_data = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
file = relationship("FileStorage", foreign_keys=[file_id])
verifier = relationship("User", foreign_keys=[verified_by])

View file

@ -0,0 +1,291 @@
from typing import List, Optional, Dict, Any
from uuid import UUID
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, desc, func
from datetime import datetime, timedelta
from adapters.db.models.file_storage import FileStorage, StorageConfig, FileVerification
from adapters.db.repositories.base import BaseRepository
class FileStorageRepository(BaseRepository[FileStorage]):
def __init__(self, db: Session):
super().__init__(FileStorage, db)
async def create_file(
self,
original_name: str,
stored_name: str,
file_path: str,
file_size: int,
mime_type: str,
storage_type: str,
uploaded_by: UUID,
module_context: str,
context_id: Optional[UUID] = None,
developer_data: Optional[Dict] = None,
checksum: Optional[str] = None,
is_temporary: bool = False,
expires_in_days: int = 30,
storage_config_id: Optional[UUID] = None
) -> FileStorage:
expires_at = None
if is_temporary:
expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
file_storage = FileStorage(
original_name=original_name,
stored_name=stored_name,
file_path=file_path,
file_size=file_size,
mime_type=mime_type,
storage_type=storage_type,
storage_config_id=storage_config_id,
uploaded_by=uploaded_by,
module_context=module_context,
context_id=context_id,
developer_data=developer_data,
checksum=checksum,
is_temporary=is_temporary,
expires_at=expires_at
)
self.db.add(file_storage)
self.db.commit()
self.db.refresh(file_storage)
return file_storage
async def get_file_by_id(self, file_id: UUID) -> Optional[FileStorage]:
return self.db.query(FileStorage).filter(
and_(
FileStorage.id == file_id,
FileStorage.deleted_at.is_(None)
)
).first()
async def get_files_by_context(
self,
module_context: str,
context_id: UUID
) -> List[FileStorage]:
return self.db.query(FileStorage).filter(
and_(
FileStorage.module_context == module_context,
FileStorage.context_id == context_id,
FileStorage.deleted_at.is_(None),
FileStorage.is_active == True
)
).order_by(desc(FileStorage.created_at)).all()
async def get_user_files(
self,
user_id: UUID,
limit: int = 50,
offset: int = 0
) -> List[FileStorage]:
return self.db.query(FileStorage).filter(
and_(
FileStorage.uploaded_by == user_id,
FileStorage.deleted_at.is_(None)
)
).order_by(desc(FileStorage.created_at)).offset(offset).limit(limit).all()
async def get_unverified_temporary_files(self) -> List[FileStorage]:
return self.db.query(FileStorage).filter(
and_(
FileStorage.is_temporary == True,
FileStorage.is_verified == False,
FileStorage.deleted_at.is_(None),
FileStorage.is_active == True
)
).all()
async def get_expired_temporary_files(self) -> List[FileStorage]:
return self.db.query(FileStorage).filter(
and_(
FileStorage.is_temporary == True,
FileStorage.expires_at < datetime.utcnow(),
FileStorage.deleted_at.is_(None)
)
).all()
async def verify_file(self, file_id: UUID, verification_data: Dict) -> bool:
file_storage = await self.get_file_by_id(file_id)
if not file_storage:
return False
file_storage.is_verified = True
file_storage.last_verified_at = datetime.utcnow()
file_storage.developer_data = {**(file_storage.developer_data or {}), **verification_data}
self.db.commit()
return True
async def soft_delete_file(self, file_id: UUID) -> bool:
file_storage = await self.get_file_by_id(file_id)
if not file_storage:
return False
file_storage.deleted_at = datetime.utcnow()
file_storage.is_active = False
self.db.commit()
return True
async def restore_file(self, file_id: UUID) -> bool:
file_storage = self.db.query(FileStorage).filter(FileStorage.id == file_id).first()
if not file_storage:
return False
file_storage.deleted_at = None
file_storage.is_active = True
self.db.commit()
return True
async def get_storage_statistics(self) -> Dict[str, Any]:
total_files = self.db.query(FileStorage).filter(
FileStorage.deleted_at.is_(None)
).count()
total_size = self.db.query(func.sum(FileStorage.file_size)).filter(
FileStorage.deleted_at.is_(None)
).scalar() or 0
temporary_files = self.db.query(FileStorage).filter(
and_(
FileStorage.is_temporary == True,
FileStorage.deleted_at.is_(None)
)
).count()
unverified_files = self.db.query(FileStorage).filter(
and_(
FileStorage.is_temporary == True,
FileStorage.is_verified == False,
FileStorage.deleted_at.is_(None)
)
).count()
return {
"total_files": total_files,
"total_size": total_size,
"temporary_files": temporary_files,
"unverified_files": unverified_files
}
class StorageConfigRepository(BaseRepository[StorageConfig]):
def __init__(self, db: Session):
super().__init__(StorageConfig, db)
async def create_config(
self,
name: str,
storage_type: str,
config_data: Dict,
created_by: UUID,
is_default: bool = False
) -> StorageConfig:
# اگر این config به عنوان پیش‌فرض تنظیم می‌شود، بقیه را غیرفعال کن
if is_default:
await self.clear_default_configs()
storage_config = StorageConfig(
name=name,
storage_type=storage_type,
config_data=config_data,
created_by=created_by,
is_default=is_default
)
self.db.add(storage_config)
self.db.commit()
self.db.refresh(storage_config)
return storage_config
async def get_default_config(self) -> Optional[StorageConfig]:
return self.db.query(StorageConfig).filter(
and_(
StorageConfig.is_default == True,
StorageConfig.is_active == True
)
).first()
async def get_all_configs(self) -> List[StorageConfig]:
return self.db.query(StorageConfig).filter(
StorageConfig.is_active == True
).order_by(desc(StorageConfig.created_at)).all()
async def set_default_config(self, config_id: UUID) -> bool:
# ابتدا همه config ها را غیرپیش‌فرض کن
await self.clear_default_configs()
# config مورد نظر را پیش‌فرض کن
config = self.db.query(StorageConfig).filter(StorageConfig.id == config_id).first()
if not config:
return False
config.is_default = True
self.db.commit()
return True
async def clear_default_configs(self):
self.db.query(StorageConfig).update({"is_default": False})
self.db.commit()
async def delete_config(self, config_id: UUID) -> bool:
config = self.db.query(StorageConfig).filter(StorageConfig.id == config_id).first()
if not config:
return False
config.is_active = False
self.db.commit()
return True
class FileVerificationRepository(BaseRepository[FileVerification]):
def __init__(self, db: Session):
super().__init__(FileVerification, db)
async def create_verification(
self,
file_id: UUID,
module_name: str,
verification_token: str,
verification_data: Optional[Dict] = None
) -> FileVerification:
verification = FileVerification(
file_id=file_id,
module_name=module_name,
verification_token=verification_token,
verification_data=verification_data
)
self.db.add(verification)
self.db.commit()
self.db.refresh(verification)
return verification
async def verify_file(
self,
file_id: UUID,
verification_token: str,
verified_by: UUID
) -> bool:
verification = self.db.query(FileVerification).filter(
and_(
FileVerification.file_id == file_id,
FileVerification.verification_token == verification_token,
FileVerification.verified_at.is_(None)
)
).first()
if not verification:
return False
verification.verified_at = datetime.utcnow()
verification.verified_by = verified_by
self.db.commit()
return True

View file

@ -12,6 +12,7 @@ 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.categories import router as support_categories_router
from adapters.api.v1.support.priorities import router as support_priorities_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 adapters.api.v1.support.statuses import router as support_statuses_router
from adapters.api.v1.admin.file_storage import router as admin_file_storage_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
@ -274,6 +275,9 @@ def create_app() -> FastAPI:
application.include_router(support_categories_router, prefix=f"{settings.api_v1_prefix}/metadata/categories") 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_priorities_router, prefix=f"{settings.api_v1_prefix}/metadata/priorities")
application.include_router(support_statuses_router, prefix=f"{settings.api_v1_prefix}/metadata/statuses") application.include_router(support_statuses_router, prefix=f"{settings.api_v1_prefix}/metadata/statuses")
# Admin endpoints
application.include_router(admin_file_storage_router, prefix=settings.api_v1_prefix)
register_error_handlers(application) register_error_handlers(application)

View file

@ -0,0 +1,227 @@
import os
import hashlib
import uuid
from typing import Optional, Dict, Any, List
from uuid import UUID
from fastapi import UploadFile, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from adapters.db.repositories.file_storage_repository import (
FileStorageRepository,
StorageConfigRepository,
FileVerificationRepository
)
from adapters.db.models.file_storage import FileStorage, StorageConfig
class FileStorageService:
def __init__(self, db: Session):
self.db = db
self.file_repo = FileStorageRepository(db)
self.config_repo = StorageConfigRepository(db)
self.verification_repo = FileVerificationRepository(db)
async def upload_file(
self,
file: UploadFile,
user_id: UUID,
module_context: str,
context_id: Optional[UUID] = None,
developer_data: Optional[Dict] = None,
is_temporary: bool = False,
expires_in_days: int = 30,
storage_config_id: Optional[UUID] = None
) -> Dict[str, Any]:
try:
# دریافت تنظیمات ذخیره‌سازی
if storage_config_id:
storage_config = self.db.query(StorageConfig).filter(
StorageConfig.id == storage_config_id
).first()
else:
storage_config = await self.config_repo.get_default_config()
if not storage_config:
raise HTTPException(status_code=400, detail="No storage configuration found")
# تولید نام فایل و مسیر
file_extension = os.path.splitext(file.filename)[1] if file.filename else ""
stored_name = f"{uuid.uuid4()}{file_extension}"
# تعیین مسیر ذخیره‌سازی
if storage_config.storage_type == "local":
file_path = await self._get_local_file_path(stored_name, storage_config.config_data)
elif storage_config.storage_type == "ftp":
file_path = await self._get_ftp_file_path(stored_name, storage_config.config_data)
else:
raise HTTPException(status_code=400, detail="Unsupported storage type")
# خواندن محتوای فایل
file_content = await file.read()
file_size = len(file_content)
# محاسبه checksum
checksum = hashlib.sha256(file_content).hexdigest()
# ذخیره فایل
await self._save_file_to_storage(file_content, file_path, storage_config)
# ذخیره اطلاعات در دیتابیس
file_storage = await self.file_repo.create_file(
original_name=file.filename or "unknown",
stored_name=stored_name,
file_path=file_path,
file_size=file_size,
mime_type=file.content_type or "application/octet-stream",
storage_type=storage_config.storage_type,
uploaded_by=user_id,
module_context=module_context,
context_id=context_id,
developer_data=developer_data,
checksum=checksum,
is_temporary=is_temporary,
expires_in_days=expires_in_days,
storage_config_id=storage_config.id
)
# تولید توکن تایید برای فایل‌های موقت
verification_token = None
if is_temporary:
verification_token = str(uuid.uuid4())
await self.verification_repo.create_verification(
file_id=file_storage.id,
module_name=module_context,
verification_token=verification_token,
verification_data=developer_data
)
return {
"file_id": str(file_storage.id),
"original_name": file_storage.original_name,
"file_size": file_storage.file_size,
"mime_type": file_storage.mime_type,
"is_temporary": file_storage.is_temporary,
"verification_token": verification_token,
"expires_at": file_storage.expires_at.isoformat() if file_storage.expires_at else None
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"File upload failed: {str(e)}")
async def get_file(self, file_id: UUID) -> Dict[str, Any]:
file_storage = await self.file_repo.get_file_by_id(file_id)
if not file_storage:
raise HTTPException(status_code=404, detail="File not found")
return {
"file_id": str(file_storage.id),
"original_name": file_storage.original_name,
"file_size": file_storage.file_size,
"mime_type": file_storage.mime_type,
"is_temporary": file_storage.is_temporary,
"is_verified": file_storage.is_verified,
"created_at": file_storage.created_at.isoformat(),
"expires_at": file_storage.expires_at.isoformat() if file_storage.expires_at else None
}
async def download_file(self, file_id: UUID) -> Dict[str, Any]:
file_storage = await self.file_repo.get_file_by_id(file_id)
if not file_storage:
raise HTTPException(status_code=404, detail="File not found")
# خواندن فایل از storage
file_content = await self._read_file_from_storage(file_storage.file_path, file_storage.storage_type)
return {
"content": file_content,
"filename": file_storage.original_name,
"mime_type": file_storage.mime_type
}
async def delete_file(self, file_id: UUID) -> bool:
file_storage = await self.file_repo.get_file_by_id(file_id)
if not file_storage:
return False
# حذف فایل از storage
await self._delete_file_from_storage(file_storage.file_path, file_storage.storage_type)
# حذف نرم از دیتابیس
return await self.file_repo.soft_delete_file(file_id)
async def verify_file_usage(self, file_id: UUID, verification_data: Dict) -> bool:
return await self.file_repo.verify_file(file_id, verification_data)
async def list_files_by_context(
self,
module_context: str,
context_id: UUID
) -> List[Dict[str, Any]]:
files = await self.file_repo.get_files_by_context(module_context, context_id)
return [
{
"file_id": str(file.id),
"original_name": file.original_name,
"file_size": file.file_size,
"mime_type": file.mime_type,
"is_temporary": file.is_temporary,
"is_verified": file.is_verified,
"created_at": file.created_at.isoformat()
}
for file in files
]
async def cleanup_unverified_files(self) -> Dict[str, Any]:
unverified_files = await self.file_repo.get_unverified_temporary_files()
cleaned_count = 0
for file_storage in unverified_files:
if file_storage.expires_at and file_storage.expires_at < datetime.utcnow():
await self._delete_file_from_storage(file_storage.file_path, file_storage.storage_type)
await self.file_repo.soft_delete_file(file_storage.id)
cleaned_count += 1
return {
"cleaned_files": cleaned_count,
"total_unverified": len(unverified_files)
}
async def get_storage_statistics(self) -> Dict[str, Any]:
return await self.file_repo.get_storage_statistics()
# Helper methods
async def _get_local_file_path(self, stored_name: str, config_data: Dict) -> str:
base_path = config_data.get("base_path", "/tmp/hesabix_files")
os.makedirs(base_path, exist_ok=True)
return os.path.join(base_path, stored_name)
async def _get_ftp_file_path(self, stored_name: str, config_data: Dict) -> str:
# برای FTP، مسیر نسبی را برمی‌گردانیم
base_path = config_data.get("base_path", "/hesabix_files")
return f"{base_path}/{stored_name}"
async def _save_file_to_storage(self, content: bytes, file_path: str, storage_config: StorageConfig):
if storage_config.storage_type == "local":
with open(file_path, "wb") as f:
f.write(content)
elif storage_config.storage_type == "ftp":
# TODO: پیاده‌سازی FTP upload
pass
async def _read_file_from_storage(self, file_path: str, storage_type: str) -> bytes:
if storage_type == "local":
with open(file_path, "rb") as f:
return f.read()
elif storage_type == "ftp":
# TODO: پیاده‌سازی FTP download
pass
return b""
async def _delete_file_from_storage(self, file_path: str, storage_type: str):
if storage_type == "local":
if os.path.exists(file_path):
os.remove(file_path)
elif storage_type == "ftp":
# TODO: پیاده‌سازی FTP delete
pass

View file

@ -8,6 +8,9 @@ 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/admin/file_storage.py
adapters/api/v1/schemas/__init__.py
adapters/api/v1/schemas/file_storage.py
adapters/api/v1/support/__init__.py adapters/api/v1/support/__init__.py
adapters/api/v1/support/categories.py adapters/api/v1/support/categories.py
adapters/api/v1/support/operator.py adapters/api/v1/support/operator.py
@ -22,6 +25,7 @@ adapters/db/models/api_key.py
adapters/db/models/business.py adapters/db/models/business.py
adapters/db/models/business_permission.py adapters/db/models/business_permission.py
adapters/db/models/captcha.py adapters/db/models/captcha.py
adapters/db/models/file_storage.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/__init__.py
@ -34,6 +38,7 @@ 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/file_storage_repository.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/__init__.py
@ -61,6 +66,7 @@ app/services/api_key_service.py
app/services/auth_service.py app/services/auth_service.py
app/services/business_service.py app/services/business_service.py
app/services/captcha_service.py app/services/captcha_service.py
app/services/file_storage_service.py
app/services/query_service.py app/services/query_service.py
app/services/pdf/__init__.py app/services/pdf/__init__.py
app/services/pdf/base_pdf_service.py app/services/pdf/base_pdf_service.py
@ -81,6 +87,7 @@ 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 migrations/versions/5553f8745c6e_add_support_tables.py
migrations/versions/8bf0dbb9fba9_add_file_storage_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

@ -202,3 +202,70 @@ msgstr "Referral Code"
msgid "status" msgid "status"
msgstr "Status" msgstr "Status"
# File Storage Management
msgid "FILE_LIST_NOT_IMPLEMENTED"
msgstr "File list - to be implemented"
msgid "FILE_LIST_ERROR"
msgstr "Error retrieving file list"
msgid "UNVERIFIED_FILES_ERROR"
msgstr "Error retrieving unverified files"
msgid "CLEANUP_COMPLETED"
msgstr "Temporary files cleanup completed successfully"
msgid "CLEANUP_ERROR"
msgstr "Error cleaning up temporary files"
msgid "FILE_DELETED_SUCCESS"
msgstr "File deleted successfully"
msgid "DELETE_FILE_ERROR"
msgstr "Error deleting file"
msgid "FILE_RESTORED_SUCCESS"
msgstr "File restored successfully"
msgid "RESTORE_FILE_ERROR"
msgstr "Error restoring file"
msgid "STATISTICS_ERROR"
msgstr "Error retrieving statistics"
msgid "STORAGE_CONFIGS_ERROR"
msgstr "Error retrieving storage configurations"
msgid "STORAGE_CONFIG_CREATED"
msgstr "Storage configuration created successfully"
msgid "CREATE_STORAGE_CONFIG_ERROR"
msgstr "Error creating storage configuration"
msgid "STORAGE_CONFIG_UPDATE_NOT_IMPLEMENTED"
msgstr "Storage configuration update - to be implemented"
msgid "UPDATE_STORAGE_CONFIG_ERROR"
msgstr "Error updating storage configuration"
msgid "STORAGE_CONFIG_NOT_FOUND"
msgstr "Storage configuration not found"
msgid "DEFAULT_STORAGE_CONFIG_UPDATED"
msgstr "Default storage configuration updated successfully"
msgid "SET_DEFAULT_STORAGE_CONFIG_ERROR"
msgstr "Error setting default configuration"
msgid "STORAGE_CONFIG_DELETED"
msgstr "Storage configuration deleted successfully"
msgid "DELETE_STORAGE_CONFIG_ERROR"
msgstr "Error deleting storage configuration"
msgid "STORAGE_CONNECTION_TEST_NOT_IMPLEMENTED"
msgstr "Storage connection test - to be implemented"
msgid "TEST_STORAGE_CONFIG_ERROR"
msgstr "Error testing storage connection"

View file

@ -203,4 +203,71 @@ msgstr "کد معرف"
msgid "status" msgid "status"
msgstr "وضعیت" msgstr "وضعیت"
# File Storage Management
msgid "FILE_LIST_NOT_IMPLEMENTED"
msgstr "لیست فایل‌ها - در حال پیاده‌سازی"
msgid "FILE_LIST_ERROR"
msgstr "خطا در دریافت لیست فایل‌ها"
msgid "UNVERIFIED_FILES_ERROR"
msgstr "خطا در دریافت فایل‌های تایید نشده"
msgid "CLEANUP_COMPLETED"
msgstr "پاکسازی فایل‌های موقت با موفقیت انجام شد"
msgid "CLEANUP_ERROR"
msgstr "خطا در پاکسازی فایل‌های موقت"
msgid "FILE_DELETED_SUCCESS"
msgstr "فایل با موفقیت حذف شد"
msgid "DELETE_FILE_ERROR"
msgstr "خطا در حذف فایل"
msgid "FILE_RESTORED_SUCCESS"
msgstr "فایل با موفقیت بازیابی شد"
msgid "RESTORE_FILE_ERROR"
msgstr "خطا در بازیابی فایل"
msgid "STATISTICS_ERROR"
msgstr "خطا در دریافت آمار"
msgid "STORAGE_CONFIGS_ERROR"
msgstr "خطا در دریافت تنظیمات ذخیره‌سازی"
msgid "STORAGE_CONFIG_CREATED"
msgstr "تنظیمات ذخیره‌سازی با موفقیت ایجاد شد"
msgid "CREATE_STORAGE_CONFIG_ERROR"
msgstr "خطا در ایجاد تنظیمات ذخیره‌سازی"
msgid "STORAGE_CONFIG_UPDATE_NOT_IMPLEMENTED"
msgstr "بروزرسانی تنظیمات ذخیره‌سازی - در حال پیاده‌سازی"
msgid "UPDATE_STORAGE_CONFIG_ERROR"
msgstr "خطا در بروزرسانی تنظیمات ذخیره‌سازی"
msgid "STORAGE_CONFIG_NOT_FOUND"
msgstr "تنظیمات ذخیره‌سازی یافت نشد"
msgid "DEFAULT_STORAGE_CONFIG_UPDATED"
msgstr "تنظیمات پیش‌فرض ذخیره‌سازی با موفقیت بروزرسانی شد"
msgid "SET_DEFAULT_STORAGE_CONFIG_ERROR"
msgstr "خطا در تنظیم پیش‌فرض"
msgid "STORAGE_CONFIG_DELETED"
msgstr "تنظیمات ذخیره‌سازی با موفقیت حذف شد"
msgid "DELETE_STORAGE_CONFIG_ERROR"
msgstr "خطا در حذف تنظیمات ذخیره‌سازی"
msgid "STORAGE_CONNECTION_TEST_NOT_IMPLEMENTED"
msgstr "تست اتصال ذخیره‌سازی - در حال پیاده‌سازی"
msgid "TEST_STORAGE_CONFIG_ERROR"
msgstr "خطا در تست اتصال"

View file

@ -0,0 +1,28 @@
"""add_file_storage_tables
Revision ID: 8bf0dbb9fba9
Revises: 5553f8745c6e
Create Date: 2025-09-21 19:23:10.515694
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8bf0dbb9fba9'
down_revision = '5553f8745c6e'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View file

@ -317,6 +317,86 @@
"close": "Close", "close": "Close",
"ticketInfo": "Ticket Information", "ticketInfo": "Ticket Information",
"conversation": "Conversation", "conversation": "Conversation",
"messageCount": "{count} messages" "messageCount": "{count} messages",
"fileStorage": "File Management",
"fileStorageSettings": "File Settings",
"storageConfigurations": "Storage Configurations",
"addStorageConfig": "Add Storage Configuration",
"editStorageConfig": "Edit Storage Configuration",
"storageName": "Configuration Name",
"storageType": "Storage Type",
"localStorage": "Local Storage",
"ftpStorage": "FTP Storage",
"isDefault": "Default",
"isActive": "Active",
"configData": "Configuration Data",
"basePath": "Base Path",
"ftpHost": "FTP Host",
"ftpPort": "FTP Port",
"ftpUsername": "FTP Username",
"ftpPassword": "FTP Password",
"ftpDirectory": "FTP Directory",
"testConnection": "Test Connection",
"connectionSuccessful": "Connection Successful",
"connectionFailed": "Connection Failed",
"setAsDefault": "Set as Default",
"fileStatistics": "File Statistics",
"totalFiles": "Total Files",
"totalSize": "Total Size",
"temporaryFiles": "Temporary Files",
"unverifiedFiles": "Unverified Files",
"cleanupTemporaryFiles": "Cleanup Temporary Files",
"cleanupCompleted": "Cleanup Completed",
"filesCleaned": "{count} files cleaned",
"fileManagement": "File Management",
"allFiles": "All Files",
"unverifiedFilesList": "Unverified Files",
"fileName": "File Name",
"fileSize": "File Size",
"mimeType": "MIME Type",
"moduleContext": "Module Context",
"createdAt": "Created At",
"expiresAt": "Expires At",
"isTemporary": "Temporary",
"isVerified": "Verified",
"forceDelete": "Force Delete",
"restoreFile": "Restore File",
"deleteConfirm": "Confirm Delete",
"deleteConfirmMessage": "Are you sure you want to delete this file?",
"restoreConfirm": "Confirm Restore",
"restoreConfirmMessage": "Are you sure you want to restore this file?",
"fileDeleted": "File deleted",
"fileRestored": "File restored",
"errorDeletingFile": "Error deleting file",
"errorRestoringFile": "Error restoring file",
"noFilesFound": "No files found",
"loadingFiles": "Loading files...",
"errorLoadingFiles": "Error loading files",
"refreshFiles": "Refresh Files",
"fileDetails": "File Details",
"originalName": "Original Name",
"storedName": "Stored Name",
"filePath": "File Path",
"checksum": "Checksum",
"uploadedBy": "Uploaded by",
"lastVerified": "Last Verified",
"developerData": "Developer Data",
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
"actions": "Actions",
"search": "Search",
"filter": "Filter",
"clear": "Clear",
"apply": "Apply",
"reset": "Reset",
"page": "Page",
"of": "of",
"itemsPerPage": "Items per page",
"previous": "Previous",
"next": "Next",
"first": "First",
"last": "Last"
} }

View file

@ -316,6 +316,86 @@
"close": "بستن", "close": "بستن",
"ticketInfo": "اطلاعات تیکت", "ticketInfo": "اطلاعات تیکت",
"conversation": "مکالمه", "conversation": "مکالمه",
"messageCount": "{count} پیام" "messageCount": "{count} پیام",
"fileStorage": "مدیریت فایل",
"fileStorageSettings": "تنظیمات فایل",
"storageConfigurations": "پیکربندی‌های ذخیره‌سازی",
"addStorageConfig": "افزودن پیکربندی ذخیره‌سازی",
"editStorageConfig": "ویرایش پیکربندی ذخیره‌سازی",
"storageName": "نام پیکربندی",
"storageType": "نوع ذخیره‌سازی",
"localStorage": "ذخیره‌سازی محلی",
"ftpStorage": "ذخیره‌سازی FTP",
"isDefault": "پیش‌فرض",
"isActive": "فعال",
"configData": "داده‌های پیکربندی",
"basePath": "مسیر پایه",
"ftpHost": "میزبان FTP",
"ftpPort": "پورت FTP",
"ftpUsername": "نام کاربری FTP",
"ftpPassword": "رمز عبور FTP",
"ftpDirectory": "پوشه FTP",
"testConnection": "تست اتصال",
"connectionSuccessful": "اتصال موفقیت‌آمیز",
"connectionFailed": "اتصال ناموفق",
"setAsDefault": "تنظیم به عنوان پیش‌فرض",
"fileStatistics": "آمار فایل‌ها",
"totalFiles": "کل فایل‌ها",
"totalSize": "حجم کل",
"temporaryFiles": "فایل‌های موقت",
"unverifiedFiles": "فایل‌های تایید نشده",
"cleanupTemporaryFiles": "پاکسازی فایل‌های موقت",
"cleanupCompleted": "پاکسازی انجام شد",
"filesCleaned": "{count} فایل پاکسازی شد",
"fileManagement": "مدیریت فایل‌ها",
"allFiles": "تمام فایل‌ها",
"unverifiedFilesList": "فایل‌های تایید نشده",
"fileName": "نام فایل",
"fileSize": "حجم فایل",
"mimeType": "نوع فایل",
"moduleContext": "زمینه ماژول",
"createdAt": "تاریخ ایجاد",
"expiresAt": "تاریخ انقضا",
"isTemporary": "موقت",
"isVerified": "تایید شده",
"forceDelete": "حذف اجباری",
"restoreFile": "بازیابی فایل",
"deleteConfirm": "تایید حذف",
"deleteConfirmMessage": "آیا از حذف این فایل مطمئن هستید؟",
"restoreConfirm": "تایید بازیابی",
"restoreConfirmMessage": "آیا از بازیابی این فایل مطمئن هستید؟",
"fileDeleted": "فایل حذف شد",
"fileRestored": "فایل بازیابی شد",
"errorDeletingFile": "خطا در حذف فایل",
"errorRestoringFile": "خطا در بازیابی فایل",
"noFilesFound": "هیچ فایلی یافت نشد",
"loadingFiles": "در حال بارگذاری فایل‌ها...",
"errorLoadingFiles": "خطا در بارگذاری فایل‌ها",
"refreshFiles": "تازه‌سازی فایل‌ها",
"fileDetails": "جزئیات فایل",
"originalName": "نام اصلی",
"storedName": "نام ذخیره شده",
"filePath": "مسیر فایل",
"checksum": "چکسام",
"uploadedBy": "آپلود شده توسط",
"lastVerified": "آخرین تایید",
"developerData": "داده‌های توسعه‌دهنده",
"save": "ذخیره",
"cancel": "لغو",
"edit": "ویرایش",
"delete": "حذف",
"actions": "عملیات",
"search": "جستجو",
"filter": "فیلتر",
"clear": "پاک کردن",
"apply": "اعمال",
"reset": "بازنشانی",
"page": "صفحه",
"of": "از",
"itemsPerPage": "آیتم در هر صفحه",
"previous": "قبلی",
"next": "بعدی",
"first": "اول",
"last": "آخر"
} }

View file

@ -1783,6 +1783,444 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Close'** /// **'Close'**
String get close; String get close;
/// No description provided for @fileStorage.
///
/// In en, this message translates to:
/// **'File Management'**
String get fileStorage;
/// No description provided for @fileStorageSettings.
///
/// In en, this message translates to:
/// **'File Settings'**
String get fileStorageSettings;
/// No description provided for @storageConfigurations.
///
/// In en, this message translates to:
/// **'Storage Configurations'**
String get storageConfigurations;
/// No description provided for @addStorageConfig.
///
/// In en, this message translates to:
/// **'Add Storage Configuration'**
String get addStorageConfig;
/// No description provided for @editStorageConfig.
///
/// In en, this message translates to:
/// **'Edit Storage Configuration'**
String get editStorageConfig;
/// No description provided for @storageName.
///
/// In en, this message translates to:
/// **'Configuration Name'**
String get storageName;
/// No description provided for @storageType.
///
/// In en, this message translates to:
/// **'Storage Type'**
String get storageType;
/// No description provided for @localStorage.
///
/// In en, this message translates to:
/// **'Local Storage'**
String get localStorage;
/// No description provided for @ftpStorage.
///
/// In en, this message translates to:
/// **'FTP Storage'**
String get ftpStorage;
/// No description provided for @isDefault.
///
/// In en, this message translates to:
/// **'Default'**
String get isDefault;
/// No description provided for @isActive.
///
/// In en, this message translates to:
/// **'Active'**
String get isActive;
/// No description provided for @configData.
///
/// In en, this message translates to:
/// **'Configuration Data'**
String get configData;
/// No description provided for @basePath.
///
/// In en, this message translates to:
/// **'Base Path'**
String get basePath;
/// No description provided for @ftpHost.
///
/// In en, this message translates to:
/// **'FTP Host'**
String get ftpHost;
/// No description provided for @ftpPort.
///
/// In en, this message translates to:
/// **'FTP Port'**
String get ftpPort;
/// No description provided for @ftpUsername.
///
/// In en, this message translates to:
/// **'FTP Username'**
String get ftpUsername;
/// No description provided for @ftpPassword.
///
/// In en, this message translates to:
/// **'FTP Password'**
String get ftpPassword;
/// No description provided for @ftpDirectory.
///
/// In en, this message translates to:
/// **'FTP Directory'**
String get ftpDirectory;
/// No description provided for @testConnection.
///
/// In en, this message translates to:
/// **'Test Connection'**
String get testConnection;
/// No description provided for @connectionSuccessful.
///
/// In en, this message translates to:
/// **'Connection Successful'**
String get connectionSuccessful;
/// No description provided for @connectionFailed.
///
/// In en, this message translates to:
/// **'Connection Failed'**
String get connectionFailed;
/// No description provided for @setAsDefault.
///
/// In en, this message translates to:
/// **'Set as Default'**
String get setAsDefault;
/// No description provided for @fileStatistics.
///
/// In en, this message translates to:
/// **'File Statistics'**
String get fileStatistics;
/// No description provided for @totalFiles.
///
/// In en, this message translates to:
/// **'Total Files'**
String get totalFiles;
/// No description provided for @totalSize.
///
/// In en, this message translates to:
/// **'Total Size'**
String get totalSize;
/// No description provided for @temporaryFiles.
///
/// In en, this message translates to:
/// **'Temporary Files'**
String get temporaryFiles;
/// No description provided for @unverifiedFiles.
///
/// In en, this message translates to:
/// **'Unverified Files'**
String get unverifiedFiles;
/// No description provided for @cleanupTemporaryFiles.
///
/// In en, this message translates to:
/// **'Cleanup Temporary Files'**
String get cleanupTemporaryFiles;
/// No description provided for @cleanupCompleted.
///
/// In en, this message translates to:
/// **'Cleanup Completed'**
String get cleanupCompleted;
/// No description provided for @filesCleaned.
///
/// In en, this message translates to:
/// **'{count} files cleaned'**
String filesCleaned(Object count);
/// No description provided for @fileManagement.
///
/// In en, this message translates to:
/// **'File Management'**
String get fileManagement;
/// No description provided for @allFiles.
///
/// In en, this message translates to:
/// **'All Files'**
String get allFiles;
/// No description provided for @unverifiedFilesList.
///
/// In en, this message translates to:
/// **'Unverified Files'**
String get unverifiedFilesList;
/// No description provided for @fileName.
///
/// In en, this message translates to:
/// **'File Name'**
String get fileName;
/// No description provided for @fileSize.
///
/// In en, this message translates to:
/// **'File Size'**
String get fileSize;
/// No description provided for @mimeType.
///
/// In en, this message translates to:
/// **'MIME Type'**
String get mimeType;
/// No description provided for @moduleContext.
///
/// In en, this message translates to:
/// **'Module Context'**
String get moduleContext;
/// No description provided for @expiresAt.
///
/// In en, this message translates to:
/// **'Expires At'**
String get expiresAt;
/// No description provided for @isTemporary.
///
/// In en, this message translates to:
/// **'Temporary'**
String get isTemporary;
/// No description provided for @isVerified.
///
/// In en, this message translates to:
/// **'Verified'**
String get isVerified;
/// No description provided for @forceDelete.
///
/// In en, this message translates to:
/// **'Force Delete'**
String get forceDelete;
/// No description provided for @restoreFile.
///
/// In en, this message translates to:
/// **'Restore File'**
String get restoreFile;
/// No description provided for @deleteConfirm.
///
/// In en, this message translates to:
/// **'Confirm Delete'**
String get deleteConfirm;
/// No description provided for @deleteConfirmMessage.
///
/// In en, this message translates to:
/// **'Are you sure you want to delete this file?'**
String get deleteConfirmMessage;
/// No description provided for @restoreConfirm.
///
/// In en, this message translates to:
/// **'Confirm Restore'**
String get restoreConfirm;
/// No description provided for @restoreConfirmMessage.
///
/// In en, this message translates to:
/// **'Are you sure you want to restore this file?'**
String get restoreConfirmMessage;
/// No description provided for @fileDeleted.
///
/// In en, this message translates to:
/// **'File deleted'**
String get fileDeleted;
/// No description provided for @fileRestored.
///
/// In en, this message translates to:
/// **'File restored'**
String get fileRestored;
/// No description provided for @errorDeletingFile.
///
/// In en, this message translates to:
/// **'Error deleting file'**
String get errorDeletingFile;
/// No description provided for @errorRestoringFile.
///
/// In en, this message translates to:
/// **'Error restoring file'**
String get errorRestoringFile;
/// No description provided for @noFilesFound.
///
/// In en, this message translates to:
/// **'No files found'**
String get noFilesFound;
/// No description provided for @loadingFiles.
///
/// In en, this message translates to:
/// **'Loading files...'**
String get loadingFiles;
/// No description provided for @errorLoadingFiles.
///
/// In en, this message translates to:
/// **'Error loading files'**
String get errorLoadingFiles;
/// No description provided for @refreshFiles.
///
/// In en, this message translates to:
/// **'Refresh Files'**
String get refreshFiles;
/// No description provided for @fileDetails.
///
/// In en, this message translates to:
/// **'File Details'**
String get fileDetails;
/// No description provided for @originalName.
///
/// In en, this message translates to:
/// **'Original Name'**
String get originalName;
/// No description provided for @storedName.
///
/// In en, this message translates to:
/// **'Stored Name'**
String get storedName;
/// No description provided for @filePath.
///
/// In en, this message translates to:
/// **'File Path'**
String get filePath;
/// No description provided for @checksum.
///
/// In en, this message translates to:
/// **'Checksum'**
String get checksum;
/// No description provided for @uploadedBy.
///
/// In en, this message translates to:
/// **'Uploaded by'**
String get uploadedBy;
/// No description provided for @lastVerified.
///
/// In en, this message translates to:
/// **'Last Verified'**
String get lastVerified;
/// No description provided for @developerData.
///
/// In en, this message translates to:
/// **'Developer Data'**
String get developerData;
/// No description provided for @edit.
///
/// In en, this message translates to:
/// **'Edit'**
String get edit;
/// No description provided for @delete.
///
/// In en, this message translates to:
/// **'Delete'**
String get delete;
/// No description provided for @actions.
///
/// In en, this message translates to:
/// **'Actions'**
String get actions;
/// No description provided for @search.
///
/// In en, this message translates to:
/// **'Search'**
String get search;
/// No description provided for @filter.
///
/// In en, this message translates to:
/// **'Filter'**
String get filter;
/// No description provided for @apply.
///
/// In en, this message translates to:
/// **'Apply'**
String get apply;
/// No description provided for @reset.
///
/// In en, this message translates to:
/// **'Reset'**
String get reset;
/// No description provided for @of.
///
/// In en, this message translates to:
/// **'of'**
String get of;
/// No description provided for @itemsPerPage.
///
/// In en, this message translates to:
/// **'Items per page'**
String get itemsPerPage;
/// No description provided for @first.
///
/// In en, this message translates to:
/// **'First'**
String get first;
/// No description provided for @last.
///
/// In en, this message translates to:
/// **'Last'**
String get last;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -877,4 +877,227 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get close => 'Close'; String get close => 'Close';
@override
String get fileStorage => 'File Management';
@override
String get fileStorageSettings => 'File Settings';
@override
String get storageConfigurations => 'Storage Configurations';
@override
String get addStorageConfig => 'Add Storage Configuration';
@override
String get editStorageConfig => 'Edit Storage Configuration';
@override
String get storageName => 'Configuration Name';
@override
String get storageType => 'Storage Type';
@override
String get localStorage => 'Local Storage';
@override
String get ftpStorage => 'FTP Storage';
@override
String get isDefault => 'Default';
@override
String get isActive => 'Active';
@override
String get configData => 'Configuration Data';
@override
String get basePath => 'Base Path';
@override
String get ftpHost => 'FTP Host';
@override
String get ftpPort => 'FTP Port';
@override
String get ftpUsername => 'FTP Username';
@override
String get ftpPassword => 'FTP Password';
@override
String get ftpDirectory => 'FTP Directory';
@override
String get testConnection => 'Test Connection';
@override
String get connectionSuccessful => 'Connection Successful';
@override
String get connectionFailed => 'Connection Failed';
@override
String get setAsDefault => 'Set as Default';
@override
String get fileStatistics => 'File Statistics';
@override
String get totalFiles => 'Total Files';
@override
String get totalSize => 'Total Size';
@override
String get temporaryFiles => 'Temporary Files';
@override
String get unverifiedFiles => 'Unverified Files';
@override
String get cleanupTemporaryFiles => 'Cleanup Temporary Files';
@override
String get cleanupCompleted => 'Cleanup Completed';
@override
String filesCleaned(Object count) {
return '$count files cleaned';
}
@override
String get fileManagement => 'File Management';
@override
String get allFiles => 'All Files';
@override
String get unverifiedFilesList => 'Unverified Files';
@override
String get fileName => 'File Name';
@override
String get fileSize => 'File Size';
@override
String get mimeType => 'MIME Type';
@override
String get moduleContext => 'Module Context';
@override
String get expiresAt => 'Expires At';
@override
String get isTemporary => 'Temporary';
@override
String get isVerified => 'Verified';
@override
String get forceDelete => 'Force Delete';
@override
String get restoreFile => 'Restore File';
@override
String get deleteConfirm => 'Confirm Delete';
@override
String get deleteConfirmMessage =>
'Are you sure you want to delete this file?';
@override
String get restoreConfirm => 'Confirm Restore';
@override
String get restoreConfirmMessage =>
'Are you sure you want to restore this file?';
@override
String get fileDeleted => 'File deleted';
@override
String get fileRestored => 'File restored';
@override
String get errorDeletingFile => 'Error deleting file';
@override
String get errorRestoringFile => 'Error restoring file';
@override
String get noFilesFound => 'No files found';
@override
String get loadingFiles => 'Loading files...';
@override
String get errorLoadingFiles => 'Error loading files';
@override
String get refreshFiles => 'Refresh Files';
@override
String get fileDetails => 'File Details';
@override
String get originalName => 'Original Name';
@override
String get storedName => 'Stored Name';
@override
String get filePath => 'File Path';
@override
String get checksum => 'Checksum';
@override
String get uploadedBy => 'Uploaded by';
@override
String get lastVerified => 'Last Verified';
@override
String get developerData => 'Developer Data';
@override
String get edit => 'Edit';
@override
String get delete => 'Delete';
@override
String get actions => 'Actions';
@override
String get search => 'Search';
@override
String get filter => 'Filter';
@override
String get apply => 'Apply';
@override
String get reset => 'Reset';
@override
String get of => 'of';
@override
String get itemsPerPage => 'Items per page';
@override
String get first => 'First';
@override
String get last => 'Last';
} }

View file

@ -873,4 +873,225 @@ class AppLocalizationsFa extends AppLocalizations {
@override @override
String get close => 'بستن'; String get close => 'بستن';
@override
String get fileStorage => 'مدیریت فایل';
@override
String get fileStorageSettings => 'تنظیمات فایل';
@override
String get storageConfigurations => 'پیکربندی‌های ذخیره‌سازی';
@override
String get addStorageConfig => 'افزودن پیکربندی ذخیره‌سازی';
@override
String get editStorageConfig => 'ویرایش پیکربندی ذخیره‌سازی';
@override
String get storageName => 'نام پیکربندی';
@override
String get storageType => 'نوع ذخیره‌سازی';
@override
String get localStorage => 'ذخیره‌سازی محلی';
@override
String get ftpStorage => 'ذخیره‌سازی FTP';
@override
String get isDefault => 'پیش‌فرض';
@override
String get isActive => 'فعال';
@override
String get configData => 'داده‌های پیکربندی';
@override
String get basePath => 'مسیر پایه';
@override
String get ftpHost => 'میزبان FTP';
@override
String get ftpPort => 'پورت FTP';
@override
String get ftpUsername => 'نام کاربری FTP';
@override
String get ftpPassword => 'رمز عبور FTP';
@override
String get ftpDirectory => 'پوشه FTP';
@override
String get testConnection => 'تست اتصال';
@override
String get connectionSuccessful => 'اتصال موفقیت‌آمیز';
@override
String get connectionFailed => 'اتصال ناموفق';
@override
String get setAsDefault => 'تنظیم به عنوان پیش‌فرض';
@override
String get fileStatistics => 'آمار فایل‌ها';
@override
String get totalFiles => 'کل فایل‌ها';
@override
String get totalSize => 'حجم کل';
@override
String get temporaryFiles => 'فایل‌های موقت';
@override
String get unverifiedFiles => 'فایل‌های تایید نشده';
@override
String get cleanupTemporaryFiles => 'پاکسازی فایل‌های موقت';
@override
String get cleanupCompleted => 'پاکسازی انجام شد';
@override
String filesCleaned(Object count) {
return '$count فایل پاکسازی شد';
}
@override
String get fileManagement => 'مدیریت فایل‌ها';
@override
String get allFiles => 'تمام فایل‌ها';
@override
String get unverifiedFilesList => 'فایل‌های تایید نشده';
@override
String get fileName => 'نام فایل';
@override
String get fileSize => 'حجم فایل';
@override
String get mimeType => 'نوع فایل';
@override
String get moduleContext => 'زمینه ماژول';
@override
String get expiresAt => 'تاریخ انقضا';
@override
String get isTemporary => 'موقت';
@override
String get isVerified => 'تایید شده';
@override
String get forceDelete => 'حذف اجباری';
@override
String get restoreFile => 'بازیابی فایل';
@override
String get deleteConfirm => 'تایید حذف';
@override
String get deleteConfirmMessage => 'آیا از حذف این فایل مطمئن هستید؟';
@override
String get restoreConfirm => 'تایید بازیابی';
@override
String get restoreConfirmMessage => 'آیا از بازیابی این فایل مطمئن هستید؟';
@override
String get fileDeleted => 'فایل حذف شد';
@override
String get fileRestored => 'فایل بازیابی شد';
@override
String get errorDeletingFile => 'خطا در حذف فایل';
@override
String get errorRestoringFile => 'خطا در بازیابی فایل';
@override
String get noFilesFound => 'هیچ فایلی یافت نشد';
@override
String get loadingFiles => 'در حال بارگذاری فایل‌ها...';
@override
String get errorLoadingFiles => 'خطا در بارگذاری فایل‌ها';
@override
String get refreshFiles => 'تازه‌سازی فایل‌ها';
@override
String get fileDetails => 'جزئیات فایل';
@override
String get originalName => 'نام اصلی';
@override
String get storedName => 'نام ذخیره شده';
@override
String get filePath => 'مسیر فایل';
@override
String get checksum => 'چکسام';
@override
String get uploadedBy => 'آپلود شده توسط';
@override
String get lastVerified => 'آخرین تایید';
@override
String get developerData => 'داده‌های توسعه‌دهنده';
@override
String get edit => 'ویرایش';
@override
String get delete => 'حذف';
@override
String get actions => 'عملیات';
@override
String get search => 'جستجو';
@override
String get filter => 'فیلتر';
@override
String get apply => 'اعمال';
@override
String get reset => 'بازنشانی';
@override
String get of => 'از';
@override
String get itemsPerPage => 'آیتم در هر صفحه';
@override
String get first => 'اول';
@override
String get last => 'آخر';
} }

View file

@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/widgets/admin/file_storage/storage_config_list_widget.dart';
import 'package:hesabix_ui/widgets/admin/file_storage/file_statistics_widget.dart';
import 'package:hesabix_ui/widgets/admin/file_storage/file_management_widget.dart';
class FileStorageSettingsPage extends StatefulWidget {
const FileStorageSettingsPage({super.key});
@override
State<FileStorageSettingsPage> createState() => _FileStorageSettingsPageState();
}
class _FileStorageSettingsPageState extends State<FileStorageSettingsPage>
with TickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.fileStorageSettings),
bottom: TabBar(
controller: _tabController,
tabs: [
Tab(
icon: const Icon(Icons.storage),
text: l10n.storageConfigurations,
),
Tab(
icon: const Icon(Icons.analytics),
text: l10n.fileStatistics,
),
Tab(
icon: const Icon(Icons.folder),
text: l10n.fileManagement,
),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
// Storage Configurations Tab
const StorageConfigListWidget(),
// File Statistics Tab
const FileStatisticsWidget(),
// File Management Tab
const FileManagementWidget(),
],
),
);
}
}

View file

@ -47,10 +47,13 @@ class _ChangePasswordPageState extends State<ChangePasswordPage> {
if (response.statusCode == 200 && response.data?['success'] == true) { if (response.statusCode == 200 && response.data?['success'] == true) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(Navigator.of(context, rootNavigator: true).context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context).changePasswordSuccess), content: Text(AppLocalizations.of(context).changePasswordSuccess),
backgroundColor: Colors.green, backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 2),
), ),
); );
_clearForm(); _clearForm();
@ -76,10 +79,12 @@ class _ChangePasswordPageState extends State<ChangePasswordPage> {
void _showError(String message) { void _showError(String message) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(Navigator.of(context, rootNavigator: true).context).showSnackBar(
SnackBar( SnackBar(
content: Text(message), content: Text(message),
backgroundColor: Colors.red, backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 5), // نمایش طولانیتر برای خواندن duration: const Duration(seconds: 5), // نمایش طولانیتر برای خواندن
), ),
); );

View file

@ -41,6 +41,63 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
super.dispose(); super.dispose();
} }
void _showOverlayMessage(String message, Color backgroundColor, Duration duration) {
final overlay = Overlay.of(context);
late OverlayEntry overlayEntry;
overlayEntry = OverlayEntry(
builder: (context) => Positioned(
top: MediaQuery.of(context).padding.top + 20,
left: 16,
right: 16,
child: Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
backgroundColor == Colors.green ? Icons.check_circle : Icons.error,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
),
);
overlay.insert(overlayEntry);
// Remove overlay after duration
Future.delayed(duration, () {
overlayEntry.remove();
});
}
Future<void> _loadData() async { Future<void> _loadData() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@ -68,11 +125,10 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
if (_selectedCategory == null || _selectedPriority == null) { if (_selectedCategory == null || _selectedPriority == null) {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar( _showOverlayMessage(
SnackBar( t.pleaseSelectCategoryAndPriority,
content: Text(t.pleaseSelectCategoryAndPriority), Colors.red,
backgroundColor: Colors.red, const Duration(seconds: 3),
),
); );
return; return;
} }
@ -94,11 +150,10 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
if (mounted) { if (mounted) {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar( _showOverlayMessage(
SnackBar( t.ticketCreatedSuccessfully,
content: Text(t.ticketCreatedSuccessfully), Colors.green,
backgroundColor: Colors.green, const Duration(seconds: 2),
),
); );
Navigator.pop(context, true); Navigator.pop(context, true);
} }

View file

@ -92,10 +92,13 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
Future<void> _submitBusiness() async { Future<void> _submitBusiness() async {
final t = Localizations.of<AppLocalizations>(context, AppLocalizations)!; final t = Localizations.of<AppLocalizations>(context, AppLocalizations)!;
if (!_businessData.isFormValid()) { if (!_businessData.isFormValid()) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(Navigator.of(context, rootNavigator: true).context).showSnackBar(
SnackBar( SnackBar(
content: Text(t.pleaseFillRequiredFields), content: Text(t.pleaseFillRequiredFields),
backgroundColor: Colors.red, backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 3),
), ),
); );
return; return;
@ -109,20 +112,26 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
await BusinessApiService.createBusiness(_businessData); await BusinessApiService.createBusiness(_businessData);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(Navigator.of(context, rootNavigator: true).context).showSnackBar(
SnackBar( SnackBar(
content: Text(t.businessCreatedSuccessfully), content: Text(t.businessCreatedSuccessfully),
backgroundColor: Colors.green, backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 2),
), ),
); );
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(Navigator.of(context, rootNavigator: true).context).showSnackBar(
SnackBar( SnackBar(
content: Text('${t.businessCreationFailed}: $e'), content: Text('${t.businessCreationFailed}: $e'),
backgroundColor: Colors.red, backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 5),
), ),
); );
} }

View file

@ -22,6 +22,9 @@ class _OperatorTicketsPageState extends State<OperatorTicketsPage> {
final SupportService _supportService = SupportService(ApiClient()); final SupportService _supportService = SupportService(ApiClient());
List<SupportStatus> _statuses = []; List<SupportStatus> _statuses = [];
List<SupportPriority> _priorities = []; List<SupportPriority> _priorities = [];
// Refresh counter to force data table refresh
int _refreshCounter = 0;
@override @override
void initState() { void initState() {
@ -52,8 +55,10 @@ class _OperatorTicketsPageState extends State<OperatorTicketsPage> {
ticket: ticket, ticket: ticket,
isOperator: true, isOperator: true,
onTicketUpdated: () { onTicketUpdated: () {
// Refresh the data table if needed // Refresh the data table after ticket update
setState(() {}); setState(() {
_refreshCounter++;
});
}, },
), ),
); );
@ -85,6 +90,7 @@ class _OperatorTicketsPageState extends State<OperatorTicketsPage> {
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded( Expanded(
child: DataTableWidget<Map<String, dynamic>>( child: DataTableWidget<Map<String, dynamic>>(
key: ValueKey('data_table_$_refreshCounter'),
config: DataTableConfig<Map<String, dynamic>>( config: DataTableConfig<Map<String, dynamic>>(
title: 'لیست تیکت‌های پشتیبانی - پنل اپراتور', title: 'لیست تیکت‌های پشتیبانی - پنل اپراتور',
endpoint: '/api/v1/support/operator/tickets/search', endpoint: '/api/v1/support/operator/tickets/search',

View file

@ -23,6 +23,9 @@ class _SupportPageState extends State<SupportPage> {
final SupportService _supportService = SupportService(ApiClient()); final SupportService _supportService = SupportService(ApiClient());
List<SupportStatus> _statuses = []; List<SupportStatus> _statuses = [];
List<SupportPriority> _priorities = []; List<SupportPriority> _priorities = [];
// Refresh counter to force data table refresh
int _refreshCounter = 0;
@override @override
void initState() { void initState() {
@ -52,7 +55,10 @@ class _SupportPageState extends State<SupportPage> {
); );
if (result == true) { if (result == true) {
// Refresh will be handled by DataTableWidget // Refresh the data table after successful ticket creation
setState(() {
_refreshCounter++;
});
} }
} }
@ -83,6 +89,7 @@ class _SupportPageState extends State<SupportPage> {
children: [ children: [
Expanded( Expanded(
child: DataTableWidget<Map<String, dynamic>>( child: DataTableWidget<Map<String, dynamic>>(
key: ValueKey('data_table_$_refreshCounter'),
config: DataTableConfig<Map<String, dynamic>>( config: DataTableConfig<Map<String, dynamic>>(
title: t.supportTickets, title: t.supportTickets,
endpoint: '/api/v1/support/search', endpoint: '/api/v1/support/search',

View file

@ -1,12 +1,13 @@
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/pages/admin/file_storage_settings_page.dart';
class SystemSettingsPage extends StatelessWidget { class SystemSettingsPage extends StatelessWidget {
const SystemSettingsPage({super.key}); const SystemSettingsPage({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context)!;
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
@ -70,10 +71,10 @@ class SystemSettingsPage extends StatelessWidget {
// Settings Cards // Settings Cards
Expanded( Expanded(
child: GridView.count( child: GridView.count(
crossAxisCount: 2, crossAxisCount: 3,
crossAxisSpacing: 16, crossAxisSpacing: 16,
mainAxisSpacing: 16, mainAxisSpacing: 16,
childAspectRatio: 1.5, childAspectRatio: 1.2,
children: [ children: [
_buildSettingCard( _buildSettingCard(
context, context,
@ -110,12 +111,26 @@ class SystemSettingsPage extends StatelessWidget {
subtitle: 'مدیریت پشتیبان‌ها', subtitle: 'مدیریت پشتیبان‌ها',
color: Colors.teal, color: Colors.teal,
), ),
_buildSettingCard(
context,
icon: Icons.storage,
title: t.fileStorage,
subtitle: t.fileStorageSettings,
color: Colors.indigo,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const FileStorageSettingsPage(),
),
);
},
),
_buildSettingCard( _buildSettingCard(
context, context,
icon: Icons.tune, icon: Icons.tune,
title: 'تنظیمات پیشرفته', title: 'تنظیمات پیشرفته',
subtitle: 'تنظیمات تخصصی سیستم', subtitle: 'تنظیمات تخصصی سیستم',
color: Colors.indigo, color: Colors.grey,
), ),
], ],
), ),
@ -164,6 +179,7 @@ class SystemSettingsPage extends StatelessWidget {
required String title, required String title,
required String subtitle, required String subtitle,
required Color color, required Color color,
VoidCallback? onTap,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
@ -175,7 +191,7 @@ class SystemSettingsPage extends StatelessWidget {
), ),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
onTap: () { onTap: onTap ?? () {
// TODO: Navigate to specific setting // TODO: Navigate to specific setting
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(

View file

@ -0,0 +1,372 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
class FileManagementWidget extends StatefulWidget {
const FileManagementWidget({super.key});
@override
State<FileManagementWidget> createState() => _FileManagementWidgetState();
}
class _FileManagementWidgetState extends State<FileManagementWidget>
with TickerProviderStateMixin {
late TabController _tabController;
List<Map<String, dynamic>> _allFiles = [];
List<Map<String, dynamic>> _unverifiedFiles = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_loadFiles();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _loadFiles() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
// TODO: Call API to load files
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
setState(() {
_allFiles = [
{
'id': '1',
'original_name': 'document.pdf',
'file_size': 1024000,
'mime_type': 'application/pdf',
'module_context': 'tickets',
'created_at': '2024-01-01T10:00:00Z',
'expires_at': null,
'is_temporary': false,
'is_verified': true,
},
{
'id': '2',
'original_name': 'image.jpg',
'file_size': 512000,
'mime_type': 'image/jpeg',
'module_context': 'accounting',
'created_at': '2024-01-02T11:00:00Z',
'expires_at': '2024-01-09T11:00:00Z',
'is_temporary': true,
'is_verified': false,
},
{
'id': '3',
'original_name': 'spreadsheet.xlsx',
'file_size': 256000,
'mime_type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'module_context': 'reports',
'created_at': '2024-01-03T12:00:00Z',
'expires_at': null,
'is_temporary': false,
'is_verified': true,
},
];
_unverifiedFiles = _allFiles.where((file) => file['is_verified'] == false).toList();
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _forceDeleteFile(String fileId) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.deleteConfirm),
content: Text(l10n.deleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.forceDelete),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
),
],
),
);
if (confirmed == true) {
try {
// TODO: Call API to force delete file
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.fileDeleted),
backgroundColor: Colors.green,
),
);
_loadFiles();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.errorDeletingFile),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _restoreFile(String fileId) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.restoreConfirm),
content: Text(l10n.restoreConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.restoreFile),
),
],
),
);
if (confirmed == true) {
try {
// TODO: Call API to restore file
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.fileRestored),
backgroundColor: Colors.green,
),
);
_loadFiles();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.errorRestoringFile),
backgroundColor: Colors.red,
),
);
}
}
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
String _formatDate(String dateString) {
final date = DateTime.parse(dateString);
return '${date.day}/${date.month}/${date.year}';
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Column(
children: [
TabBar(
controller: _tabController,
tabs: [
Tab(
icon: const Icon(Icons.folder),
text: l10n.allFiles,
),
Tab(
icon: const Icon(Icons.warning),
text: l10n.unverifiedFilesList,
),
],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildFilesList(_allFiles),
_buildFilesList(_unverifiedFiles),
],
),
),
],
);
}
Widget _buildFilesList(List<Map<String, dynamic>> files) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
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: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
_error!,
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadFiles,
child: Text(l10n.retry),
),
],
),
);
}
if (files.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.folder_outlined,
size: 64,
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
l10n.noFilesFound,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: files.length,
itemBuilder: (context, index) {
final file = files[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
_getFileIcon(file['mime_type']),
color: theme.colorScheme.primary,
),
title: Text(
file['original_name'],
style: theme.textTheme.titleMedium,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${l10n.fileSize}: ${_formatFileSize(file['file_size'])}'),
Text('${l10n.moduleContext}: ${file['module_context']}'),
Text('${l10n.createdAt}: ${_formatDate(file['created_at'])}'),
if (file['is_temporary'] == true)
Text(
'${l10n.isTemporary}: ${file['expires_at'] != null ? _formatDate(file['expires_at']) : 'N/A'}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
if (file['is_verified'] == false)
Text(
l10n.isVerified,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'delete':
_forceDeleteFile(file['id']);
break;
case 'restore':
_restoreFile(file['id']);
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, color: theme.colorScheme.error),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.forceDelete),
],
),
),
PopupMenuItem(
value: 'restore',
child: Row(
children: [
Icon(Icons.restore, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.restoreFile),
],
),
),
],
),
),
);
},
);
}
IconData _getFileIcon(String mimeType) {
if (mimeType.startsWith('image/')) return Icons.image;
if (mimeType.startsWith('video/')) return Icons.video_file;
if (mimeType.startsWith('audio/')) return Icons.audio_file;
if (mimeType.contains('pdf')) return Icons.picture_as_pdf;
if (mimeType.contains('word')) return Icons.description;
if (mimeType.contains('excel') || mimeType.contains('spreadsheet')) return Icons.table_chart;
if (mimeType.contains('zip') || mimeType.contains('rar')) return Icons.archive;
return Icons.insert_drive_file;
}
}

View file

@ -0,0 +1,335 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
class FileStatisticsWidget extends StatefulWidget {
const FileStatisticsWidget({super.key});
@override
State<FileStatisticsWidget> createState() => _FileStatisticsWidgetState();
}
class _FileStatisticsWidgetState extends State<FileStatisticsWidget> {
Map<String, dynamic>? _statistics;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadStatistics();
}
Future<void> _loadStatistics() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
// TODO: Call API to load statistics
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
setState(() {
_statistics = {
'total_files': 1250,
'total_size': 2048576000, // 2GB in bytes
'temporary_files': 45,
'unverified_files': 12,
};
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _cleanupTemporaryFiles() async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.cleanupTemporaryFiles),
content: Text(l10n.deleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.cleanupTemporaryFiles),
),
],
),
);
if (confirmed == true) {
try {
// TODO: Call API to cleanup temporary files
await Future.delayed(const Duration(seconds: 2)); // Simulate API call
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.cleanupCompleted),
backgroundColor: Colors.green,
),
);
_loadStatistics();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
String _formatFileSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
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: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
_error!,
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadStatistics,
child: Text(l10n.retry),
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
l10n.fileStatistics,
style: theme.textTheme.headlineSmall,
),
),
ElevatedButton.icon(
onPressed: _cleanupTemporaryFiles,
icon: const Icon(Icons.cleaning_services),
label: Text(l10n.cleanupTemporaryFiles),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onError,
),
),
],
),
const SizedBox(height: 24),
// Statistics Cards
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: MediaQuery.of(context).size.width > 600 ? 2 : 1,
childAspectRatio: 2.5,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
children: [
_buildStatCard(
context,
l10n.totalFiles,
_statistics!['total_files'].toString(),
Icons.folder,
theme.colorScheme.primary,
),
_buildStatCard(
context,
l10n.totalSize,
_formatFileSize(_statistics!['total_size']),
Icons.storage,
theme.colorScheme.secondary,
),
_buildStatCard(
context,
l10n.temporaryFiles,
_statistics!['temporary_files'].toString(),
Icons.schedule,
theme.colorScheme.tertiary,
),
_buildStatCard(
context,
l10n.unverifiedFiles,
_statistics!['unverified_files'].toString(),
Icons.warning,
theme.colorScheme.error,
),
],
),
const SizedBox(height: 24),
// Additional Information
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Storage Information',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildInfoRow(
context,
'Average file size',
_formatFileSize(_statistics!['total_size'] ~/ _statistics!['total_files']),
Icons.info_outline,
),
_buildInfoRow(
context,
'Storage efficiency',
'95%',
Icons.trending_up,
),
_buildInfoRow(
context,
'Last cleanup',
'2 days ago',
Icons.cleaning_services,
),
],
),
),
),
],
),
);
}
Widget _buildStatCard(
BuildContext context,
String title,
String value,
IconData icon,
Color color,
) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: 4),
Text(
value,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
),
],
),
),
);
}
Widget _buildInfoRow(
BuildContext context,
String label,
String value,
IconData icon,
) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
icon,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: theme.textTheme.bodyMedium,
),
),
Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
);
}
}

View file

@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
class StorageConfigCard extends StatelessWidget {
final Map<String, dynamic> config;
final VoidCallback? onEdit;
final VoidCallback? onSetDefault;
final VoidCallback? onTestConnection;
final VoidCallback? onDelete;
const StorageConfigCard({
super.key,
required this.config,
this.onEdit,
this.onSetDefault,
this.onTestConnection,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final isDefault = config['is_default'] == true;
final isActive = config['is_active'] == true;
final storageType = config['storage_type'] as String;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
storageType == 'local' ? Icons.storage : Icons.cloud_upload,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
config['name'] ?? '',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (isDefault)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
l10n.isDefault,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimary,
),
),
),
if (!isActive)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.error,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Inactive',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onError,
),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
l10n.storageType,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
const SizedBox(width: 8),
Text(
storageType == 'local' ? l10n.localStorage : l10n.ftpStorage,
style: theme.textTheme.bodySmall,
),
],
),
const SizedBox(height: 4),
if (storageType == 'local') ...[
Row(
children: [
Icon(
Icons.folder_outlined,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
l10n.basePath,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
config['config_data']?['base_path'] ?? '',
style: theme.textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
],
),
] else if (storageType == 'ftp') ...[
Row(
children: [
Icon(
Icons.cloud_outlined,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
'${l10n.ftpHost}: ${config['config_data']?['host'] ?? ''}',
style: theme.textTheme.bodySmall,
),
],
),
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.person_outline,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
'${l10n.ftpUsername}: ${config['config_data']?['username'] ?? ''}',
style: theme.textTheme.bodySmall,
),
],
),
],
const SizedBox(height: 12),
Row(
children: [
if (onEdit != null)
TextButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit, size: 16),
label: Text(l10n.edit),
),
if (onTestConnection != null)
TextButton.icon(
onPressed: onTestConnection,
icon: const Icon(Icons.wifi_protected_setup, size: 16),
label: Text(l10n.testConnection),
),
if (onSetDefault != null)
TextButton.icon(
onPressed: onSetDefault,
icon: const Icon(Icons.star, size: 16),
label: Text(l10n.setAsDefault),
),
if (onDelete != null)
TextButton.icon(
onPressed: onDelete,
icon: const Icon(Icons.delete, size: 16),
label: Text(l10n.delete),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.error,
),
),
],
),
],
),
),
);
}
}

View file

@ -0,0 +1,368 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
class StorageConfigFormDialog extends StatefulWidget {
final Map<String, dynamic>? config;
const StorageConfigFormDialog({
super.key,
this.config,
});
@override
State<StorageConfigFormDialog> createState() => _StorageConfigFormDialogState();
}
class _StorageConfigFormDialogState extends State<StorageConfigFormDialog> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _basePathController = TextEditingController();
final _ftpHostController = TextEditingController();
final _ftpPortController = TextEditingController();
final _ftpUsernameController = TextEditingController();
final _ftpPasswordController = TextEditingController();
final _ftpDirectoryController = TextEditingController();
String _selectedStorageType = 'local';
bool _isDefault = false;
bool _isActive = true;
bool _isLoading = false;
@override
void initState() {
super.initState();
if (widget.config != null) {
_loadConfigData();
}
}
void _loadConfigData() {
final config = widget.config!;
_nameController.text = config['name'] ?? '';
_selectedStorageType = config['storage_type'] ?? 'local';
_isDefault = config['is_default'] == true;
_isActive = config['is_active'] == true;
final configData = config['config_data'] ?? {};
if (_selectedStorageType == 'local') {
_basePathController.text = configData['base_path'] ?? '';
} else if (_selectedStorageType == 'ftp') {
_ftpHostController.text = configData['host'] ?? '';
_ftpPortController.text = configData['port']?.toString() ?? '21';
_ftpUsernameController.text = configData['username'] ?? '';
_ftpPasswordController.text = configData['password'] ?? '';
_ftpDirectoryController.text = configData['directory'] ?? '';
}
}
@override
void dispose() {
_nameController.dispose();
_basePathController.dispose();
_ftpHostController.dispose();
_ftpPortController.dispose();
_ftpUsernameController.dispose();
_ftpPasswordController.dispose();
_ftpDirectoryController.dispose();
super.dispose();
}
Map<String, dynamic> _buildConfigData() {
if (_selectedStorageType == 'local') {
return {
'base_path': _basePathController.text,
};
} else if (_selectedStorageType == 'ftp') {
return {
'host': _ftpHostController.text,
'port': int.tryParse(_ftpPortController.text) ?? 21,
'username': _ftpUsernameController.text,
'password': _ftpPasswordController.text,
'directory': _ftpDirectoryController.text,
};
}
return {};
}
Future<void> _saveConfig() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
try {
// TODO: Call API to save configuration
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
final configData = {
'name': _nameController.text,
'storage_type': _selectedStorageType,
'is_default': _isDefault,
'is_active': _isActive,
'config_data': _buildConfigData(),
};
if (mounted) {
Navigator.of(context).pop(configData);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final isEditing = widget.config != null;
return Dialog(
child: Container(
width: MediaQuery.of(context).size.width * 0.8,
constraints: const BoxConstraints(maxWidth: 600),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
isEditing ? Icons.edit : Icons.add,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
isEditing ? l10n.editStorageConfig : l10n.addStorageConfig,
style: theme.textTheme.titleLarge,
),
const Spacer(),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: l10n.storageName,
border: const OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.requiredField;
}
return null;
},
),
const SizedBox(height: 16),
// Storage Type
DropdownButtonFormField<String>(
value: _selectedStorageType,
decoration: InputDecoration(
labelText: l10n.storageType,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem(
value: 'local',
child: Text(l10n.localStorage),
),
DropdownMenuItem(
value: 'ftp',
child: Text(l10n.ftpStorage),
),
],
onChanged: (value) {
setState(() {
_selectedStorageType = value!;
});
},
),
const SizedBox(height: 16),
// Configuration based on storage type
if (_selectedStorageType == 'local') ...[
TextFormField(
controller: _basePathController,
decoration: InputDecoration(
labelText: l10n.basePath,
border: const OutlineInputBorder(),
hintText: '/var/hesabix/files',
),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.requiredField;
}
return null;
},
),
] else if (_selectedStorageType == 'ftp') ...[
TextFormField(
controller: _ftpHostController,
decoration: InputDecoration(
labelText: l10n.ftpHost,
border: const OutlineInputBorder(),
hintText: 'ftp.example.com',
),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.requiredField;
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _ftpPortController,
decoration: InputDecoration(
labelText: l10n.ftpPort,
border: const OutlineInputBorder(),
hintText: '21',
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.requiredField;
}
if (int.tryParse(value) == null) {
return 'Invalid port number';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _ftpUsernameController,
decoration: InputDecoration(
labelText: l10n.ftpUsername,
border: const OutlineInputBorder(),
hintText: 'username',
),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.requiredField;
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _ftpPasswordController,
decoration: InputDecoration(
labelText: l10n.ftpPassword,
border: const OutlineInputBorder(),
hintText: 'password',
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.requiredField;
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _ftpDirectoryController,
decoration: InputDecoration(
labelText: l10n.ftpDirectory,
border: const OutlineInputBorder(),
hintText: '/hesabix/files',
),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.requiredField;
}
return null;
},
),
],
const SizedBox(height: 16),
// Options
Row(
children: [
Checkbox(
value: _isDefault,
onChanged: (value) {
setState(() {
_isDefault = value ?? false;
});
},
),
Text(l10n.isDefault),
const SizedBox(width: 24),
Checkbox(
value: _isActive,
onChanged: (value) {
setState(() {
_isActive = value ?? false;
});
},
),
Text(l10n.isActive),
],
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isLoading ? null : _saveConfig,
child: _isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.save),
),
],
),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/widgets/admin/file_storage/storage_config_form_dialog.dart';
import 'package:hesabix_ui/widgets/admin/file_storage/storage_config_card.dart';
class StorageConfigListWidget extends StatefulWidget {
const StorageConfigListWidget({super.key});
@override
State<StorageConfigListWidget> createState() => _StorageConfigListWidgetState();
}
class _StorageConfigListWidgetState extends State<StorageConfigListWidget> {
List<Map<String, dynamic>> _storageConfigs = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadStorageConfigs();
}
Future<void> _loadStorageConfigs() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
// TODO: Call API to load storage configurations
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
// Mock data for now
setState(() {
_storageConfigs = [
{
'id': '1',
'name': 'Local Storage Default',
'storage_type': 'local',
'is_default': true,
'is_active': true,
'config_data': {
'base_path': '/var/hesabix/files'
},
'created_at': '2024-01-01T00:00:00Z',
},
{
'id': '2',
'name': 'FTP Backup',
'storage_type': 'ftp',
'is_default': false,
'is_active': true,
'config_data': {
'host': 'ftp.example.com',
'port': 21,
'username': 'hesabix',
'password': '***',
'directory': '/hesabix/files'
},
'created_at': '2024-01-02T00:00:00Z',
},
];
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _addStorageConfig() async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => const StorageConfigFormDialog(),
);
if (result != null) {
_loadStorageConfigs();
}
}
Future<void> _editStorageConfig(Map<String, dynamic> config) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => StorageConfigFormDialog(config: config),
);
if (result != null) {
_loadStorageConfigs();
}
}
Future<void> _setAsDefault(String configId) async {
final l10n = AppLocalizations.of(context)!;
try {
// TODO: Call API to set as default
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.setAsDefault),
backgroundColor: Colors.green,
),
);
_loadStorageConfigs();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: Colors.red,
),
);
}
}
Future<void> _testConnection(String configId) async {
final l10n = AppLocalizations.of(context)!;
try {
// TODO: Call API to test connection
await Future.delayed(const Duration(seconds: 2)); // Simulate API call
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.connectionSuccessful),
backgroundColor: Colors.green,
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.connectionFailed),
backgroundColor: Colors.red,
),
);
}
}
Future<void> _deleteConfig(String configId) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.deleteConfirm),
content: Text(l10n.deleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.delete),
),
],
),
);
if (confirmed == true) {
try {
// TODO: Call API to delete config
await Future.delayed(const Duration(seconds: 1)); // Simulate API call
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.fileDeleted),
backgroundColor: Colors.green,
),
);
_loadStorageConfigs();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
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: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
_error!,
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadStorageConfigs,
child: Text(l10n.retry),
),
],
),
);
}
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: Text(
l10n.storageConfigurations,
style: theme.textTheme.headlineSmall,
),
),
ElevatedButton.icon(
onPressed: _addStorageConfig,
icon: const Icon(Icons.add),
label: Text(l10n.addStorageConfig),
),
],
),
),
Expanded(
child: _storageConfigs.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.storage_outlined,
size: 64,
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
l10n.noFilesFound,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _storageConfigs.length,
itemBuilder: (context, index) {
final config = _storageConfigs[index];
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: StorageConfigCard(
config: config,
onEdit: () => _editStorageConfig(config),
onSetDefault: config['is_default'] == false
? () => _setAsDefault(config['id'])
: null,
onTestConnection: () => _testConnection(config['id']),
onDelete: config['is_default'] == false
? () => _deleteConfig(config['id'])
: null,
),
);
},
),
),
],
);
}
}

View file

@ -255,6 +255,9 @@ class DataTableConfig<T> {
// Show individual action buttons // Show individual action buttons
final bool showFiltersButton; final bool showFiltersButton;
// Refresh callback
final VoidCallback? onRefresh;
const DataTableConfig({ const DataTableConfig({
required this.endpoint, required this.endpoint,
@ -318,6 +321,7 @@ class DataTableConfig<T> {
this.onColumnSettingsChanged, this.onColumnSettingsChanged,
this.customHeaderActions, this.customHeaderActions,
this.showFiltersButton = false, this.showFiltersButton = false,
this.onRefresh,
}); });
/// Get column width as double /// Get column width as double

View file

@ -18,12 +18,14 @@ class DataTableWidget<T> extends StatefulWidget {
final DataTableConfig<T> config; final DataTableConfig<T> config;
final T Function(Map<String, dynamic>) fromJson; final T Function(Map<String, dynamic>) fromJson;
final CalendarController? calendarController; final CalendarController? calendarController;
final VoidCallback? onRefresh;
const DataTableWidget({ const DataTableWidget({
super.key, super.key,
required this.config, required this.config,
required this.fromJson, required this.fromJson,
this.calendarController, this.calendarController,
this.onRefresh,
}); });
@override @override
@ -82,6 +84,11 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
_fetchData(); _fetchData();
} }
/// Public method to refresh the data table
void refresh() {
_fetchData();
}
@override @override
void dispose() { void dispose() {
_searchCtrl.dispose(); _searchCtrl.dispose();
@ -200,6 +207,13 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
_totalPages = response.totalPages; _totalPages = response.totalPages;
_selectedRows.clear(); // Clear selection when data changes _selectedRows.clear(); // Clear selection when data changes
}); });
// Call the refresh callback if provided
if (widget.onRefresh != null) {
widget.onRefresh!();
} else if (widget.config.onRefresh != null) {
widget.config.onRefresh!();
}
} }
} catch (e) { } catch (e) {
setState(() { setState(() {

View file

@ -44,6 +44,63 @@ class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
super.dispose(); super.dispose();
} }
void _showOverlayMessage(String message, Color backgroundColor, Duration duration) {
final overlay = Overlay.of(context);
late OverlayEntry overlayEntry;
overlayEntry = OverlayEntry(
builder: (context) => Positioned(
top: MediaQuery.of(context).padding.top + 20,
left: 16,
right: 16,
child: Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
backgroundColor == Colors.green ? Icons.check_circle : Icons.error,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
),
);
overlay.insert(overlayEntry);
// Remove overlay after duration
Future.delayed(duration, () {
overlayEntry.remove();
});
}
Future<void> _loadMessages() async { Future<void> _loadMessages() async {
if (_isLoading) return; if (_isLoading) return;
@ -84,15 +141,11 @@ class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
if (mounted) { if (mounted) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar( // Show error message using Overlay to appear above dialog
SnackBar( _showOverlayMessage(
content: Text(l10n.ticketLoadingError), l10n.ticketLoadingError,
backgroundColor: Colors.red, Colors.red,
behavior: SnackBarBehavior.floating, const Duration(seconds: 3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
); );
} }
} }
@ -146,15 +199,11 @@ class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
if (mounted) { if (mounted) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar( // Show success message using Overlay to appear above dialog
SnackBar( _showOverlayMessage(
content: Text(l10n.messageSentSuccessfully), l10n.messageSentSuccessfully,
backgroundColor: Colors.green, Colors.green,
behavior: SnackBarBehavior.floating, const Duration(seconds: 2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
); );
} }
@ -167,41 +216,16 @@ class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
if (mounted) { if (mounted) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar( // Show error message using Overlay to appear above dialog
SnackBar( _showOverlayMessage(
content: Text(l10n.errorSendingMessage), l10n.errorSendingMessage,
backgroundColor: Colors.red, Colors.red,
behavior: SnackBarBehavior.floating, const Duration(seconds: 3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
); );
} }
} }
} }
String _formatDate(DateTime dateTime, AppLocalizations l10n) {
final now = DateTime.now();
final difference = now.difference(dateTime);
// If the difference is negative (future time), show just now
if (difference.isNegative) {
return l10n.justNow;
}
if (difference.inDays > 0) {
return l10n.daysAgo(difference.inDays.toString());
} else if (difference.inHours > 0) {
return l10n.hoursAgo(difference.inHours.toString());
} else if (difference.inMinutes > 0) {
return l10n.minutesAgo(difference.inMinutes.toString());
} else if (difference.inSeconds > 10) {
return l10n.justNow;
} else {
return l10n.justNow;
}
}
Widget _buildInfoChip(String label, String value, IconData icon) { Widget _buildInfoChip(String label, String value, IconData icon) {
return Container( return Container(
@ -299,90 +323,60 @@ class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
), ),
), ),
// Messages Section (Main Focus) // Messages Section (Main Focus) - No Card Container
Expanded( Expanded(
child: Container( child: Column(
margin: const EdgeInsets.all(20), children: [
decoration: BoxDecoration( // Messages Header with Ticket Info
color: Colors.grey[50], Container(
borderRadius: BorderRadius.circular(12), padding: const EdgeInsets.all(16),
border: Border.all(color: Colors.grey[200]!), decoration: BoxDecoration(
), color: Colors.grey[50],
child: Column( border: Border(
children: [ bottom: BorderSide(color: Colors.grey[200]!),
// Messages Header with Ticket Info
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
border: Border(
bottom: BorderSide(color: Colors.grey[200]!),
),
), ),
child: Column( ),
children: [ child: Column(
// Conversation Title children: [
Row( // Conversation Title
children: [ Row(
Icon( children: [
Icons.chat_bubble_outline, Icon(
Icons.chat_bubble_outline,
color: theme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
Text(
l10n.conversation,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.primaryColor, color: theme.primaryColor,
size: 20,
), ),
const SizedBox(width: 8), ),
Text( const Spacer(),
l10n.conversation, Text(
style: theme.textTheme.titleSmall?.copyWith( l10n.messageCount(_messages.length.toString()),
fontWeight: FontWeight.bold, style: theme.textTheme.bodySmall?.copyWith(
color: theme.primaryColor, color: Colors.grey[600],
),
), ),
const Spacer(), ),
Text( ],
l10n.messageCount(_messages.length.toString()), ),
style: theme.textTheme.bodySmall?.copyWith( if (widget.isOperator && (_ticket.user != null || _ticket.assignedOperator != null)) ...[
color: Colors.grey[600],
),
),
],
),
const SizedBox(height: 12), const SizedBox(height: 12),
// Ticket Info Chips // Only show user info for operators
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
_buildInfoChip( if (_ticket.user != null)
l10n.status,
_ticket.status?.name ?? '',
Icons.info_outline,
),
_buildInfoChip(
l10n.category,
_ticket.category?.name ?? '',
Icons.category_outlined,
),
_buildInfoChip(
l10n.priority,
_ticket.priority?.name ?? '',
Icons.priority_high,
),
_buildInfoChip(
l10n.createdAt,
_formatDate(_ticket.createdAt, l10n),
Icons.schedule,
),
if (widget.isOperator && _ticket.user != null)
_buildInfoChip( _buildInfoChip(
l10n.createdBy, l10n.createdBy,
_ticket.user!.displayName, _ticket.user!.displayName,
Icons.person, Icons.person,
), ),
if (widget.isOperator && _ticket.assignedOperator != null) if (_ticket.assignedOperator != null)
_buildInfoChip( _buildInfoChip(
l10n.assignedTo, l10n.assignedTo,
_ticket.assignedOperator!.displayName, _ticket.assignedOperator!.displayName,
@ -391,125 +385,121 @@ class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
], ],
), ),
], ],
), ],
), ),
),
// Messages List // Messages List
Expanded( Expanded(
child: _isLoading child: _isLoading
? const Center( ? const Center(
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
) )
: _messages.isEmpty : _messages.isEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
Icons.chat_bubble_outline, Icons.chat_bubble_outline,
size: 48, size: 48,
color: Colors.grey[400], color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
l10n.noMessagesFound,
style: theme.textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
),
],
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MessageBubble(
message: message,
),
);
},
),
),
// Message Input
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
border: Border(
top: BorderSide(color: Colors.grey[200]!),
),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: widget.isOperator
? l10n.writeYourResponse
: l10n.writeYourMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(color: Colors.grey[300]!),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(color: Colors.grey[300]!),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(color: theme.primaryColor),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
filled: true,
fillColor: Colors.grey[50],
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 12),
Container(
decoration: BoxDecoration(
color: theme.primaryColor,
borderRadius: BorderRadius.circular(25),
),
child: IconButton(
onPressed: _isSending ? null : _sendMessage,
icon: _isSending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(
Icons.send,
color: Colors.white,
), ),
), const SizedBox(height: 16),
), Text(
], l10n.noMessagesFound,
style: theme.textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
),
],
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MessageBubble(
message: message,
),
);
},
),
),
// Message Input
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[50],
border: Border(
top: BorderSide(color: Colors.grey[200]!),
), ),
), ),
], child: Row(
), children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: widget.isOperator
? l10n.writeYourResponse
: l10n.writeYourMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(color: Colors.grey[300]!),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(color: Colors.grey[300]!),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(color: theme.primaryColor),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
filled: true,
fillColor: Colors.white,
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 12),
Container(
decoration: BoxDecoration(
color: theme.primaryColor,
borderRadius: BorderRadius.circular(25),
),
child: IconButton(
onPressed: _isSending ? null : _sendMessage,
icon: _isSending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(
Icons.send,
color: Colors.white,
),
),
),
],
),
),
],
), ),
), ),
], ],