more progress in permissions

This commit is contained in:
Hesabix 2025-09-25 01:01:27 +03:30
parent 798dd63627
commit 44eef85039
40 changed files with 4427 additions and 170 deletions

View file

@ -0,0 +1,443 @@
# Removed __future__ annotations to fix OpenAPI schema generation
from fastapi import APIRouter, Depends, Request, HTTPException
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.api.v1.schemas import (
BusinessUsersListResponse, AddUserRequest, AddUserResponse,
UpdatePermissionsRequest, UpdatePermissionsResponse, RemoveUserResponse
)
from app.core.responses import success_response, format_datetime_fields
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
from adapters.db.models.user import User
from adapters.db.models.business import Business
router = APIRouter(prefix="/business", tags=["business-users"])
@router.get("/{business_id}/users",
summary="لیست کاربران کسب و کار",
description="دریافت لیست کاربران یک کسب و کار",
response_model=BusinessUsersListResponse,
responses={
200: {
"description": "لیست کاربران با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "لیست کاربران دریافت شد",
"users": [
{
"id": 1,
"business_id": 1,
"user_id": 2,
"user_name": "علی احمدی",
"user_email": "ali@example.com",
"user_phone": "09123456789",
"role": "member",
"status": "active",
"added_at": "2024-01-01T00:00:00Z",
"last_active": "2024-01-01T12:00:00Z",
"permissions": {
"sales": {
"read": True,
"write": True,
"delete": False
},
"reports": {
"read": True,
"export": True
}
}
}
],
"total_count": 1
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز به کسب و کار"
}
}
)
@require_business_access("business_id")
def get_users(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""دریافت لیست کاربران کسب و کار"""
import logging
logger = logging.getLogger(__name__)
current_user_id = ctx.get_user_id()
logger.info(f"Getting users for business {business_id}, current user: {current_user_id}")
# Check if user is business owner or has permission to manage users
business = db.get(Business, business_id)
if not business:
logger.error(f"Business {business_id} not found")
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
is_owner = business.owner_id == current_user_id
can_manage = ctx.can_manage_business_users()
logger.info(f"Business owner: {business.owner_id}, is_owner: {is_owner}, can_manage: {can_manage}")
if not is_owner and not can_manage:
logger.warning(f"User {current_user_id} does not have permission to manage users for business {business_id}")
raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید")
# Get business permissions for this business
permission_repo = BusinessPermissionRepository(db)
business_permissions = permission_repo.get_business_users(business_id)
logger.info(f"Found {len(business_permissions)} business permissions for business {business_id}")
# Format users data
formatted_users = []
# Add business owner first
owner = db.get(User, business.owner_id)
if owner:
logger.info(f"Adding business owner: {owner.id} - {owner.email}")
owner_data = {
"id": business.owner_id, # Use owner_id as id
"business_id": business_id,
"user_id": business.owner_id,
"user_name": f"{owner.first_name or ''} {owner.last_name or ''}".strip(),
"user_email": owner.email or "",
"user_phone": owner.mobile,
"role": "owner",
"status": "active",
"added_at": business.created_at,
"last_active": business.updated_at,
"permissions": {}, # Owner has all permissions
}
formatted_users.append(owner_data)
else:
logger.warning(f"Business owner {business.owner_id} not found in users table")
# Add other users with permissions
for perm in business_permissions:
# Skip if this is the owner (already added)
if perm.user_id == business.owner_id:
logger.info(f"Skipping owner user {perm.user_id} as already added")
continue
user = db.get(User, perm.user_id)
if user:
logger.info(f"Adding user with permissions: {user.id} - {user.email}")
user_data = {
"id": perm.id,
"business_id": perm.business_id,
"user_id": perm.user_id,
"user_name": f"{user.first_name or ''} {user.last_name or ''}".strip(),
"user_email": user.email or "",
"user_phone": user.mobile,
"role": "member",
"status": "active",
"added_at": perm.created_at,
"last_active": perm.updated_at,
"permissions": perm.business_permissions or {},
}
formatted_users.append(user_data)
else:
logger.warning(f"User {perm.user_id} not found in users table")
logger.info(f"Returning {len(formatted_users)} users for business {business_id}")
# Format datetime fields based on calendar type
formatted_users = format_datetime_fields(formatted_users, request)
return success_response(
data={
"users": formatted_users,
"total_count": len(formatted_users)
},
request=request,
message="لیست کاربران دریافت شد"
)
@router.post("/{business_id}/users",
summary="افزودن کاربر به کسب و کار",
description="افزودن کاربر جدید به کسب و کار با ایمیل یا شماره تلفن",
response_model=AddUserResponse,
responses={
200: {
"description": "کاربر با موفقیت اضافه شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کاربر با موفقیت اضافه شد",
"user": {
"id": 1,
"business_id": 1,
"user_id": 2,
"user_name": "علی احمدی",
"user_email": "ali@example.com",
"user_phone": "09123456789",
"role": "member",
"status": "active",
"added_at": "2024-01-01T00:00:00Z",
"last_active": None,
"permissions": {}
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها"
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز یا مجوز کافی نیست"
},
404: {
"description": "کاربر یافت نشد"
}
}
)
@require_business_access("business_id")
def add_user(
request: Request,
business_id: int,
add_request: AddUserRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""افزودن کاربر به کسب و کار"""
import logging
logger = logging.getLogger(__name__)
current_user_id = ctx.get_user_id()
logger.info(f"Adding user to business {business_id}, current user: {current_user_id}")
logger.info(f"Add request: {add_request.email_or_phone}")
# Check if user is business owner or has permission to manage users
business = db.get(Business, business_id)
if not business:
logger.error(f"Business {business_id} not found")
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
is_owner = business.owner_id == current_user_id
can_manage = ctx.can_manage_business_users(business_id)
logger.info(f"Business owner: {business.owner_id}, is_owner: {is_owner}, can_manage: {can_manage}")
logger.info(f"User {current_user_id} business_id from context: {ctx.business_id}")
logger.info(f"User {current_user_id} is superadmin: {ctx.is_superadmin()}")
if not is_owner and not can_manage:
logger.warning(f"User {current_user_id} does not have permission to add users to business {business_id}")
raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید")
# Find user by email or phone
logger.info(f"Searching for user with email/phone: {add_request.email_or_phone}")
user = db.query(User).filter(
(User.email == add_request.email_or_phone) |
(User.mobile == add_request.email_or_phone)
).first()
if not user:
logger.warning(f"User not found with email/phone: {add_request.email_or_phone}")
raise HTTPException(status_code=404, detail="کاربر یافت نشد")
logger.info(f"Found user: {user.id} - {user.email}")
# Check if user is already added to this business
permission_repo = BusinessPermissionRepository(db)
existing_permission = permission_repo.get_by_user_and_business(user.id, business_id)
if existing_permission:
logger.warning(f"User {user.id} already exists in business {business_id}")
raise HTTPException(status_code=400, detail="کاربر قبلاً به این کسب و کار اضافه شده است")
# Add user to business with default permissions
logger.info(f"Adding user {user.id} to business {business_id}")
permission_obj = permission_repo.create_or_update(
user_id=user.id,
business_id=business_id,
permissions={} # Default empty permissions
)
logger.info(f"Created permission object: {permission_obj.id}")
# Format user data
user_data = {
"id": permission_obj.id,
"business_id": permission_obj.business_id,
"user_id": permission_obj.user_id,
"user_name": f"{user.first_name or ''} {user.last_name or ''}".strip(),
"user_email": user.email or "",
"user_phone": user.mobile,
"role": "member",
"status": "active",
"added_at": permission_obj.created_at,
"last_active": None,
"permissions": permission_obj.business_permissions or {},
}
logger.info(f"Returning user data: {user_data}")
# Format datetime fields based on calendar type
formatted_user_data = format_datetime_fields(user_data, request)
return success_response(
data={"user": formatted_user_data},
request=request,
message="کاربر با موفقیت اضافه شد"
)
@router.put("/{business_id}/users/{user_id}/permissions",
summary="به‌روزرسانی دسترسی‌های کاربر",
description="به‌روزرسانی دسترسی‌های یک کاربر در کسب و کار",
response_model=UpdatePermissionsResponse,
responses={
200: {
"description": "دسترسی‌ها با موفقیت به‌روزرسانی شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "دسترسی‌ها با موفقیت به‌روزرسانی شد"
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها"
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز یا مجوز کافی نیست"
},
404: {
"description": "کاربر یافت نشد"
}
}
)
@require_business_access("business_id")
def update_permissions(
request: Request,
business_id: int,
user_id: int,
update_request: UpdatePermissionsRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""به‌روزرسانی دسترسی‌های کاربر"""
current_user_id = ctx.get_user_id()
# Check if user is business owner or has permission to manage users
business = db.get(Business, business_id)
if not business:
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
is_owner = business.owner_id == current_user_id
can_manage = ctx.can_manage_business_users()
if not is_owner and not can_manage:
raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید")
# Check if target user exists
target_user = db.get(User, user_id)
if not target_user:
raise HTTPException(status_code=404, detail="کاربر یافت نشد")
# Update permissions
permission_repo = BusinessPermissionRepository(db)
permission_obj = permission_repo.create_or_update(
user_id=user_id,
business_id=business_id,
permissions=update_request.permissions
)
return success_response(
data={},
request=request,
message="دسترسی‌ها با موفقیت به‌روزرسانی شد"
)
@router.delete("/{business_id}/users/{user_id}",
summary="حذف کاربر از کسب و کار",
description="حذف کاربر از کسب و کار",
response_model=RemoveUserResponse,
responses={
200: {
"description": "کاربر با موفقیت حذف شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کاربر با موفقیت حذف شد"
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز یا مجوز کافی نیست"
},
404: {
"description": "کاربر یافت نشد"
}
}
)
@require_business_access("business_id")
def remove_user(
request: Request,
business_id: int,
user_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""حذف کاربر از کسب و کار"""
current_user_id = ctx.get_user_id()
# Check if user is business owner or has permission to manage users
business = db.get(Business, business_id)
if not business:
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
is_owner = business.owner_id == current_user_id
can_manage = ctx.can_manage_business_users()
if not is_owner and not can_manage:
raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید")
# Check if target user is business owner
business = db.get(Business, business_id)
if business and business.owner_id == user_id:
raise HTTPException(status_code=400, detail="نمی‌توان مالک کسب و کار را حذف کرد")
# Remove user permissions
permission_repo = BusinessPermissionRepository(db)
success = permission_repo.delete_by_user_and_business(user_id, business_id)
if not success:
raise HTTPException(status_code=404, detail="کاربر یافت نشد")
return success_response(
data={},
request=request,
message="کاربر با موفقیت حذف شد"
)

View file

@ -1,6 +1,7 @@
from typing import Any, List, Optional, Union, Generic, TypeVar from typing import Any, List, Optional, Union, Generic, TypeVar
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
from enum import Enum from enum import Enum
from datetime import datetime
T = TypeVar('T') T = TypeVar('T')
@ -247,3 +248,79 @@ class PaginatedResponse(BaseModel, Generic[T]):
) )
# Business User Schemas
class BusinessUserSchema(BaseModel):
id: int
business_id: int
user_id: int
user_name: str
user_email: str
user_phone: Optional[str] = None
role: str
status: str
added_at: datetime
last_active: Optional[datetime] = None
permissions: dict
class Config:
from_attributes = True
class AddUserRequest(BaseModel):
email_or_phone: str
class Config:
json_schema_extra = {
"example": {
"email_or_phone": "user@example.com"
}
}
class AddUserResponse(BaseModel):
success: bool
message: str
user: Optional[BusinessUserSchema] = None
class UpdatePermissionsRequest(BaseModel):
permissions: dict
class Config:
json_schema_extra = {
"example": {
"permissions": {
"sales": {
"read": True,
"write": True,
"delete": False
},
"reports": {
"read": True,
"export": True
},
"settings": {
"manage_users": True
}
}
}
}
class UpdatePermissionsResponse(BaseModel):
success: bool
message: str
class RemoveUserResponse(BaseModel):
success: bool
message: str
class BusinessUsersListResponse(BaseModel):
success: bool
message: str
data: dict
calendar_type: Optional[str] = None

View file

@ -7,6 +7,7 @@ from .captcha import Captcha # noqa: F401
from .password_reset import PasswordReset # noqa: F401 from .password_reset import PasswordReset # noqa: F401
from .business import Business # noqa: F401 from .business import Business # noqa: F401
from .business_permission import BusinessPermission # noqa: F401 from .business_permission import BusinessPermission # noqa: F401
# Business user models removed - using business_permissions instead
# Import support models # Import support models
from .support import * # noqa: F401, F403 from .support import * # noqa: F401, F403

View file

@ -4,7 +4,7 @@ from datetime import datetime
from enum import Enum from enum import Enum
from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base from adapters.db.session import Base
@ -53,3 +53,6 @@ class Business(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships - using business_permissions instead
# users = relationship("BusinessUser", back_populates="business", cascade="all, delete-orphan")

View file

@ -29,4 +29,7 @@ class User(Base):
# Support relationships # Support relationships
tickets = relationship("Ticket", foreign_keys="Ticket.user_id", back_populates="user") tickets = relationship("Ticket", foreign_keys="Ticket.user_id", back_populates="user")
# Business relationships - using business_permissions instead
# businesses = relationship("BusinessUser", back_populates="user", cascade="all, delete-orphan")

View file

@ -121,14 +121,21 @@ class AuthContext:
"""بررسی دسترسی به پنل اپراتور پشتیبانی""" """بررسی دسترسی به پنل اپراتور پشتیبانی"""
return self.has_app_permission("support_operator") return self.has_app_permission("support_operator")
def is_business_owner(self) -> bool: def is_business_owner(self, business_id: int = None) -> bool:
"""بررسی اینکه آیا کاربر مالک کسب و کار است یا نه""" """بررسی اینکه آیا کاربر مالک کسب و کار است یا نه"""
if not self.business_id or not self.db: import logging
logger = logging.getLogger(__name__)
target_business_id = business_id or self.business_id
if not target_business_id or not self.db:
logger.info(f"is_business_owner: no business_id ({target_business_id}) or db ({self.db is not None})")
return False return False
from adapters.db.models.business import Business from adapters.db.models.business import Business
business = self.db.get(Business, self.business_id) business = self.db.get(Business, target_business_id)
return business and business.owner_id == self.user.id is_owner = business and business.owner_id == self.user.id
logger.info(f"is_business_owner: business_id={target_business_id}, business={business}, owner_id={business.owner_id if business else None}, user_id={self.user.id}, is_owner={is_owner}")
return is_owner
# بررسی دسترسی‌های کسب و کار # بررسی دسترسی‌های کسب و کار
def has_business_permission(self, section: str, action: str) -> bool: def has_business_permission(self, section: str, action: str) -> bool:
@ -188,9 +195,25 @@ class AuthContext:
"""بررسی دسترسی صادرات در بخش""" """بررسی دسترسی صادرات در بخش"""
return self.has_business_permission(section, "export") return self.has_business_permission(section, "export")
def can_manage_business_users(self) -> bool: def can_manage_business_users(self, business_id: int = None) -> bool:
"""بررسی دسترسی مدیریت کاربران کسب و کار""" """بررسی دسترسی مدیریت کاربران کسب و کار"""
return self.has_business_permission("settings", "manage_users") import logging
logger = logging.getLogger(__name__)
# SuperAdmin دسترسی کامل دارد
if self.is_superadmin():
logger.info(f"can_manage_business_users: user {self.user.id} is superadmin")
return True
# مالک کسب و کار دسترسی کامل دارد
if self.is_business_owner(business_id):
logger.info(f"can_manage_business_users: user {self.user.id} is business owner")
return True
# بررسی دسترسی در سطح کسب و کار
has_permission = self.has_business_permission("settings", "manage_users")
logger.info(f"can_manage_business_users: user {self.user.id} has permission: {has_permission}")
return has_permission
# ترکیب دسترسی‌ها # ترکیب دسترسی‌ها
def has_any_permission(self, section: str, action: str) -> bool: def has_any_permission(self, section: str, action: str) -> bool:
@ -204,16 +227,25 @@ class AuthContext:
def can_access_business(self, business_id: int) -> bool: def can_access_business(self, business_id: int) -> bool:
"""بررسی دسترسی به کسب و کار خاص""" """بررسی دسترسی به کسب و کار خاص"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Checking business access: user {self.user.id}, business {business_id}, context business_id {self.business_id}")
# SuperAdmin دسترسی به همه کسب و کارها دارد # SuperAdmin دسترسی به همه کسب و کارها دارد
if self.is_superadmin(): if self.is_superadmin():
logger.info(f"User {self.user.id} is superadmin, granting access to business {business_id}")
return True return True
# اگر مالک کسب و کار است، دسترسی دارد # اگر مالک کسب و کار است، دسترسی دارد
if self.is_business_owner() and business_id == self.business_id: if self.is_business_owner() and business_id == self.business_id:
logger.info(f"User {self.user.id} is business owner of {business_id}, granting access")
return True return True
# بررسی دسترسی‌های کسب و کار # بررسی دسترسی‌های کسب و کار
return business_id == self.business_id has_access = business_id == self.business_id
logger.info(f"Business access check: {business_id} == {self.business_id} = {has_access}")
return has_access
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""تبدیل به dictionary برای استفاده در API""" """تبدیل به dictionary برای استفاده در API"""
@ -252,10 +284,15 @@ def get_current_user(
db: Session = Depends(get_db) db: Session = Depends(get_db)
) -> AuthContext: ) -> AuthContext:
"""دریافت اطلاعات کامل کاربر کنونی و تنظیمات از درخواست""" """دریافت اطلاعات کامل کاربر کنونی و تنظیمات از درخواست"""
import logging
logger = logging.getLogger(__name__)
# Get authorization from request headers # Get authorization from request headers
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
logger.info(f"Auth header: {auth_header}")
if not auth_header or not auth_header.startswith("ApiKey "): if not auth_header or not auth_header.startswith("ApiKey "):
logger.warning(f"Invalid auth header: {auth_header}")
raise ApiError("UNAUTHORIZED", "Missing or invalid API key", http_status=401) raise ApiError("UNAUTHORIZED", "Missing or invalid API key", http_status=401)
api_key = auth_header[len("ApiKey ") :].strip() api_key = auth_header[len("ApiKey ") :].strip()

View file

@ -74,6 +74,9 @@ def require_business_access(business_id_param: str = "business_id"):
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs) -> Any: def wrapper(*args, **kwargs) -> Any:
import logging
logger = logging.getLogger(__name__)
# Find request in args or kwargs # Find request in args or kwargs
request = None request = None
for arg in args: for arg in args:
@ -85,6 +88,7 @@ def require_business_access(business_id_param: str = "business_id"):
request = kwargs['request'] request = kwargs['request']
if not request: if not request:
logger.error("Request not found in function arguments")
raise ApiError("INTERNAL_ERROR", "Request not found", http_status=500) raise ApiError("INTERNAL_ERROR", "Request not found", http_status=500)
# Get database session # Get database session
@ -92,8 +96,17 @@ def require_business_access(business_id_param: str = "business_id"):
db = next(get_db()) db = next(get_db())
ctx = get_current_user(request, db) ctx = get_current_user(request, db)
business_id = kwargs.get(business_id_param) business_id = kwargs.get(business_id_param)
logger.info(f"Checking business access for user {ctx.get_user_id()} to business {business_id}")
logger.info(f"User business_id from context: {ctx.business_id}")
logger.info(f"User is superadmin: {ctx.is_superadmin()}")
logger.info(f"User is business owner: {ctx.is_business_owner()}")
if business_id and not ctx.can_access_business(business_id): if business_id and not ctx.can_access_business(business_id):
logger.warning(f"User {ctx.get_user_id()} does not have access to business {business_id}")
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403) raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
logger.info(f"User {ctx.get_user_id()} has access to business {business_id}")
return func(*args, **kwargs) return func(*args, **kwargs)
return wrapper return wrapper
return decorator return decorator

View file

@ -7,8 +7,16 @@ from fastapi import HTTPException, status, Request
from .calendar import CalendarConverter, CalendarType from .calendar import CalendarConverter, CalendarType
def success_response(data: Any, request: Request = None) -> dict[str, Any]: def success_response(data: Any, request: Request = None, message: str = None) -> dict[str, Any]:
response = {"success": True, "data": data} response = {"success": True}
# Add data if provided
if data is not None:
response["data"] = data
# Add message if provided
if message is not None:
response["message"] = message
# Add calendar type information if request is available # Add calendar type information if request is available
if request and hasattr(request.state, 'calendar_type'): if request and hasattr(request.state, 'calendar_type'):
@ -27,8 +35,17 @@ def format_datetime_fields(data: Any, request: Request) -> Any:
if isinstance(data, dict): if isinstance(data, dict):
formatted_data = {} formatted_data = {}
for key, value in data.items(): for key, value in data.items():
if isinstance(value, datetime): if value is None:
formatted_data[key] = CalendarConverter.format_datetime(value, calendar_type) formatted_data[key] = None
elif isinstance(value, datetime):
# Format the main date field based on calendar type
if calendar_type == "jalali":
formatted_data[key] = CalendarConverter.to_jalali(value)["formatted"]
else:
formatted_data[key] = value.isoformat()
# Add formatted date as additional field
formatted_data[f"{key}_formatted"] = CalendarConverter.format_datetime(value, calendar_type)
# Convert raw date to the same calendar type as the formatted date # Convert raw date to the same calendar type as the formatted date
if calendar_type == "jalali": if calendar_type == "jalali":
formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(value)["formatted"] formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(value)["formatted"]

View file

@ -8,6 +8,7 @@ from adapters.api.v1.auth import router as auth_router
from adapters.api.v1.users import router as users_router from adapters.api.v1.users import router as users_router
from adapters.api.v1.businesses import router as businesses_router from adapters.api.v1.businesses import router as businesses_router
from adapters.api.v1.business_dashboard import router as business_dashboard_router from adapters.api.v1.business_dashboard import router as business_dashboard_router
from adapters.api.v1.business_users import router as business_users_router
from adapters.api.v1.support.tickets import router as support_tickets_router from adapters.api.v1.support.tickets import router as support_tickets_router
from adapters.api.v1.support.operator import router as support_operator_router from adapters.api.v1.support.operator import router as support_operator_router
from adapters.api.v1.support.categories import router as support_categories_router from adapters.api.v1.support.categories import router as support_categories_router
@ -271,6 +272,7 @@ def create_app() -> FastAPI:
application.include_router(users_router, prefix=settings.api_v1_prefix) application.include_router(users_router, prefix=settings.api_v1_prefix)
application.include_router(businesses_router, prefix=settings.api_v1_prefix) application.include_router(businesses_router, prefix=settings.api_v1_prefix)
application.include_router(business_dashboard_router, prefix=settings.api_v1_prefix) application.include_router(business_dashboard_router, prefix=settings.api_v1_prefix)
application.include_router(business_users_router, prefix=settings.api_v1_prefix)
# Support endpoints # Support endpoints
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support") application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")

View file

@ -4,6 +4,8 @@ adapters/__init__.py
adapters/api/__init__.py adapters/api/__init__.py
adapters/api/v1/__init__.py adapters/api/v1/__init__.py
adapters/api/v1/auth.py adapters/api/v1/auth.py
adapters/api/v1/business_dashboard.py
adapters/api/v1/business_users.py
adapters/api/v1/businesses.py adapters/api/v1/businesses.py
adapters/api/v1/health.py adapters/api/v1/health.py
adapters/api/v1/schemas.py adapters/api/v1/schemas.py
@ -68,6 +70,7 @@ app/core/settings.py
app/core/smart_normalizer.py app/core/smart_normalizer.py
app/services/api_key_service.py app/services/api_key_service.py
app/services/auth_service.py app/services/auth_service.py
app/services/business_dashboard_service.py
app/services/business_service.py app/services/business_service.py
app/services/captcha_service.py app/services/captcha_service.py
app/services/email_service.py app/services/email_service.py

View file

@ -10,6 +10,7 @@ class AuthStore with ChangeNotifier {
static const _kDeviceId = 'device_id'; static const _kDeviceId = 'device_id';
static const _kAppPermissions = 'app_permissions'; static const _kAppPermissions = 'app_permissions';
static const _kIsSuperAdmin = 'is_superadmin'; static const _kIsSuperAdmin = 'is_superadmin';
static const _kLastUrl = 'last_url';
final FlutterSecureStorage _secure = const FlutterSecureStorage(); final FlutterSecureStorage _secure = const FlutterSecureStorage();
String? _apiKey; String? _apiKey;
@ -21,6 +22,8 @@ class AuthStore with ChangeNotifier {
String get deviceId => _deviceId ?? ''; String get deviceId => _deviceId ?? '';
Map<String, dynamic>? get appPermissions => _appPermissions; Map<String, dynamic>? get appPermissions => _appPermissions;
bool get isSuperAdmin => _isSuperAdmin; bool get isSuperAdmin => _isSuperAdmin;
int? _currentUserId;
int? get currentUserId => _currentUserId;
Future<void> load() async { Future<void> load() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@ -98,8 +101,9 @@ class AuthStore with ChangeNotifier {
} catch (_) {} } catch (_) {}
await prefs.remove(_kApiKey); await prefs.remove(_kApiKey);
} }
// پاک کردن دسترسیها هنگام خروج // پاک کردن دسترسیها و آخرین URL هنگام خروج
await _clearAppPermissions(); await _clearAppPermissions();
await clearLastUrl();
} else { } else {
if (kIsWeb) { if (kIsWeb) {
await prefs.setString(_kApiKey, key); await prefs.setString(_kApiKey, key);
@ -113,10 +117,13 @@ class AuthStore with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> saveAppPermissions(Map<String, dynamic>? permissions, bool isSuperAdmin) async { Future<void> saveAppPermissions(Map<String, dynamic>? permissions, bool isSuperAdmin, {int? userId}) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_appPermissions = permissions; _appPermissions = permissions;
_isSuperAdmin = isSuperAdmin; _isSuperAdmin = isSuperAdmin;
if (userId != null) {
_currentUserId = userId;
}
if (permissions == null) { if (permissions == null) {
await _clearAppPermissions(); await _clearAppPermissions();
@ -174,10 +181,16 @@ class AuthStore with ChangeNotifier {
if (user != null) { if (user != null) {
final appPermissions = user['app_permissions'] as Map<String, dynamic>?; final appPermissions = user['app_permissions'] as Map<String, dynamic>?;
final isSuperAdmin = appPermissions?['superadmin'] == true; final isSuperAdmin = appPermissions?['superadmin'] == true;
final userId = user['id'] as int?;
if (appPermissions != null) { if (appPermissions != null) {
await saveAppPermissions(appPermissions, isSuperAdmin); await saveAppPermissions(appPermissions, isSuperAdmin);
} }
if (userId != null) {
_currentUserId = userId;
notifyListeners();
}
} }
} }
} }
@ -195,6 +208,32 @@ class AuthStore with ChangeNotifier {
} }
bool get canAccessSupportOperator => hasAppPermission('support_operator'); bool get canAccessSupportOperator => hasAppPermission('support_operator');
// ذخیره آخرین URL برای بازیابی بعد از refresh
Future<void> saveLastUrl(String url) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kLastUrl, url);
} catch (_) {}
}
// بازیابی آخرین URL
Future<String?> getLastUrl() async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_kLastUrl);
} catch (_) {
return null;
}
}
// پاک کردن آخرین URL
Future<void> clearLastUrl() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kLastUrl);
} catch (_) {}
}
} }

View file

@ -483,6 +483,77 @@
"backToProfile": "Back to Profile", "backToProfile": "Back to Profile",
"noBusinessesFound": "No businesses found", "noBusinessesFound": "No businesses found",
"createFirstBusiness": "Create your first business", "createFirstBusiness": "Create your first business",
"accessDenied": "Access denied" "accessDenied": "Access denied",
"basicTools": "Basic Tools",
"businessSettings": "Business Settings",
"printDocuments": "Print Documents",
"people": "People",
"peopleList": "People List",
"receipts": "Receipts",
"payments": "Payments",
"practicalTools": "Practical Tools",
"usersAndPermissions": "Users and Permissions",
"businessUsers": "Business Users",
"addNewUser": "Add New User",
"userEmailOrPhone": "Email or Phone",
"userEmailOrPhoneHint": "Enter user email or phone number",
"addUser": "Add User",
"userAddedSuccessfully": "User added successfully",
"userAddFailed": "Failed to add user",
"userRemovedSuccessfully": "User removed successfully",
"userRemoveFailed": "Failed to remove user",
"permissionsUpdatedSuccessfully": "Permissions updated successfully",
"permissionsUpdateFailed": "Failed to update permissions",
"userNotFound": "User not found",
"invalidEmailOrPhone": "Invalid email or phone number",
"userAlreadyExists": "User already exists",
"removeUser": "Remove User",
"removeUserConfirm": "Are you sure you want to remove this user?",
"userPermissions": "User Permissions",
"permissions": "Permissions",
"permission": "Permission",
"hasPermission": "Has Permission",
"noPermission": "No Permission",
"viewUsers": "View Users",
"managePermissions": "Manage Permissions",
"totalUsers": "Total Users",
"activeUsers": "Active Users",
"pendingUsers": "Pending Users",
"userName": "User Name",
"userEmail": "Email",
"userPhone": "Phone",
"userStatus": "Status",
"userRole": "Role",
"userAddedAt": "Added At",
"lastActive": "Last Active",
"active": "Active",
"inactive": "Inactive",
"pending": "Pending",
"owner": "Owner",
"admin": "Admin",
"member": "Member",
"viewer": "Viewer",
"editPermissions": "Edit Permissions",
"savePermissions": "Save Permissions",
"cancel": "Cancel",
"loading": "Loading...",
"noUsersFound": "No users found",
"searchUsers": "Search users...",
"filterByStatus": "Filter by Status",
"filterByRole": "Filter by Role",
"allStatuses": "All Statuses",
"allRoles": "All Roles",
"permissionDashboard": "Dashboard Access",
"permissionPeople": "People Access",
"permissionReceipts": "Receipts Access",
"permissionPayments": "Payments Access",
"permissionReports": "Reports Access",
"permissionSettings": "Settings Access",
"permissionUsers": "Users Access",
"permissionPrint": "Print Access",
"ownerWarning": "Warning: Business owner does not need to be added and always has full access to all sections",
"ownerWarningTitle": "Business Owner",
"alreadyAddedWarning": "This user has already been added to the business",
"alreadyAddedWarningTitle": "Existing User"
} }

View file

@ -482,6 +482,77 @@
"backToProfile": "بازگشت به پروفایل", "backToProfile": "بازگشت به پروفایل",
"noBusinessesFound": "هیچ کسب و کاری یافت نشد", "noBusinessesFound": "هیچ کسب و کاری یافت نشد",
"createFirstBusiness": "اولین کسب و کار خود را ایجاد کنید", "createFirstBusiness": "اولین کسب و کار خود را ایجاد کنید",
"accessDenied": "دسترسی غیرمجاز" "accessDenied": "دسترسی غیرمجاز",
"basicTools": "ابزارهای پایه",
"businessSettings": "تنظیمات کسب و کار",
"printDocuments": "چاپ اسناد",
"people": "اشخاص",
"peopleList": "لیست اشخاص",
"receipts": "دریافت‌ها",
"payments": "پرداخت‌ها",
"practicalTools": "ابزارهای کاربردی",
"usersAndPermissions": "کاربران و دسترسی‌ها",
"businessUsers": "کاربران کسب و کار",
"addNewUser": "افزودن کاربر جدید",
"userEmailOrPhone": "ایمیل یا شماره تلفن",
"userEmailOrPhoneHint": "ایمیل یا شماره تلفن کاربر را وارد کنید",
"addUser": "افزودن کاربر",
"userAddedSuccessfully": "کاربر با موفقیت اضافه شد",
"userAddFailed": "خطا در افزودن کاربر",
"userRemovedSuccessfully": "کاربر با موفقیت حذف شد",
"userRemoveFailed": "خطا در حذف کاربر",
"permissionsUpdatedSuccessfully": "دسترسی‌ها با موفقیت به‌روزرسانی شد",
"permissionsUpdateFailed": "خطا در به‌روزرسانی دسترسی‌ها",
"userNotFound": "کاربر یافت نشد",
"invalidEmailOrPhone": "ایمیل یا شماره تلفن نامعتبر است",
"userAlreadyExists": "کاربر قبلاً اضافه شده است",
"removeUser": "حذف کاربر",
"removeUserConfirm": "آیا مطمئن هستید که می‌خواهید این کاربر را حذف کنید؟",
"userPermissions": "دسترسی‌های کاربر",
"permissions": "دسترسی‌ها",
"permission": "دسترسی",
"hasPermission": "دارای دسترسی",
"noPermission": "بدون دسترسی",
"viewUsers": "مشاهده کاربران",
"managePermissions": "مدیریت دسترسی‌ها",
"totalUsers": "کل کاربران",
"activeUsers": "کاربران فعال",
"pendingUsers": "کاربران در انتظار",
"userName": "نام کاربر",
"userEmail": "ایمیل",
"userPhone": "تلفن",
"userStatus": "وضعیت",
"userRole": "نقش",
"userAddedAt": "تاریخ افزودن",
"lastActive": "آخرین فعالیت",
"active": "فعال",
"inactive": "غیرفعال",
"pending": "در انتظار",
"owner": "مالک",
"admin": "مدیر",
"member": "عضو",
"viewer": "مشاهده‌گر",
"editPermissions": "ویرایش دسترسی‌ها",
"savePermissions": "ذخیره دسترسی‌ها",
"cancel": "لغو",
"loading": "در حال بارگذاری...",
"noUsersFound": "هیچ کاربری یافت نشد",
"searchUsers": "جستجوی کاربران...",
"filterByStatus": "فیلتر بر اساس وضعیت",
"filterByRole": "فیلتر بر اساس نقش",
"allStatuses": "همه وضعیت‌ها",
"allRoles": "همه نقش‌ها",
"permissionDashboard": "دسترسی به داشبورد",
"permissionPeople": "دسترسی به اشخاص",
"permissionReceipts": "دسترسی به دریافت‌ها",
"permissionPayments": "دسترسی به پرداخت‌ها",
"permissionReports": "دسترسی به گزارش‌ها",
"permissionSettings": "دسترسی به تنظیمات",
"permissionUsers": "دسترسی به کاربران",
"permissionPrint": "دسترسی به چاپ اسناد",
"ownerWarning": "هشدار: کاربر مالک کسب و کار نیازی به افزودن ندارد و همیشه دسترسی کامل به همه بخش‌ها دارد",
"ownerWarningTitle": "کاربر مالک",
"alreadyAddedWarning": "این کاربر قبلاً به کسب و کار اضافه شده است",
"alreadyAddedWarningTitle": "کاربر موجود"
} }

View file

@ -2713,6 +2713,402 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Access denied'** /// **'Access denied'**
String get accessDenied; String get accessDenied;
/// No description provided for @basicTools.
///
/// In en, this message translates to:
/// **'Basic Tools'**
String get basicTools;
/// No description provided for @businessSettings.
///
/// In en, this message translates to:
/// **'Business Settings'**
String get businessSettings;
/// No description provided for @printDocuments.
///
/// In en, this message translates to:
/// **'Print Documents'**
String get printDocuments;
/// No description provided for @people.
///
/// In en, this message translates to:
/// **'People'**
String get people;
/// No description provided for @peopleList.
///
/// In en, this message translates to:
/// **'People List'**
String get peopleList;
/// No description provided for @receipts.
///
/// In en, this message translates to:
/// **'Receipts'**
String get receipts;
/// No description provided for @payments.
///
/// In en, this message translates to:
/// **'Payments'**
String get payments;
/// No description provided for @practicalTools.
///
/// In en, this message translates to:
/// **'Practical Tools'**
String get practicalTools;
/// No description provided for @usersAndPermissions.
///
/// In en, this message translates to:
/// **'Users and Permissions'**
String get usersAndPermissions;
/// No description provided for @businessUsers.
///
/// In en, this message translates to:
/// **'Business Users'**
String get businessUsers;
/// No description provided for @addNewUser.
///
/// In en, this message translates to:
/// **'Add New User'**
String get addNewUser;
/// No description provided for @userEmailOrPhone.
///
/// In en, this message translates to:
/// **'Email or Phone'**
String get userEmailOrPhone;
/// No description provided for @userEmailOrPhoneHint.
///
/// In en, this message translates to:
/// **'Enter user email or phone number'**
String get userEmailOrPhoneHint;
/// No description provided for @addUser.
///
/// In en, this message translates to:
/// **'Add User'**
String get addUser;
/// No description provided for @userAddedSuccessfully.
///
/// In en, this message translates to:
/// **'User added successfully'**
String get userAddedSuccessfully;
/// No description provided for @userAddFailed.
///
/// In en, this message translates to:
/// **'Failed to add user'**
String get userAddFailed;
/// No description provided for @userRemovedSuccessfully.
///
/// In en, this message translates to:
/// **'User removed successfully'**
String get userRemovedSuccessfully;
/// No description provided for @userRemoveFailed.
///
/// In en, this message translates to:
/// **'Failed to remove user'**
String get userRemoveFailed;
/// No description provided for @permissionsUpdatedSuccessfully.
///
/// In en, this message translates to:
/// **'Permissions updated successfully'**
String get permissionsUpdatedSuccessfully;
/// No description provided for @permissionsUpdateFailed.
///
/// In en, this message translates to:
/// **'Failed to update permissions'**
String get permissionsUpdateFailed;
/// No description provided for @userNotFound.
///
/// In en, this message translates to:
/// **'User not found'**
String get userNotFound;
/// No description provided for @invalidEmailOrPhone.
///
/// In en, this message translates to:
/// **'Invalid email or phone number'**
String get invalidEmailOrPhone;
/// No description provided for @userAlreadyExists.
///
/// In en, this message translates to:
/// **'User already exists'**
String get userAlreadyExists;
/// No description provided for @removeUser.
///
/// In en, this message translates to:
/// **'Remove User'**
String get removeUser;
/// No description provided for @removeUserConfirm.
///
/// In en, this message translates to:
/// **'Are you sure you want to remove this user?'**
String get removeUserConfirm;
/// No description provided for @userPermissions.
///
/// In en, this message translates to:
/// **'User Permissions'**
String get userPermissions;
/// No description provided for @permissions.
///
/// In en, this message translates to:
/// **'Permissions'**
String get permissions;
/// No description provided for @permission.
///
/// In en, this message translates to:
/// **'Permission'**
String get permission;
/// No description provided for @hasPermission.
///
/// In en, this message translates to:
/// **'Has Permission'**
String get hasPermission;
/// No description provided for @noPermission.
///
/// In en, this message translates to:
/// **'No Permission'**
String get noPermission;
/// No description provided for @viewUsers.
///
/// In en, this message translates to:
/// **'View Users'**
String get viewUsers;
/// No description provided for @managePermissions.
///
/// In en, this message translates to:
/// **'Manage Permissions'**
String get managePermissions;
/// No description provided for @totalUsers.
///
/// In en, this message translates to:
/// **'Total Users'**
String get totalUsers;
/// No description provided for @activeUsers.
///
/// In en, this message translates to:
/// **'Active Users'**
String get activeUsers;
/// No description provided for @pendingUsers.
///
/// In en, this message translates to:
/// **'Pending Users'**
String get pendingUsers;
/// No description provided for @userName.
///
/// In en, this message translates to:
/// **'User Name'**
String get userName;
/// No description provided for @userEmail.
///
/// In en, this message translates to:
/// **'Email'**
String get userEmail;
/// No description provided for @userPhone.
///
/// In en, this message translates to:
/// **'Phone'**
String get userPhone;
/// No description provided for @userStatus.
///
/// In en, this message translates to:
/// **'Status'**
String get userStatus;
/// No description provided for @userRole.
///
/// In en, this message translates to:
/// **'Role'**
String get userRole;
/// No description provided for @userAddedAt.
///
/// In en, this message translates to:
/// **'Added At'**
String get userAddedAt;
/// No description provided for @lastActive.
///
/// In en, this message translates to:
/// **'Last Active'**
String get lastActive;
/// No description provided for @inactive.
///
/// In en, this message translates to:
/// **'Inactive'**
String get inactive;
/// No description provided for @pending.
///
/// In en, this message translates to:
/// **'Pending'**
String get pending;
/// No description provided for @admin.
///
/// In en, this message translates to:
/// **'Admin'**
String get admin;
/// No description provided for @viewer.
///
/// In en, this message translates to:
/// **'Viewer'**
String get viewer;
/// No description provided for @editPermissions.
///
/// In en, this message translates to:
/// **'Edit Permissions'**
String get editPermissions;
/// No description provided for @savePermissions.
///
/// In en, this message translates to:
/// **'Save Permissions'**
String get savePermissions;
/// No description provided for @noUsersFound.
///
/// In en, this message translates to:
/// **'No users found'**
String get noUsersFound;
/// No description provided for @searchUsers.
///
/// In en, this message translates to:
/// **'Search users...'**
String get searchUsers;
/// No description provided for @filterByStatus.
///
/// In en, this message translates to:
/// **'Filter by Status'**
String get filterByStatus;
/// No description provided for @filterByRole.
///
/// In en, this message translates to:
/// **'Filter by Role'**
String get filterByRole;
/// No description provided for @allStatuses.
///
/// In en, this message translates to:
/// **'All Statuses'**
String get allStatuses;
/// No description provided for @allRoles.
///
/// In en, this message translates to:
/// **'All Roles'**
String get allRoles;
/// No description provided for @permissionDashboard.
///
/// In en, this message translates to:
/// **'Dashboard Access'**
String get permissionDashboard;
/// No description provided for @permissionPeople.
///
/// In en, this message translates to:
/// **'People Access'**
String get permissionPeople;
/// No description provided for @permissionReceipts.
///
/// In en, this message translates to:
/// **'Receipts Access'**
String get permissionReceipts;
/// No description provided for @permissionPayments.
///
/// In en, this message translates to:
/// **'Payments Access'**
String get permissionPayments;
/// No description provided for @permissionReports.
///
/// In en, this message translates to:
/// **'Reports Access'**
String get permissionReports;
/// No description provided for @permissionSettings.
///
/// In en, this message translates to:
/// **'Settings Access'**
String get permissionSettings;
/// No description provided for @permissionUsers.
///
/// In en, this message translates to:
/// **'Users Access'**
String get permissionUsers;
/// No description provided for @permissionPrint.
///
/// In en, this message translates to:
/// **'Print Access'**
String get permissionPrint;
/// No description provided for @ownerWarning.
///
/// In en, this message translates to:
/// **'Warning: Business owner does not need to be added and always has full access to all sections'**
String get ownerWarning;
/// No description provided for @ownerWarningTitle.
///
/// In en, this message translates to:
/// **'Business Owner'**
String get ownerWarningTitle;
/// No description provided for @alreadyAddedWarning.
///
/// In en, this message translates to:
/// **'This user has already been added to the business'**
String get alreadyAddedWarning;
/// No description provided for @alreadyAddedWarningTitle.
///
/// In en, this message translates to:
/// **'Existing User'**
String get alreadyAddedWarningTitle;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -1356,4 +1356,205 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get accessDenied => 'Access denied'; String get accessDenied => 'Access denied';
@override
String get basicTools => 'Basic Tools';
@override
String get businessSettings => 'Business Settings';
@override
String get printDocuments => 'Print Documents';
@override
String get people => 'People';
@override
String get peopleList => 'People List';
@override
String get receipts => 'Receipts';
@override
String get payments => 'Payments';
@override
String get practicalTools => 'Practical Tools';
@override
String get usersAndPermissions => 'Users and Permissions';
@override
String get businessUsers => 'Business Users';
@override
String get addNewUser => 'Add New User';
@override
String get userEmailOrPhone => 'Email or Phone';
@override
String get userEmailOrPhoneHint => 'Enter user email or phone number';
@override
String get addUser => 'Add User';
@override
String get userAddedSuccessfully => 'User added successfully';
@override
String get userAddFailed => 'Failed to add user';
@override
String get userRemovedSuccessfully => 'User removed successfully';
@override
String get userRemoveFailed => 'Failed to remove user';
@override
String get permissionsUpdatedSuccessfully =>
'Permissions updated successfully';
@override
String get permissionsUpdateFailed => 'Failed to update permissions';
@override
String get userNotFound => 'User not found';
@override
String get invalidEmailOrPhone => 'Invalid email or phone number';
@override
String get userAlreadyExists => 'User already exists';
@override
String get removeUser => 'Remove User';
@override
String get removeUserConfirm => 'Are you sure you want to remove this user?';
@override
String get userPermissions => 'User Permissions';
@override
String get permissions => 'Permissions';
@override
String get permission => 'Permission';
@override
String get hasPermission => 'Has Permission';
@override
String get noPermission => 'No Permission';
@override
String get viewUsers => 'View Users';
@override
String get managePermissions => 'Manage Permissions';
@override
String get totalUsers => 'Total Users';
@override
String get activeUsers => 'Active Users';
@override
String get pendingUsers => 'Pending Users';
@override
String get userName => 'User Name';
@override
String get userEmail => 'Email';
@override
String get userPhone => 'Phone';
@override
String get userStatus => 'Status';
@override
String get userRole => 'Role';
@override
String get userAddedAt => 'Added At';
@override
String get lastActive => 'Last Active';
@override
String get inactive => 'Inactive';
@override
String get pending => 'Pending';
@override
String get admin => 'Admin';
@override
String get viewer => 'Viewer';
@override
String get editPermissions => 'Edit Permissions';
@override
String get savePermissions => 'Save Permissions';
@override
String get noUsersFound => 'No users found';
@override
String get searchUsers => 'Search users...';
@override
String get filterByStatus => 'Filter by Status';
@override
String get filterByRole => 'Filter by Role';
@override
String get allStatuses => 'All Statuses';
@override
String get allRoles => 'All Roles';
@override
String get permissionDashboard => 'Dashboard Access';
@override
String get permissionPeople => 'People Access';
@override
String get permissionReceipts => 'Receipts Access';
@override
String get permissionPayments => 'Payments Access';
@override
String get permissionReports => 'Reports Access';
@override
String get permissionSettings => 'Settings Access';
@override
String get permissionUsers => 'Users Access';
@override
String get permissionPrint => 'Print Access';
@override
String get ownerWarning =>
'Warning: Business owner does not need to be added and always has full access to all sections';
@override
String get ownerWarningTitle => 'Business Owner';
@override
String get alreadyAddedWarning =>
'This user has already been added to the business';
@override
String get alreadyAddedWarningTitle => 'Existing User';
} }

View file

@ -1346,4 +1346,206 @@ class AppLocalizationsFa extends AppLocalizations {
@override @override
String get accessDenied => 'دسترسی غیرمجاز'; String get accessDenied => 'دسترسی غیرمجاز';
@override
String get basicTools => 'ابزارهای پایه';
@override
String get businessSettings => 'تنظیمات کسب و کار';
@override
String get printDocuments => 'چاپ اسناد';
@override
String get people => 'اشخاص';
@override
String get peopleList => 'لیست اشخاص';
@override
String get receipts => 'دریافت‌ها';
@override
String get payments => 'پرداخت‌ها';
@override
String get practicalTools => 'ابزارهای کاربردی';
@override
String get usersAndPermissions => 'کاربران و دسترسی‌ها';
@override
String get businessUsers => 'کاربران کسب و کار';
@override
String get addNewUser => 'افزودن کاربر جدید';
@override
String get userEmailOrPhone => 'ایمیل یا شماره تلفن';
@override
String get userEmailOrPhoneHint => 'ایمیل یا شماره تلفن کاربر را وارد کنید';
@override
String get addUser => 'افزودن کاربر';
@override
String get userAddedSuccessfully => 'کاربر با موفقیت اضافه شد';
@override
String get userAddFailed => 'خطا در افزودن کاربر';
@override
String get userRemovedSuccessfully => 'کاربر با موفقیت حذف شد';
@override
String get userRemoveFailed => 'خطا در حذف کاربر';
@override
String get permissionsUpdatedSuccessfully =>
'دسترسی‌ها با موفقیت به‌روزرسانی شد';
@override
String get permissionsUpdateFailed => 'خطا در به‌روزرسانی دسترسی‌ها';
@override
String get userNotFound => 'کاربر یافت نشد';
@override
String get invalidEmailOrPhone => 'ایمیل یا شماره تلفن نامعتبر است';
@override
String get userAlreadyExists => 'کاربر قبلاً اضافه شده است';
@override
String get removeUser => 'حذف کاربر';
@override
String get removeUserConfirm =>
'آیا مطمئن هستید که می‌خواهید این کاربر را حذف کنید؟';
@override
String get userPermissions => 'دسترسی‌های کاربر';
@override
String get permissions => 'دسترسی‌ها';
@override
String get permission => 'دسترسی';
@override
String get hasPermission => 'دارای دسترسی';
@override
String get noPermission => 'بدون دسترسی';
@override
String get viewUsers => 'مشاهده کاربران';
@override
String get managePermissions => 'مدیریت دسترسی‌ها';
@override
String get totalUsers => 'کل کاربران';
@override
String get activeUsers => 'کاربران فعال';
@override
String get pendingUsers => 'کاربران در انتظار';
@override
String get userName => 'نام کاربر';
@override
String get userEmail => 'ایمیل';
@override
String get userPhone => 'تلفن';
@override
String get userStatus => 'وضعیت';
@override
String get userRole => 'نقش';
@override
String get userAddedAt => 'تاریخ افزودن';
@override
String get lastActive => 'آخرین فعالیت';
@override
String get inactive => 'غیرفعال';
@override
String get pending => 'در انتظار';
@override
String get admin => 'مدیر';
@override
String get viewer => 'مشاهده‌گر';
@override
String get editPermissions => 'ویرایش دسترسی‌ها';
@override
String get savePermissions => 'ذخیره دسترسی‌ها';
@override
String get noUsersFound => 'هیچ کاربری یافت نشد';
@override
String get searchUsers => 'جستجوی کاربران...';
@override
String get filterByStatus => 'فیلتر بر اساس وضعیت';
@override
String get filterByRole => 'فیلتر بر اساس نقش';
@override
String get allStatuses => 'همه وضعیت‌ها';
@override
String get allRoles => 'همه نقش‌ها';
@override
String get permissionDashboard => 'دسترسی به داشبورد';
@override
String get permissionPeople => 'دسترسی به اشخاص';
@override
String get permissionReceipts => 'دسترسی به دریافت‌ها';
@override
String get permissionPayments => 'دسترسی به پرداخت‌ها';
@override
String get permissionReports => 'دسترسی به گزارش‌ها';
@override
String get permissionSettings => 'دسترسی به تنظیمات';
@override
String get permissionUsers => 'دسترسی به کاربران';
@override
String get permissionPrint => 'دسترسی به چاپ اسناد';
@override
String get ownerWarning =>
'هشدار: کاربر مالک کسب و کار نیازی به افزودن ندارد و همیشه دسترسی کامل به همه بخش‌ها دارد';
@override
String get ownerWarningTitle => 'کاربر مالک';
@override
String get alreadyAddedWarning =>
'این کاربر قبلاً به کسب و کار اضافه شده است';
@override
String get alreadyAddedWarningTitle => 'کاربر موجود';
} }

View file

@ -20,6 +20,7 @@ import 'pages/admin/system_logs_page.dart';
import 'pages/admin/email_settings_page.dart'; import 'pages/admin/email_settings_page.dart';
import 'pages/business/business_shell.dart'; import 'pages/business/business_shell.dart';
import 'pages/business/dashboard/business_dashboard_page.dart'; import 'pages/business/dashboard/business_dashboard_page.dart';
import 'pages/business/users_permissions_page.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'core/locale_controller.dart'; import 'core/locale_controller.dart';
import 'core/calendar_controller.dart'; import 'core/calendar_controller.dart';
@ -29,6 +30,7 @@ import 'theme/app_theme.dart';
import 'core/auth_store.dart'; import 'core/auth_store.dart';
import 'core/permission_guard.dart'; import 'core/permission_guard.dart';
import 'widgets/simple_splash_screen.dart'; import 'widgets/simple_splash_screen.dart';
import 'widgets/url_tracker.dart';
void main() { void main() {
// Use path-based routing instead of hash routing // Use path-based routing instead of hash routing
@ -98,15 +100,34 @@ class _MyAppState extends State<MyApp> {
ApiClient.bindCalendarController(_calendarController!); ApiClient.bindCalendarController(_calendarController!);
ApiClient.bindAuthStore(_authStore!); ApiClient.bindAuthStore(_authStore!);
// اطمینان از حداقل 4 ثانیه نمایش splash screen // اطمینان از حداقل 1 ثانیه نمایش splash screen
final elapsed = DateTime.now().difference(_loadStartTime!); final elapsed = DateTime.now().difference(_loadStartTime!);
const minimumDuration = Duration(seconds: 4); const minimumDuration = Duration(seconds: 1);
if (elapsed < minimumDuration) { if (elapsed < minimumDuration) {
await Future.delayed(minimumDuration - elapsed); await Future.delayed(minimumDuration - elapsed);
} }
// ذخیره URL فعلی قبل از اتمام loading
if (_authStore != null) {
try {
final currentUrl = Uri.base.path;
print('🔍 LOADING DEBUG: Current URL before finishing loading: $currentUrl');
if (currentUrl.isNotEmpty &&
currentUrl != '/' &&
currentUrl != '/login' &&
(currentUrl.startsWith('/user/profile/') || currentUrl.startsWith('/business/'))) {
print('🔍 LOADING DEBUG: Saving current URL: $currentUrl');
await _authStore!.saveLastUrl(currentUrl);
}
} catch (e) {
print('🔍 LOADING DEBUG: Error saving current URL: $e');
}
}
// اتمام loading // اتمام loading
if (mounted) { if (mounted) {
print('🔍 LOADING DEBUG: Finishing loading, setting _isLoading to false');
setState(() { setState(() {
_isLoading = false; _isLoading = false;
}); });
@ -116,12 +137,16 @@ class _MyAppState extends State<MyApp> {
// Root of application with GoRouter // Root of application with GoRouter
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
print('🔍 BUILD DEBUG: Building app, _isLoading: $_isLoading');
print('🔍 BUILD DEBUG: Controllers - locale: ${_controller != null}, calendar: ${_calendarController != null}, theme: ${_themeController != null}, auth: ${_authStore != null}');
// اگر هنوز loading است، splash screen نمایش بده // اگر هنوز loading است، splash screen نمایش بده
if (_isLoading || if (_isLoading ||
_controller == null || _controller == null ||
_calendarController == null || _calendarController == null ||
_themeController == null || _themeController == null ||
_authStore == null) { _authStore == null) {
print('🔍 BUILD DEBUG: Still loading, showing splash screen');
final loadingRouter = GoRouter( final loadingRouter = GoRouter(
redirect: (context, state) { redirect: (context, state) {
// در حین loading، هیچ redirect نکن - URL را حفظ کن // در حین loading، هیچ redirect نکن - URL را حفظ کن
@ -165,11 +190,13 @@ class _MyAppState extends State<MyApp> {
return SimpleSplashScreen( return SimpleSplashScreen(
message: loadingMessage, message: loadingMessage,
showLogo: true, showLogo: true,
displayDuration: const Duration(seconds: 4), displayDuration: const Duration(seconds: 1),
locale: _controller?.locale, locale: _controller?.locale,
authStore: _authStore,
onComplete: () { onComplete: () {
// این callback زمانی فراخوانی میشود که splash screen تمام شود // این callback زمانی فراخوانی میشود که splash screen تمام شود
// اما ما از splash controller استفاده میکنیم // اما ما از splash controller استفاده میکنیم
print('🔍 SPLASH DEBUG: Splash screen completed');
}, },
); );
}, },
@ -194,41 +221,69 @@ class _MyAppState extends State<MyApp> {
final controller = _controller!; final controller = _controller!;
final themeController = _themeController!; final themeController = _themeController!;
print('🔍 BUILD DEBUG: All controllers loaded, creating main router');
final router = GoRouter( final router = GoRouter(
initialLocation: '/', initialLocation: '/',
redirect: (context, state) { redirect: (context, state) async {
final currentPath = state.uri.path; final currentPath = state.uri.path;
final fullUri = state.uri.toString();
print('🔍 REDIRECT DEBUG: Current path: $currentPath');
print('🔍 REDIRECT DEBUG: Full URI: $fullUri');
// اگر authStore هنوز load نشده، منتظر بمان // اگر authStore هنوز load نشده، منتظر بمان
if (_authStore == null) { if (_authStore == null) {
print('🔍 REDIRECT DEBUG: AuthStore is null, staying on current path');
return null; return null;
} }
final hasKey = _authStore!.apiKey != null && _authStore!.apiKey!.isNotEmpty; final hasKey = _authStore!.apiKey != null && _authStore!.apiKey!.isNotEmpty;
print('🔍 REDIRECT DEBUG: Has API key: $hasKey');
// اگر API key ندارد // اگر API key ندارد
if (!hasKey) { if (!hasKey) {
print('🔍 REDIRECT DEBUG: No API key');
// اگر در login نیست، به login برود // اگر در login نیست، به login برود
if (currentPath != '/login') { if (currentPath != '/login') {
print('🔍 REDIRECT DEBUG: Redirecting to login from $currentPath');
return '/login'; return '/login';
} }
// اگر در login است، بماند // اگر در login است، بماند
print('🔍 REDIRECT DEBUG: Already on login, staying');
return null; return null;
} }
// اگر API key دارد // اگر API key دارد
print('🔍 REDIRECT DEBUG: Has API key, checking current path');
// اگر در login است، به dashboard برود // اگر در login است، به dashboard برود
if (currentPath == '/login') { if (currentPath == '/login') {
print('🔍 REDIRECT DEBUG: On login page, redirecting to dashboard');
return '/user/profile/dashboard'; return '/user/profile/dashboard';
} }
// اگر در root است، به dashboard برود // اگر در root است، آخرین URL را بررسی کن
if (currentPath == '/') { if (currentPath == '/') {
print('🔍 REDIRECT DEBUG: On root path, checking last URL');
// اگر آخرین URL موجود است و معتبر است، به آن برود
final lastUrl = await _authStore!.getLastUrl();
print('🔍 REDIRECT DEBUG: Last URL: $lastUrl');
if (lastUrl != null &&
lastUrl.isNotEmpty &&
lastUrl != '/' &&
lastUrl != '/login' &&
(lastUrl.startsWith('/user/profile/') || lastUrl.startsWith('/business/'))) {
print('🔍 REDIRECT DEBUG: Redirecting to last URL: $lastUrl');
return lastUrl;
}
// وگرنه به dashboard برود (فقط اگر در root باشیم)
print('🔍 REDIRECT DEBUG: No valid last URL, redirecting to dashboard');
return '/user/profile/dashboard'; return '/user/profile/dashboard';
} }
// برای سایر صفحات (شامل صفحات profile)، redirect نکن (بماند) // برای سایر صفحات (شامل صفحات profile و business)، redirect نکن (بماند)
// این مهم است: اگر کاربر در صفحات profile است، بماند // این مهم است: اگر کاربر در صفحات profile یا business است، بماند
print('🔍 REDIRECT DEBUG: On other page ($currentPath), staying on current path');
return null; return null;
}, },
routes: <RouteBase>[ routes: <RouteBase>[
@ -373,7 +428,9 @@ class _MyAppState extends State<MyApp> {
return BusinessShell( return BusinessShell(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!, calendarController: _calendarController!,
themeController: themeController,
child: const SizedBox.shrink(), // Will be replaced by child routes child: const SizedBox.shrink(), // Will be replaced by child routes
); );
}, },
@ -386,11 +443,32 @@ class _MyAppState extends State<MyApp> {
return BusinessShell( return BusinessShell(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!, calendarController: _calendarController!,
themeController: themeController,
child: BusinessDashboardPage(businessId: businessId), child: BusinessDashboardPage(businessId: businessId),
); );
}, },
), ),
GoRoute(
path: 'users-permissions',
name: 'business_users_permissions',
builder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: UsersPermissionsPage(
businessId: businessId.toString(),
authStore: _authStore!,
calendarController: _calendarController!,
),
);
},
),
// TODO: Add other business routes (sales, accounting, etc.) // TODO: Add other business routes (sales, accounting, etc.)
], ],
), ),
@ -400,7 +478,9 @@ class _MyAppState extends State<MyApp> {
return AnimatedBuilder( return AnimatedBuilder(
animation: Listenable.merge([controller, themeController]), animation: Listenable.merge([controller, themeController]),
builder: (context, _) { builder: (context, _) {
return MaterialApp.router( return UrlTracker(
authStore: _authStore!,
child: MaterialApp.router(
title: 'Hesabix', title: 'Hesabix',
theme: AppTheme.build( theme: AppTheme.build(
isDark: false, isDark: false,
@ -422,6 +502,7 @@ class _MyAppState extends State<MyApp> {
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
], ],
),
); );
}, },
); );

View file

@ -0,0 +1,306 @@
import 'package:shamsi_date/shamsi_date.dart';
class BusinessUser {
final int id;
final int businessId;
final int userId;
final String userName;
final String userEmail;
final String? userPhone;
final String role;
final String status;
final DateTime addedAt;
final DateTime? lastActive;
final Map<String, dynamic> permissions;
const BusinessUser({
required this.id,
required this.businessId,
required this.userId,
required this.userName,
required this.userEmail,
this.userPhone,
required this.role,
required this.status,
required this.addedAt,
this.lastActive,
required this.permissions,
});
factory BusinessUser.fromJson(Map<String, dynamic> json) {
return BusinessUser(
id: json['id'] as int,
businessId: json['business_id'] as int,
userId: json['user_id'] as int,
userName: json['user_name'] as String,
userEmail: json['user_email'] as String,
userPhone: json['user_phone'] as String?,
role: json['role'] as String,
status: json['status'] as String,
addedAt: _parseDateTime(json['added_at']),
lastActive: json['last_active'] != null
? _parseDateTime(json['last_active'])
: null,
permissions: (json['permissions'] as Map<String, dynamic>?) ?? {},
);
}
static DateTime _parseDateTime(dynamic dateValue) {
if (dateValue == null) return DateTime.now();
if (dateValue is String) {
// Check if it's a Jalali date format (YYYY/MM/DD HH:MM:SS)
if (dateValue.contains('/') && !dateValue.contains('-')) {
try {
// Parse Jalali date format: YYYY/MM/DD HH:MM:SS
final parts = dateValue.split(' ');
if (parts.length >= 1) {
final dateParts = parts[0].split('/');
if (dateParts.length == 3) {
final year = int.parse(dateParts[0]);
final month = int.parse(dateParts[1]);
final day = int.parse(dateParts[2]);
final jalali = Jalali(year, month, day);
return jalali.toDateTime();
}
}
} catch (e) {
// Fall back to standard parsing
}
}
return DateTime.parse(dateValue);
} else if (dateValue is int) {
return DateTime.fromMillisecondsSinceEpoch(dateValue);
}
return DateTime.now();
}
Map<String, dynamic> toJson() {
return {
'id': id,
'business_id': businessId,
'user_id': userId,
'user_name': userName,
'user_email': userEmail,
'user_phone': userPhone,
'role': role,
'status': status,
'added_at': addedAt.toIso8601String(),
'last_active': lastActive?.toIso8601String(),
'permissions': permissions,
};
}
BusinessUser copyWith({
int? id,
int? businessId,
int? userId,
String? userName,
String? userEmail,
String? userPhone,
String? role,
String? status,
DateTime? addedAt,
DateTime? lastActive,
Map<String, dynamic>? permissions,
}) {
return BusinessUser(
id: id ?? this.id,
businessId: businessId ?? this.businessId,
userId: userId ?? this.userId,
userName: userName ?? this.userName,
userEmail: userEmail ?? this.userEmail,
userPhone: userPhone ?? this.userPhone,
role: role ?? this.role,
status: status ?? this.status,
addedAt: addedAt ?? this.addedAt,
lastActive: lastActive ?? this.lastActive,
permissions: permissions ?? this.permissions,
);
}
// Helper methods for permissions
bool hasPermission(String section, String action) {
if (permissions.isEmpty) return false;
if (!permissions.containsKey(section)) return false;
final sectionPerms = permissions[section] as Map<String, dynamic>?;
if (sectionPerms == null) return action == 'read';
return sectionPerms[action] == true;
}
bool canRead(String section) {
return hasPermission(section, 'read') || permissions.containsKey(section);
}
bool canWrite(String section) {
return hasPermission(section, 'write');
}
bool canDelete(String section) {
return hasPermission(section, 'delete');
}
bool canApprove(String section) {
return hasPermission(section, 'approve');
}
bool canExport(String section) {
return hasPermission(section, 'export');
}
bool canManageUsers() {
return hasPermission('settings', 'manage_users');
}
// Get all available sections
List<String> get availableSections {
return permissions.keys.toList();
}
// Get all actions for a section
List<String> getActionsForSection(String section) {
final sectionPerms = permissions[section] as Map<String, dynamic>?;
if (sectionPerms == null) return ['read'];
return sectionPerms.keys.where((key) => sectionPerms[key] == true).toList();
}
}
// Request/Response models
class AddUserRequest {
final int businessId;
final String emailOrPhone;
const AddUserRequest({
required this.businessId,
required this.emailOrPhone,
});
Map<String, dynamic> toJson() {
return {
'business_id': businessId,
'email_or_phone': emailOrPhone,
};
}
}
class AddUserResponse {
final bool success;
final String message;
final BusinessUser? user;
const AddUserResponse({
required this.success,
required this.message,
this.user,
});
factory AddUserResponse.fromJson(Map<String, dynamic> json) {
return AddUserResponse(
success: json['success'] as bool? ?? false,
message: json['message'] as String? ?? '',
user: json['user'] != null
? BusinessUser.fromJson(json['user'] as Map<String, dynamic>)
: null,
);
}
}
class UpdatePermissionsRequest {
final int businessId;
final int userId;
final Map<String, dynamic> permissions;
const UpdatePermissionsRequest({
required this.businessId,
required this.userId,
required this.permissions,
});
Map<String, dynamic> toJson() {
return {
'business_id': businessId,
'user_id': userId,
'permissions': permissions,
};
}
}
class UpdatePermissionsResponse {
final bool success;
final String message;
const UpdatePermissionsResponse({
required this.success,
required this.message,
});
factory UpdatePermissionsResponse.fromJson(Map<String, dynamic> json) {
return UpdatePermissionsResponse(
success: json['success'] as bool? ?? false,
message: json['message'] as String? ?? '',
);
}
}
class RemoveUserRequest {
final int businessId;
final int userId;
const RemoveUserRequest({
required this.businessId,
required this.userId,
});
Map<String, dynamic> toJson() {
return {
'business_id': businessId,
'user_id': userId,
};
}
}
class RemoveUserResponse {
final bool success;
final String message;
const RemoveUserResponse({
required this.success,
required this.message,
});
factory RemoveUserResponse.fromJson(Map<String, dynamic> json) {
return RemoveUserResponse(
success: json['success'] as bool? ?? false,
message: json['message'] as String? ?? '',
);
}
}
class BusinessUsersResponse {
final bool success;
final String message;
final List<BusinessUser> users;
final int totalCount;
const BusinessUsersResponse({
required this.success,
required this.message,
required this.users,
required this.totalCount,
});
factory BusinessUsersResponse.fromJson(Map<String, dynamic> json) {
return BusinessUsersResponse(
success: json['success'] as bool? ?? false,
message: json['message'] as String? ?? '',
users: (json['users'] as List<dynamic>?)
?.map((userJson) => BusinessUser.fromJson(userJson as Map<String, dynamic>))
.toList() ?? [],
totalCount: json['total_count'] as int? ?? 0,
);
}
}

View file

@ -341,11 +341,11 @@ class _UserManagementPageState extends State<UserManagementPage> {
content: const Text('User creation form would go here'), content: const Text('User creation form would go here'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(AppLocalizations.of(context).cancel), child: Text(AppLocalizations.of(context).cancel),
), ),
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(AppLocalizations.of(context).save), child: Text(AppLocalizations.of(context).save),
), ),
], ],
@ -361,11 +361,11 @@ class _UserManagementPageState extends State<UserManagementPage> {
content: const Text('User edit form would go here'), content: const Text('User edit form would go here'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(AppLocalizations.of(context).cancel), child: Text(AppLocalizations.of(context).cancel),
), ),
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(AppLocalizations.of(context).save), child: Text(AppLocalizations.of(context).save),
), ),
], ],
@ -381,11 +381,11 @@ class _UserManagementPageState extends State<UserManagementPage> {
content: const Text('Permissions management would go here'), content: const Text('Permissions management would go here'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(AppLocalizations.of(context).cancel), child: Text(AppLocalizations.of(context).cancel),
), ),
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(AppLocalizations.of(context).save), child: Text(AppLocalizations.of(context).save),
), ),
], ],
@ -401,12 +401,12 @@ class _UserManagementPageState extends State<UserManagementPage> {
content: Text('Are you sure you want to delete ${user['name']}?'), content: Text('Are you sure you want to delete ${user['name']}?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(AppLocalizations.of(context).cancel), child: Text(AppLocalizations.of(context).cancel),
), ),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); context.pop();
// Delete user logic here // Delete user logic here
}, },
style: ElevatedButton.styleFrom(backgroundColor: Colors.red), style: ElevatedButton.styleFrom(backgroundColor: Colors.red),

View file

@ -1,21 +1,29 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../core/auth_store.dart'; import '../../core/auth_store.dart';
import '../../core/locale_controller.dart';
import '../../core/calendar_controller.dart'; import '../../core/calendar_controller.dart';
import '../../theme/theme_controller.dart';
import '../../widgets/settings_menu_button.dart';
import '../../widgets/user_account_menu_button.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
class BusinessShell extends StatefulWidget { class BusinessShell extends StatefulWidget {
final int businessId; final int businessId;
final AuthStore authStore;
final CalendarController calendarController;
final Widget child; final Widget child;
final AuthStore authStore;
final LocaleController? localeController;
final CalendarController? calendarController;
final ThemeController? themeController;
const BusinessShell({ const BusinessShell({
super.key, super.key,
required this.businessId, required this.businessId,
required this.authStore,
required this.calendarController,
required this.child, required this.child,
required this.authStore,
this.localeController,
this.calendarController,
this.themeController,
}); });
@override @override
@ -23,6 +31,9 @@ class BusinessShell extends StatefulWidget {
} }
class _BusinessShellState extends State<BusinessShell> { class _BusinessShellState extends State<BusinessShell> {
int _hoverIndex = -1;
bool _isBasicToolsExpanded = false;
bool _isPeopleExpanded = false;
@override @override
void initState() { void initState() {
@ -41,40 +52,206 @@ class _BusinessShellState extends State<BusinessShell> {
final bool useRail = width >= 700; final bool useRail = width >= 700;
final bool railExtended = width >= 1100; final bool railExtended = width >= 1100;
final ColorScheme scheme = Theme.of(context).colorScheme; final ColorScheme scheme = Theme.of(context).colorScheme;
final String location = GoRouterState.of(context).uri.toString(); String location = '/business/${widget.businessId}/dashboard'; // default location
try {
location = GoRouterState.of(context).uri.toString();
} catch (e) {
// اگر GoRouterState در دسترس نیست، از default استفاده کن
}
final bool isDark = Theme.of(context).brightness == Brightness.dark; final bool isDark = Theme.of(context).brightness == Brightness.dark;
final String logoAsset = isDark final String logoAsset = isDark
? 'assets/images/logo-light.png' ? 'assets/images/logo-light.png'
: 'assets/images/logo-light.png'; : 'assets/images/logo-light.png';
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
final destinations = <_Dest>[
_Dest(t.businessDashboard, Icons.dashboard_outlined, Icons.dashboard, '/business/${widget.businessId}/dashboard'), // ساختار متمرکز منو
_Dest(t.sales, Icons.sell, Icons.sell, '/business/${widget.businessId}/sales'), final menuItems = <_MenuItem>[
_Dest(t.accounting, Icons.account_balance, Icons.account_balance, '/business/${widget.businessId}/accounting'), _MenuItem(
_Dest(t.inventory, Icons.inventory, Icons.inventory, '/business/${widget.businessId}/inventory'), label: t.businessDashboard,
_Dest(t.reports, Icons.assessment, Icons.assessment, '/business/${widget.businessId}/reports'), icon: Icons.dashboard_outlined,
_Dest(t.members, Icons.people, Icons.people, '/business/${widget.businessId}/members'), selectedIcon: Icons.dashboard,
_Dest(t.settings, Icons.settings, Icons.settings, '/business/${widget.businessId}/settings'), path: '/business/${widget.businessId}/dashboard',
type: _MenuItemType.simple,
),
_MenuItem(
label: t.practicalTools,
icon: Icons.category,
selectedIcon: Icons.category,
path: null, // آیتم جداکننده
type: _MenuItemType.separator,
),
_MenuItem(
label: t.people,
icon: Icons.people,
selectedIcon: Icons.people,
path: null, // برای منوی بازشونده
type: _MenuItemType.expandable,
children: [
_MenuItem(
label: t.peopleList,
icon: Icons.list,
selectedIcon: Icons.list,
path: '/business/${widget.businessId}/people-list',
type: _MenuItemType.simple,
),
_MenuItem(
label: t.receipts,
icon: Icons.receipt,
selectedIcon: Icons.receipt,
path: '/business/${widget.businessId}/receipts',
type: _MenuItemType.simple,
hasAddButton: true,
),
_MenuItem(
label: t.payments,
icon: Icons.payment,
selectedIcon: Icons.payment,
path: '/business/${widget.businessId}/payments',
type: _MenuItemType.simple,
hasAddButton: true,
),
],
),
_MenuItem(
label: t.settings,
icon: Icons.settings,
selectedIcon: Icons.settings,
path: null, // برای منوی بازشونده
type: _MenuItemType.expandable,
children: [
_MenuItem(
label: t.businessSettings,
icon: Icons.business,
selectedIcon: Icons.business,
path: '/business/${widget.businessId}/business-settings',
type: _MenuItemType.simple,
),
_MenuItem(
label: t.printDocuments,
icon: Icons.print,
selectedIcon: Icons.print,
path: '/business/${widget.businessId}/print-documents',
type: _MenuItemType.simple,
),
_MenuItem(
label: t.usersAndPermissions,
icon: Icons.people_outline,
selectedIcon: Icons.people,
path: '/business/${widget.businessId}/users-permissions',
type: _MenuItemType.simple,
),
],
),
]; ];
int selectedIndex = 0; int selectedIndex = 0;
for (int i = 0; i < destinations.length; i++) { for (int i = 0; i < menuItems.length; i++) {
if (location.startsWith(destinations[i].path)) { final item = menuItems[i];
if (item.type == _MenuItemType.separator) continue; // نادیده گرفتن آیتم جداکننده
if (item.type == _MenuItemType.simple && item.path != null && location.startsWith(item.path!)) {
selectedIndex = i; selectedIndex = i;
break; break;
} else if (item.type == _MenuItemType.expandable && item.children != null) {
for (int j = 0; j < item.children!.length; j++) {
final child = item.children![j];
if (child.path != null && location.startsWith(child.path!)) {
selectedIndex = i;
// تنظیم وضعیت باز بودن منو
if (i == 2) _isPeopleExpanded = true; // اشخاص در ایندکس 2
if (i == 3) _isBasicToolsExpanded = true; // تنظیمات در ایندکس 3
break;
}
}
} }
} }
Future<void> onSelect(int index) async { Future<void> onSelect(int index) async {
final path = destinations[index].path; final item = menuItems[index];
if (GoRouterState.of(context).uri.toString() != path) { if (item.type == _MenuItemType.separator) return; // آیتم جداکننده قابل کلیک نیست
context.go(path);
if (item.type == _MenuItemType.simple && item.path != null) {
try {
if (GoRouterState.of(context).uri.toString() != item.path!) {
context.go(item.path!);
}
} catch (e) {
// اگر GoRouterState در دسترس نیست، مستقیماً به مسیر برود
context.go(item.path!);
}
} else if (item.type == _MenuItemType.expandable) {
// تغییر وضعیت باز/بسته بودن منو
if (item.label == t.people) _isPeopleExpanded = !_isPeopleExpanded;
if (item.label == t.settings) _isBasicToolsExpanded = !_isBasicToolsExpanded;
setState(() {});
} }
} }
Future<void> onBackToProfile() async { Future<void> onSelectChild(int parentIndex, int childIndex) async {
context.go('/user/profile/businesses'); final parent = menuItems[parentIndex];
if (parent.type == _MenuItemType.expandable && parent.children != null) {
final child = parent.children![childIndex];
if (child.path != null) {
try {
if (GoRouterState.of(context).uri.toString() != child.path!) {
context.go(child.path!);
}
} catch (e) {
// اگر GoRouterState در دسترس نیست، مستقیماً به مسیر برود
context.go(child.path!);
}
}
}
}
Future<void> onLogout() async {
await widget.authStore.saveApiKey(null);
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(content: Text('خروج انجام شد')));
context.go('/login');
}
bool isExpanded(_MenuItem item) {
if (item.label == t.people) return _isPeopleExpanded;
if (item.label == t.settings) return _isBasicToolsExpanded;
return false;
}
int getTotalMenuItemsCount() {
int count = 0;
for (final item in menuItems) {
if (item.type == _MenuItemType.separator) {
count++; // آیتم جداکننده هم شمرده میشود
} else {
count++; // آیتم اصلی
if (item.type == _MenuItemType.expandable && isExpanded(item) && railExtended) {
count += item.children?.length ?? 0;
}
}
}
return count;
}
int getMenuIndexFromTotalIndex(int totalIndex) {
int currentIndex = 0;
for (int i = 0; i < menuItems.length; i++) {
if (currentIndex == totalIndex) return i;
currentIndex++;
final item = menuItems[i];
if (item.type == _MenuItemType.expandable && isExpanded(item) && railExtended) {
final childrenCount = item.children?.length ?? 0;
if (totalIndex >= currentIndex && totalIndex < currentIndex + childrenCount) {
return i;
}
currentIndex += childrenCount;
}
}
return 0;
} }
// Brand top bar with contrast color // Brand top bar with contrast color
@ -88,86 +265,431 @@ class _BusinessShellState extends State<BusinessShell> {
final appBar = AppBar( final appBar = AppBar(
backgroundColor: appBarBg, backgroundColor: appBarBg,
foregroundColor: appBarFg, foregroundColor: appBarFg,
elevation: 0, automaticallyImplyLeading: !useRail,
titleSpacing: 0,
title: Row( title: Row(
children: [ children: [
Image.asset(
logoAsset,
height: 32,
width: 32,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.business,
color: appBarFg,
size: 32,
),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Image.asset(logoAsset, height: 28),
'Hesabix', const SizedBox(width: 12),
style: TextStyle( Text(t.appTitle, style: TextStyle(color: appBarFg, fontWeight: FontWeight.w700)),
color: appBarFg,
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
], ],
), ),
actions: [ leading: useRail
IconButton( ? null
icon: Icon(Icons.arrow_back, color: appBarFg), : Builder(
onPressed: onBackToProfile, builder: (ctx) => IconButton(
tooltip: t.backToProfile, icon: Icon(Icons.menu, color: appBarFg),
onPressed: () => Scaffold.of(ctx).openDrawer(),
tooltip: t.menu,
), ),
),
actions: [
SettingsMenuButton(
localeController: widget.localeController,
calendarController: widget.calendarController,
themeController: widget.themeController,
),
const SizedBox(width: 8),
UserAccountMenuButton(authStore: widget.authStore),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
); );
final content = Container(
color: scheme.surface,
child: SafeArea(
child: widget.child,
),
);
// Side colors and styles
final Color sideBg = Theme.of(context).brightness == Brightness.dark
? scheme.surfaceContainerHighest
: scheme.surfaceContainerLow;
final Color sideFg = scheme.onSurfaceVariant;
final Color activeBg = scheme.primaryContainer;
final Color activeFg = scheme.onPrimaryContainer;
if (useRail) { if (useRail) {
return Scaffold( return Scaffold(
appBar: appBar, appBar: appBar,
body: Row( body: Row(
children: [ children: [
NavigationRail( Container(
selectedIndex: selectedIndex, width: railExtended ? 240 : 88,
onDestinationSelected: onSelect, height: double.infinity,
labelType: railExtended ? NavigationRailLabelType.selected : NavigationRailLabelType.all, color: sideBg,
extended: railExtended, child: ListView.builder(
destinations: destinations.map((dest) => NavigationRailDestination( padding: EdgeInsets.zero,
icon: Icon(dest.icon), itemCount: getTotalMenuItemsCount(),
selectedIcon: Icon(dest.selectedIcon), itemBuilder: (ctx, index) {
label: Text(dest.label), final menuIndex = getMenuIndexFromTotalIndex(index);
)).toList(), final item = menuItems[menuIndex];
final bool isHovered = index == _hoverIndex;
final bool isSelected = menuIndex == selectedIndex;
final bool active = isSelected || isHovered;
final BorderRadius br = (isSelected && useRail)
? BorderRadius.zero
: (isHovered ? BorderRadius.zero : BorderRadius.circular(8));
final Color bgColor = active
? (isHovered && !isSelected ? activeBg.withValues(alpha: 0.85) : activeBg)
: Colors.transparent;
// اگر آیتم بازشونده است و در حالت باز است، زیرآیتمها را نمایش بده
if (item.type == _MenuItemType.expandable && isExpanded(item) && railExtended) {
if (index == getMenuIndexFromTotalIndex(index)) {
// آیتم اصلی
return MouseRegion(
onEnter: (_) => setState(() => _hoverIndex = index),
onExit: (_) => setState(() => _hoverIndex = -1),
child: InkWell(
borderRadius: br,
onTap: () {
setState(() {
if (item.label == t.people) _isPeopleExpanded = !_isPeopleExpanded;
if (item.label == t.settings) _isBasicToolsExpanded = !_isBasicToolsExpanded;
});
},
child: Container(
margin: EdgeInsets.zero,
padding: EdgeInsets.symmetric(
horizontal: railExtended ? 16 : 8,
vertical: 8,
), ),
const VerticalDivider(thickness: 1, width: 1), decoration: BoxDecoration(
color: bgColor,
borderRadius: br,
),
child: Row(
children: [
Icon(
active ? item.selectedIcon : item.icon,
color: active ? activeFg : sideFg,
size: 24,
),
if (railExtended) ...[
const SizedBox(width: 12),
Expanded( Expanded(
child: widget.child, child: Text(
item.label,
style: TextStyle(
color: active ? activeFg : sideFg,
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
), ),
),
),
Icon(
isExpanded(item) ? Icons.expand_less : Icons.expand_more,
color: sideFg,
size: 20,
),
],
],
),
),
),
);
} else {
// زیرآیتمها
final childIndex = index - getMenuIndexFromTotalIndex(index) - 1;
if (childIndex < (item.children?.length ?? 0)) {
final child = item.children![childIndex];
return MouseRegion(
onEnter: (_) => setState(() => _hoverIndex = index),
onExit: (_) => setState(() => _hoverIndex = -1),
child: InkWell(
borderRadius: br,
onTap: () => onSelectChild(menuIndex, childIndex),
child: Container(
margin: EdgeInsets.zero,
padding: EdgeInsets.symmetric(
horizontal: railExtended ? 24 : 16, // بیشتر indent برای زیرآیتم
vertical: 8,
),
decoration: BoxDecoration(
color: bgColor,
borderRadius: br,
),
child: Row(
children: [
Icon(
child.icon,
color: sideFg,
size: 20,
),
if (railExtended) ...[
const SizedBox(width: 12),
Expanded(
child: Text(
child.label,
style: TextStyle(
color: sideFg,
fontWeight: FontWeight.w400,
),
),
),
if (child.hasAddButton)
IconButton(
icon: const Icon(Icons.add, size: 16),
onPressed: () {
// Navigate to add new receipt/payment
if (child.label == t.receipts) {
// Navigate to add receipt
} else if (child.label == t.payments) {
// Navigate to add payment
}
},
),
],
],
),
),
),
);
}
}
} else {
// آیتم ساده، آیتم بازشونده در حالت بسته، یا آیتم جداکننده
if (item.type == _MenuItemType.separator) {
// آیتم جداکننده
return Container(
margin: EdgeInsets.symmetric(
horizontal: railExtended ? 16 : 8,
vertical: 8,
),
child: Row(
children: [
if (railExtended) ...[
Expanded(
child: Divider(
color: sideFg.withValues(alpha: 0.3),
thickness: 1,
),
),
const SizedBox(width: 12),
Text(
item.label,
style: TextStyle(
color: sideFg.withValues(alpha: 0.7),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 12),
Expanded(
child: Divider(
color: sideFg.withValues(alpha: 0.3),
thickness: 1,
),
),
] else ...[
Expanded(
child: Divider(
color: sideFg.withValues(alpha: 0.3),
thickness: 1,
),
),
],
], ],
), ),
); );
} else { } else {
return Scaffold( // آیتم ساده یا آیتم بازشونده در حالت بسته
appBar: appBar, return MouseRegion(
body: widget.child, onEnter: (_) => setState(() => _hoverIndex = index),
bottomNavigationBar: NavigationBar( onExit: (_) => setState(() => _hoverIndex = -1),
selectedIndex: selectedIndex, child: InkWell(
onDestinationSelected: onSelect, borderRadius: br,
destinations: destinations.map((dest) => NavigationDestination( onTap: () {
icon: Icon(dest.icon), if (item.type == _MenuItemType.expandable) {
selectedIcon: Icon(dest.selectedIcon), setState(() {
label: dest.label, if (item.label == t.people) _isPeopleExpanded = !_isPeopleExpanded;
)).toList(), if (item.label == t.settings) _isBasicToolsExpanded = !_isBasicToolsExpanded;
});
} else {
onSelect(menuIndex);
}
},
child: Container(
margin: EdgeInsets.zero,
padding: EdgeInsets.symmetric(
horizontal: railExtended ? 16 : 8,
vertical: 8,
),
decoration: BoxDecoration(
color: bgColor,
borderRadius: br,
),
child: Row(
children: [
Icon(
active ? item.selectedIcon : item.icon,
color: active ? activeFg : sideFg,
size: 24,
),
if (railExtended) ...[
const SizedBox(width: 12),
Expanded(
child: Text(
item.label,
style: TextStyle(
color: active ? activeFg : sideFg,
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
),
),
),
if (item.type == _MenuItemType.expandable)
Icon(
isExpanded(item) ? Icons.expand_less : Icons.expand_more,
color: sideFg,
size: 20,
),
],
],
),
),
), ),
); );
} }
} }
return const SizedBox.shrink();
},
),
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: content),
],
),
);
} }
class _Dest { return Scaffold(
appBar: appBar,
drawer: Drawer(
backgroundColor: sideBg,
child: SafeArea(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
// آیتمهای منو
for (int i = 0; i < menuItems.length; i++) ...[
Builder(builder: (ctx) {
final item = menuItems[i];
final bool active = i == selectedIndex;
if (item.type == _MenuItemType.separator) {
// آیتم جداکننده در منوی موبایل
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
child: Divider(
color: sideFg.withValues(alpha: 0.3),
thickness: 1,
),
),
const SizedBox(width: 12),
Text(
item.label,
style: TextStyle(
color: sideFg.withValues(alpha: 0.7),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 12),
Expanded(
child: Divider(
color: sideFg.withValues(alpha: 0.3),
thickness: 1,
),
),
],
),
);
} else if (item.type == _MenuItemType.simple) {
return ListTile(
leading: Icon(item.selectedIcon, color: active ? activeFg : sideFg),
title: Text(item.label, style: TextStyle(color: active ? activeFg : sideFg, fontWeight: active ? FontWeight.w600 : FontWeight.w400)),
selected: active,
selectedTileColor: activeBg,
onTap: () {
context.pop();
onSelect(i);
},
);
} else if (item.type == _MenuItemType.expandable) {
return ExpansionTile(
leading: Icon(item.icon, color: sideFg),
title: Text(item.label, style: TextStyle(color: sideFg)),
initiallyExpanded: isExpanded(item),
onExpansionChanged: (expanded) {
setState(() {
if (item.label == t.people) _isPeopleExpanded = expanded;
if (item.label == t.settings) _isBasicToolsExpanded = expanded;
});
},
children: item.children?.map((child) => ListTile(
leading: const SizedBox(width: 24),
title: Text(child.label),
trailing: child.hasAddButton ? IconButton(
icon: const Icon(Icons.add, size: 20),
onPressed: () {
context.pop();
// Navigate to add new receipt/payment
if (child.label == t.receipts) {
// Navigate to add receipt
} else if (child.label == t.payments) {
// Navigate to add payment
}
},
) : null,
onTap: () {
context.pop();
onSelectChild(i, item.children!.indexOf(child));
},
)).toList() ?? [],
);
}
return const SizedBox.shrink();
}),
],
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: Text(t.logout),
onTap: onLogout,
),
],
),
),
),
body: content,
);
}
}
enum _MenuItemType { simple, expandable, separator }
class _MenuItem {
final String label; final String label;
final IconData icon; final IconData icon;
final IconData selectedIcon; final IconData selectedIcon;
final String path; final String? path;
final _MenuItemType type;
final List<_MenuItem>? children;
final bool hasAddButton;
const _Dest(this.label, this.icon, this.selectedIcon, this.path); const _MenuItem({
required this.label,
required this.icon,
required this.selectedIcon,
this.path,
required this.type,
this.children,
this.hasAddButton = false,
});
} }

File diff suppressed because it is too large Load diff

View file

@ -248,22 +248,28 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
// ذخیره دسترسیهای اپلیکیشن // ذخیره دسترسیهای اپلیکیشن
final appPermissions = user?['app_permissions'] as Map<String, dynamic>?; final appPermissions = user?['app_permissions'] as Map<String, dynamic>?;
final isSuperAdmin = appPermissions?['superadmin'] == true; final isSuperAdmin = appPermissions?['superadmin'] == true;
final userId = user?['id'] as int?;
if (appPermissions != null) { if (appPermissions != null) {
await widget.authStore.saveAppPermissions(appPermissions, isSuperAdmin); await widget.authStore.saveAppPermissions(appPermissions, isSuperAdmin, userId: userId);
} }
if (!mounted) return; if (!mounted) return;
_showSnack(t.homeWelcome); _showSnack(t.homeWelcome);
// بعد از login موفق، به صفحه قبلی یا dashboard برود // بعد از login موفق، به صفحه قبلی یا dashboard برود
try {
final currentPath = GoRouterState.of(context).uri.path; final currentPath = GoRouterState.of(context).uri.path;
if (currentPath.startsWith('/user/profile/') || currentPath.startsWith('/acc/')) { if (currentPath.startsWith('/user/profile/') || currentPath.startsWith('/acc/') || currentPath.startsWith('/business/')) {
// اگر در صفحه محافظت شده بود، همان صفحه را refresh کند // اگر در صفحه محافظت شده بود، همان صفحه را refresh کند
context.go(currentPath); context.go(currentPath);
} else { } else {
// وگرنه به dashboard برود // وگرنه به dashboard برود
context.go('/user/profile/dashboard'); context.go('/user/profile/dashboard');
} }
} catch (e) {
// اگر GoRouterState در دسترس نیست، به dashboard برود
context.go('/user/profile/dashboard');
}
} catch (e) { } catch (e) {
final msg = _extractErrorMessage(e, AppLocalizations.of(context)); final msg = _extractErrorMessage(e, AppLocalizations.of(context));
_showSnack(msg); _showSnack(msg);
@ -344,9 +350,10 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
// ذخیره دسترسیهای اپلیکیشن // ذخیره دسترسیهای اپلیکیشن
final appPermissions = user?['app_permissions'] as Map<String, dynamic>?; final appPermissions = user?['app_permissions'] as Map<String, dynamic>?;
final isSuperAdmin = appPermissions?['superadmin'] == true; final isSuperAdmin = appPermissions?['superadmin'] == true;
final userId = user?['id'] as int?;
if (appPermissions != null) { if (appPermissions != null) {
await widget.authStore.saveAppPermissions(appPermissions, isSuperAdmin); await widget.authStore.saveAppPermissions(appPermissions, isSuperAdmin, userId: userId);
} }
_showSnack(t.registerSuccess); _showSnack(t.registerSuccess);
// پاکسازی کد معرف پس از ثبتنام موفق // پاکسازی کد معرف پس از ثبتنام موفق

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/api_client.dart'; import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/services/support_service.dart'; import 'package:hesabix_ui/services/support_service.dart';
@ -235,7 +236,7 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
), ),
), ),
IconButton( IconButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.surface, backgroundColor: theme.colorScheme.surface,
@ -645,7 +646,7 @@ class _CreateTicketPageState extends State<CreateTicketPage> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
OutlinedButton( OutlinedButton(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), onPressed: _isSubmitting ? null : () => context.pop(),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../models/business_models.dart'; import '../../models/business_models.dart';
import '../../services/business_api_service.dart'; import '../../services/business_api_service.dart';
@ -121,7 +122,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
Navigator.of(context).pop(); context.pop();
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {

View file

@ -42,7 +42,12 @@ class _ProfileShellState extends State<ProfileShell> {
final bool useRail = width >= 700; final bool useRail = width >= 700;
final bool railExtended = width >= 1100; final bool railExtended = width >= 1100;
final ColorScheme scheme = Theme.of(context).colorScheme; final ColorScheme scheme = Theme.of(context).colorScheme;
final String location = GoRouterState.of(context).uri.toString(); String location = '/user/profile/dashboard'; // default location
try {
location = GoRouterState.of(context).uri.toString();
} catch (e) {
// اگر GoRouterState در دسترس نیست، از default استفاده کن
}
final bool isDark = Theme.of(context).brightness == Brightness.dark; final bool isDark = Theme.of(context).brightness == Brightness.dark;
final String logoAsset = isDark final String logoAsset = isDark
? 'assets/images/logo-light.png' ? 'assets/images/logo-light.png'
@ -85,9 +90,14 @@ class _ProfileShellState extends State<ProfileShell> {
Future<void> onSelect(int index) async { Future<void> onSelect(int index) async {
final path = allDestinations[index].path; final path = allDestinations[index].path;
try {
if (GoRouterState.of(context).uri.toString() != path) { if (GoRouterState.of(context).uri.toString() != path) {
context.go(path); context.go(path);
} }
} catch (e) {
// اگر GoRouterState در دسترس نیست، مستقیماً به مسیر برود
context.go(path);
}
} }
Future<void> onLogout() async { Future<void> onLogout() async {
@ -251,7 +261,7 @@ class _ProfileShellState extends State<ProfileShell> {
selected: active, selected: active,
selectedTileColor: activeBg, selectedTileColor: activeBg,
onTap: () { onTap: () {
Navigator.of(context).pop(); context.pop();
onSelect(i); onSelect(i);
}, },
); );

View file

@ -0,0 +1,66 @@
import '../core/api_client.dart';
import '../models/business_user_model.dart';
class BusinessUserService {
final ApiClient _apiClient;
BusinessUserService(this._apiClient);
/// Get all users for a business
Future<BusinessUsersResponse> getBusinessUsers(int businessId) async {
try {
final response = await _apiClient.get('/api/v1/business/$businessId/users');
return BusinessUsersResponse.fromJson(response.data['data']);
} catch (e) {
throw Exception('Failed to fetch business users: $e');
}
}
/// Add a new user to business
Future<AddUserResponse> addUser(AddUserRequest request) async {
try {
final response = await _apiClient.post(
'/api/v1/business/${request.businessId}/users',
data: request.toJson(),
);
return AddUserResponse.fromJson(response.data);
} catch (e) {
throw Exception('Failed to add user: $e');
}
}
/// Update user permissions
Future<UpdatePermissionsResponse> updatePermissions(UpdatePermissionsRequest request) async {
try {
final response = await _apiClient.put(
'/api/v1/business/${request.businessId}/users/${request.userId}/permissions',
data: request.toJson(),
);
return UpdatePermissionsResponse.fromJson(response.data['data']);
} catch (e) {
throw Exception('Failed to update permissions: $e');
}
}
/// Remove user from business
Future<RemoveUserResponse> removeUser(RemoveUserRequest request) async {
try {
final response = await _apiClient.delete(
'/api/v1/business/${request.businessId}/users/${request.userId}',
);
return RemoveUserResponse.fromJson(response.data);
} catch (e) {
throw Exception('Failed to remove user: $e');
}
}
/// Get user details
Future<BusinessUser> getUserDetails(int businessId, int userId) async {
try {
final response = await _apiClient.get('/api/v1/business/$businessId/users/$userId');
return BusinessUser.fromJson(response.data['data']['user']);
} catch (e) {
throw Exception('Failed to fetch user details: $e');
}
}
}

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../../core/api_client.dart'; import '../../../core/api_client.dart';
@ -121,7 +122,7 @@ class _StorageConfigFormDialogState extends State<StorageConfigFormDialog> {
} }
if (mounted) { if (mounted) {
Navigator.of(context).pop(); context.pop();
// Only show SnackBar if there's no onSaved callback (parent will handle notification) // Only show SnackBar if there's no onSaved callback (parent will handle notification)
if (widget.onSaved == null) { if (widget.onSaved == null) {
@ -202,7 +203,7 @@ class _StorageConfigFormDialogState extends State<StorageConfigFormDialog> {
), ),
), ),
IconButton( IconButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
color: theme.colorScheme.onPrimary, color: theme.colorScheme.onPrimary,
), ),
@ -344,7 +345,7 @@ class _StorageConfigFormDialogState extends State<StorageConfigFormDialog> {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
TextButton( TextButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(), onPressed: _isLoading ? null : () => context.pop(),
child: Text(l10n.cancel), child: Text(l10n.cancel),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'data_table_config.dart'; import 'data_table_config.dart';
import 'helpers/column_settings_service.dart'; import 'helpers/column_settings_service.dart';
@ -65,7 +66,7 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
), ),
const Spacer(), const Spacer(),
IconButton( IconButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
), ),
], ],
@ -96,7 +97,7 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
OutlinedButton( OutlinedButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text(t.cancel), child: Text(t.cancel),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'data_table_config.dart'; import 'data_table_config.dart';
@ -234,7 +235,7 @@ class _DataTableSearchDialogState extends State<DataTableSearchDialog> {
return [ return [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); context.pop();
}, },
child: Text(t.cancel), child: Text(t.cancel),
), ),
@ -242,7 +243,7 @@ class _DataTableSearchDialogState extends State<DataTableSearchDialog> {
TextButton( TextButton(
onPressed: () { onPressed: () {
widget.onClear(); widget.onClear();
Navigator.of(context).pop(); context.pop();
}, },
child: Text(t.clear), child: Text(t.clear),
), ),
@ -302,7 +303,7 @@ class _DataTableSearchDialogState extends State<DataTableSearchDialog> {
widget.onApply(_controller.text.trim(), _selectedType); widget.onApply(_controller.text.trim(), _selectedType);
break; break;
} }
Navigator.of(context).pop(); context.pop();
} }
Future<void> _selectFromDate(AppLocalizations t, bool isJalali) async { Future<void> _selectFromDate(AppLocalizations t, bool isJalali) async {
@ -449,7 +450,7 @@ class _DataTableDateRangeDialogState extends State<DataTableDateRangeDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); context.pop();
}, },
child: Text(t.cancel), child: Text(t.cancel),
), ),
@ -457,7 +458,7 @@ class _DataTableDateRangeDialogState extends State<DataTableDateRangeDialog> {
TextButton( TextButton(
onPressed: () { onPressed: () {
widget.onClear(); widget.onClear();
Navigator.of(context).pop(); context.pop();
}, },
child: Text(t.clear), child: Text(t.clear),
), ),
@ -465,7 +466,7 @@ class _DataTableDateRangeDialogState extends State<DataTableDateRangeDialog> {
onPressed: _fromDate != null && _toDate != null onPressed: _fromDate != null && _toDate != null
? () { ? () {
widget.onApply(_fromDate, _toDate); widget.onApply(_fromDate, _toDate);
Navigator.of(context).pop(); context.pop();
} }
: null, : null,
child: Text(t.applyFilter), child: Text(t.applyFilter),

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shamsi_date/shamsi_date.dart'; import 'package:shamsi_date/shamsi_date.dart';
/// DatePicker سفارشی برای تقویم شمسی /// DatePicker سفارشی برای تقویم شمسی
@ -100,7 +101,7 @@ class _JalaliDatePickerState extends State<JalaliDatePicker> {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
child: Text( child: Text(
'انصراف', 'انصراف',
style: TextStyle(color: theme.textTheme.bodyMedium?.color), style: TextStyle(color: theme.textTheme.bodyMedium?.color),

View file

@ -134,11 +134,11 @@ class _ProgressSplashScreenState extends State<ProgressSplashScreen>
colors: isDark colors: isDark
? [ ? [
bgColor, bgColor,
bgColor.withOpacity(0.95), bgColor.withValues(alpha: 0.95),
] ]
: [ : [
bgColor, bgColor,
bgColor.withOpacity(0.98), bgColor.withValues(alpha: 0.98),
], ],
), ),
), ),
@ -161,7 +161,7 @@ class _ProgressSplashScreenState extends State<ProgressSplashScreen>
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: primary.withOpacity(0.2), color: primary.withValues(alpha: 0.2),
blurRadius: 20, blurRadius: 20,
spreadRadius: 2, spreadRadius: 2,
), ),
@ -204,7 +204,7 @@ class _ProgressSplashScreenState extends State<ProgressSplashScreen>
// Subtitle // Subtitle
Text( Text(
AppLocalizations.of(context)?.businessManagementPlatform ?? 'Business Management Platform', AppLocalizations.of(context).businessManagementPlatform,
style: theme.textTheme.bodyLarge?.copyWith( style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
@ -243,7 +243,7 @@ class _ProgressSplashScreenState extends State<ProgressSplashScreen>
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
gradient: LinearGradient( gradient: LinearGradient(
colors: [primary, primary.withOpacity(0.8)], colors: [primary, primary.withValues(alpha: 0.8)],
), ),
), ),
), ),
@ -255,7 +255,7 @@ class _ProgressSplashScreenState extends State<ProgressSplashScreen>
// Loading Message // Loading Message
Text( Text(
widget.message ?? AppLocalizations.of(context)?.loading ?? 'Loading...', widget.message ?? AppLocalizations.of(context).loading,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@ -268,7 +268,7 @@ class _ProgressSplashScreenState extends State<ProgressSplashScreen>
Text( Text(
'${_remainingSeconds}s', '${_remainingSeconds}s',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant.withOpacity(0.7), color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
), ),
@ -281,7 +281,7 @@ class _ProgressSplashScreenState extends State<ProgressSplashScreen>
Text( Text(
'Version 1.0.0', 'Version 1.0.0',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant.withOpacity(0.6), color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
), ),
), ),
], ],

View file

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'calendar_switcher.dart';
import 'language_switcher.dart';
import 'theme_mode_switcher.dart';
import '../core/locale_controller.dart';
import '../core/calendar_controller.dart';
import '../theme/theme_controller.dart';
class SettingsMenuButton extends StatelessWidget {
final LocaleController? localeController;
final CalendarController? calendarController;
final ThemeController? themeController;
const SettingsMenuButton({
super.key,
this.localeController,
this.calendarController,
this.themeController,
});
void _showSettingsDialog(BuildContext context) {
final t = AppLocalizations.of(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(t.settings),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (calendarController != null) ...[
Row(
children: [
Text(t.calendar),
const Spacer(),
CalendarSwitcher(controller: calendarController!),
],
),
const SizedBox(height: 16),
],
if (localeController != null) ...[
Row(
children: [
Text(t.language),
const Spacer(),
LanguageSwitcher(controller: localeController!),
],
),
const SizedBox(height: 16),
],
if (themeController != null) ...[
Row(
children: [
Text(t.theme),
const Spacer(),
ThemeModeSwitcher(controller: themeController!),
],
),
],
],
),
),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return IconButton(
icon: CircleAvatar(
radius: 16,
backgroundColor: cs.surfaceContainerHighest,
foregroundColor: cs.onSurface,
child: const Icon(Icons.settings, size: 18),
),
onPressed: () => _showSettingsDialog(context),
tooltip: AppLocalizations.of(context).settings,
);
}
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:async'; import 'dart:async';
import '../core/auth_store.dart';
class SimpleSplashScreen extends StatefulWidget { class SimpleSplashScreen extends StatefulWidget {
final String? message; final String? message;
@ -9,6 +10,7 @@ class SimpleSplashScreen extends StatefulWidget {
final Duration displayDuration; final Duration displayDuration;
final VoidCallback? onComplete; final VoidCallback? onComplete;
final Locale? locale; final Locale? locale;
final AuthStore? authStore;
const SimpleSplashScreen({ const SimpleSplashScreen({
super.key, super.key,
@ -16,9 +18,10 @@ class SimpleSplashScreen extends StatefulWidget {
this.showLogo = true, this.showLogo = true,
this.backgroundColor, this.backgroundColor,
this.primaryColor, this.primaryColor,
this.displayDuration = const Duration(seconds: 4), this.displayDuration = const Duration(seconds: 1),
this.onComplete, this.onComplete,
this.locale, this.locale,
this.authStore,
}); });
@override @override
@ -68,12 +71,51 @@ class _SimpleSplashScreenState extends State<SimpleSplashScreen>
_fadeController.forward(); _fadeController.forward();
_scaleController.forward(); _scaleController.forward();
// Start display timer // Start display timer with authentication check
_displayTimer = Timer(widget.displayDuration, () { _displayTimer = Timer(widget.displayDuration, () {
widget.onComplete?.call(); _checkAuthenticationAndComplete();
}); });
} }
void _checkAuthenticationAndComplete() {
print('🔍 SPLASH DEBUG: Checking authentication and completing splash screen');
// اگر authStore موجود است، وضعیت احراز هویت را بررسی کن
if (widget.authStore != null) {
final hasApiKey = widget.authStore!.apiKey != null && widget.authStore!.apiKey!.isNotEmpty;
print('🔍 SPLASH DEBUG: AuthStore available, has API key: $hasApiKey');
// اگر کاربر وارد شده، URL فعلی را ذخیره کن
if (hasApiKey) {
// URL فعلی را از window.location بگیر
try {
// در web، URL فعلی را از window.location میگیریم
final currentUrl = Uri.base.path;
print('🔍 SPLASH DEBUG: Current URL from Uri.base: $currentUrl');
if (currentUrl.isNotEmpty &&
currentUrl != '/' &&
currentUrl != '/login' &&
(currentUrl.startsWith('/user/profile/') || currentUrl.startsWith('/business/'))) {
print('🔍 SPLASH DEBUG: Saving current URL: $currentUrl');
widget.authStore!.saveLastUrl(currentUrl);
}
} catch (e) {
print('🔍 SPLASH DEBUG: Error getting current URL: $e');
}
}
// اگر کاربر وارد نشده، به صفحه لاگین هدایت میشود
// اگر کاربر وارد شده، در صفحه کنونی میماند
// این منطق در main.dart در GoRouter redirect مدیریت میشود
} else {
print('🔍 SPLASH DEBUG: AuthStore is null');
}
print('🔍 SPLASH DEBUG: Calling onComplete callback');
widget.onComplete?.call();
}
@override @override
void dispose() { void dispose() {
_fadeController.dispose(); _fadeController.dispose();

View file

@ -36,11 +36,11 @@ class SplashScreen extends StatelessWidget {
colors: isDark colors: isDark
? [ ? [
bgColor, bgColor,
bgColor.withOpacity(0.95), bgColor.withValues(alpha: 0.95),
] ]
: [ : [
bgColor, bgColor,
bgColor.withOpacity(0.98), bgColor.withValues(alpha: 0.98),
], ],
), ),
), ),
@ -56,7 +56,7 @@ class SplashScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: primary.withOpacity(0.2), color: primary.withValues(alpha: 0.2),
blurRadius: 20, blurRadius: 20,
spreadRadius: 2, spreadRadius: 2,
), ),
@ -99,7 +99,7 @@ class SplashScreen extends StatelessWidget {
// Subtitle // Subtitle
Text( Text(
AppLocalizations.of(context)?.businessManagementPlatform ?? 'Business Management Platform', AppLocalizations.of(context).businessManagementPlatform,
style: theme.textTheme.bodyLarge?.copyWith( style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
@ -122,7 +122,7 @@ class SplashScreen extends StatelessWidget {
// Loading Message // Loading Message
Text( Text(
message ?? AppLocalizations.of(context)?.loading ?? 'Loading...', message ?? AppLocalizations.of(context).loading,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@ -137,7 +137,7 @@ class SplashScreen extends StatelessWidget {
Text( Text(
'Version 1.0.0', 'Version 1.0.0',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant.withOpacity(0.6), color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
), ),
), ),
], ],

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/models/support_models.dart'; import 'package:hesabix_ui/models/support_models.dart';
import 'package:hesabix_ui/services/support_service.dart'; import 'package:hesabix_ui/services/support_service.dart';
@ -312,7 +313,7 @@ class _TicketDetailsDialogState extends State<TicketDetailsDialog> {
), ),
), ),
IconButton( IconButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => context.pop(),
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: Colors.grey[200], backgroundColor: Colors.grey[200],

View file

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../core/auth_store.dart';
class UrlTracker extends StatefulWidget {
final Widget child;
final AuthStore authStore;
const UrlTracker({
super.key,
required this.child,
required this.authStore,
});
@override
State<UrlTracker> createState() => _UrlTrackerState();
}
class _UrlTrackerState extends State<UrlTracker> {
String? _lastTrackedUrl;
@override
void initState() {
super.initState();
_trackCurrentUrl();
}
void _trackCurrentUrl() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
final currentUrl = GoRouterState.of(context).uri.path;
if (currentUrl != _lastTrackedUrl &&
currentUrl.isNotEmpty &&
currentUrl != '/' &&
currentUrl != '/login' &&
(currentUrl.startsWith('/user/profile/') || currentUrl.startsWith('/business/'))) {
_lastTrackedUrl = currentUrl;
widget.authStore.saveLastUrl(currentUrl);
}
} catch (e) {
// اگر GoRouterState در دسترس نیست، URL را track نکن
// این ممکن است در splash screen یا loading state اتفاق بیفتد
}
}
});
}
@override
Widget build(BuildContext context) {
// هر بار که widget rebuild میشود، URL فعلی را track کن
_trackCurrentUrl();
return widget.child;
}
}

View file

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../core/auth_store.dart';
class UserAccountMenuButton extends StatelessWidget {
final AuthStore authStore;
const UserAccountMenuButton({
super.key,
required this.authStore,
});
void _showUserMenu(BuildContext context) {
final t = AppLocalizations.of(context);
final cs = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(t.profile),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(Icons.person, color: cs.onSurface),
title: Text(t.profile),
onTap: () {
context.pop();
// Navigate to profile page
},
),
ListTile(
leading: Icon(Icons.settings, color: cs.onSurface),
title: Text(t.settings),
onTap: () {
context.pop();
// Navigate to account settings
},
),
const Divider(),
ListTile(
leading: Icon(Icons.logout, color: cs.error),
title: Text(t.logout, style: TextStyle(color: cs.error)),
onTap: () {
context.pop();
// Trigger logout
_confirmLogout(context);
},
),
],
),
),
);
}
void _confirmLogout(BuildContext context) {
final t = AppLocalizations.of(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(t.logoutConfirmTitle),
content: Text(t.logoutConfirmMessage),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text(t.cancel),
),
FilledButton(
onPressed: () async {
context.pop();
await authStore.saveApiKey(null);
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(t.logoutDone)));
},
child: Text(t.logout),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return IconButton(
icon: CircleAvatar(
radius: 16,
backgroundColor: cs.surfaceContainerHighest,
foregroundColor: cs.onSurface,
child: const Icon(Icons.person, size: 18),
),
onPressed: () => _showUserMenu(context),
tooltip: AppLocalizations.of(context).profile,
);
}
}

View file

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'calendar_switcher.dart';
import 'language_switcher.dart';
import 'theme_mode_switcher.dart';
import 'logout_button.dart';
import '../core/auth_store.dart';
import '../core/locale_controller.dart';
import '../core/calendar_controller.dart';
import '../theme/theme_controller.dart';
class UserMenuButton extends StatelessWidget {
final AuthStore authStore;
final LocaleController? localeController;
final CalendarController? calendarController;
final ThemeController? themeController;
const UserMenuButton({
super.key,
required this.authStore,
this.localeController,
this.calendarController,
this.themeController,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (calendarController != null) ...[
CalendarSwitcher(controller: calendarController!),
const SizedBox(width: 8),
],
if (localeController != null) ...[
LanguageSwitcher(controller: localeController!),
const SizedBox(width: 8),
],
if (themeController != null) ...[
ThemeModeSwitcher(controller: themeController!),
const SizedBox(width: 8),
],
LogoutButton(authStore: authStore),
],
);
}
}