progress in file service
This commit is contained in:
parent
d782cbfffc
commit
b690e115d4
402
hesabixAPI/adapters/api/v1/admin/file_storage.py
Normal file
402
hesabixAPI/adapters/api/v1/admin/file_storage.py
Normal 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
|
||||
)
|
||||
2
hesabixAPI/adapters/api/v1/schemas/__init__.py
Normal file
2
hesabixAPI/adapters/api/v1/schemas/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Import all schemas
|
||||
from .file_storage import *
|
||||
79
hesabixAPI/adapters/api/v1/schemas/file_storage.py
Normal file
79
hesabixAPI/adapters/api/v1/schemas/file_storage.py
Normal 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="کل فایلهای تایید نشده")
|
||||
|
|
@ -225,6 +225,10 @@ async def send_operator_message(
|
|||
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
|
||||
message_data = MessageResponse.from_orm(message).dict()
|
||||
formatted_data = format_datetime_fields(message_data, request)
|
||||
|
|
|
|||
|
|
@ -11,4 +11,7 @@ from .business_permission import BusinessPermission # noqa: F401
|
|||
# Import support models
|
||||
from .support import * # noqa: F401, F403
|
||||
|
||||
# Import file storage models
|
||||
from .file_storage import * # noqa: F401, F403
|
||||
|
||||
|
||||
|
|
|
|||
72
hesabixAPI/adapters/db/models/file_storage.py
Normal file
72
hesabixAPI/adapters/db/models/file_storage.py
Normal 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])
|
||||
291
hesabixAPI/adapters/db/repositories/file_storage_repository.py
Normal file
291
hesabixAPI/adapters/db/repositories/file_storage_repository.py
Normal 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
|
||||
|
|
@ -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.priorities import router as support_priorities_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.error_handlers import register_error_handlers
|
||||
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
||||
|
|
@ -275,6 +276,9 @@ def create_app() -> FastAPI:
|
|||
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")
|
||||
|
||||
# Admin endpoints
|
||||
application.include_router(admin_file_storage_router, prefix=settings.api_v1_prefix)
|
||||
|
||||
register_error_handlers(application)
|
||||
|
||||
@application.get("/",
|
||||
|
|
|
|||
227
hesabixAPI/app/services/file_storage_service.py
Normal file
227
hesabixAPI/app/services/file_storage_service.py
Normal 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
|
||||
|
|
@ -8,6 +8,9 @@ adapters/api/v1/businesses.py
|
|||
adapters/api/v1/health.py
|
||||
adapters/api/v1/schemas.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/categories.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_permission.py
|
||||
adapters/db/models/captcha.py
|
||||
adapters/db/models/file_storage.py
|
||||
adapters/db/models/password_reset.py
|
||||
adapters/db/models/user.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/business_permission_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/user_repo.py
|
||||
adapters/db/repositories/support/__init__.py
|
||||
|
|
@ -61,6 +66,7 @@ app/services/api_key_service.py
|
|||
app/services/auth_service.py
|
||||
app/services/business_service.py
|
||||
app/services/captcha_service.py
|
||||
app/services/file_storage_service.py
|
||||
app/services/query_service.py
|
||||
app/services/pdf/__init__.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/20250916_000002_add_referral_fields.py
|
||||
migrations/versions/5553f8745c6e_add_support_tables.py
|
||||
migrations/versions/8bf0dbb9fba9_add_file_storage_tables.py
|
||||
tests/__init__.py
|
||||
tests/test_health.py
|
||||
tests/test_permissions.py
|
||||
Binary file not shown.
|
|
@ -202,3 +202,70 @@ msgstr "Referral Code"
|
|||
|
||||
msgid "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"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -203,4 +203,71 @@ msgstr "کد معرف"
|
|||
msgid "status"
|
||||
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 "خطا در تست اتصال"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -317,6 +317,86 @@
|
|||
"close": "Close",
|
||||
"ticketInfo": "Ticket Information",
|
||||
"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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -316,6 +316,86 @@
|
|||
"close": "بستن",
|
||||
"ticketInfo": "اطلاعات تیکت",
|
||||
"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": "آخر"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1783,6 +1783,444 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'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
|
||||
|
|
|
|||
|
|
@ -877,4 +877,227 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -873,4 +873,225 @@ class AppLocalizationsFa extends AppLocalizations {
|
|||
|
||||
@override
|
||||
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 => 'آخر';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -47,10 +47,13 @@ class _ChangePasswordPageState extends State<ChangePasswordPage> {
|
|||
|
||||
if (response.statusCode == 200 && response.data?['success'] == true) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(Navigator.of(context, rootNavigator: true).context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context).changePasswordSuccess),
|
||||
backgroundColor: Colors.green,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(16),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
_clearForm();
|
||||
|
|
@ -76,10 +79,12 @@ class _ChangePasswordPageState extends State<ChangePasswordPage> {
|
|||
|
||||
void _showError(String message) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(Navigator.of(context, rootNavigator: true).context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(16),
|
||||
duration: const Duration(seconds: 5), // نمایش طولانیتر برای خواندن
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,63 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
|
|||
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 {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
|
|
@ -68,11 +125,10 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
|
|||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_selectedCategory == null || _selectedPriority == null) {
|
||||
final t = AppLocalizations.of(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.pleaseSelectCategoryAndPriority),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
_showOverlayMessage(
|
||||
t.pleaseSelectCategoryAndPriority,
|
||||
Colors.red,
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -94,11 +150,10 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
|
|||
|
||||
if (mounted) {
|
||||
final t = AppLocalizations.of(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.ticketCreatedSuccessfully),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
_showOverlayMessage(
|
||||
t.ticketCreatedSuccessfully,
|
||||
Colors.green,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,10 +92,13 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
Future<void> _submitBusiness() async {
|
||||
final t = Localizations.of<AppLocalizations>(context, AppLocalizations)!;
|
||||
if (!_businessData.isFormValid()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(Navigator.of(context, rootNavigator: true).context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.pleaseFillRequiredFields),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(16),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
|
@ -109,20 +112,26 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
await BusinessApiService.createBusiness(_businessData);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(Navigator.of(context, rootNavigator: true).context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.businessCreatedSuccessfully),
|
||||
backgroundColor: Colors.green,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(16),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(Navigator.of(context, rootNavigator: true).context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${t.businessCreationFailed}: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(16),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ class _OperatorTicketsPageState extends State<OperatorTicketsPage> {
|
|||
List<SupportStatus> _statuses = [];
|
||||
List<SupportPriority> _priorities = [];
|
||||
|
||||
// Refresh counter to force data table refresh
|
||||
int _refreshCounter = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -52,8 +55,10 @@ class _OperatorTicketsPageState extends State<OperatorTicketsPage> {
|
|||
ticket: ticket,
|
||||
isOperator: true,
|
||||
onTicketUpdated: () {
|
||||
// Refresh the data table if needed
|
||||
setState(() {});
|
||||
// Refresh the data table after ticket update
|
||||
setState(() {
|
||||
_refreshCounter++;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -85,6 +90,7 @@ class _OperatorTicketsPageState extends State<OperatorTicketsPage> {
|
|||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: DataTableWidget<Map<String, dynamic>>(
|
||||
key: ValueKey('data_table_$_refreshCounter'),
|
||||
config: DataTableConfig<Map<String, dynamic>>(
|
||||
title: 'لیست تیکتهای پشتیبانی - پنل اپراتور',
|
||||
endpoint: '/api/v1/support/operator/tickets/search',
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ class _SupportPageState extends State<SupportPage> {
|
|||
List<SupportStatus> _statuses = [];
|
||||
List<SupportPriority> _priorities = [];
|
||||
|
||||
// Refresh counter to force data table refresh
|
||||
int _refreshCounter = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -52,7 +55,10 @@ class _SupportPageState extends State<SupportPage> {
|
|||
);
|
||||
|
||||
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: [
|
||||
Expanded(
|
||||
child: DataTableWidget<Map<String, dynamic>>(
|
||||
key: ValueKey('data_table_$_refreshCounter'),
|
||||
config: DataTableConfig<Map<String, dynamic>>(
|
||||
title: t.supportTickets,
|
||||
endpoint: '/api/v1/support/search',
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import 'package:hesabix_ui/pages/admin/file_storage_settings_page.dart';
|
||||
|
||||
class SystemSettingsPage extends StatelessWidget {
|
||||
const SystemSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final t = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
|
|
@ -70,10 +71,10 @@ class SystemSettingsPage extends StatelessWidget {
|
|||
// Settings Cards
|
||||
Expanded(
|
||||
child: GridView.count(
|
||||
crossAxisCount: 2,
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 1.5,
|
||||
childAspectRatio: 1.2,
|
||||
children: [
|
||||
_buildSettingCard(
|
||||
context,
|
||||
|
|
@ -110,12 +111,26 @@ class SystemSettingsPage extends StatelessWidget {
|
|||
subtitle: 'مدیریت پشتیبانها',
|
||||
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(
|
||||
context,
|
||||
icon: Icons.tune,
|
||||
title: 'تنظیمات پیشرفته',
|
||||
subtitle: 'تنظیمات تخصصی سیستم',
|
||||
color: Colors.indigo,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -164,6 +179,7 @@ class SystemSettingsPage extends StatelessWidget {
|
|||
required String title,
|
||||
required String subtitle,
|
||||
required Color color,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
|
@ -175,7 +191,7 @@ class SystemSettingsPage extends StatelessWidget {
|
|||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () {
|
||||
onTap: onTap ?? () {
|
||||
// TODO: Navigate to specific setting
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -256,6 +256,9 @@ class DataTableConfig<T> {
|
|||
// Show individual action buttons
|
||||
final bool showFiltersButton;
|
||||
|
||||
// Refresh callback
|
||||
final VoidCallback? onRefresh;
|
||||
|
||||
const DataTableConfig({
|
||||
required this.endpoint,
|
||||
required this.columns,
|
||||
|
|
@ -318,6 +321,7 @@ class DataTableConfig<T> {
|
|||
this.onColumnSettingsChanged,
|
||||
this.customHeaderActions,
|
||||
this.showFiltersButton = false,
|
||||
this.onRefresh,
|
||||
});
|
||||
|
||||
/// Get column width as double
|
||||
|
|
|
|||
|
|
@ -18,12 +18,14 @@ class DataTableWidget<T> extends StatefulWidget {
|
|||
final DataTableConfig<T> config;
|
||||
final T Function(Map<String, dynamic>) fromJson;
|
||||
final CalendarController? calendarController;
|
||||
final VoidCallback? onRefresh;
|
||||
|
||||
const DataTableWidget({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.fromJson,
|
||||
this.calendarController,
|
||||
this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -82,6 +84,11 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
_fetchData();
|
||||
}
|
||||
|
||||
/// Public method to refresh the data table
|
||||
void refresh() {
|
||||
_fetchData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
|
|
@ -200,6 +207,13 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
_totalPages = response.totalPages;
|
||||
_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) {
|
||||
setState(() {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,63 @@ class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
|
|||
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 {
|
||||
if (_isLoading) return;
|
||||
|
||||
|
|
@ -84,15 +141,11 @@ class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
|
|||
|
||||
if (mounted) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.ticketLoadingError),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
// Show error message using Overlay to appear above dialog
|
||||
_showOverlayMessage(
|
||||
l10n.ticketLoadingError,
|
||||
Colors.red,
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -146,15 +199,11 @@ class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
|
|||
|
||||
if (mounted) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.messageSentSuccessfully),
|
||||
backgroundColor: Colors.green,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
// Show success message using Overlay to appear above dialog
|
||||
_showOverlayMessage(
|
||||
l10n.messageSentSuccessfully,
|
||||
Colors.green,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -167,41 +216,16 @@ class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
|
|||
|
||||
if (mounted) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.errorSendingMessage),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
// Show error message using Overlay to appear above dialog
|
||||
_showOverlayMessage(
|
||||
l10n.errorSendingMessage,
|
||||
Colors.red,
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return Container(
|
||||
|
|
@ -299,90 +323,60 @@ class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
|
|||
),
|
||||
),
|
||||
|
||||
// Messages Section (Main Focus)
|
||||
// Messages Section (Main Focus) - No Card Container
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey[200]!),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 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: [
|
||||
// Messages Header with Ticket Info
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey[200]!),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Conversation Title
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Conversation Title
|
||||
Row(
|
||||
children: [
|
||||
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,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.conversation,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.primaryColor,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
l10n.messageCount(_messages.length.toString()),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
l10n.messageCount(_messages.length.toString()),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.isOperator && (_ticket.user != null || _ticket.assignedOperator != null)) ...[
|
||||
const SizedBox(height: 12),
|
||||
// Ticket Info Chips
|
||||
// Only show user info for operators
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildInfoChip(
|
||||
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)
|
||||
if (_ticket.user != null)
|
||||
_buildInfoChip(
|
||||
l10n.createdBy,
|
||||
_ticket.user!.displayName,
|
||||
Icons.person,
|
||||
),
|
||||
if (widget.isOperator && _ticket.assignedOperator != null)
|
||||
if (_ticket.assignedOperator != null)
|
||||
_buildInfoChip(
|
||||
l10n.assignedTo,
|
||||
_ticket.assignedOperator!.displayName,
|
||||
|
|
@ -391,125 +385,121 @@ class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
|
|||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Messages List
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: _messages.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 48,
|
||||
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,
|
||||
// Messages List
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: _messages.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 48,
|
||||
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.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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue