From b690e115d4dcc61ad4c9cb2fd0124e3e80acab9c Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Sun, 21 Sep 2025 19:53:21 +0330 Subject: [PATCH] progress in file service --- .../adapters/api/v1/admin/file_storage.py | 402 ++++++++++++++++ .../adapters/api/v1/schemas/__init__.py | 2 + .../adapters/api/v1/schemas/file_storage.py | 79 +++ .../adapters/api/v1/support/operator.py | 4 + hesabixAPI/adapters/db/models/__init__.py | 3 + hesabixAPI/adapters/db/models/file_storage.py | 72 +++ .../repositories/file_storage_repository.py | 291 +++++++++++ hesabixAPI/app/main.py | 4 + .../app/services/file_storage_service.py | 227 +++++++++ hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 7 + hesabixAPI/locales/en/LC_MESSAGES/messages.mo | Bin 3214 -> 4881 bytes hesabixAPI/locales/en/LC_MESSAGES/messages.po | 67 +++ hesabixAPI/locales/fa/LC_MESSAGES/messages.mo | Bin 4023 -> 6171 bytes hesabixAPI/locales/fa/LC_MESSAGES/messages.po | 67 +++ .../8bf0dbb9fba9_add_file_storage_tables.py | 28 ++ hesabixUI/hesabix_ui/lib/l10n/app_en.arb | 82 +++- hesabixUI/hesabix_ui/lib/l10n/app_fa.arb | 82 +++- .../lib/l10n/app_localizations.dart | 438 +++++++++++++++++ .../lib/l10n/app_localizations_en.dart | 223 +++++++++ .../lib/l10n/app_localizations_fa.dart | 221 +++++++++ .../admin/file_storage_settings_page.dart | 70 +++ .../pages/profile/change_password_page.dart | 9 +- .../lib/pages/profile/create_ticket_page.dart | 75 ++- .../lib/pages/profile/new_business_page.dart | 15 +- .../operator/operator_tickets_page.dart | 10 +- .../lib/pages/profile/support_page.dart | 9 +- .../lib/pages/system_settings_page.dart | 26 +- .../file_storage/file_management_widget.dart | 372 ++++++++++++++ .../file_storage/file_statistics_widget.dart | 335 +++++++++++++ .../file_storage/storage_config_card.dart | 196 ++++++++ .../storage_config_form_dialog.dart | 368 ++++++++++++++ .../storage_config_list_widget.dart | 289 +++++++++++ .../widgets/data_table/data_table_config.dart | 4 + .../widgets/data_table/data_table_widget.dart | 14 + .../support/ticket_details_dialog.dart | 452 +++++++++--------- 35 files changed, 4287 insertions(+), 256 deletions(-) create mode 100644 hesabixAPI/adapters/api/v1/admin/file_storage.py create mode 100644 hesabixAPI/adapters/api/v1/schemas/__init__.py create mode 100644 hesabixAPI/adapters/api/v1/schemas/file_storage.py create mode 100644 hesabixAPI/adapters/db/models/file_storage.py create mode 100644 hesabixAPI/adapters/db/repositories/file_storage_repository.py create mode 100644 hesabixAPI/app/services/file_storage_service.py create mode 100644 hesabixAPI/migrations/versions/8bf0dbb9fba9_add_file_storage_tables.py create mode 100644 hesabixUI/hesabix_ui/lib/pages/admin/file_storage_settings_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/file_management_widget.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/file_statistics_widget.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_card.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_form_dialog.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_list_widget.dart diff --git a/hesabixAPI/adapters/api/v1/admin/file_storage.py b/hesabixAPI/adapters/api/v1/admin/file_storage.py new file mode 100644 index 0000000..f8e9852 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/admin/file_storage.py @@ -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 + ) diff --git a/hesabixAPI/adapters/api/v1/schemas/__init__.py b/hesabixAPI/adapters/api/v1/schemas/__init__.py new file mode 100644 index 0000000..5f4296c --- /dev/null +++ b/hesabixAPI/adapters/api/v1/schemas/__init__.py @@ -0,0 +1,2 @@ +# Import all schemas +from .file_storage import * diff --git a/hesabixAPI/adapters/api/v1/schemas/file_storage.py b/hesabixAPI/adapters/api/v1/schemas/file_storage.py new file mode 100644 index 0000000..a96df3b --- /dev/null +++ b/hesabixAPI/adapters/api/v1/schemas/file_storage.py @@ -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="کل فایل‌های تایید نشده") diff --git a/hesabixAPI/adapters/api/v1/support/operator.py b/hesabixAPI/adapters/api/v1/support/operator.py index 1fda7ff..d3aec63 100644 --- a/hesabixAPI/adapters/api/v1/support/operator.py +++ b/hesabixAPI/adapters/api/v1/support/operator.py @@ -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) diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py index 4eb887f..bbb2d13 100644 --- a/hesabixAPI/adapters/db/models/__init__.py +++ b/hesabixAPI/adapters/db/models/__init__.py @@ -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 + diff --git a/hesabixAPI/adapters/db/models/file_storage.py b/hesabixAPI/adapters/db/models/file_storage.py new file mode 100644 index 0000000..e6a1ee5 --- /dev/null +++ b/hesabixAPI/adapters/db/models/file_storage.py @@ -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]) diff --git a/hesabixAPI/adapters/db/repositories/file_storage_repository.py b/hesabixAPI/adapters/db/repositories/file_storage_repository.py new file mode 100644 index 0000000..f0bffae --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/file_storage_repository.py @@ -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 diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index 4037c65..9b8cb32 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -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 @@ -274,6 +275,9 @@ def create_app() -> FastAPI: application.include_router(support_categories_router, prefix=f"{settings.api_v1_prefix}/metadata/categories") application.include_router(support_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) diff --git a/hesabixAPI/app/services/file_storage_service.py b/hesabixAPI/app/services/file_storage_service.py new file mode 100644 index 0000000..c7d4911 --- /dev/null +++ b/hesabixAPI/app/services/file_storage_service.py @@ -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 diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index 5c9edde..e134326 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.mo b/hesabixAPI/locales/en/LC_MESSAGES/messages.mo index 57b00d385e17d7ce908bfe3423488313b37046b4..b49073d4993eb59b9f12143abca0921654623239 100644 GIT binary patch literal 4881 zcmaKuYltLQ700h`jAqts;`)k-(HnJqY^-r+V%E(pE7WvV&s5Ug)mGI^TqP;)blvVL zQ(e_tRXwv4l?eIdE1;MV$od3DP!mK}14jJd2OmWAi=ZD82vI~31W^{&2ma5k+tt0Z z9fzL!)qS0F?m6e)I?o@!=23>fZT!C(|IfahTGPKzyn?YC(flNMJ@^Rt8t{T{KMCG~ z_A}sHz~AffOS=EMYZ-er`WL}t;41h!&;)M)x4}1oeem_*r@-sL$G`>fYv60a?|?M_ zr@H+c@Fuih1j(*{gU7*zS5|s%1<}Rc3)1)7yTAw}dp`%#`U@cK^Lrqz|2as0dtQ%U2Fc!| z*gWY!4w4@@9%T0k5L1`|lDv~3T+B8={8$?gT7R!@-w%?UU6B002fh(}7$kX5fY<{2 z3P^IlrTG*{et#OokNpe}+Q+ZKqu_HOjsFECegDwye}UJaeJut_-%-t*K(coc#E+fC zgYw4&v4r^`$+-`tbrDE*p94wXLwfwPAkF_GNc+B^`3;cPeH;A9%NV-|lAmuxC&@Vh zE`g`O+rR<%4)D|9D)=NwdGLbn|0hU!bu$)|op*pF&jxP>L-1DcLEU~FB)fh9Qhxsg zBtJh3Qoj5Vqv*7LEXF$sTAA;nMUxFNb4kW+* z7bL%NI0diF2Fc!S5GJwvK)9YIAlbbSQeHg>;>RAwgW~omNd9^Pr1jqfY29~0lKUeN zAz;4%$?v}dN#5T;%GbYxB=<5%a*n`gxRzZHQu`K=wB8#K!|1!fi&+6Ao=6V;1TfqAlZEpq;=1LwEtg$H2)8J{7)e1{VPcOzNGmdkk%a` zp=P7uZgqmta(r{WWm~Law(O2&dSP(3YqLhnHalD0u;I45Ejyrb-D7*6>#>Gsn}Hqr zf$NzYHl}r&&PG^`S$5OhY6XV}x4IUVSj@6XVAyn8cD+FL%oS$0O{Wz)op8&yS<|sw zR_NIu+HyQdQX(pGR_Jdv8n*9Ooh`=?v~V>^wL5MQI<%d(-62ENh-Z^pd%AwZvo~DN zF*|HC2)bH`V_}H{_1;vw)7ds#juke{ZqV2?S>1*-nr*iHY2TLTp)c&3zJIUlS@nS0 zUfr6isk_?lIvmCBHd|1}s@B9E?mcWG&<`y)qz$%B7*ltLZFmlG-3T@rc4`M<;ND|* zAfU3{^+Nl@UFA&eh}pRKX4{^cOsiiav{l^wzzpDbr{Qa_T^Z2%y2DCfgju%>dI=#}xFWEGlmQp&twB1xLDOd`CMte2&wh|_{a zaWP87fs8!a%W@3Lp@kRtEx0*UOYvE-m`cp`<)q7-?leTMgPT(ELCF4}PPE-SN0 z9N<}sqggt9c-UxW`LI~y=}0w;Gpnb0^?K&rDZlLuUwO~zg|^eSr`uauT3%?t zD>8*bglvtMh~fK?g>ibC_jW~I$nwm%>>H=1^T;s7)UeZD79lOx_^F+^T&RO(tRJlL zb+IE8zPhyX0jOYSvvJNvm5MwrI5J)ABr;lJ4Ur(fMb1^m1agQJ5cvV`$4EflOQcB0 zBi_q~qeLRuycqX-NYef|LDcO$&+=*#?BJM)44)M+nW3VC83o)4H$q<8j|XrX#f(|9 zFUCnZe-u(9O2K)WBcvc53}vm>Rrk%;Wz&%+7l;H#MF%`i`M8ivOx*`GkmE4Nxs1@0 zNUp@q*PhEVkC9JWKUZg3EuuGW@G|2&lE>t_AtF2xnM*S5u*?k{$1IO=b9gWSQ+OYi zVvQ4FZQPG#K5N|)bJ9JL#1Zd_QHe}}Pa|xj?u7zchx-aa9_Kk4d?bouA2%Bbijh6& z?UnTsb=z?fMKG|KRXZHR8tg>yN@FzPy6+YV3}x-v$@3zBZ*E8K zPmL5QHb;q)52u|`#&PHIp}?M5ce0<_!jdCnVeKh(cyPVaZSP5(B1Gc>nN~Zmwt)@E z^CJrNlFpqfGLwK_Y0X4M6!&rX$eaB<8}i;E*J?>ns1h^MseNU_SLbFXf9R4#&cwZ< zwxrIjxp`bLf8J5zJ@r94bXew3otxS7r;^9}>U%SPygH(DGk#`4ie$ra#~tw!r^Cmd zahQ;tWtQM-@nRxB!y-dQ$J`0SG#bW?#Y~vP*mM(dWJIlkc^pYX`7Ch zx}i9}pjpn$s+6d}C$)r`BzV87n6M_r1fP80)YYS^2|im*G^v{4v*iS*%%oCdQc-YK z2W(aq1)r7_e72^j?jL7TQ_x4Ol1i1tRHSMtdF@GE0Si@O(aAXWD|DzS=*kKzEtspy zis~!TrMkk?bp=<2Mcq=Bg*Q`KaQY%+HfgxxeK095I6eVhw$J>kuJCnT!QB*}n7TDv bSycY>tI8rkWx=b`B2cA8ph^q%tbPAK0jUna delta 1153 zcmYk)Ur3Wt7{~EvZLX=?)Mcr)g-uW*T4Z)nR3z=S23odZMHh{B5eRBGLFGkTw+5lS zNI`T_;7vh7gKnfCFM?nY6hu^1)J+C@5&fY*7kz(lE*fX==RM~==Q+=L-gl<^YDf89 zA~b9K_VZ8bzlzI$zhua)nSKgmn87IS!8qoy9#3EcmT(P@V-sF>{TXbhKZlC*2oq*y zt8t@1?=g%kZo+p|F2Oppbr?nkPT@w}jtVr4+UOK2(Ftc66>l0f?;0x6IaJ(vjIh7m z=SB+`kY+V5n)nA#1L8Pc{7%^pp6?2 zW1E~ksD)ecWWa1UcF>>YSPC?cdY2DT8^1)w`HXs%Us06_Ft`>wuobsq3bSs!R8ReT z8MwfJ-tk>jW{cQ~Pf?})f*D-KZcK9QG#lccldSz@KqpasREY$#m$jn;q)`huqXPG#D&(OmlSc&{LyBc*QE|_s;#5$Tn{xf@ zs5rOF?m-o`!DCdQ8q(|;7kvY7Pys$TS5OK6MViIQMkim7LCl~M??tWKg{r_o)cj-a zdAh`nHXcQt{G4+VwXhQPC+pH1yrKTwU@>>NI8yLzAm<`nu+AGlJv!#!insbZVu@-sb}8TwH0|^sH1|}!mRjH+vU+Zj diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.po b/hesabixAPI/locales/en/LC_MESSAGES/messages.po index 88ba57d..7760e9c 100644 --- a/hesabixAPI/locales/en/LC_MESSAGES/messages.po +++ b/hesabixAPI/locales/en/LC_MESSAGES/messages.po @@ -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" diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo b/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo index 9e820244735dea0a5c1280791eda2069e54bac3c..fdcfc5a09e834c178fa7849e4989e37c62a6c104 100644 GIT binary patch literal 6171 zcmbW4ZER#!8Gw%l!7U%^A_^+zqFGdQmhSRt*9E6Dx80HXs58?ALgeOd=k9i7I@6uG z1=c9C*e|5v2Z<(Ti2-8l+TCsGZWlof7>&_HjTryjyA34j527(98k0?gA3pE7XYNeP zENN`o``q*Op7-m#=l1pO7kx+Zv!B0@@%OuTORMwq_4g?CewyEam%}IF2jJ7Dy$nA} z`)BauFfiliP5-uwmAZ`n9q z4=-mki9-$E2It`b{5h2K@Fu(-UQP1uf_K6%z{7AST!x5Jufb2izr#<%%W<0UR+xki zl=;s>3%&|R;J-}&AWo7v9EQX2Yo`4R)BY{Ilm06)O2$j@lkgb47rp{T&ud8H>);pR zUU&cw!Drwgd;{JLZy@>agANq`{1nQ%OE{b&_cQQXn1gq~2Ve>=nf`x5(f2YAh2-^B z5E1G|C~-?c881LN7xzFyqaJ~ntR6S*??H+G51{z_1jH2e8z_38gOXR*acBsIx*p0Md2n zB*XT_&hDA~{$w_jc2dcaa%x{v#cia?>}0mo=^HH;>2pSsrPAGnVmcns=EbefHgQ+3 zFo>hnmy%gFQ&Fq!j>284EwJRI3yv6=OQKBN?d0$raUCx2Q&?)3opRwWJCA@!_d?OJ z?-|j~G>+(wODUPNJ1WcS3xqa`d#Ri(t)KQK;tkhYrfZPDYsS|s(Q5wZ@wC9)P1*FZ>dVP zHCc0yc$K0z)@U;5O}N#X^6HhA^H9|vSKi?%x7JbztId|5cPBh`sOh-~bo4;n^2gn} z8uwc6f$GD_v9ZQf-LKXUm5#K0Z$ee;{5rL2%ZEm}nrr0MyjIHy)2~|6gxh?;6A49c zveEQay^(f(cL=c+-u-BPOEnIby@!3(nDQs5{PK}WPffaqJk|6Ldd;R=OEoH;Mpm|O zdWS&M^{b6K5@l56cfAgKb&qkcQmCtDk%OJ=WN`+iZFM-YNfJ;`Yuw z(THFs?D|-vLOu*ww;!nbeQ}f$gGUCeLHB@Hvv%*?bsJl#;8buXSPYI^=Y9lMgX6*K z`hpd#g)_mT75rp`5z|IXq&F-=@;;oalmH2Xeb;v_)`R>C>$RY5y? zM`gbFhM1x)ju%b!sA@;9mw86L%`uWd;jl%PE?MC;uOvxqp-BOCp#duqMH)7RUKm3W$E}<4#;|l+75EKuuesr^OC4!D)*{vOP&In~Rp>w@6Ld zDx791VT-4648@BwWlit57)MogHtelZw~diD+LAbXBE|HnM?L(|tu^Y#28z4MfSn>x z&eYo{U%(?W{fxxJWbTEQZ#9j@3Urw3;VI5Ok;lMro&rG7Rz*=Oc#ac;7E~8JFU1JY zuV@=!FF87F;;2(Hk7_q<-ENseDfMniq#BP%6`3b$97^dqjqEknNEO(WB-+YqfNiqhjM z*ovI36hpGk6pE$bEZcG~_qx@#R9HC~loClkuEnUhaEZvVFoj6HeFiB-#;|ewO4%6d z&Dw1o?M{_;Hk&I#P6A@r7j*XOaryt}RwE-Solzx|Pn|RDagvIJ9{T%+7kx8w zszdYD5|7O3T5KkuRLOh4kJ0lCaHn? z+SXrAI(wqJp)-fc7^*y@uKSfMXn5A;l@xj~mxng?O7usJklB8P0Wh*#=%0$1@)3HW% zUPXN_ig=bBDm*1hos>|C-sp$kOQJ3x<58Z(J8Hk^^VXRoy02+7u!ji>gI)+;!gDAj g`K9kVPN_(q(GySWd!su?<$Bz>% delta 1143 zcmYk*Pe_w-9LMorZrYPh=WJyym%3pF)hMb%(w_}B?Ln=zF-nR?Bf4d`ATKK-2m?_b zq#(KkgXmz?4js%A4?*N1BB(<+sFOv}!HVio?+^SAjc3p6_kEu4@B8~czdgIX^KoZ+ zsm(ua_$2vu=v&9>zfXtXm{#H_He&(W8?l6I@jM3cijAjm191ga=P9-s zQ#OkXRH%jx_|+zSNA=>XH)cIHpbAHEGwwna%AyuJjcRn%T1M5IMCIK?HCjQ{eSi+@ zn@0>ZaTYnuA}1xjMiqE(!bHOA4wZPq=gojHK2dl=Pl4x3ot zlx)H%a+pgtzKm>wxq(`6(s~Eg@O@O_Sq$NG)B^92!+hYRdCREAe_#{(=~DMwv8)O0 z40ssRZS6%(+=2O3#`NKC;t3v03oT$DzCpb+L5e7UJH~JyZpBm9YnURQ!32K8F6?Y% z|FaB|Jce#eAvMiB4&VzLhiF@I1P@`!#&>aq_!D+xnl9pa4x>1Zs&@}J;zQIs^b(n3 zY613N37@&pgDc3ICc=K{H|jyfdr@1KL_P5UYA4QOKVC%rRCCB-7C7mpeua8RYN&ZX zQS-vwRD)Z}3=T3#qZYb~Dl~yg^iT~}khx|CBlyJLe~asgm+kexsQd`st6m(HpFlO@ zqUxNn_hT3Ag)w{KI;sH=^*6f}^v3FAn=->Gcc|zdDHie>)9+@6(#2fnSi#L@(%y8# uuRuOEoGE5g`TX&tId9yFSARPNpEut;QeA49tMi_PPI^vwu(}fN_5B0CWN>x> diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po index e0098ff..f47a8b4 100644 --- a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po +++ b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po @@ -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 "خطا در تست اتصال" + diff --git a/hesabixAPI/migrations/versions/8bf0dbb9fba9_add_file_storage_tables.py b/hesabixAPI/migrations/versions/8bf0dbb9fba9_add_file_storage_tables.py new file mode 100644 index 0000000..690e21e --- /dev/null +++ b/hesabixAPI/migrations/versions/8bf0dbb9fba9_add_file_storage_tables.py @@ -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 ### diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index 8ae3974..4d91189 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -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" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index 39608fb..80955e8 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -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": "آخر" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index 8ca258a..6488f47 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -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 diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart index 42623c7..aa08f01 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index d23da8c..4d5cb11 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -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 => 'آخر'; } diff --git a/hesabixUI/hesabix_ui/lib/pages/admin/file_storage_settings_page.dart b/hesabixUI/hesabix_ui/lib/pages/admin/file_storage_settings_page.dart new file mode 100644 index 0000000..735f829 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/admin/file_storage_settings_page.dart @@ -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 createState() => _FileStorageSettingsPageState(); +} + +class _FileStorageSettingsPageState extends State + 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(), + ], + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/change_password_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/change_password_page.dart index d8ce15d..46075e3 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/change_password_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/change_password_page.dart @@ -47,10 +47,13 @@ class _ChangePasswordPageState extends State { 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 { 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), // نمایش طولانی‌تر برای خواندن ), ); diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart index a04c6ec..78fee57 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/create_ticket_page.dart @@ -41,6 +41,63 @@ class _CreateTicketPageState extends State { 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 _loadData() async { setState(() { _isLoading = true; @@ -68,11 +125,10 @@ class _CreateTicketPageState extends State { 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 { 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); } diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart index 50995e0..674f45c 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart @@ -92,10 +92,13 @@ class _NewBusinessPageState extends State { Future _submitBusiness() async { final t = Localizations.of(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 { 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), ), ); } diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_tickets_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_tickets_page.dart index ce23a29..9227da7 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_tickets_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/operator/operator_tickets_page.dart @@ -22,6 +22,9 @@ class _OperatorTicketsPageState extends State { final SupportService _supportService = SupportService(ApiClient()); List _statuses = []; List _priorities = []; + + // Refresh counter to force data table refresh + int _refreshCounter = 0; @override void initState() { @@ -52,8 +55,10 @@ class _OperatorTicketsPageState extends State { 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 { const SizedBox(height: 16), Expanded( child: DataTableWidget>( + key: ValueKey('data_table_$_refreshCounter'), config: DataTableConfig>( title: 'لیست تیکت‌های پشتیبانی - پنل اپراتور', endpoint: '/api/v1/support/operator/tickets/search', diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/support_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/support_page.dart index 9bf6f52..20f59d3 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/support_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/support_page.dart @@ -23,6 +23,9 @@ class _SupportPageState extends State { final SupportService _supportService = SupportService(ApiClient()); List _statuses = []; List _priorities = []; + + // Refresh counter to force data table refresh + int _refreshCounter = 0; @override void initState() { @@ -52,7 +55,10 @@ class _SupportPageState extends State { ); 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 { children: [ Expanded( child: DataTableWidget>( + key: ValueKey('data_table_$_refreshCounter'), config: DataTableConfig>( title: t.supportTickets, endpoint: '/api/v1/support/search', diff --git a/hesabixUI/hesabix_ui/lib/pages/system_settings_page.dart b/hesabixUI/hesabix_ui/lib/pages/system_settings_page.dart index f70b42f..3e4c469 100644 --- a/hesabixUI/hesabix_ui/lib/pages/system_settings_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/system_settings_page.dart @@ -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( diff --git a/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/file_management_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/file_management_widget.dart new file mode 100644 index 0000000..7fbb2f5 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/file_management_widget.dart @@ -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 createState() => _FileManagementWidgetState(); +} + +class _FileManagementWidgetState extends State + with TickerProviderStateMixin { + late TabController _tabController; + List> _allFiles = []; + List> _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 _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 _forceDeleteFile(String fileId) async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + 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 _restoreFile(String fileId) async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + 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> 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( + 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; + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/file_statistics_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/file_statistics_widget.dart new file mode 100644 index 0000000..b2a0774 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/file_statistics_widget.dart @@ -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 createState() => _FileStatisticsWidgetState(); +} + +class _FileStatisticsWidgetState extends State { + Map? _statistics; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadStatistics(); + } + + Future _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 _cleanupTemporaryFiles() async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + 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, + ), + ), + ], + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_card.dart b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_card.dart new file mode 100644 index 0000000..4d0ea6c --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_card.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; + +class StorageConfigCard extends StatelessWidget { + final Map 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, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_form_dialog.dart new file mode 100644 index 0000000..033b3b5 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_form_dialog.dart @@ -0,0 +1,368 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; + +class StorageConfigFormDialog extends StatefulWidget { + final Map? config; + + const StorageConfigFormDialog({ + super.key, + this.config, + }); + + @override + State createState() => _StorageConfigFormDialogState(); +} + +class _StorageConfigFormDialogState extends State { + final _formKey = GlobalKey(); + 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 _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 _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( + 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), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_list_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_list_widget.dart new file mode 100644 index 0000000..f077ed5 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/admin/file_storage/storage_config_list_widget.dart @@ -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 createState() => _StorageConfigListWidgetState(); +} + +class _StorageConfigListWidgetState extends State { + List> _storageConfigs = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadStorageConfigs(); + } + + Future _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 _addStorageConfig() async { + final result = await showDialog>( + context: context, + builder: (context) => const StorageConfigFormDialog(), + ); + + if (result != null) { + _loadStorageConfigs(); + } + } + + Future _editStorageConfig(Map config) async { + final result = await showDialog>( + context: context, + builder: (context) => StorageConfigFormDialog(config: config), + ); + + if (result != null) { + _loadStorageConfigs(); + } + } + + Future _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 _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 _deleteConfig(String configId) async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + 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, + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart index cd65c28..829c4ad 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart @@ -255,6 +255,9 @@ class DataTableConfig { // Show individual action buttons final bool showFiltersButton; + + // Refresh callback + final VoidCallback? onRefresh; const DataTableConfig({ required this.endpoint, @@ -318,6 +321,7 @@ class DataTableConfig { this.onColumnSettingsChanged, this.customHeaderActions, this.showFiltersButton = false, + this.onRefresh, }); /// Get column width as double diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart index 5b43e2f..b085eb1 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart @@ -18,12 +18,14 @@ class DataTableWidget extends StatefulWidget { final DataTableConfig config; final T Function(Map) 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 extends State> { _fetchData(); } + /// Public method to refresh the data table + void refresh() { + _fetchData(); + } + @override void dispose() { _searchCtrl.dispose(); @@ -200,6 +207,13 @@ class _DataTableWidgetState extends State> { _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(() { diff --git a/hesabixUI/hesabix_ui/lib/widgets/support/ticket_details_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_details_dialog.dart index fbc5668..89200f0 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/support/ticket_details_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/support/ticket_details_dialog.dart @@ -44,6 +44,63 @@ class _TicketDetailsDialogState extends State { 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 _loadMessages() async { if (_isLoading) return; @@ -84,15 +141,11 @@ class _TicketDetailsDialogState extends State { 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 { 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 { 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 { ), ), - // 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 { ], ), ], - ), + ], ), + ), - // 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(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(Colors.white), + ), + ) + : const Icon( + Icons.send, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ], ), ), ],