progress in permissions

This commit is contained in:
Hesabix 2025-09-25 22:36:08 +03:30
parent 2dde8eeef9
commit 898e0fb993
39 changed files with 5462 additions and 135 deletions

View file

@ -192,3 +192,101 @@ def get_business_statistics(
stats_data = get_business_statistics(db, business_id, ctx) stats_data = get_business_statistics(db, business_id, ctx)
formatted_data = format_datetime_fields(stats_data, request) formatted_data = format_datetime_fields(stats_data, request)
return success_response(formatted_data, request) return success_response(formatted_data, request)
@router.post("/{business_id}/info-with-permissions",
summary="دریافت اطلاعات کسب و کار و دسترسی‌ها",
description="دریافت اطلاعات کسب و کار همراه با دسترسی‌های کاربر",
response_model=SuccessResponse,
responses={
200: {
"description": "اطلاعات کسب و کار و دسترسی‌ها با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "اطلاعات کسب و کار و دسترسی‌ها دریافت شد",
"data": {
"business_info": {
"id": 1,
"name": "شرکت نمونه",
"business_type": "شرکت",
"business_field": "تولیدی",
"owner_id": 1,
"address": "تهران، خیابان ولیعصر",
"phone": "02112345678",
"mobile": "09123456789",
"created_at": "1403/01/01 00:00:00"
},
"user_permissions": {
"people": {"add": True, "view": True, "edit": True, "delete": False},
"products": {"add": True, "view": True, "edit": False, "delete": False},
"invoices": {"add": True, "view": True, "edit": True, "delete": True}
},
"is_owner": False,
"role": "عضو",
"has_access": True
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز به کسب و کار"
},
404: {
"description": "کسب و کار یافت نشد"
}
}
)
@require_business_access("business_id")
def get_business_info_with_permissions(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""دریافت اطلاعات کسب و کار همراه با دسترسی‌های کاربر"""
from adapters.db.models.business import Business
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
# دریافت اطلاعات کسب و کار
business = db.get(Business, business_id)
if not business:
from app.core.responses import ApiError
raise ApiError("NOT_FOUND", "Business not found", http_status=404)
# دریافت دسترسی‌های کاربر
permissions = {}
if not ctx.is_superadmin() and not ctx.is_business_owner(business_id):
# دریافت دسترسی‌های کسب و کار از business_permissions
permission_repo = BusinessPermissionRepository(db)
business_permission = permission_repo.get_by_business_and_user(business_id, ctx.get_user_id())
if business_permission:
permissions = business_permission.business_permissions or {}
business_info = {
"id": business.id,
"name": business.name,
"business_type": business.business_type.value,
"business_field": business.business_field.value,
"owner_id": business.owner_id,
"address": business.address,
"phone": business.phone,
"mobile": business.mobile,
"created_at": business.created_at.isoformat(),
}
response_data = {
"business_info": business_info,
"user_permissions": permissions,
"is_owner": ctx.is_business_owner(business_id),
"role": "مالک" if ctx.is_business_owner(business_id) else "عضو",
"has_access": ctx.can_access_business(business_id)
}
formatted_data = format_datetime_fields(response_data, request)
return success_response(formatted_data, request)

View file

@ -270,7 +270,7 @@ def add_user(
permission_obj = permission_repo.create_or_update( permission_obj = permission_repo.create_or_update(
user_id=user.id, user_id=user.id,
business_id=business_id, business_id=business_id,
permissions={} # Default empty permissions permissions={'join': True} # Default permissions with join access
) )
logger.info(f"Created permission object: {permission_obj.id}") logger.info(f"Created permission object: {permission_obj.id}")

View file

@ -12,7 +12,7 @@ from app.core.responses import success_response, format_datetime_fields
from app.core.auth_dependency import get_current_user, AuthContext from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_management from app.core.permissions import require_business_management
from app.services.business_service import ( from app.services.business_service import (
create_business, get_business_by_id, get_businesses_by_owner, create_business, get_business_by_id, get_businesses_by_owner, get_user_businesses,
update_business, delete_business, get_business_summary update_business, delete_business, get_business_summary
) )
@ -116,8 +116,8 @@ def list_user_businesses(
sort_desc: bool = True, sort_desc: bool = True,
search: str = None search: str = None
) -> dict: ) -> dict:
"""لیست کسب و کارهای کاربر""" """لیست کسب و کارهای کاربر (مالک + عضو)"""
owner_id = ctx.get_user_id() user_id = ctx.get_user_id()
query_dict = { query_dict = {
"take": take, "take": take,
"skip": skip, "skip": skip,
@ -125,34 +125,10 @@ def list_user_businesses(
"sort_desc": sort_desc, "sort_desc": sort_desc,
"search": search "search": search
} }
businesses = get_businesses_by_owner(db, owner_id, query_dict) businesses = get_user_businesses(db, user_id, query_dict)
formatted_data = format_datetime_fields(businesses, request) formatted_data = format_datetime_fields(businesses, request)
# اگر formatted_data یک dict با کلید items است، آن را استخراج کنیم return success_response(formatted_data, request)
if isinstance(formatted_data, dict) and 'items' in formatted_data:
items = formatted_data['items']
else:
items = formatted_data
# برای حالا total را برابر با تعداد items قرار می‌دهیم
# در آینده می‌توان total را از service دریافت کرد
total = len(items)
page = (skip // take) + 1
total_pages = (total + take - 1) // take
response_data = {
"items": items,
"pagination": {
"total": total,
"page": page,
"per_page": take,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1,
},
"query_info": query_dict
}
return success_response(response_data, request)
@router.post("/{business_id}/details", @router.post("/{business_id}/details",

View file

@ -0,0 +1,253 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from typing import Dict, Any
from adapters.db.session import get_db
from adapters.api.v1.schema_models.person import (
PersonCreateRequest, PersonUpdateRequest, PersonResponse,
PersonListResponse, PersonSummaryResponse, PersonBankAccountCreateRequest
)
from adapters.api.v1.schemas import QueryInfo, SuccessResponse
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_management
from app.services.person_service import (
create_person, get_person_by_id, get_persons_by_business,
update_person, delete_person, get_person_summary
)
from adapters.db.models.person import Person
router = APIRouter(prefix="/persons", tags=["persons"])
@router.post("/businesses/{business_id}/persons/create",
summary="ایجاد شخص جدید",
description="ایجاد شخص جدید برای کسب و کار مشخص",
response_model=SuccessResponse,
responses={
200: {
"description": "شخص با موفقیت ایجاد شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "شخص با موفقیت ایجاد شد",
"data": {
"id": 1,
"business_id": 1,
"alias_name": "علی احمدی",
"person_type": "مشتری",
"created_at": "2024-01-01T00:00:00Z"
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها"
},
401: {
"description": "عدم احراز هویت"
},
403: {
"description": "عدم دسترسی به کسب و کار"
}
}
)
async def create_person_endpoint(
business_id: int,
person_data: PersonCreateRequest,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management())
):
"""ایجاد شخص جدید برای کسب و کار"""
result = create_person(db, business_id, person_data)
return success_response(
message=result['message'],
data=format_datetime_fields(result['data'])
)
@router.post("/businesses/{business_id}/persons",
summary="لیست اشخاص کسب و کار",
description="دریافت لیست اشخاص یک کسب و کار با امکان جستجو و فیلتر",
response_model=SuccessResponse,
responses={
200: {
"description": "لیست اشخاص با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "لیست اشخاص با موفقیت دریافت شد",
"data": {
"items": [],
"pagination": {
"total": 0,
"page": 1,
"per_page": 20,
"total_pages": 0,
"has_next": False,
"has_prev": False
},
"query_info": {}
}
}
}
}
}
}
)
async def get_persons_endpoint(
business_id: int,
query_info: QueryInfo,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user)
):
"""دریافت لیست اشخاص کسب و کار"""
query_dict = {
"take": query_info.take,
"skip": query_info.skip,
"sort_by": query_info.sort_by,
"sort_desc": query_info.sort_desc,
"search": query_info.search
}
result = get_persons_by_business(db, business_id, query_dict)
# فرمت کردن تاریخ‌ها
for item in result['items']:
item = format_datetime_fields(item)
return success_response(
message="لیست اشخاص با موفقیت دریافت شد",
data=result
)
@router.get("/persons/{person_id}",
summary="جزئیات شخص",
description="دریافت جزئیات یک شخص",
response_model=SuccessResponse,
responses={
200: {
"description": "جزئیات شخص با موفقیت دریافت شد"
},
404: {
"description": "شخص یافت نشد"
}
}
)
async def get_person_endpoint(
person_id: int,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management())
):
"""دریافت جزئیات شخص"""
# ابتدا باید business_id را از person دریافت کنیم
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(status_code=404, detail="شخص یافت نشد")
result = get_person_by_id(db, person_id, person.business_id)
if not result:
raise HTTPException(status_code=404, detail="شخص یافت نشد")
return success_response(
message="جزئیات شخص با موفقیت دریافت شد",
data=format_datetime_fields(result)
)
@router.put("/persons/{person_id}",
summary="ویرایش شخص",
description="ویرایش اطلاعات یک شخص",
response_model=SuccessResponse,
responses={
200: {
"description": "شخص با موفقیت ویرایش شد"
},
404: {
"description": "شخص یافت نشد"
}
}
)
async def update_person_endpoint(
person_id: int,
person_data: PersonUpdateRequest,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management())
):
"""ویرایش شخص"""
# ابتدا باید business_id را از person دریافت کنیم
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(status_code=404, detail="شخص یافت نشد")
result = update_person(db, person_id, person.business_id, person_data)
if not result:
raise HTTPException(status_code=404, detail="شخص یافت نشد")
return success_response(
message=result['message'],
data=format_datetime_fields(result['data'])
)
@router.delete("/persons/{person_id}",
summary="حذف شخص",
description="حذف یک شخص",
response_model=SuccessResponse,
responses={
200: {
"description": "شخص با موفقیت حذف شد"
},
404: {
"description": "شخص یافت نشد"
}
}
)
async def delete_person_endpoint(
person_id: int,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management())
):
"""حذف شخص"""
# ابتدا باید business_id را از person دریافت کنیم
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(status_code=404, detail="شخص یافت نشد")
success = delete_person(db, person_id, person.business_id)
if not success:
raise HTTPException(status_code=404, detail="شخص یافت نشد")
return success_response(message="شخص با موفقیت حذف شد")
@router.get("/businesses/{business_id}/persons/summary",
summary="خلاصه اشخاص کسب و کار",
description="دریافت خلاصه آماری اشخاص یک کسب و کار",
response_model=SuccessResponse,
responses={
200: {
"description": "خلاصه اشخاص با موفقیت دریافت شد"
}
}
)
async def get_persons_summary_endpoint(
business_id: int,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management())
):
"""دریافت خلاصه اشخاص کسب و کار"""
result = get_person_summary(db, business_id)
return success_response(
message="خلاصه اشخاص با موفقیت دریافت شد",
data=result
)

View file

@ -0,0 +1,168 @@
from typing import List, Optional
from pydantic import BaseModel, Field
from enum import Enum
from datetime import datetime
class PersonType(str, Enum):
"""نوع شخص"""
CUSTOMER = "مشتری"
MARKETER = "بازاریاب"
EMPLOYEE = "کارمند"
SUPPLIER = "تامین‌کننده"
PARTNER = "همکار"
SELLER = "فروشنده"
class PersonBankAccountCreateRequest(BaseModel):
"""درخواست ایجاد حساب بانکی شخص"""
bank_name: str = Field(..., min_length=1, max_length=255, description="نام بانک")
account_number: Optional[str] = Field(default=None, max_length=50, description="شماره حساب")
card_number: Optional[str] = Field(default=None, max_length=20, description="شماره کارت")
sheba_number: Optional[str] = Field(default=None, max_length=30, description="شماره شبا")
class PersonBankAccountUpdateRequest(BaseModel):
"""درخواست ویرایش حساب بانکی شخص"""
bank_name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام بانک")
account_number: Optional[str] = Field(default=None, max_length=50, description="شماره حساب")
card_number: Optional[str] = Field(default=None, max_length=20, description="شماره کارت")
sheba_number: Optional[str] = Field(default=None, max_length=30, description="شماره شبا")
is_active: Optional[bool] = Field(default=None, description="وضعیت فعال بودن")
class PersonBankAccountResponse(BaseModel):
"""پاسخ اطلاعات حساب بانکی شخص"""
id: int = Field(..., description="شناسه حساب بانکی")
person_id: int = Field(..., description="شناسه شخص")
bank_name: str = Field(..., description="نام بانک")
account_number: Optional[str] = Field(default=None, description="شماره حساب")
card_number: Optional[str] = Field(default=None, description="شماره کارت")
sheba_number: Optional[str] = Field(default=None, description="شماره شبا")
is_active: bool = Field(..., description="وضعیت فعال بودن")
created_at: str = Field(..., description="تاریخ ایجاد")
updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
class Config:
from_attributes = True
class PersonCreateRequest(BaseModel):
"""درخواست ایجاد شخص جدید"""
# اطلاعات پایه
alias_name: str = Field(..., min_length=1, max_length=255, description="نام مستعار (الزامی)")
first_name: Optional[str] = Field(default=None, max_length=100, description="نام")
last_name: Optional[str] = Field(default=None, max_length=100, description="نام خانوادگی")
person_type: PersonType = Field(..., description="نوع شخص")
company_name: Optional[str] = Field(default=None, max_length=255, description="نام شرکت")
payment_id: Optional[str] = Field(default=None, max_length=100, description="شناسه پرداخت")
# اطلاعات اقتصادی
national_id: Optional[str] = Field(default=None, max_length=20, description="شناسه ملی")
registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت")
economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی")
# اطلاعات تماس
country: Optional[str] = Field(default=None, max_length=100, description="کشور")
province: Optional[str] = Field(default=None, max_length=100, description="استان")
city: Optional[str] = Field(default=None, max_length=100, description="شهرستان")
address: Optional[str] = Field(default=None, description="آدرس")
postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
phone: Optional[str] = Field(default=None, max_length=20, description="تلفن")
mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل")
fax: Optional[str] = Field(default=None, max_length=20, description="فکس")
email: Optional[str] = Field(default=None, max_length=255, description="پست الکترونیکی")
website: Optional[str] = Field(default=None, max_length=255, description="وب‌سایت")
# حساب‌های بانکی
bank_accounts: Optional[List[PersonBankAccountCreateRequest]] = Field(default=[], description="حساب‌های بانکی")
class PersonUpdateRequest(BaseModel):
"""درخواست ویرایش شخص"""
# اطلاعات پایه
alias_name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام مستعار")
first_name: Optional[str] = Field(default=None, max_length=100, description="نام")
last_name: Optional[str] = Field(default=None, max_length=100, description="نام خانوادگی")
person_type: Optional[PersonType] = Field(default=None, description="نوع شخص")
company_name: Optional[str] = Field(default=None, max_length=255, description="نام شرکت")
payment_id: Optional[str] = Field(default=None, max_length=100, description="شناسه پرداخت")
# اطلاعات اقتصادی
national_id: Optional[str] = Field(default=None, max_length=20, description="شناسه ملی")
registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت")
economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی")
# اطلاعات تماس
country: Optional[str] = Field(default=None, max_length=100, description="کشور")
province: Optional[str] = Field(default=None, max_length=100, description="استان")
city: Optional[str] = Field(default=None, max_length=100, description="شهرستان")
address: Optional[str] = Field(default=None, description="آدرس")
postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
phone: Optional[str] = Field(default=None, max_length=20, description="تلفن")
mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل")
fax: Optional[str] = Field(default=None, max_length=20, description="فکس")
email: Optional[str] = Field(default=None, max_length=255, description="پست الکترونیکی")
website: Optional[str] = Field(default=None, max_length=255, description="وب‌سایت")
# وضعیت
is_active: Optional[bool] = Field(default=None, description="وضعیت فعال بودن")
class PersonResponse(BaseModel):
"""پاسخ اطلاعات شخص"""
id: int = Field(..., description="شناسه شخص")
business_id: int = Field(..., description="شناسه کسب و کار")
# اطلاعات پایه
alias_name: str = Field(..., description="نام مستعار")
first_name: Optional[str] = Field(default=None, description="نام")
last_name: Optional[str] = Field(default=None, description="نام خانوادگی")
person_type: str = Field(..., description="نوع شخص")
company_name: Optional[str] = Field(default=None, description="نام شرکت")
payment_id: Optional[str] = Field(default=None, description="شناسه پرداخت")
# اطلاعات اقتصادی
national_id: Optional[str] = Field(default=None, description="شناسه ملی")
registration_number: Optional[str] = Field(default=None, description="شماره ثبت")
economic_id: Optional[str] = Field(default=None, description="شناسه اقتصادی")
# اطلاعات تماس
country: Optional[str] = Field(default=None, description="کشور")
province: Optional[str] = Field(default=None, description="استان")
city: Optional[str] = Field(default=None, description="شهرستان")
address: Optional[str] = Field(default=None, description="آدرس")
postal_code: Optional[str] = Field(default=None, description="کد پستی")
phone: Optional[str] = Field(default=None, description="تلفن")
mobile: Optional[str] = Field(default=None, description="موبایل")
fax: Optional[str] = Field(default=None, description="فکس")
email: Optional[str] = Field(default=None, description="پست الکترونیکی")
website: Optional[str] = Field(default=None, description="وب‌سایت")
# وضعیت
is_active: bool = Field(..., description="وضعیت فعال بودن")
# زمان‌بندی
created_at: str = Field(..., description="تاریخ ایجاد")
updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
# حساب‌های بانکی
bank_accounts: List[PersonBankAccountResponse] = Field(default=[], description="حساب‌های بانکی")
class Config:
from_attributes = True
class PersonListResponse(BaseModel):
"""پاسخ لیست اشخاص"""
items: List[PersonResponse] = Field(..., description="لیست اشخاص")
pagination: dict = Field(..., description="اطلاعات صفحه‌بندی")
query_info: dict = Field(..., description="اطلاعات جستجو و فیلتر")
class PersonSummaryResponse(BaseModel):
"""پاسخ خلاصه اشخاص"""
total_persons: int = Field(..., description="تعداد کل اشخاص")
by_type: dict = Field(..., description="تعداد بر اساس نوع")
active_persons: int = Field(..., description="تعداد اشخاص فعال")
inactive_persons: int = Field(..., description="تعداد اشخاص غیرفعال")

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
from .person import Person, PersonBankAccount # noqa: F401
# Business user models removed - using business_permissions instead # Business user models removed - using business_permissions instead
# Import support models # Import support models

View file

@ -54,5 +54,5 @@ 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 # Relationships
# users = relationship("BusinessUser", back_populates="business", cascade="all, delete-orphan") persons: Mapped[list["Person"]] = relationship("Person", back_populates="business", cascade="all, delete-orphan")

View file

@ -0,0 +1,85 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class PersonType(str, Enum):
"""نوع شخص"""
CUSTOMER = "مشتری" # مشتری
MARKETER = "بازاریاب" # بازاریاب
EMPLOYEE = "کارمند" # کارمند
SUPPLIER = "تامین‌کننده" # تامین‌کننده
PARTNER = "همکار" # همکار
SELLER = "فروشنده" # فروشنده
class Person(Base):
__tablename__ = "persons"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
# اطلاعات پایه
alias_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام مستعار (الزامی)")
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام")
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام خانوادگی")
person_type: Mapped[PersonType] = mapped_column(SQLEnum(PersonType), nullable=False, comment="نوع شخص")
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت")
payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شناسه پرداخت")
# اطلاعات اقتصادی
national_id: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True, comment="شناسه ملی")
registration_number: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="شماره ثبت")
economic_id: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="شناسه اقتصادی")
# اطلاعات تماس
country: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="کشور")
province: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="استان")
city: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شهرستان")
address: Mapped[str | None] = mapped_column(Text, nullable=True, comment="آدرس")
postal_code: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="کد پستی")
phone: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="تلفن")
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="موبایل")
fax: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="فکس")
email: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="پست الکترونیکی")
website: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="وب‌سایت")
# وضعیت
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="وضعیت فعال بودن")
# زمان‌بندی
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
business: Mapped["Business"] = relationship("Business", back_populates="persons")
bank_accounts: Mapped[list["PersonBankAccount"]] = relationship("PersonBankAccount", back_populates="person", cascade="all, delete-orphan")
class PersonBankAccount(Base):
__tablename__ = "person_bank_accounts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
person_id: Mapped[int] = mapped_column(Integer, ForeignKey("persons.id", ondelete="CASCADE"), nullable=False, index=True)
# اطلاعات حساب بانکی
bank_name: Mapped[str] = mapped_column(String(255), nullable=False, comment="نام بانک")
account_number: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="شماره حساب")
card_number: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="شماره کارت")
sheba_number: Mapped[str | None] = mapped_column(String(30), nullable=True, comment="شماره شبا")
# وضعیت
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="وضعیت فعال بودن")
# زمان‌بندی
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
person: Mapped["Person"] = relationship("Person", back_populates="bank_accounts")

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from sqlalchemy import select, and_ from sqlalchemy import select, and_, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from adapters.db.models.business_permission import BusinessPermission from adapters.db.models.business_permission import BusinessPermission
@ -61,3 +61,16 @@ class BusinessPermissionRepository(BaseRepository[BusinessPermission]):
"""دریافت تمام کاربرانی که دسترسی به کسب و کار دارند""" """دریافت تمام کاربرانی که دسترسی به کسب و کار دارند"""
stmt = select(BusinessPermission).where(BusinessPermission.business_id == business_id) stmt = select(BusinessPermission).where(BusinessPermission.business_id == business_id)
return self.db.execute(stmt).scalars().all() return self.db.execute(stmt).scalars().all()
def get_user_member_businesses(self, user_id: int) -> list[BusinessPermission]:
"""دریافت تمام کسب و کارهایی که کاربر عضو آن‌ها است (دسترسی join)"""
# ابتدا تمام دسترسی‌های کاربر را دریافت می‌کنیم
all_permissions = self.get_user_businesses(user_id)
# سپس فیلتر می‌کنیم
member_permissions = []
for perm in all_permissions:
if perm.business_permissions and perm.business_permissions.get('join') == True:
member_permissions.append(perm)
return member_permissions

View file

@ -247,6 +247,42 @@ class AuthContext:
logger.info(f"Business access check: {business_id} == {self.business_id} = {has_access}") logger.info(f"Business access check: {business_id} == {self.business_id} = {has_access}")
return has_access return has_access
def is_business_member(self, business_id: int) -> bool:
"""بررسی اینکه آیا کاربر عضو کسب و کار است یا نه (دسترسی join)"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Checking business membership: user {self.user.id}, business {business_id}")
# SuperAdmin عضو همه کسب و کارها محسوب می‌شود
if self.is_superadmin():
logger.info(f"User {self.user.id} is superadmin, is member of all businesses")
return True
# اگر مالک کسب و کار است، عضو محسوب می‌شود
if self.is_business_owner() and business_id == self.business_id:
logger.info(f"User {self.user.id} is business owner of {business_id}, is member")
return True
# بررسی دسترسی join در business_permissions
if not self.db:
logger.info(f"No database session available")
return False
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
repo = BusinessPermissionRepository(self.db)
permission_obj = repo.get_by_user_and_business(self.user.id, business_id)
if not permission_obj:
logger.info(f"No business permission found for user {self.user.id} and business {business_id}")
return False
# بررسی دسترسی join
business_perms = permission_obj.business_permissions or {}
has_join_access = business_perms.get('join', False)
logger.info(f"Business membership check: user {self.user.id} join access to business {business_id}: {has_join_access}")
return has_join_access
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""تبدیل به dictionary برای استفاده در API""" """تبدیل به dictionary برای استفاده در API"""
return { return {

View file

@ -9,6 +9,7 @@ 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.business_users import router as business_users_router
from adapters.api.v1.persons import router as persons_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
@ -273,6 +274,7 @@ def create_app() -> FastAPI:
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) application.include_router(business_users_router, prefix=settings.api_v1_prefix)
application.include_router(persons_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

@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import select, and_, func from sqlalchemy import select, and_, func
from adapters.db.repositories.business_repo import BusinessRepository from adapters.db.repositories.business_repo import BusinessRepository
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
from adapters.db.models.business import Business, BusinessType, BusinessField from adapters.db.models.business import Business, BusinessType, BusinessField
from adapters.api.v1.schemas import ( from adapters.api.v1.schemas import (
BusinessCreateRequest, BusinessUpdateRequest, BusinessResponse, BusinessCreateRequest, BusinessUpdateRequest, BusinessResponse,
@ -110,6 +111,91 @@ def get_businesses_by_owner(db: Session, owner_id: int, query_info: Dict[str, An
} }
def get_user_businesses(db: Session, user_id: int, query_info: Dict[str, Any]) -> Dict[str, Any]:
"""دریافت لیست کسب و کارهای کاربر (مالک + عضو)"""
business_repo = BusinessRepository(db)
permission_repo = BusinessPermissionRepository(db)
# دریافت کسب و کارهای مالک
owned_businesses = business_repo.get_by_owner_id(user_id)
# دریافت کسب و کارهای عضو
member_permissions = permission_repo.get_user_member_businesses(user_id)
member_business_ids = [perm.business_id for perm in member_permissions]
member_businesses = []
for business_id in member_business_ids:
business = business_repo.get_by_id(business_id)
if business:
member_businesses.append(business)
# ترکیب لیست‌ها
all_businesses = []
# اضافه کردن کسب و کارهای مالک با نقش owner
for business in owned_businesses:
business_dict = _business_to_dict(business)
business_dict['is_owner'] = True
business_dict['role'] = 'مالک'
business_dict['permissions'] = {}
all_businesses.append(business_dict)
# اضافه کردن کسب و کارهای عضو با نقش member
for business in member_businesses:
# اگر قبلاً به عنوان مالک اضافه شده، نادیده بگیر
if business.id not in [b['id'] for b in all_businesses]:
business_dict = _business_to_dict(business)
business_dict['is_owner'] = False
business_dict['role'] = 'عضو'
# دریافت دسترسی‌های کاربر برای این کسب و کار
permission_obj = permission_repo.get_by_user_and_business(user_id, business.id)
business_dict['permissions'] = permission_obj.business_permissions if permission_obj else {}
all_businesses.append(business_dict)
# اعمال فیلترها
if query_info.get('search'):
search_term = query_info['search']
all_businesses = [b for b in all_businesses if search_term.lower() in b['name'].lower()]
# اعمال مرتب‌سازی
sort_by = query_info.get('sort_by', 'created_at')
sort_desc = query_info.get('sort_desc', True)
if sort_by == 'name':
all_businesses.sort(key=lambda x: x['name'], reverse=sort_desc)
elif sort_by == 'business_type':
all_businesses.sort(key=lambda x: x['business_type'], reverse=sort_desc)
elif sort_by == 'created_at':
all_businesses.sort(key=lambda x: x['created_at'], reverse=sort_desc)
# صفحه‌بندی
total = len(all_businesses)
skip = query_info.get('skip', 0)
take = query_info.get('take', 10)
start_idx = skip
end_idx = skip + take
paginated_businesses = all_businesses[start_idx:end_idx]
# محاسبه اطلاعات صفحه‌بندی
total_pages = (total + take - 1) // take
current_page = (skip // take) + 1
pagination = PaginationInfo(
total=total,
page=current_page,
per_page=take,
total_pages=total_pages,
has_next=current_page < total_pages,
has_prev=current_page > 1
)
return {
"items": paginated_businesses,
"pagination": pagination.dict(),
"query_info": query_info
}
def update_business(db: Session, business_id: int, business_data: BusinessUpdateRequest, owner_id: int) -> Optional[Dict[str, Any]]: def update_business(db: Session, business_id: int, business_data: BusinessUpdateRequest, owner_id: int) -> Optional[Dict[str, Any]]:
"""ویرایش کسب و کار""" """ویرایش کسب و کار"""
business_repo = BusinessRepository(db) business_repo = BusinessRepository(db)

View file

@ -0,0 +1,294 @@
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
from adapters.db.models.person import Person, PersonBankAccount, PersonType
from adapters.api.v1.schema_models.person import (
PersonCreateRequest, PersonUpdateRequest, PersonBankAccountCreateRequest
)
from app.core.responses import success_response
def create_person(db: Session, business_id: int, person_data: PersonCreateRequest) -> Dict[str, Any]:
"""ایجاد شخص جدید"""
# ایجاد شخص
person = Person(
business_id=business_id,
alias_name=person_data.alias_name,
first_name=person_data.first_name,
last_name=person_data.last_name,
person_type=person_data.person_type,
company_name=person_data.company_name,
payment_id=person_data.payment_id,
national_id=person_data.national_id,
registration_number=person_data.registration_number,
economic_id=person_data.economic_id,
country=person_data.country,
province=person_data.province,
city=person_data.city,
address=person_data.address,
postal_code=person_data.postal_code,
phone=person_data.phone,
mobile=person_data.mobile,
fax=person_data.fax,
email=person_data.email,
website=person_data.website,
)
db.add(person)
db.flush() # برای دریافت ID
# ایجاد حساب‌های بانکی
if person_data.bank_accounts:
for bank_account_data in person_data.bank_accounts:
bank_account = PersonBankAccount(
person_id=person.id,
bank_name=bank_account_data.bank_name,
account_number=bank_account_data.account_number,
card_number=bank_account_data.card_number,
sheba_number=bank_account_data.sheba_number,
)
db.add(bank_account)
db.commit()
db.refresh(person)
return success_response(
message="شخص با موفقیت ایجاد شد",
data=_person_to_dict(person)
)
def get_person_by_id(db: Session, person_id: int, business_id: int) -> Optional[Dict[str, Any]]:
"""دریافت شخص بر اساس شناسه"""
person = db.query(Person).filter(
and_(Person.id == person_id, Person.business_id == business_id)
).first()
if not person:
return None
return _person_to_dict(person)
def get_persons_by_business(
db: Session,
business_id: int,
query_info: Dict[str, Any]
) -> Dict[str, Any]:
"""دریافت لیست اشخاص با جستجو و فیلتر"""
query = db.query(Person).filter(Person.business_id == business_id)
# اعمال جستجو
if query_info.get('search') and query_info.get('search_fields'):
search_term = f"%{query_info['search']}%"
search_conditions = []
for field in query_info['search_fields']:
if field == 'alias_name':
search_conditions.append(Person.alias_name.ilike(search_term))
elif field == 'first_name':
search_conditions.append(Person.first_name.ilike(search_term))
elif field == 'last_name':
search_conditions.append(Person.last_name.ilike(search_term))
elif field == 'company_name':
search_conditions.append(Person.company_name.ilike(search_term))
elif field == 'mobile':
search_conditions.append(Person.mobile.ilike(search_term))
elif field == 'email':
search_conditions.append(Person.email.ilike(search_term))
elif field == 'national_id':
search_conditions.append(Person.national_id.ilike(search_term))
if search_conditions:
query = query.filter(or_(*search_conditions))
# اعمال فیلترها
if query_info.get('filters'):
for filter_item in query_info['filters']:
field = filter_item.get('property')
operator = filter_item.get('operator')
value = filter_item.get('value')
if field == 'person_type':
if operator == '=':
query = query.filter(Person.person_type == value)
elif operator == 'in':
query = query.filter(Person.person_type.in_(value))
elif field == 'is_active':
if operator == '=':
query = query.filter(Person.is_active == value)
elif field == 'country':
if operator == '=':
query = query.filter(Person.country == value)
elif operator == 'like':
query = query.filter(Person.country.ilike(f"%{value}%"))
elif field == 'province':
if operator == '=':
query = query.filter(Person.province == value)
elif operator == 'like':
query = query.filter(Person.province.ilike(f"%{value}%"))
# شمارش کل رکوردها
total = query.count()
# اعمال مرتب‌سازی
sort_by = query_info.get('sort_by', 'created_at')
sort_desc = query_info.get('sort_desc', True)
if sort_by == 'alias_name':
query = query.order_by(Person.alias_name.desc() if sort_desc else Person.alias_name.asc())
elif sort_by == 'first_name':
query = query.order_by(Person.first_name.desc() if sort_desc else Person.first_name.asc())
elif sort_by == 'last_name':
query = query.order_by(Person.last_name.desc() if sort_desc else Person.last_name.asc())
elif sort_by == 'person_type':
query = query.order_by(Person.person_type.desc() if sort_desc else Person.person_type.asc())
elif sort_by == 'created_at':
query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc())
elif sort_by == 'updated_at':
query = query.order_by(Person.updated_at.desc() if sort_desc else Person.updated_at.asc())
else:
query = query.order_by(Person.created_at.desc())
# اعمال صفحه‌بندی
skip = query_info.get('skip', 0)
take = query_info.get('take', 20)
persons = query.offset(skip).limit(take).all()
# تبدیل به دیکشنری
items = [_person_to_dict(person) for person in persons]
# محاسبه اطلاعات صفحه‌بندی
total_pages = (total + take - 1) // take
current_page = (skip // take) + 1
pagination = {
'total': total,
'page': current_page,
'per_page': take,
'total_pages': total_pages,
'has_next': current_page < total_pages,
'has_prev': current_page > 1
}
return {
'items': items,
'pagination': pagination,
'query_info': query_info
}
def update_person(
db: Session,
person_id: int,
business_id: int,
person_data: PersonUpdateRequest
) -> Optional[Dict[str, Any]]:
"""ویرایش شخص"""
person = db.query(Person).filter(
and_(Person.id == person_id, Person.business_id == business_id)
).first()
if not person:
return None
# به‌روزرسانی فیلدها
update_data = person_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(person, field, value)
db.commit()
db.refresh(person)
return success_response(
message="شخص با موفقیت ویرایش شد",
data=_person_to_dict(person)
)
def delete_person(db: Session, person_id: int, business_id: int) -> bool:
"""حذف شخص"""
person = db.query(Person).filter(
and_(Person.id == person_id, Person.business_id == business_id)
).first()
if not person:
return False
db.delete(person)
db.commit()
return True
def get_person_summary(db: Session, business_id: int) -> Dict[str, Any]:
"""دریافت خلاصه اشخاص"""
# تعداد کل اشخاص
total_persons = db.query(Person).filter(Person.business_id == business_id).count()
# تعداد اشخاص فعال و غیرفعال
active_persons = db.query(Person).filter(
and_(Person.business_id == business_id, Person.is_active == True)
).count()
inactive_persons = total_persons - active_persons
# تعداد بر اساس نوع
by_type = {}
for person_type in PersonType:
count = db.query(Person).filter(
and_(Person.business_id == business_id, Person.person_type == person_type)
).count()
by_type[person_type.value] = count
return {
'total_persons': total_persons,
'by_type': by_type,
'active_persons': active_persons,
'inactive_persons': inactive_persons
}
def _person_to_dict(person: Person) -> Dict[str, Any]:
"""تبدیل مدل Person به دیکشنری"""
return {
'id': person.id,
'business_id': person.business_id,
'alias_name': person.alias_name,
'first_name': person.first_name,
'last_name': person.last_name,
'person_type': person.person_type.value,
'company_name': person.company_name,
'payment_id': person.payment_id,
'national_id': person.national_id,
'registration_number': person.registration_number,
'economic_id': person.economic_id,
'country': person.country,
'province': person.province,
'city': person.city,
'address': person.address,
'postal_code': person.postal_code,
'phone': person.phone,
'mobile': person.mobile,
'fax': person.fax,
'email': person.email,
'website': person.website,
'is_active': person.is_active,
'created_at': person.created_at.isoformat(),
'updated_at': person.updated_at.isoformat(),
'bank_accounts': [
{
'id': ba.id,
'person_id': ba.person_id,
'bank_name': ba.bank_name,
'account_number': ba.account_number,
'card_number': ba.card_number,
'sheba_number': ba.sheba_number,
'is_active': ba.is_active,
'created_at': ba.created_at.isoformat(),
'updated_at': ba.updated_at.isoformat(),
}
for ba in person.bank_accounts
]
}

View file

@ -0,0 +1,117 @@
# خلاصه پیاده‌سازی سیستم دسترسی Join
## تاریخ: 2025-01-20
## مشکل اصلی
کاربران فقط می‌توانستند کسب و کارهای خودشان (که مالک آن‌ها بودند) را در لیست کسب و کارها مشاهده کنند. اگر کاربری عضو کسب و کار دیگری بود، نمی‌توانست آن را در لیست مشاهده کند.
## راه‌حل پیاده‌سازی شده
### 1. تعریف دسترسی `join`
- دسترسی جدید `join: true` برای نشان دادن عضویت کاربر در کسب و کار
- این دسترسی در فیلد `business_permissions` ذخیره می‌شود
### 2. تغییرات در بکند
#### AuthContext (`app/core/auth_dependency.py`)
```python
def is_business_member(self, business_id: int) -> bool:
"""بررسی اینکه آیا کاربر عضو کسب و کار است یا نه (دسترسی join)"""
```
#### BusinessPermissionRepository (`adapters/db/repositories/business_permission_repo.py`)
```python
def get_user_member_businesses(self, user_id: int) -> list[BusinessPermission]:
"""دریافت تمام کسب و کارهایی که کاربر عضو آن‌ها است (دسترسی join)"""
```
#### BusinessService (`app/services/business_service.py`)
```python
def get_user_businesses(db: Session, user_id: int, query_info: Dict[str, Any]) -> Dict[str, Any]:
"""دریافت لیست کسب و کارهای کاربر (مالک + عضو)"""
```
#### API Endpoint (`adapters/api/v1/businesses.py`)
- endpoint `/api/v1/businesses/list` به‌روزرسانی شد
- حالا هم کسب و کارهای مالک و هم کسب و کارهای عضو را نمایش می‌دهد
#### افزودن کاربر (`adapters/api/v1/business_users.py`)
```python
permission_obj = permission_repo.create_or_update(
user_id=user.id,
business_id=business_id,
permissions={'join': True} # دسترسی join به طور خودکار اضافه می‌شود
)
```
### 3. تغییرات در فرانت‌اند
#### BusinessDashboardService (`hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart`)
- متد `getUserBusinesses()` به‌روزرسانی شد
- حالا از API جدید استفاده می‌کند که هم مالک و هم عضو را پشتیبانی می‌کند
### 4. نحوه کارکرد
#### مالک کسب و کار
- به طور خودکار عضو محسوب می‌شود
- در لیست با نقش "مالک" نمایش داده می‌شود
#### SuperAdmin
- به طور خودکار عضو همه کسب و کارها محسوب می‌شود
#### کاربران عضو
- باید دسترسی `join: true` داشته باشند
- در لیست با نقش "عضو" نمایش داده می‌شوند
- می‌توانند کسب و کار را در لیست مشاهده کنند
### 5. مثال JSON دسترسی‌ها
```json
{
"join": true,
"sales": {
"read": true,
"write": false
},
"reports": {
"read": true,
"export": false
}
}
```
### 6. تست‌های انجام شده
**تست 1**: افزودن دسترسی join به کاربر موجود
**تست 2**: دریافت لیست کسب و کارهای کاربر (مالک + عضو)
**تست 3**: API endpoint لیست کسب و کارها
**تست 4**: افزودن کاربر جدید به کسب و کار
**تست 5**: نمایش کسب و کار در فرانت‌اند
### 7. فایل‌های تغییر یافته
#### بکند
- `app/core/auth_dependency.py` - اضافه شدن متد `is_business_member`
- `adapters/db/repositories/business_permission_repo.py` - اضافه شدن متد `get_user_member_businesses`
- `app/services/business_service.py` - اضافه شدن متد `get_user_businesses`
- `adapters/api/v1/businesses.py` - به‌روزرسانی endpoint لیست کسب و کارها
- `adapters/api/v1/business_users.py` - اصلاح متد افزودن کاربر
#### فرانت‌اند
- `hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart` - به‌روزرسانی متد `getUserBusinesses`
#### مستندات
- `docs/JOIN_PERMISSION_SYSTEM.md` - مستندات کامل سیستم
- `migrations/versions/20250120_000002_add_join_permission.py` - Migration
### 8. نتیجه نهایی
🎉 **مشکل حل شد!** حالا کاربران می‌توانند:
- کسب و کارهایی که مالک آن‌ها هستند را مشاهده کنند (نقش: مالک)
- کسب و کارهایی که عضو آن‌ها هستند را مشاهده کنند (نقش: عضو)
- دسترسی `join` به طور خودکار هنگام افزودن کاربر به کسب و کار اضافه می‌شود
### 9. سازگاری
- تمام تغییرات با سیستم قبلی سازگار است
- کاربران موجود نیازی به تغییر ندارند
- API های موجود همچنان کار می‌کنند

View file

@ -0,0 +1,135 @@
# سیستم دسترسی Join برای عضویت در کسب و کار
## خلاصه تغییرات
این سند توضیح می‌دهد که چگونه سیستم دسترسی `join` برای عضویت کاربران در کسب و کارها پیاده‌سازی شده است.
## مشکل قبلی
قبلاً کاربران فقط می‌توانستند کسب و کارهای خودشان (که مالک آن‌ها بودند) را در لیست کسب و کارها مشاهده کنند. اگر کاربری عضو کسب و کار دیگری بود، نمی‌توانست آن را در لیست مشاهده کند.
## راه‌حل
### 1. تعریف دسترسی `join`
یک دسترسی جدید به نام `join` تعریف شده که نشان‌دهنده عضویت کاربر در کسب و کار است:
```json
{
"join": true,
"sales": {
"read": true,
"write": false
}
}
```
### 2. تغییرات در بکند
#### AuthContext
- متد `is_business_member()` اضافه شد
- این متد بررسی می‌کند که آیا کاربر عضو کسب و کار است یا نه
#### BusinessPermissionRepository
- متد `get_user_member_businesses()` اضافه شد
- این متد کسب و کارهایی که کاربر عضو آن‌ها است را برمی‌گرداند
#### BusinessService
- متد `get_user_businesses()` اضافه شد
- این متد هم کسب و کارهای مالک و هم کسب و کارهای عضو را برمی‌گرداند
#### API Endpoint
- endpoint `/api/v1/businesses/list` به‌روزرسانی شد
- حالا هم کسب و کارهای مالک و هم کسب و کارهای عضو را نمایش می‌دهد
### 3. تغییرات در فرانت‌اند
#### BusinessDashboardService
- متد `getUserBusinesses()` به‌روزرسانی شد
- حالا از API جدید استفاده می‌کند که هم مالک و هم عضو را پشتیبانی می‌کند
#### BusinessWithPermission Model
- فیلدهای `isOwner` و `role` قبلاً وجود داشتند
- این فیلدها برای تشخیص نقش کاربر استفاده می‌شوند
## نحوه استفاده
### 1. اضافه کردن کاربر به کسب و کار
```python
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
permission_repo = BusinessPermissionRepository(db)
permission_repo.create_or_update(
user_id=user_id,
business_id=business_id,
permissions={'join': True, 'sales': {'read': True}}
)
```
### 2. بررسی عضویت کاربر
```python
from app.core.auth_dependency import AuthContext
auth_ctx = AuthContext(user=user, db=db)
is_member = auth_ctx.is_business_member(business_id)
```
### 3. دریافت لیست کسب و کارهای کاربر
```python
from app.services.business_service import get_user_businesses
result = get_user_businesses(db, user_id, query_info)
# result['items'] شامل هم کسب و کارهای مالک و هم عضو است
```
## اسکریپت‌های کمکی
### 1. تست سیستم
```bash
cd hesabixAPI
python scripts/test_business_membership.py
```
### 2. اضافه کردن دسترسی join به کاربران موجود
```bash
cd hesabixAPI
python scripts/add_join_permissions.py
```
### 3. تست دسترسی join
```bash
cd hesabixAPI
python scripts/test_join_permission.py
```
## Migration
فایل migration `20250120_000002_add_join_permission.py` ایجاد شده که فقط برای مستندسازی است زیرا جدول `business_permissions` قبلاً وجود دارد و JSON field است.
## نکات مهم
1. **مالک کسب و کار**: مالک کسب و کار به طور خودکار عضو محسوب می‌شود
2. **SuperAdmin**: SuperAdmin به طور خودکار عضو همه کسب و کارها محسوب می‌شود
3. **دسترسی join**: این دسترسی باید به صورت دستی برای کاربران عضو اضافه شود
4. **سازگاری**: تغییرات با سیستم قبلی سازگار است
## مثال کامل
```python
# ایجاد کسب و کار
business = create_business(db, business_data, owner_id)
# اضافه کردن کاربر به کسب و کار
permission_repo.create_or_update(
user_id=member_user_id,
business_id=business.id,
permissions={'join': True, 'sales': {'read': True, 'write': False}}
)
# دریافت لیست کسب و کارهای کاربر
result = get_user_businesses(db, member_user_id, query_info)
# حالا کاربر هم کسب و کارهای مالک و هم کسب و کارهای عضو را می‌بیند
```

View file

@ -8,6 +8,7 @@ adapters/api/v1/business_dashboard.py
adapters/api/v1/business_users.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/persons.py
adapters/api/v1/schemas.py adapters/api/v1/schemas.py
adapters/api/v1/users.py adapters/api/v1/users.py
adapters/api/v1/admin/email_config.py adapters/api/v1/admin/email_config.py
@ -15,6 +16,7 @@ adapters/api/v1/admin/file_storage.py
adapters/api/v1/schema_models/__init__.py adapters/api/v1/schema_models/__init__.py
adapters/api/v1/schema_models/email.py adapters/api/v1/schema_models/email.py
adapters/api/v1/schema_models/file_storage.py adapters/api/v1/schema_models/file_storage.py
adapters/api/v1/schema_models/person.py
adapters/api/v1/support/__init__.py adapters/api/v1/support/__init__.py
adapters/api/v1/support/categories.py adapters/api/v1/support/categories.py
adapters/api/v1/support/operator.py adapters/api/v1/support/operator.py
@ -32,6 +34,7 @@ adapters/db/models/captcha.py
adapters/db/models/email_config.py adapters/db/models/email_config.py
adapters/db/models/file_storage.py adapters/db/models/file_storage.py
adapters/db/models/password_reset.py adapters/db/models/password_reset.py
adapters/db/models/person.py
adapters/db/models/user.py adapters/db/models/user.py
adapters/db/models/support/__init__.py adapters/db/models/support/__init__.py
adapters/db/models/support/category.py adapters/db/models/support/category.py
@ -75,6 +78,7 @@ 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
app/services/file_storage_service.py app/services/file_storage_service.py
app/services/person_service.py
app/services/query_service.py app/services/query_service.py
app/services/pdf/__init__.py app/services/pdf/__init__.py
app/services/pdf/base_pdf_service.py app/services/pdf/base_pdf_service.py
@ -94,6 +98,8 @@ migrations/versions/20250117_000006_add_app_permissions_to_users.py
migrations/versions/20250117_000007_create_business_permissions_table.py migrations/versions/20250117_000007_create_business_permissions_table.py
migrations/versions/20250117_000008_add_email_config_table.py migrations/versions/20250117_000008_add_email_config_table.py
migrations/versions/20250117_000009_add_is_default_to_email_config.py migrations/versions/20250117_000009_add_is_default_to_email_config.py
migrations/versions/20250120_000001_add_persons_tables.py
migrations/versions/20250120_000002_add_join_permission.py
migrations/versions/20250915_000001_init_auth_tables.py migrations/versions/20250915_000001_init_auth_tables.py
migrations/versions/20250916_000002_add_referral_fields.py migrations/versions/20250916_000002_add_referral_fields.py
migrations/versions/5553f8745c6e_add_support_tables.py migrations/versions/5553f8745c6e_add_support_tables.py

View file

@ -0,0 +1,82 @@
"""add_persons_tables
Revision ID: 20250120_000001
Revises: 5553f8745c6e
Create Date: 2025-01-20 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '20250120_000001'
down_revision = '5553f8745c6e'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Create persons table
op.create_table('persons',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب و کار'),
sa.Column('alias_name', sa.String(length=255), nullable=False, comment='نام مستعار (الزامی)'),
sa.Column('first_name', sa.String(length=100), nullable=True, comment='نام'),
sa.Column('last_name', sa.String(length=100), nullable=True, comment='نام خانوادگی'),
sa.Column('person_type', sa.Enum('CUSTOMER', 'MARKETER', 'EMPLOYEE', 'SUPPLIER', 'PARTNER', 'SELLER', name='persontype'), nullable=False, comment='نوع شخص'),
sa.Column('company_name', sa.String(length=255), nullable=True, comment='نام شرکت'),
sa.Column('payment_id', sa.String(length=100), nullable=True, comment='شناسه پرداخت'),
sa.Column('national_id', sa.String(length=20), nullable=True, comment='شناسه ملی'),
sa.Column('registration_number', sa.String(length=50), nullable=True, comment='شماره ثبت'),
sa.Column('economic_id', sa.String(length=50), nullable=True, comment='شناسه اقتصادی'),
sa.Column('country', sa.String(length=100), nullable=True, comment='کشور'),
sa.Column('province', sa.String(length=100), nullable=True, comment='استان'),
sa.Column('city', sa.String(length=100), nullable=True, comment='شهرستان'),
sa.Column('address', sa.Text(), nullable=True, comment='آدرس'),
sa.Column('postal_code', sa.String(length=20), nullable=True, comment='کد پستی'),
sa.Column('phone', sa.String(length=20), nullable=True, comment='تلفن'),
sa.Column('mobile', sa.String(length=20), nullable=True, comment='موبایل'),
sa.Column('fax', sa.String(length=20), nullable=True, comment='فکس'),
sa.Column('email', sa.String(length=255), nullable=True, comment='پست الکترونیکی'),
sa.Column('website', sa.String(length=255), nullable=True, comment='وب‌سایت'),
sa.Column('is_active', sa.Boolean(), nullable=False, comment='وضعیت فعال بودن'),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_persons_business_id'), 'persons', ['business_id'], unique=False)
op.create_index(op.f('ix_persons_alias_name'), 'persons', ['alias_name'], unique=False)
op.create_index(op.f('ix_persons_national_id'), 'persons', ['national_id'], unique=False)
# Create person_bank_accounts table
op.create_table('person_bank_accounts',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('person_id', sa.Integer(), nullable=False, comment='شناسه شخص'),
sa.Column('bank_name', sa.String(length=255), nullable=False, comment='نام بانک'),
sa.Column('account_number', sa.String(length=50), nullable=True, comment='شماره حساب'),
sa.Column('card_number', sa.String(length=20), nullable=True, comment='شماره کارت'),
sa.Column('sheba_number', sa.String(length=30), nullable=True, comment='شماره شبا'),
sa.Column('is_active', sa.Boolean(), nullable=False, comment='وضعیت فعال بودن'),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_person_bank_accounts_person_id'), 'person_bank_accounts', ['person_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_person_bank_accounts_person_id'), table_name='person_bank_accounts')
op.drop_table('person_bank_accounts')
op.drop_index(op.f('ix_persons_national_id'), table_name='persons')
op.drop_index(op.f('ix_persons_alias_name'), table_name='persons')
op.drop_index(op.f('ix_persons_business_id'), table_name='persons')
op.drop_table('persons')
# ### end Alembic commands ###

View file

@ -0,0 +1,30 @@
"""add join permission
Revision ID: 20250120_000002
Revises: 20250120_000001
Create Date: 2025-01-20 00:00:02.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20250120_000002'
down_revision = '20250120_000001'
branch_labels = None
depends_on = None
def upgrade():
"""Add join permission support"""
# این migration فقط برای مستندسازی است
# جدول business_permissions قبلاً وجود دارد و JSON field است
# بنابراین نیازی به تغییر schema نیست
pass
def downgrade():
"""Remove join permission support"""
# این migration فقط برای مستندسازی است
pass

View file

@ -8,6 +8,7 @@ import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text
from adapters.db.session import get_db from adapters.db.session import get_db
from adapters.db.models.user import User from adapters.db.models.user import User
@ -82,7 +83,7 @@ def list_operators():
try: try:
operators = db.query(User).filter( operators = db.query(User).filter(
User.app_permissions['support_operator'].astext == 'true' text("app_permissions->>'support_operator' = 'true'")
).all() ).all()
if not operators: if not operators:

View file

@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'api_client.dart'; import 'api_client.dart';
import '../models/business_dashboard_models.dart';
class AuthStore with ChangeNotifier { class AuthStore with ChangeNotifier {
static const _kApiKey = 'auth_api_key'; static const _kApiKey = 'auth_api_key';
@ -11,12 +12,15 @@ class AuthStore with ChangeNotifier {
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'; static const _kLastUrl = 'last_url';
static const _kCurrentBusiness = 'current_business';
final FlutterSecureStorage _secure = const FlutterSecureStorage(); final FlutterSecureStorage _secure = const FlutterSecureStorage();
String? _apiKey; String? _apiKey;
String? _deviceId; String? _deviceId;
Map<String, dynamic>? _appPermissions; Map<String, dynamic>? _appPermissions;
bool _isSuperAdmin = false; bool _isSuperAdmin = false;
BusinessWithPermission? _currentBusiness;
Map<String, dynamic>? _businessPermissions;
String? get apiKey => _apiKey; String? get apiKey => _apiKey;
String get deviceId => _deviceId ?? ''; String get deviceId => _deviceId ?? '';
@ -24,6 +28,8 @@ class AuthStore with ChangeNotifier {
bool get isSuperAdmin => _isSuperAdmin; bool get isSuperAdmin => _isSuperAdmin;
int? _currentUserId; int? _currentUserId;
int? get currentUserId => _currentUserId; int? get currentUserId => _currentUserId;
BusinessWithPermission? get currentBusiness => _currentBusiness;
Map<String, dynamic>? get businessPermissions => _businessPermissions;
Future<void> load() async { Future<void> load() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@ -234,6 +240,147 @@ class AuthStore with ChangeNotifier {
await prefs.remove(_kLastUrl); await prefs.remove(_kLastUrl);
} catch (_) {} } catch (_) {}
} }
// مدیریت کسب و کار فعلی
Future<void> setCurrentBusiness(BusinessWithPermission business) async {
_currentBusiness = business;
_businessPermissions = business.permissions;
notifyListeners();
// ذخیره در حافظه محلی
await _saveCurrentBusiness();
}
Future<void> clearCurrentBusiness() async {
_currentBusiness = null;
_businessPermissions = null;
notifyListeners();
// پاک کردن از حافظه محلی
await _clearCurrentBusiness();
}
Future<void> _saveCurrentBusiness() async {
if (_currentBusiness == null) return;
try {
final prefs = await SharedPreferences.getInstance();
final businessJson = const JsonEncoder().convert({
'id': _currentBusiness!.id,
'name': _currentBusiness!.name,
'business_type': _currentBusiness!.businessType,
'business_field': _currentBusiness!.businessField,
'owner_id': _currentBusiness!.ownerId,
'address': _currentBusiness!.address,
'phone': _currentBusiness!.phone,
'mobile': _currentBusiness!.mobile,
'created_at': _currentBusiness!.createdAt,
'is_owner': _currentBusiness!.isOwner,
'role': _currentBusiness!.role,
'permissions': _currentBusiness!.permissions,
});
if (kIsWeb) {
await prefs.setString(_kCurrentBusiness, businessJson);
} else {
try {
await _secure.write(key: _kCurrentBusiness, value: businessJson);
} catch (_) {
await prefs.setString(_kCurrentBusiness, businessJson);
}
}
} catch (e) {
// Silent fail
}
}
Future<void> _clearCurrentBusiness() async {
try {
final prefs = await SharedPreferences.getInstance();
if (kIsWeb) {
await prefs.remove(_kCurrentBusiness);
} else {
try {
await _secure.delete(key: _kCurrentBusiness);
} catch (_) {}
await prefs.remove(_kCurrentBusiness);
}
} catch (e) {
// Silent fail
}
}
// بررسی دسترسیهای کسب و کار
bool hasBusinessPermission(String section, String action) {
if (_currentBusiness?.isOwner == true) return true;
if (_businessPermissions == null) return false;
final sectionPerms = _businessPermissions![section] as Map<String, dynamic>?;
if (sectionPerms == null) return action == 'view'; // دسترسی خواندن پیشفرض
return sectionPerms[action] == true;
}
// دسترسیهای کلی
bool canReadSection(String section) {
return hasBusinessPermission(section, 'view') ||
_businessPermissions?.containsKey(section) == true;
}
bool canWriteSection(String section) {
return hasBusinessPermission(section, 'add') ||
hasBusinessPermission(section, 'edit');
}
bool canDeleteSection(String section) {
return hasBusinessPermission(section, 'delete');
}
// دسترسیهای خاص
bool canManageDrafts(String section) {
return hasBusinessPermission(section, 'draft');
}
bool canCollectChecks() {
return hasBusinessPermission('checks', 'collect');
}
bool canTransferChecks() {
return hasBusinessPermission('checks', 'transfer');
}
bool canReturnChecks() {
return hasBusinessPermission('checks', 'return');
}
bool canChargeWallet() {
return hasBusinessPermission('wallet', 'charge');
}
bool canManageUsers() {
return hasBusinessPermission('settings', 'users');
}
// بررسی دسترسی به کسب و کار
bool canAccessBusiness(int businessId) {
if (_currentBusiness == null) return false;
return _currentBusiness!.id == businessId;
}
// دریافت دسترسیهای موجود برای یک بخش
List<String> getAvailableActions(String section) {
if (_currentBusiness?.isOwner == true) {
return ['add', 'view', 'edit', 'delete', 'draft', 'collect', 'transfer', 'return', 'charge'];
}
if (_businessPermissions == null) return ['view'];
final sectionPerms = _businessPermissions![section] as Map<String, dynamic>?;
if (sectionPerms == null) return ['view'];
return sectionPerms.keys.where((key) => sectionPerms[key] == true).toList();
}
} }

View file

@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import '../core/auth_store.dart';
import '../widgets/permission/permission_widgets.dart';
/// مثال کامل از نحوه استفاده از سیستم دسترسیها
class PermissionUsageExample extends StatelessWidget {
final AuthStore authStore;
const PermissionUsageExample({
super.key,
required this.authStore,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('مثال استفاده از سیستم دسترسی‌ها'),
actions: [
// دکمه اضافه کردن فقط در صورت داشتن دسترسی
PermissionButton(
section: 'people',
action: 'add',
authStore: authStore,
child: IconButton(
onPressed: () => _addPerson(),
icon: const Icon(Icons.add),
tooltip: 'اضافه کردن شخص',
),
),
// دکمه ویرایش فقط در صورت داشتن دسترسی
PermissionButton(
section: 'people',
action: 'edit',
authStore: authStore,
child: IconButton(
onPressed: () => _editPerson(),
icon: const Icon(Icons.edit),
tooltip: 'ویرایش شخص',
),
),
],
),
body: Column(
children: [
// لیست اشخاص با بررسی دسترسیها
Expanded(
child: ListView.builder(
itemCount: 10, // مثال
itemBuilder: (context, index) {
return PermissionListTile(
section: 'people',
action: 'view',
authStore: authStore,
child: ListTile(
leading: const CircleAvatar(
child: Icon(Icons.person),
),
title: Text('شخص ${index + 1}'),
subtitle: const Text('توضیحات شخص'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// دکمه ویرایش
PermissionButton(
section: 'people',
action: 'edit',
authStore: authStore,
child: IconButton(
onPressed: () => _editPerson(),
icon: const Icon(Icons.edit),
tooltip: 'ویرایش',
),
),
// دکمه حذف
PermissionButton(
section: 'people',
action: 'delete',
authStore: authStore,
child: IconButton(
onPressed: () => _deletePerson(),
icon: const Icon(Icons.delete),
tooltip: 'حذف',
),
),
],
),
),
);
},
),
),
// دکمههای عملیات
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// دکمه گزارش
PermissionButton(
section: 'reports',
action: 'view',
authStore: authStore,
child: ElevatedButton.icon(
onPressed: () => _viewReports(),
icon: const Icon(Icons.assessment),
label: const Text('مشاهده گزارش‌ها'),
),
),
const SizedBox(width: 16),
// دکمه صادرات
PermissionButton(
section: 'reports',
action: 'export',
authStore: authStore,
child: ElevatedButton.icon(
onPressed: () => _exportReports(),
icon: const Icon(Icons.download),
label: const Text('صادرات گزارش'),
),
),
],
),
),
],
),
);
}
void _addPerson() {
// منطق اضافه کردن شخص
print('اضافه کردن شخص جدید');
}
void _editPerson() {
// منطق ویرایش شخص
print('ویرایش شخص');
}
void _deletePerson() {
// منطق حذف شخص
print('حذف شخص');
}
void _viewReports() {
// منطق مشاهده گزارشها
print('مشاهده گزارش‌ها');
}
void _exportReports() {
// منطق صادرات گزارش
print('صادرات گزارش');
}
}
/// مثال استفاده از PermissionWidget
class ExamplePermissionWidget extends StatelessWidget {
final AuthStore authStore;
const ExamplePermissionWidget({
super.key,
required this.authStore,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
// نمایش ویجت فقط در صورت داشتن دسترسی
PermissionWidget(
section: 'settings',
action: 'view',
authStore: authStore,
child: Card(
child: ListTile(
leading: const Icon(Icons.settings),
title: const Text('تنظیمات'),
subtitle: const Text('مدیریت تنظیمات سیستم'),
onTap: () => _openSettings(),
),
),
),
// نمایش پیام عدم دسترسی در صورت عدم دسترسی
PermissionWidget(
section: 'admin',
action: 'view',
authStore: authStore,
fallbackWidget: const AccessDeniedWidget(
message: 'شما دسترسی لازم برای مشاهده پنل مدیریت را ندارید',
icon: Icons.admin_panel_settings,
),
child: Card(
child: ListTile(
leading: const Icon(Icons.admin_panel_settings),
title: const Text('پنل مدیریت'),
subtitle: const Text('دسترسی به پنل مدیریت'),
onTap: () => _openAdminPanel(),
),
),
),
],
);
}
void _openSettings() {
print('باز کردن تنظیمات');
}
void _openAdminPanel() {
print('باز کردن پنل مدیریت');
}
}

View file

@ -491,6 +491,7 @@
"peopleList": "People List", "peopleList": "People List",
"receipts": "Receipts", "receipts": "Receipts",
"payments": "Payments", "payments": "Payments",
"receiptsAndPayments": "Receipts and Payments",
"productsAndServices": "Products and Services", "productsAndServices": "Products and Services",
"products": "Products", "products": "Products",
"services": "Services", "services": "Services",
@ -777,6 +778,65 @@
"dataBackupDialogContent": "In this section you can create a backup of all business data.", "dataBackupDialogContent": "In this section you can create a backup of all business data.",
"dataRestoreDialogContent": "In this section you can restore data from a previous backup.", "dataRestoreDialogContent": "In this section you can restore data from a previous backup.",
"systemLogsDialogContent": "In this section you can view system reports, errors and user activities.", "systemLogsDialogContent": "In this section you can view system reports, errors and user activities.",
"accountManagement": "Account Management" "accountManagement": "Account Management",
"persons": "Persons",
"personsList": "Persons List",
"addPerson": "Add Person",
"editPerson": "Edit Person",
"personDetails": "Person Details",
"deletePerson": "Delete Person",
"personAliasName": "Alias Name",
"personFirstName": "First Name",
"personLastName": "Last Name",
"personType": "Person Type",
"personCompanyName": "Company Name",
"personPaymentId": "Payment ID",
"personNationalId": "National ID",
"personRegistrationNumber": "Registration Number",
"personEconomicId": "Economic ID",
"personCountry": "Country",
"personProvince": "Province",
"personCity": "City",
"personAddress": "Address",
"personPostalCode": "Postal Code",
"personPhone": "Phone",
"personMobile": "Mobile",
"personFax": "Fax",
"personEmail": "Email",
"personWebsite": "Website",
"personBankAccounts": "Bank Accounts",
"addBankAccount": "Add Bank Account",
"editBankAccount": "Edit Bank Account",
"deleteBankAccount": "Delete Bank Account",
"bankName": "Bank Name",
"accountNumber": "Account Number",
"cardNumber": "Card Number",
"shebaNumber": "Sheba Number",
"personTypeCustomer": "Customer",
"personTypeMarketer": "Marketer",
"personTypeEmployee": "Employee",
"personTypeSupplier": "Supplier",
"personTypePartner": "Partner",
"personTypeSeller": "Seller",
"personCreatedSuccessfully": "Person created successfully",
"personUpdatedSuccessfully": "Person updated successfully",
"personDeletedSuccessfully": "Person deleted successfully",
"personNotFound": "Person not found",
"personAliasNameRequired": "Alias name is required",
"personTypeRequired": "Person type is required",
"bankAccountAddedSuccessfully": "Bank account added successfully",
"bankAccountUpdatedSuccessfully": "Bank account updated successfully",
"bankAccountDeletedSuccessfully": "Bank account deleted successfully",
"bankNameRequired": "Bank name is required",
"personBasicInfo": "Basic Information",
"personEconomicInfo": "Economic Information",
"personContactInfo": "Contact Information",
"personBankInfo": "Bank Accounts",
"personSummary": "Persons Summary",
"totalPersons": "Total Persons",
"activePersons": "Active Persons",
"inactivePersons": "Inactive Persons",
"personsByType": "Persons by Type",
"update": "Update"
} }

View file

@ -490,6 +490,7 @@
"peopleList": "لیست اشخاص", "peopleList": "لیست اشخاص",
"receipts": "دریافت‌ها", "receipts": "دریافت‌ها",
"payments": "پرداخت‌ها", "payments": "پرداخت‌ها",
"receiptsAndPayments": "دریافت و پرداخت",
"productsAndServices": "کالا و خدمات", "productsAndServices": "کالا و خدمات",
"products": "کالاها", "products": "کالاها",
"services": "خدمات", "services": "خدمات",
@ -776,6 +777,65 @@
"dataBackupDialogContent": "در این بخش می‌توانید از تمام اطلاعات کسب و کار نسخه پشتیبان تهیه کنید.", "dataBackupDialogContent": "در این بخش می‌توانید از تمام اطلاعات کسب و کار نسخه پشتیبان تهیه کنید.",
"dataRestoreDialogContent": "در این بخش می‌توانید اطلاعات را از نسخه پشتیبان قبلی بازیابی کنید.", "dataRestoreDialogContent": "در این بخش می‌توانید اطلاعات را از نسخه پشتیبان قبلی بازیابی کنید.",
"systemLogsDialogContent": "در این بخش می‌توانید گزارش‌های سیستم، خطاها و فعالیت‌های کاربران را مشاهده کنید.", "systemLogsDialogContent": "در این بخش می‌توانید گزارش‌های سیستم، خطاها و فعالیت‌های کاربران را مشاهده کنید.",
"accountManagement": "مدیریت حساب کاربری" "accountManagement": "مدیریت حساب کاربری",
"persons": "اشخاص",
"personsList": "لیست اشخاص",
"addPerson": "افزودن شخص",
"editPerson": "ویرایش شخص",
"personDetails": "جزئیات شخص",
"deletePerson": "حذف شخص",
"personAliasName": "نام مستعار",
"personFirstName": "نام",
"personLastName": "نام خانوادگی",
"personType": "نوع شخص",
"personCompanyName": "نام شرکت",
"personPaymentId": "شناسه پرداخت",
"personNationalId": "شناسه ملی",
"personRegistrationNumber": "شماره ثبت",
"personEconomicId": "شناسه اقتصادی",
"personCountry": "کشور",
"personProvince": "استان",
"personCity": "شهرستان",
"personAddress": "آدرس",
"personPostalCode": "کد پستی",
"personPhone": "تلفن",
"personMobile": "موبایل",
"personFax": "فکس",
"personEmail": "پست الکترونیکی",
"personWebsite": "وب‌سایت",
"personBankAccounts": "حساب‌های بانکی",
"addBankAccount": "افزودن حساب بانکی",
"editBankAccount": "ویرایش حساب بانکی",
"deleteBankAccount": "حذف حساب بانکی",
"bankName": "نام بانک",
"accountNumber": "شماره حساب",
"cardNumber": "شماره کارت",
"shebaNumber": "شماره شبا",
"personTypeCustomer": "مشتری",
"personTypeMarketer": "بازاریاب",
"personTypeEmployee": "کارمند",
"personTypeSupplier": "تامین‌کننده",
"personTypePartner": "همکار",
"personTypeSeller": "فروشنده",
"personCreatedSuccessfully": "شخص با موفقیت ایجاد شد",
"personUpdatedSuccessfully": "شخص با موفقیت ویرایش شد",
"personDeletedSuccessfully": "شخص با موفقیت حذف شد",
"personNotFound": "شخص یافت نشد",
"personAliasNameRequired": "نام مستعار الزامی است",
"personTypeRequired": "نوع شخص الزامی است",
"bankAccountAddedSuccessfully": "حساب بانکی با موفقیت اضافه شد",
"bankAccountUpdatedSuccessfully": "حساب بانکی با موفقیت ویرایش شد",
"bankAccountDeletedSuccessfully": "حساب بانکی با موفقیت حذف شد",
"bankNameRequired": "نام بانک الزامی است",
"personBasicInfo": "اطلاعات پایه",
"personEconomicInfo": "اطلاعات اقتصادی",
"personContactInfo": "اطلاعات تماس",
"personBankInfo": "حساب‌های بانکی",
"personSummary": "خلاصه اشخاص",
"totalPersons": "تعداد کل اشخاص",
"activePersons": "اشخاص فعال",
"inactivePersons": "اشخاص غیرفعال",
"personsByType": "اشخاص بر اساس نوع",
"update": "ویرایش"
} }

View file

@ -2756,6 +2756,12 @@ abstract class AppLocalizations {
/// **'Payments'** /// **'Payments'**
String get payments; String get payments;
/// No description provided for @receiptsAndPayments.
///
/// In en, this message translates to:
/// **'Receipts and Payments'**
String get receiptsAndPayments;
/// No description provided for @productsAndServices. /// No description provided for @productsAndServices.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -3323,7 +3329,7 @@ abstract class AppLocalizations {
/// No description provided for @addPerson. /// No description provided for @addPerson.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Add New Person'** /// **'Add Person'**
String get addPerson; String get addPerson;
/// No description provided for @viewPeople. /// No description provided for @viewPeople.
@ -4219,6 +4225,348 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Account Management'** /// **'Account Management'**
String get accountManagement; String get accountManagement;
/// No description provided for @persons.
///
/// In en, this message translates to:
/// **'Persons'**
String get persons;
/// No description provided for @personsList.
///
/// In en, this message translates to:
/// **'Persons List'**
String get personsList;
/// No description provided for @editPerson.
///
/// In en, this message translates to:
/// **'Edit Person'**
String get editPerson;
/// No description provided for @personDetails.
///
/// In en, this message translates to:
/// **'Person Details'**
String get personDetails;
/// No description provided for @deletePerson.
///
/// In en, this message translates to:
/// **'Delete Person'**
String get deletePerson;
/// No description provided for @personAliasName.
///
/// In en, this message translates to:
/// **'Alias Name'**
String get personAliasName;
/// No description provided for @personFirstName.
///
/// In en, this message translates to:
/// **'First Name'**
String get personFirstName;
/// No description provided for @personLastName.
///
/// In en, this message translates to:
/// **'Last Name'**
String get personLastName;
/// No description provided for @personType.
///
/// In en, this message translates to:
/// **'Person Type'**
String get personType;
/// No description provided for @personCompanyName.
///
/// In en, this message translates to:
/// **'Company Name'**
String get personCompanyName;
/// No description provided for @personPaymentId.
///
/// In en, this message translates to:
/// **'Payment ID'**
String get personPaymentId;
/// No description provided for @personNationalId.
///
/// In en, this message translates to:
/// **'National ID'**
String get personNationalId;
/// No description provided for @personRegistrationNumber.
///
/// In en, this message translates to:
/// **'Registration Number'**
String get personRegistrationNumber;
/// No description provided for @personEconomicId.
///
/// In en, this message translates to:
/// **'Economic ID'**
String get personEconomicId;
/// No description provided for @personCountry.
///
/// In en, this message translates to:
/// **'Country'**
String get personCountry;
/// No description provided for @personProvince.
///
/// In en, this message translates to:
/// **'Province'**
String get personProvince;
/// No description provided for @personCity.
///
/// In en, this message translates to:
/// **'City'**
String get personCity;
/// No description provided for @personAddress.
///
/// In en, this message translates to:
/// **'Address'**
String get personAddress;
/// No description provided for @personPostalCode.
///
/// In en, this message translates to:
/// **'Postal Code'**
String get personPostalCode;
/// No description provided for @personPhone.
///
/// In en, this message translates to:
/// **'Phone'**
String get personPhone;
/// No description provided for @personMobile.
///
/// In en, this message translates to:
/// **'Mobile'**
String get personMobile;
/// No description provided for @personFax.
///
/// In en, this message translates to:
/// **'Fax'**
String get personFax;
/// No description provided for @personEmail.
///
/// In en, this message translates to:
/// **'Email'**
String get personEmail;
/// No description provided for @personWebsite.
///
/// In en, this message translates to:
/// **'Website'**
String get personWebsite;
/// No description provided for @personBankAccounts.
///
/// In en, this message translates to:
/// **'Bank Accounts'**
String get personBankAccounts;
/// No description provided for @editBankAccount.
///
/// In en, this message translates to:
/// **'Edit Bank Account'**
String get editBankAccount;
/// No description provided for @deleteBankAccount.
///
/// In en, this message translates to:
/// **'Delete Bank Account'**
String get deleteBankAccount;
/// No description provided for @bankName.
///
/// In en, this message translates to:
/// **'Bank Name'**
String get bankName;
/// No description provided for @accountNumber.
///
/// In en, this message translates to:
/// **'Account Number'**
String get accountNumber;
/// No description provided for @cardNumber.
///
/// In en, this message translates to:
/// **'Card Number'**
String get cardNumber;
/// No description provided for @shebaNumber.
///
/// In en, this message translates to:
/// **'Sheba Number'**
String get shebaNumber;
/// No description provided for @personTypeCustomer.
///
/// In en, this message translates to:
/// **'Customer'**
String get personTypeCustomer;
/// No description provided for @personTypeMarketer.
///
/// In en, this message translates to:
/// **'Marketer'**
String get personTypeMarketer;
/// No description provided for @personTypeEmployee.
///
/// In en, this message translates to:
/// **'Employee'**
String get personTypeEmployee;
/// No description provided for @personTypeSupplier.
///
/// In en, this message translates to:
/// **'Supplier'**
String get personTypeSupplier;
/// No description provided for @personTypePartner.
///
/// In en, this message translates to:
/// **'Partner'**
String get personTypePartner;
/// No description provided for @personTypeSeller.
///
/// In en, this message translates to:
/// **'Seller'**
String get personTypeSeller;
/// No description provided for @personCreatedSuccessfully.
///
/// In en, this message translates to:
/// **'Person created successfully'**
String get personCreatedSuccessfully;
/// No description provided for @personUpdatedSuccessfully.
///
/// In en, this message translates to:
/// **'Person updated successfully'**
String get personUpdatedSuccessfully;
/// No description provided for @personDeletedSuccessfully.
///
/// In en, this message translates to:
/// **'Person deleted successfully'**
String get personDeletedSuccessfully;
/// No description provided for @personNotFound.
///
/// In en, this message translates to:
/// **'Person not found'**
String get personNotFound;
/// No description provided for @personAliasNameRequired.
///
/// In en, this message translates to:
/// **'Alias name is required'**
String get personAliasNameRequired;
/// No description provided for @personTypeRequired.
///
/// In en, this message translates to:
/// **'Person type is required'**
String get personTypeRequired;
/// No description provided for @bankAccountAddedSuccessfully.
///
/// In en, this message translates to:
/// **'Bank account added successfully'**
String get bankAccountAddedSuccessfully;
/// No description provided for @bankAccountUpdatedSuccessfully.
///
/// In en, this message translates to:
/// **'Bank account updated successfully'**
String get bankAccountUpdatedSuccessfully;
/// No description provided for @bankAccountDeletedSuccessfully.
///
/// In en, this message translates to:
/// **'Bank account deleted successfully'**
String get bankAccountDeletedSuccessfully;
/// No description provided for @bankNameRequired.
///
/// In en, this message translates to:
/// **'Bank name is required'**
String get bankNameRequired;
/// No description provided for @personBasicInfo.
///
/// In en, this message translates to:
/// **'Basic Information'**
String get personBasicInfo;
/// No description provided for @personEconomicInfo.
///
/// In en, this message translates to:
/// **'Economic Information'**
String get personEconomicInfo;
/// No description provided for @personContactInfo.
///
/// In en, this message translates to:
/// **'Contact Information'**
String get personContactInfo;
/// No description provided for @personBankInfo.
///
/// In en, this message translates to:
/// **'Bank Accounts'**
String get personBankInfo;
/// No description provided for @personSummary.
///
/// In en, this message translates to:
/// **'Persons Summary'**
String get personSummary;
/// No description provided for @totalPersons.
///
/// In en, this message translates to:
/// **'Total Persons'**
String get totalPersons;
/// No description provided for @activePersons.
///
/// In en, this message translates to:
/// **'Active Persons'**
String get activePersons;
/// No description provided for @inactivePersons.
///
/// In en, this message translates to:
/// **'Inactive Persons'**
String get inactivePersons;
/// No description provided for @personsByType.
///
/// In en, this message translates to:
/// **'Persons by Type'**
String get personsByType;
/// No description provided for @update.
///
/// In en, this message translates to:
/// **'Update'**
String get update;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -1379,6 +1379,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get payments => 'Payments'; String get payments => 'Payments';
@override
String get receiptsAndPayments => 'Receipts and Payments';
@override @override
String get productsAndServices => 'Products and Services'; String get productsAndServices => 'Products and Services';
@ -1665,7 +1668,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get draft => 'Manage Drafts'; String get draft => 'Manage Drafts';
@override @override
String get addPerson => 'Add New Person'; String get addPerson => 'Add Person';
@override @override
String get viewPeople => 'View People List'; String get viewPeople => 'View People List';
@ -2126,4 +2129,177 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get accountManagement => 'Account Management'; String get accountManagement => 'Account Management';
@override
String get persons => 'Persons';
@override
String get personsList => 'Persons List';
@override
String get editPerson => 'Edit Person';
@override
String get personDetails => 'Person Details';
@override
String get deletePerson => 'Delete Person';
@override
String get personAliasName => 'Alias Name';
@override
String get personFirstName => 'First Name';
@override
String get personLastName => 'Last Name';
@override
String get personType => 'Person Type';
@override
String get personCompanyName => 'Company Name';
@override
String get personPaymentId => 'Payment ID';
@override
String get personNationalId => 'National ID';
@override
String get personRegistrationNumber => 'Registration Number';
@override
String get personEconomicId => 'Economic ID';
@override
String get personCountry => 'Country';
@override
String get personProvince => 'Province';
@override
String get personCity => 'City';
@override
String get personAddress => 'Address';
@override
String get personPostalCode => 'Postal Code';
@override
String get personPhone => 'Phone';
@override
String get personMobile => 'Mobile';
@override
String get personFax => 'Fax';
@override
String get personEmail => 'Email';
@override
String get personWebsite => 'Website';
@override
String get personBankAccounts => 'Bank Accounts';
@override
String get editBankAccount => 'Edit Bank Account';
@override
String get deleteBankAccount => 'Delete Bank Account';
@override
String get bankName => 'Bank Name';
@override
String get accountNumber => 'Account Number';
@override
String get cardNumber => 'Card Number';
@override
String get shebaNumber => 'Sheba Number';
@override
String get personTypeCustomer => 'Customer';
@override
String get personTypeMarketer => 'Marketer';
@override
String get personTypeEmployee => 'Employee';
@override
String get personTypeSupplier => 'Supplier';
@override
String get personTypePartner => 'Partner';
@override
String get personTypeSeller => 'Seller';
@override
String get personCreatedSuccessfully => 'Person created successfully';
@override
String get personUpdatedSuccessfully => 'Person updated successfully';
@override
String get personDeletedSuccessfully => 'Person deleted successfully';
@override
String get personNotFound => 'Person not found';
@override
String get personAliasNameRequired => 'Alias name is required';
@override
String get personTypeRequired => 'Person type is required';
@override
String get bankAccountAddedSuccessfully => 'Bank account added successfully';
@override
String get bankAccountUpdatedSuccessfully =>
'Bank account updated successfully';
@override
String get bankAccountDeletedSuccessfully =>
'Bank account deleted successfully';
@override
String get bankNameRequired => 'Bank name is required';
@override
String get personBasicInfo => 'Basic Information';
@override
String get personEconomicInfo => 'Economic Information';
@override
String get personContactInfo => 'Contact Information';
@override
String get personBankInfo => 'Bank Accounts';
@override
String get personSummary => 'Persons Summary';
@override
String get totalPersons => 'Total Persons';
@override
String get activePersons => 'Active Persons';
@override
String get inactivePersons => 'Inactive Persons';
@override
String get personsByType => 'Persons by Type';
@override
String get update => 'Update';
} }

View file

@ -1368,6 +1368,9 @@ class AppLocalizationsFa extends AppLocalizations {
@override @override
String get payments => 'پرداخت‌ها'; String get payments => 'پرداخت‌ها';
@override
String get receiptsAndPayments => 'دریافت و پرداخت';
@override @override
String get productsAndServices => 'کالا و خدمات'; String get productsAndServices => 'کالا و خدمات';
@ -1655,7 +1658,7 @@ class AppLocalizationsFa extends AppLocalizations {
String get draft => 'مدیریت پیش‌نویس‌ها'; String get draft => 'مدیریت پیش‌نویس‌ها';
@override @override
String get addPerson => 'افزودن شخص جدید'; String get addPerson => 'افزودن شخص';
@override @override
String get viewPeople => 'مشاهده لیست اشخاص'; String get viewPeople => 'مشاهده لیست اشخاص';
@ -2112,4 +2115,175 @@ class AppLocalizationsFa extends AppLocalizations {
@override @override
String get accountManagement => 'مدیریت حساب کاربری'; String get accountManagement => 'مدیریت حساب کاربری';
@override
String get persons => 'اشخاص';
@override
String get personsList => 'لیست اشخاص';
@override
String get editPerson => 'ویرایش شخص';
@override
String get personDetails => 'جزئیات شخص';
@override
String get deletePerson => 'حذف شخص';
@override
String get personAliasName => 'نام مستعار';
@override
String get personFirstName => 'نام';
@override
String get personLastName => 'نام خانوادگی';
@override
String get personType => 'نوع شخص';
@override
String get personCompanyName => 'نام شرکت';
@override
String get personPaymentId => 'شناسه پرداخت';
@override
String get personNationalId => 'شناسه ملی';
@override
String get personRegistrationNumber => 'شماره ثبت';
@override
String get personEconomicId => 'شناسه اقتصادی';
@override
String get personCountry => 'کشور';
@override
String get personProvince => 'استان';
@override
String get personCity => 'شهرستان';
@override
String get personAddress => 'آدرس';
@override
String get personPostalCode => 'کد پستی';
@override
String get personPhone => 'تلفن';
@override
String get personMobile => 'موبایل';
@override
String get personFax => 'فکس';
@override
String get personEmail => 'پست الکترونیکی';
@override
String get personWebsite => 'وب‌سایت';
@override
String get personBankAccounts => 'حساب‌های بانکی';
@override
String get editBankAccount => 'ویرایش حساب بانکی';
@override
String get deleteBankAccount => 'حذف حساب بانکی';
@override
String get bankName => 'نام بانک';
@override
String get accountNumber => 'شماره حساب';
@override
String get cardNumber => 'شماره کارت';
@override
String get shebaNumber => 'شماره شبا';
@override
String get personTypeCustomer => 'مشتری';
@override
String get personTypeMarketer => 'بازاریاب';
@override
String get personTypeEmployee => 'کارمند';
@override
String get personTypeSupplier => 'تامین‌کننده';
@override
String get personTypePartner => 'همکار';
@override
String get personTypeSeller => 'فروشنده';
@override
String get personCreatedSuccessfully => 'شخص با موفقیت ایجاد شد';
@override
String get personUpdatedSuccessfully => 'شخص با موفقیت ویرایش شد';
@override
String get personDeletedSuccessfully => 'شخص با موفقیت حذف شد';
@override
String get personNotFound => 'شخص یافت نشد';
@override
String get personAliasNameRequired => 'نام مستعار الزامی است';
@override
String get personTypeRequired => 'نوع شخص الزامی است';
@override
String get bankAccountAddedSuccessfully => 'حساب بانکی با موفقیت اضافه شد';
@override
String get bankAccountUpdatedSuccessfully => 'حساب بانکی با موفقیت ویرایش شد';
@override
String get bankAccountDeletedSuccessfully => 'حساب بانکی با موفقیت حذف شد';
@override
String get bankNameRequired => 'نام بانک الزامی است';
@override
String get personBasicInfo => 'اطلاعات پایه';
@override
String get personEconomicInfo => 'اطلاعات اقتصادی';
@override
String get personContactInfo => 'اطلاعات تماس';
@override
String get personBankInfo => 'حساب‌های بانکی';
@override
String get personSummary => 'خلاصه اشخاص';
@override
String get totalPersons => 'تعداد کل اشخاص';
@override
String get activePersons => 'اشخاص فعال';
@override
String get inactivePersons => 'اشخاص غیرفعال';
@override
String get personsByType => 'اشخاص بر اساس نوع';
@override
String get update => 'ویرایش';
} }

View file

@ -23,6 +23,8 @@ 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 'pages/business/users_permissions_page.dart';
import 'pages/business/settings_page.dart'; import 'pages/business/settings_page.dart';
import 'pages/business/persons_page.dart';
import 'pages/error_404_page.dart';
import 'core/locale_controller.dart'; import 'core/locale_controller.dart';
import 'core/calendar_controller.dart'; import 'core/calendar_controller.dart';
import 'core/api_client.dart'; import 'core/api_client.dart';
@ -516,10 +518,35 @@ class _MyAppState extends State<MyApp> {
); );
}, },
), ),
GoRoute(
path: 'persons',
name: 'business_persons',
builder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: PersonsPage(
businessId: businessId,
authStore: _authStore!,
),
);
},
),
// TODO: Add other business routes (sales, accounting, etc.) // TODO: Add other business routes (sales, accounting, etc.)
], ],
), ),
// صفحه 404 برای مسیرهای نامعتبر
GoRoute(
path: '/404',
name: 'error_404',
builder: (context, state) => const Error404Page(),
),
], ],
errorBuilder: (context, state) => const Error404Page(),
); );
return AnimatedBuilder( return AnimatedBuilder(

View file

@ -0,0 +1,428 @@
class PersonBankAccount {
final int? id;
final int personId;
final String bankName;
final String? accountNumber;
final String? cardNumber;
final String? shebaNumber;
final bool isActive;
final DateTime createdAt;
final DateTime updatedAt;
PersonBankAccount({
this.id,
required this.personId,
required this.bankName,
this.accountNumber,
this.cardNumber,
this.shebaNumber,
this.isActive = true,
required this.createdAt,
required this.updatedAt,
});
factory PersonBankAccount.fromJson(Map<String, dynamic> json) {
return PersonBankAccount(
id: json['id'],
personId: json['person_id'],
bankName: json['bank_name'],
accountNumber: json['account_number'],
cardNumber: json['card_number'],
shebaNumber: json['sheba_number'],
isActive: json['is_active'] ?? true,
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'person_id': personId,
'bank_name': bankName,
'account_number': accountNumber,
'card_number': cardNumber,
'sheba_number': shebaNumber,
'is_active': isActive,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}
PersonBankAccount copyWith({
int? id,
int? personId,
String? bankName,
String? accountNumber,
String? cardNumber,
String? shebaNumber,
bool? isActive,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return PersonBankAccount(
id: id ?? this.id,
personId: personId ?? this.personId,
bankName: bankName ?? this.bankName,
accountNumber: accountNumber ?? this.accountNumber,
cardNumber: cardNumber ?? this.cardNumber,
shebaNumber: shebaNumber ?? this.shebaNumber,
isActive: isActive ?? this.isActive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
enum PersonType {
customer('مشتری', 'Customer'),
marketer('بازاریاب', 'Marketer'),
employee('کارمند', 'Employee'),
supplier('تامین‌کننده', 'Supplier'),
partner('همکار', 'Partner'),
seller('فروشنده', 'Seller');
const PersonType(this.persianName, this.englishName);
final String persianName;
final String englishName;
static PersonType fromString(String value) {
return PersonType.values.firstWhere(
(type) => type.persianName == value || type.englishName == value,
orElse: () => PersonType.customer,
);
}
}
class Person {
final int? id;
final int businessId;
final String aliasName;
final String? firstName;
final String? lastName;
final PersonType personType;
final String? companyName;
final String? paymentId;
final String? nationalId;
final String? registrationNumber;
final String? economicId;
final String? country;
final String? province;
final String? city;
final String? address;
final String? postalCode;
final String? phone;
final String? mobile;
final String? fax;
final String? email;
final String? website;
final bool isActive;
final DateTime createdAt;
final DateTime updatedAt;
final List<PersonBankAccount> bankAccounts;
Person({
this.id,
required this.businessId,
required this.aliasName,
this.firstName,
this.lastName,
required this.personType,
this.companyName,
this.paymentId,
this.nationalId,
this.registrationNumber,
this.economicId,
this.country,
this.province,
this.city,
this.address,
this.postalCode,
this.phone,
this.mobile,
this.fax,
this.email,
this.website,
this.isActive = true,
required this.createdAt,
required this.updatedAt,
this.bankAccounts = const [],
});
factory Person.fromJson(Map<String, dynamic> json) {
return Person(
id: json['id'],
businessId: json['business_id'],
aliasName: json['alias_name'],
firstName: json['first_name'],
lastName: json['last_name'],
personType: PersonType.fromString(json['person_type']),
companyName: json['company_name'],
paymentId: json['payment_id'],
nationalId: json['national_id'],
registrationNumber: json['registration_number'],
economicId: json['economic_id'],
country: json['country'],
province: json['province'],
city: json['city'],
address: json['address'],
postalCode: json['postal_code'],
phone: json['phone'],
mobile: json['mobile'],
fax: json['fax'],
email: json['email'],
website: json['website'],
isActive: json['is_active'] ?? true,
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
bankAccounts: (json['bank_accounts'] as List<dynamic>?)
?.map((ba) => PersonBankAccount.fromJson(ba))
.toList() ?? [],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'business_id': businessId,
'alias_name': aliasName,
'first_name': firstName,
'last_name': lastName,
'person_type': personType.persianName,
'company_name': companyName,
'payment_id': paymentId,
'national_id': nationalId,
'registration_number': registrationNumber,
'economic_id': economicId,
'country': country,
'province': province,
'city': city,
'address': address,
'postal_code': postalCode,
'phone': phone,
'mobile': mobile,
'fax': fax,
'email': email,
'website': website,
'is_active': isActive,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'bank_accounts': bankAccounts.map((ba) => ba.toJson()).toList(),
};
}
Person copyWith({
int? id,
int? businessId,
String? aliasName,
String? firstName,
String? lastName,
PersonType? personType,
String? companyName,
String? paymentId,
String? nationalId,
String? registrationNumber,
String? economicId,
String? country,
String? province,
String? city,
String? address,
String? postalCode,
String? phone,
String? mobile,
String? fax,
String? email,
String? website,
bool? isActive,
DateTime? createdAt,
DateTime? updatedAt,
List<PersonBankAccount>? bankAccounts,
}) {
return Person(
id: id ?? this.id,
businessId: businessId ?? this.businessId,
aliasName: aliasName ?? this.aliasName,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
personType: personType ?? this.personType,
companyName: companyName ?? this.companyName,
paymentId: paymentId ?? this.paymentId,
nationalId: nationalId ?? this.nationalId,
registrationNumber: registrationNumber ?? this.registrationNumber,
economicId: economicId ?? this.economicId,
country: country ?? this.country,
province: province ?? this.province,
city: city ?? this.city,
address: address ?? this.address,
postalCode: postalCode ?? this.postalCode,
phone: phone ?? this.phone,
mobile: mobile ?? this.mobile,
fax: fax ?? this.fax,
email: email ?? this.email,
website: website ?? this.website,
isActive: isActive ?? this.isActive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
bankAccounts: bankAccounts ?? this.bankAccounts,
);
}
String get fullName {
if (firstName != null && lastName != null) {
return '$firstName $lastName';
} else if (firstName != null) {
return firstName!;
} else if (lastName != null) {
return lastName!;
}
return aliasName;
}
String get displayName {
return fullName.isNotEmpty ? fullName : aliasName;
}
}
class PersonCreateRequest {
final String aliasName;
final String? firstName;
final String? lastName;
final PersonType personType;
final String? companyName;
final String? paymentId;
final String? nationalId;
final String? registrationNumber;
final String? economicId;
final String? country;
final String? province;
final String? city;
final String? address;
final String? postalCode;
final String? phone;
final String? mobile;
final String? fax;
final String? email;
final String? website;
final List<PersonBankAccount> bankAccounts;
PersonCreateRequest({
required this.aliasName,
this.firstName,
this.lastName,
required this.personType,
this.companyName,
this.paymentId,
this.nationalId,
this.registrationNumber,
this.economicId,
this.country,
this.province,
this.city,
this.address,
this.postalCode,
this.phone,
this.mobile,
this.fax,
this.email,
this.website,
this.bankAccounts = const [],
});
Map<String, dynamic> toJson() {
return {
'alias_name': aliasName,
'first_name': firstName,
'last_name': lastName,
'person_type': personType.persianName,
'company_name': companyName,
'payment_id': paymentId,
'national_id': nationalId,
'registration_number': registrationNumber,
'economic_id': economicId,
'country': country,
'province': province,
'city': city,
'address': address,
'postal_code': postalCode,
'phone': phone,
'mobile': mobile,
'fax': fax,
'email': email,
'website': website,
'bank_accounts': bankAccounts.map((ba) => ba.toJson()).toList(),
};
}
}
class PersonUpdateRequest {
final String? aliasName;
final String? firstName;
final String? lastName;
final PersonType? personType;
final String? companyName;
final String? paymentId;
final String? nationalId;
final String? registrationNumber;
final String? economicId;
final String? country;
final String? province;
final String? city;
final String? address;
final String? postalCode;
final String? phone;
final String? mobile;
final String? fax;
final String? email;
final String? website;
final bool? isActive;
PersonUpdateRequest({
this.aliasName,
this.firstName,
this.lastName,
this.personType,
this.companyName,
this.paymentId,
this.nationalId,
this.registrationNumber,
this.economicId,
this.country,
this.province,
this.city,
this.address,
this.postalCode,
this.phone,
this.mobile,
this.fax,
this.email,
this.website,
this.isActive,
});
Map<String, dynamic> toJson() {
final Map<String, dynamic> json = {};
if (aliasName != null) json['alias_name'] = aliasName;
if (firstName != null) json['first_name'] = firstName;
if (lastName != null) json['last_name'] = lastName;
if (personType != null) json['person_type'] = personType!.persianName;
if (companyName != null) json['company_name'] = companyName;
if (paymentId != null) json['payment_id'] = paymentId;
if (nationalId != null) json['national_id'] = nationalId;
if (registrationNumber != null) json['registration_number'] = registrationNumber;
if (economicId != null) json['economic_id'] = economicId;
if (country != null) json['country'] = country;
if (province != null) json['province'] = province;
if (city != null) json['city'] = city;
if (address != null) json['address'] = address;
if (postalCode != null) json['postal_code'] = postalCode;
if (phone != null) json['phone'] = phone;
if (mobile != null) json['mobile'] = mobile;
if (fax != null) json['fax'] = fax;
if (email != null) json['email'] = email;
if (website != null) json['website'] = website;
if (isActive != null) json['is_active'] = isActive;
return json;
}
}

View file

@ -5,6 +5,9 @@ import '../../core/locale_controller.dart';
import '../../core/calendar_controller.dart'; import '../../core/calendar_controller.dart';
import '../../theme/theme_controller.dart'; import '../../theme/theme_controller.dart';
import '../../widgets/combined_user_menu_button.dart'; import '../../widgets/combined_user_menu_button.dart';
import '../../widgets/person/person_form_dialog.dart';
import '../../services/business_dashboard_service.dart';
import '../../core/api_client.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
class BusinessShell extends StatefulWidget { class BusinessShell extends StatefulWidget {
@ -31,11 +34,11 @@ class BusinessShell extends StatefulWidget {
class _BusinessShellState extends State<BusinessShell> { class _BusinessShellState extends State<BusinessShell> {
int _hoverIndex = -1; int _hoverIndex = -1;
bool _isPeopleExpanded = false;
bool _isProductsAndServicesExpanded = false; bool _isProductsAndServicesExpanded = false;
bool _isBankingExpanded = false; bool _isBankingExpanded = false;
bool _isAccountingMenuExpanded = false; bool _isAccountingMenuExpanded = false;
bool _isWarehouseManagementExpanded = false; bool _isWarehouseManagementExpanded = false;
final BusinessDashboardService _businessService = BusinessDashboardService(ApiClient());
@override @override
void initState() { void initState() {
@ -46,6 +49,29 @@ class _BusinessShellState extends State<BusinessShell> {
setState(() {}); setState(() {});
} }
}); });
// بارگذاری اطلاعات کسب و کار و دسترسیها
_loadBusinessInfo();
}
Future<void> _loadBusinessInfo() async {
if (widget.authStore.currentBusiness?.id == widget.businessId) {
return; // اطلاعات قبلاً بارگذاری شده
}
try {
final businessData = await _businessService.getBusinessWithPermissions(widget.businessId);
await widget.authStore.setCurrentBusiness(businessData);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در بارگذاری اطلاعات کسب و کار: $e'),
backgroundColor: Colors.red,
),
);
}
}
} }
@override @override
@ -68,7 +94,7 @@ class _BusinessShellState extends State<BusinessShell> {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
// ساختار متمرکز منو // ساختار متمرکز منو
final menuItems = <_MenuItem>[ final allMenuItems = <_MenuItem>[
_MenuItem( _MenuItem(
label: t.businessDashboard, label: t.businessDashboard,
icon: Icons.dashboard_outlined, icon: Icons.dashboard_outlined,
@ -87,33 +113,9 @@ class _BusinessShellState extends State<BusinessShell> {
label: t.people, label: t.people,
icon: Icons.people, icon: Icons.people,
selectedIcon: Icons.people, selectedIcon: Icons.people,
path: null, // برای منوی بازشونده path: '/business/${widget.businessId}/persons',
type: _MenuItemType.expandable, type: _MenuItemType.simple,
children: [ hasAddButton: true,
_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( _MenuItem(
label: t.productsAndServices, label: t.productsAndServices,
@ -203,14 +205,6 @@ class _BusinessShellState extends State<BusinessShell> {
type: _MenuItemType.simple, type: _MenuItemType.simple,
hasAddButton: true, hasAddButton: true,
), ),
_MenuItem(
label: t.transfers,
icon: Icons.swap_horiz,
selectedIcon: Icons.swap_horiz,
path: '/business/${widget.businessId}/transfers',
type: _MenuItemType.simple,
hasAddButton: true,
),
], ],
), ),
_MenuItem( _MenuItem(
@ -228,6 +222,14 @@ class _BusinessShellState extends State<BusinessShell> {
type: _MenuItemType.simple, type: _MenuItemType.simple,
hasAddButton: true, hasAddButton: true,
), ),
_MenuItem(
label: t.receiptsAndPayments,
icon: Icons.account_balance_wallet,
selectedIcon: Icons.account_balance_wallet,
path: '/business/${widget.businessId}/receipts-payments',
type: _MenuItemType.simple,
hasAddButton: true,
),
_MenuItem( _MenuItem(
label: t.expenseAndIncome, label: t.expenseAndIncome,
icon: Icons.account_balance_wallet, icon: Icons.account_balance_wallet,
@ -236,6 +238,22 @@ class _BusinessShellState extends State<BusinessShell> {
type: _MenuItemType.simple, type: _MenuItemType.simple,
hasAddButton: true, hasAddButton: true,
), ),
_MenuItem(
label: t.transfers,
icon: Icons.swap_horiz,
selectedIcon: Icons.swap_horiz,
path: '/business/${widget.businessId}/transfers',
type: _MenuItemType.simple,
hasAddButton: true,
),
_MenuItem(
label: t.documents,
icon: Icons.description,
selectedIcon: Icons.description,
path: '/business/${widget.businessId}/documents',
type: _MenuItemType.simple,
hasAddButton: true,
),
_MenuItem( _MenuItem(
label: t.accountingMenu, label: t.accountingMenu,
icon: Icons.calculate, icon: Icons.calculate,
@ -243,14 +261,6 @@ class _BusinessShellState extends State<BusinessShell> {
path: null, // برای منوی بازشونده path: null, // برای منوی بازشونده
type: _MenuItemType.expandable, type: _MenuItemType.expandable,
children: [ children: [
_MenuItem(
label: t.documents,
icon: Icons.description,
selectedIcon: Icons.description,
path: '/business/${widget.businessId}/documents',
type: _MenuItemType.simple,
hasAddButton: true,
),
_MenuItem( _MenuItem(
label: t.chartOfAccounts, label: t.chartOfAccounts,
icon: Icons.table_chart, icon: Icons.table_chart,
@ -373,6 +383,9 @@ class _BusinessShellState extends State<BusinessShell> {
), ),
]; ];
// فیلتر کردن منو بر اساس دسترسیها
final menuItems = _getFilteredMenuItems(allMenuItems);
int selectedIndex = 0; int selectedIndex = 0;
for (int i = 0; i < menuItems.length; i++) { for (int i = 0; i < menuItems.length; i++) {
final item = menuItems[i]; final item = menuItems[i];
@ -387,11 +400,10 @@ class _BusinessShellState extends State<BusinessShell> {
if (child.path != null && location.startsWith(child.path!)) { if (child.path != null && location.startsWith(child.path!)) {
selectedIndex = i; selectedIndex = i;
// تنظیم وضعیت باز بودن منو // تنظیم وضعیت باز بودن منو
if (i == 2) _isPeopleExpanded = true; // اشخاص در ایندکس 2 if (i == 2) _isProductsAndServicesExpanded = true; // کالا و خدمات در ایندکس 2
if (i == 3) _isProductsAndServicesExpanded = true; // کالا و خدمات در ایندکس 3 if (i == 3) _isBankingExpanded = true; // بانکداری در ایندکس 3
if (i == 4) _isBankingExpanded = true; // بانکداری در ایندکس 4 if (i == 5) _isAccountingMenuExpanded = true; // حسابداری در ایندکس 5
if (i == 6) _isAccountingMenuExpanded = true; // حسابداری در ایندکس 6 if (i == 7) _isWarehouseManagementExpanded = true; // انبارداری در ایندکس 7
if (i == 8) _isWarehouseManagementExpanded = true; // انبارداری در ایندکس 8
break; break;
} }
} }
@ -413,7 +425,6 @@ class _BusinessShellState extends State<BusinessShell> {
} }
} else if (item.type == _MenuItemType.expandable) { } else if (item.type == _MenuItemType.expandable) {
// تغییر وضعیت باز/بسته بودن منو // تغییر وضعیت باز/بسته بودن منو
if (item.label == t.people) _isPeopleExpanded = !_isPeopleExpanded;
if (item.label == t.productsAndServices) _isProductsAndServicesExpanded = !_isProductsAndServicesExpanded; if (item.label == t.productsAndServices) _isProductsAndServicesExpanded = !_isProductsAndServicesExpanded;
if (item.label == t.banking) _isBankingExpanded = !_isBankingExpanded; if (item.label == t.banking) _isBankingExpanded = !_isBankingExpanded;
if (item.label == t.accountingMenu) _isAccountingMenuExpanded = !_isAccountingMenuExpanded; if (item.label == t.accountingMenu) _isAccountingMenuExpanded = !_isAccountingMenuExpanded;
@ -449,8 +460,20 @@ class _BusinessShellState extends State<BusinessShell> {
context.go('/login'); context.go('/login');
} }
Future<void> _showAddPersonDialog() async {
final result = await showDialog<bool>(
context: context,
builder: (context) => PersonFormDialog(
businessId: widget.businessId,
),
);
if (result == true) {
// Refresh the persons page if it's currently open
// This will be handled by the PersonsPage itself
}
}
bool isExpanded(_MenuItem item) { bool isExpanded(_MenuItem item) {
if (item.label == t.people) return _isPeopleExpanded;
if (item.label == t.productsAndServices) return _isProductsAndServicesExpanded; if (item.label == t.productsAndServices) return _isProductsAndServicesExpanded;
if (item.label == t.banking) return _isBankingExpanded; if (item.label == t.banking) return _isBankingExpanded;
if (item.label == t.accountingMenu) return _isAccountingMenuExpanded; if (item.label == t.accountingMenu) return _isAccountingMenuExpanded;
@ -622,10 +645,9 @@ class _BusinessShellState extends State<BusinessShell> {
GestureDetector( GestureDetector(
onTap: () { onTap: () {
// Navigate to add new item // Navigate to add new item
if (child.label == t.receipts) { if (child.label == t.personsList) {
// Navigate to add receipt // Navigate to add person
} else if (child.label == t.payments) { _showAddPersonDialog();
// Navigate to add payment
} else if (child.label == t.products) { } else if (child.label == t.products) {
// Navigate to add product // Navigate to add product
} else if (child.label == t.priceLists) { } else if (child.label == t.priceLists) {
@ -644,14 +666,10 @@ class _BusinessShellState extends State<BusinessShell> {
// Navigate to add wallet // Navigate to add wallet
} else if (child.label == t.checks) { } else if (child.label == t.checks) {
// Navigate to add check // Navigate to add check
} else if (child.label == t.transfers) {
// Navigate to add transfer
} else if (child.label == t.invoice) { } else if (child.label == t.invoice) {
// Navigate to add invoice // Navigate to add invoice
} else if (child.label == t.expenseAndIncome) { } else if (child.label == t.expenseAndIncome) {
// Navigate to add expense/income // Navigate to add expense/income
} else if (child.label == t.documents) {
// Navigate to add document
} else if (child.label == t.warehouses) { } else if (child.label == t.warehouses) {
// Navigate to add warehouse // Navigate to add warehouse
} else if (child.label == t.shipments) { } else if (child.label == t.shipments) {
@ -733,7 +751,6 @@ class _BusinessShellState extends State<BusinessShell> {
onTap: () { onTap: () {
if (item.type == _MenuItemType.expandable) { if (item.type == _MenuItemType.expandable) {
setState(() { setState(() {
if (item.label == t.people) _isPeopleExpanded = !_isPeopleExpanded;
if (item.label == t.productsAndServices) _isProductsAndServicesExpanded = !_isProductsAndServicesExpanded; if (item.label == t.productsAndServices) _isProductsAndServicesExpanded = !_isProductsAndServicesExpanded;
if (item.label == t.banking) _isBankingExpanded = !_isBankingExpanded; if (item.label == t.banking) _isBankingExpanded = !_isBankingExpanded;
if (item.label == t.accountingMenu) _isAccountingMenuExpanded = !_isAccountingMenuExpanded; if (item.label == t.accountingMenu) _isAccountingMenuExpanded = !_isAccountingMenuExpanded;
@ -781,8 +798,17 @@ class _BusinessShellState extends State<BusinessShell> {
GestureDetector( GestureDetector(
onTap: () { onTap: () {
// Navigate to add new item // Navigate to add new item
if (item.label == t.invoice) { if (item.label == t.people) {
// Navigate to add person
_showAddPersonDialog();
} else if (item.label == t.invoice) {
// Navigate to add invoice // Navigate to add invoice
} else if (item.label == t.receiptsAndPayments) {
// Navigate to add receipt/payment
} else if (item.label == t.transfers) {
// Navigate to add transfer
} else if (item.label == t.documents) {
// Navigate to add document
} else if (item.label == t.expenseAndIncome) { } else if (item.label == t.expenseAndIncome) {
// Navigate to add expense/income // Navigate to add expense/income
} else if (item.label == t.reports) { } else if (item.label == t.reports) {
@ -892,7 +918,6 @@ class _BusinessShellState extends State<BusinessShell> {
initiallyExpanded: isExpanded(item), initiallyExpanded: isExpanded(item),
onExpansionChanged: (expanded) { onExpansionChanged: (expanded) {
setState(() { setState(() {
if (item.label == t.people) _isPeopleExpanded = expanded;
if (item.label == t.productsAndServices) _isProductsAndServicesExpanded = expanded; if (item.label == t.productsAndServices) _isProductsAndServicesExpanded = expanded;
if (item.label == t.banking) _isBankingExpanded = expanded; if (item.label == t.banking) _isBankingExpanded = expanded;
if (item.label == t.accountingMenu) _isAccountingMenuExpanded = expanded; if (item.label == t.accountingMenu) _isAccountingMenuExpanded = expanded;
@ -906,11 +931,7 @@ class _BusinessShellState extends State<BusinessShell> {
onTap: () { onTap: () {
context.pop(); context.pop();
// Navigate to add new item // Navigate to add new item
if (child.label == t.receipts) { if (child.label == t.products) {
// Navigate to add receipt
} else if (child.label == t.payments) {
// Navigate to add payment
} else if (child.label == t.products) {
// Navigate to add product // Navigate to add product
} else if (child.label == t.priceLists) { } else if (child.label == t.priceLists) {
// Navigate to add price list // Navigate to add price list
@ -928,14 +949,10 @@ class _BusinessShellState extends State<BusinessShell> {
// Navigate to add wallet // Navigate to add wallet
} else if (child.label == t.checks) { } else if (child.label == t.checks) {
// Navigate to add check // Navigate to add check
} else if (child.label == t.transfers) {
// Navigate to add transfer
} else if (child.label == t.invoice) { } else if (child.label == t.invoice) {
// Navigate to add invoice // Navigate to add invoice
} else if (child.label == t.expenseAndIncome) { } else if (child.label == t.expenseAndIncome) {
// Navigate to add expense/income // Navigate to add expense/income
} else if (child.label == t.documents) {
// Navigate to add document
} else if (child.label == t.warehouses) { } else if (child.label == t.warehouses) {
// Navigate to add warehouse // Navigate to add warehouse
} else if (child.label == t.shipments) { } else if (child.label == t.shipments) {
@ -980,6 +997,67 @@ class _BusinessShellState extends State<BusinessShell> {
body: content, body: content,
); );
} }
// فیلتر کردن منو بر اساس دسترسیها
List<_MenuItem> _getFilteredMenuItems(List<_MenuItem> allItems) {
return allItems.where((item) {
if (item.type == _MenuItemType.separator) return true;
if (item.type == _MenuItemType.simple) {
return _hasAccessToMenuItem(item);
}
if (item.type == _MenuItemType.expandable) {
return _hasAccessToExpandableMenuItem(item);
}
return false;
}).toList();
}
bool _hasAccessToMenuItem(_MenuItem item) {
final sectionMap = {
'people': 'people',
'products': 'products',
'priceLists': 'price_lists',
'categories': 'categories',
'productAttributes': 'product_attributes',
'accounts': 'bank_accounts',
'pettyCash': 'petty_cash',
'cashBox': 'cash',
'wallet': 'wallet',
'checks': 'checks',
'invoice': 'invoices',
'receiptsAndPayments': 'accounting_documents',
'expenseAndIncome': 'expenses_income',
'transfers': 'transfers',
'documents': 'accounting_documents',
'chartOfAccounts': 'chart_of_accounts',
'openingBalance': 'opening_balance',
'yearEndClosing': 'opening_balance',
'accountingSettings': 'settings',
'reports': 'reports',
'warehouses': 'warehouses',
'shipments': 'warehouse_transfers',
'inquiries': 'reports',
'storageSpace': 'storage',
'taxpayers': 'settings',
'settings': 'settings',
'pluginMarketplace': 'marketplace',
};
final section = sectionMap[item.label];
if (section == null) return true; // اگر بخشی تعریف نشده، نمایش داده شود
return widget.authStore.canReadSection(section);
}
bool _hasAccessToExpandableMenuItem(_MenuItem item) {
if (item.children == null) return false;
// اگر حداقل یکی از زیرآیتمها قابل دسترسی باشد، منو نمایش داده شود
return item.children!.any((child) => _hasAccessToMenuItem(child));
}
} }
enum _MenuItemType { simple, expandable, separator } enum _MenuItemType { simple, expandable, separator }

View file

@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../widgets/data_table/data_table_widget.dart';
import '../../widgets/data_table/data_table_config.dart';
import '../../widgets/person/person_form_dialog.dart';
import '../../widgets/permission/permission_widgets.dart';
import '../../models/person_model.dart';
import '../../services/person_service.dart';
import '../../core/auth_store.dart';
class PersonsPage extends StatefulWidget {
final int businessId;
final AuthStore authStore;
const PersonsPage({
super.key,
required this.businessId,
required this.authStore,
});
@override
State<PersonsPage> createState() => _PersonsPageState();
}
class _PersonsPageState extends State<PersonsPage> {
final _personService = PersonService();
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
// بررسی دسترسی خواندن
if (!widget.authStore.canReadSection('people')) {
return AccessDeniedPage(
message: 'شما دسترسی لازم برای مشاهده لیست اشخاص را ندارید',
);
}
return Scaffold(
appBar: AppBar(
title: Text(t.personsList),
actions: [
// دکمه اضافه کردن فقط در صورت داشتن دسترسی
PermissionButton(
section: 'people',
action: 'add',
authStore: widget.authStore,
child: IconButton(
onPressed: _addPerson,
icon: const Icon(Icons.add),
tooltip: t.addPerson,
),
),
],
),
body: DataTableWidget<Person>(
config: _buildDataTableConfig(t),
fromJson: Person.fromJson,
),
);
}
DataTableConfig<Person> _buildDataTableConfig(AppLocalizations t) {
return DataTableConfig<Person>(
endpoint: '/api/v1/persons/businesses/${widget.businessId}/persons',
title: t.personsList,
columns: [
TextColumn(
'alias_name',
t.personAliasName,
width: ColumnWidth.large,
formatter: (person) => person.aliasName,
),
TextColumn(
'first_name',
t.personFirstName,
width: ColumnWidth.medium,
formatter: (person) => person.firstName ?? '-',
),
TextColumn(
'last_name',
t.personLastName,
width: ColumnWidth.medium,
formatter: (person) => person.lastName ?? '-',
),
TextColumn(
'person_type',
t.personType,
width: ColumnWidth.medium,
formatter: (person) => person.personType.persianName,
),
TextColumn(
'company_name',
t.personCompanyName,
width: ColumnWidth.medium,
formatter: (person) => person.companyName ?? '-',
),
TextColumn(
'mobile',
t.personMobile,
width: ColumnWidth.medium,
formatter: (person) => person.mobile ?? '-',
),
TextColumn(
'email',
t.personEmail,
width: ColumnWidth.large,
formatter: (person) => person.email ?? '-',
),
TextColumn(
'is_active',
'وضعیت',
width: ColumnWidth.small,
formatter: (person) => person.isActive ? 'فعال' : 'غیرفعال',
),
DateColumn(
'created_at',
'تاریخ ایجاد',
width: ColumnWidth.medium,
),
ActionColumn(
'actions',
'عملیات',
actions: [
DataTableAction(
icon: Icons.edit,
label: t.edit,
onTap: (person) => _editPerson(person),
),
DataTableAction(
icon: Icons.delete,
label: t.delete,
color: Colors.red,
onTap: (person) => _deletePerson(person),
),
],
),
],
searchFields: [
'alias_name',
'first_name',
'last_name',
'company_name',
'mobile',
'email',
'national_id',
],
filterFields: [
'person_type',
'is_active',
'country',
'province',
],
defaultPageSize: 20,
);
}
void _addPerson() {
showDialog(
context: context,
builder: (context) => PersonFormDialog(
businessId: widget.businessId,
onSuccess: () {
// DataTableWidget will automatically refresh
},
),
);
}
void _editPerson(Person person) {
showDialog(
context: context,
builder: (context) => PersonFormDialog(
businessId: widget.businessId,
person: person,
onSuccess: () {
// DataTableWidget will automatically refresh
},
),
);
}
void _deletePerson(Person person) {
final t = AppLocalizations.of(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(t.deletePerson),
content: Text('آیا از حذف شخص "${person.displayName}" مطمئن هستید؟'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.cancel),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await _performDelete(person);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(t.delete),
),
],
),
);
}
Future<void> _performDelete(Person person) async {
try {
await _personService.deletePerson(person.id!);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).personDeletedSuccessfully),
backgroundColor: Colors.green,
),
);
// DataTableWidget will automatically refresh
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در حذف شخص: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}

View file

@ -0,0 +1,333 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class Error404Page extends StatefulWidget {
const Error404Page({super.key});
@override
State<Error404Page> createState() => _Error404PageState();
}
class _Error404PageState extends State<Error404Page>
with TickerProviderStateMixin {
late AnimationController _fadeController;
late AnimationController _slideController;
late AnimationController _bounceController;
late AnimationController _pulseController;
late AnimationController _rotateController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
late Animation<double> _bounceAnimation;
late Animation<double> _pulseAnimation;
late Animation<double> _rotateAnimation;
@override
void initState() {
super.initState();
// کنترلرهای انیمیشن
_fadeController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
);
_slideController = AnimationController(
duration: const Duration(milliseconds: 1800),
vsync: this,
);
_bounceController = AnimationController(
duration: const Duration(milliseconds: 2500),
vsync: this,
);
_pulseController = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
);
_rotateController = AnimationController(
duration: const Duration(milliseconds: 3000),
vsync: this,
);
// انیمیشنها
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fadeController,
curve: Curves.easeInOut,
));
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController,
curve: Curves.elasticOut,
));
_bounceAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _bounceController,
curve: Curves.elasticOut,
));
_pulseAnimation = Tween<double>(
begin: 1.0,
end: 1.05,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
_rotateAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _rotateController,
curve: Curves.easeInOut,
));
// شروع انیمیشنها
_startAnimations();
}
void _startAnimations() async {
await _fadeController.forward();
await _slideController.forward();
await _bounceController.forward();
// انیمیشنهای مداوم
_pulseController.repeat(reverse: true);
_rotateController.repeat();
}
@override
void dispose() {
_fadeController.dispose();
_slideController.dispose();
_bounceController.dispose();
_pulseController.dispose();
_rotateController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? const Color(0xFF0F0F0F) : const Color(0xFFFAFAFA),
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [
const Color(0xFF0F0F0F),
const Color(0xFF1A1A2E),
const Color(0xFF16213E),
]
: [
const Color(0xFFFAFAFA),
const Color(0xFFF1F5F9),
const Color(0xFFE2E8F0),
],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// انیمیشن 404 با افکتهای پیشرفته
AnimatedBuilder(
animation: Listenable.merge([_bounceAnimation, _pulseAnimation, _rotateAnimation]),
builder: (context, child) {
return Transform.scale(
scale: _bounceAnimation.value * _pulseAnimation.value,
child: Transform.rotate(
angle: _rotateAnimation.value * 0.1,
child: Container(
width: 220,
height: 220,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: isDark
? [
const Color(0xFF6366F1).withValues(alpha: 0.4),
const Color(0xFF8B5CF6).withValues(alpha: 0.2),
const Color(0xFFEC4899).withValues(alpha: 0.1),
]
: [
const Color(0xFF6366F1).withValues(alpha: 0.3),
const Color(0xFF8B5CF6).withValues(alpha: 0.15),
const Color(0xFFEC4899).withValues(alpha: 0.05),
],
),
boxShadow: [
BoxShadow(
color: isDark
? const Color(0xFF6366F1).withValues(alpha: 0.3)
: const Color(0xFF4F46E5).withValues(alpha: 0.2),
blurRadius: 30,
spreadRadius: 5,
),
],
),
child: Stack(
alignment: Alignment.center,
children: [
// حلقههای متحرک
...List.generate(3, (index) {
return AnimatedBuilder(
animation: _rotateAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _rotateAnimation.value * (2 * 3.14159) * (index + 1) * 0.3,
child: Container(
width: 180 - (index * 20),
height: 180 - (index * 20),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isDark
? const Color(0xFF6366F1).withValues(alpha: 0.3 - (index * 0.1))
: const Color(0xFF4F46E5).withValues(alpha: 0.2 - (index * 0.05)),
width: 2,
),
),
),
);
},
);
}),
// متن 404
Text(
'404',
style: TextStyle(
fontSize: 80,
fontWeight: FontWeight.bold,
color: isDark
? const Color(0xFF6366F1)
: const Color(0xFF4F46E5),
shadows: [
Shadow(
color: isDark
? const Color(0xFF6366F1).withValues(alpha: 0.6)
: const Color(0xFF4F46E5).withValues(alpha: 0.4),
blurRadius: 25,
),
],
),
),
],
),
),
),
);
},
),
const SizedBox(height: 50),
// متن اصلی با انیمیشن
FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Column(
children: [
// عنوان اصلی
Text(
'صفحه مورد نظر یافت نشد',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : const Color(0xFF1E293B),
height: 1.2,
letterSpacing: 0.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
// توضیحات
Container(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
'متأسفانه صفحه‌ای که به دنبال آن هستید وجود ندارد یا حذف شده است. لطفاً آدرس را بررسی کنید یا از دکمه‌های زیر استفاده کنید.',
style: TextStyle(
fontSize: 18,
color: isDark
? Colors.grey[300]
: const Color(0xFF64748B),
height: 1.6,
letterSpacing: 0.3,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 60),
// دکمه بازگشت
AnimatedBuilder(
animation: _fadeAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, 30 * (1 - _fadeAnimation.value)),
child: ElevatedButton.icon(
onPressed: () {
// همیشه سعی کن به صفحه قبلی برگردی
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else {
// اگر نمیتونی pop کنی، به root برگرد
context.go('/');
}
},
icon: const Icon(Icons.arrow_back_ios, size: 20),
label: const Text('بازگشت به صفحه قبلی'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6366F1),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 20,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 6,
shadowColor: const Color(0xFF6366F1).withValues(alpha: 0.4),
),
),
);
},
),
],
),
),
),
],
),
),
),
),
),
);
}
}

View file

@ -85,8 +85,8 @@ class BusinessDashboardService {
/// دریافت لیست کسب و کارهای کاربر (مالک + عضو) /// دریافت لیست کسب و کارهای کاربر (مالک + عضو)
Future<List<BusinessWithPermission>> getUserBusinesses() async { Future<List<BusinessWithPermission>> getUserBusinesses() async {
try { try {
// دریافت کسب و کارهای مالک با POST request // دریافت کسب و کارهای کاربر (مالک + عضو) با POST request
final ownedResponse = await _apiClient.post<Map<String, dynamic>>( final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/businesses/list', '/api/v1/businesses/list',
data: { data: {
'take': 100, 'take': 100,
@ -99,32 +99,13 @@ class BusinessDashboardService {
List<BusinessWithPermission> businesses = []; List<BusinessWithPermission> businesses = [];
if (ownedResponse.data?['success'] == true) { if (response.data?['success'] == true) {
final ownedItems = ownedResponse.data!['data']['items'] as List<dynamic>; final items = response.data!['data']['items'] as List<dynamic>;
businesses.addAll( businesses.addAll(
ownedItems.map((item) { items.map((item) => BusinessWithPermission.fromJson(item)),
final business = BusinessWithPermission.fromJson(item);
return BusinessWithPermission(
id: business.id,
name: business.name,
businessType: business.businessType,
businessField: business.businessField,
ownerId: business.ownerId,
address: business.address,
phone: business.phone,
mobile: business.mobile,
createdAt: business.createdAt,
isOwner: true,
role: 'مالک',
permissions: {},
);
}),
); );
} }
// TODO: در آینده میتوان کسب و کارهای عضو را نیز اضافه کرد
// از API endpoint جدید برای کسب و کارهای عضو
return businesses; return businesses;
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == 401) { if (e.response?.statusCode == 401) {
@ -136,4 +117,55 @@ class BusinessDashboardService {
throw Exception('خطا در بارگذاری کسب و کارها: $e'); throw Exception('خطا در بارگذاری کسب و کارها: $e');
} }
} }
/// دریافت اطلاعات کسب و کار همراه با دسترسیهای کاربر
Future<BusinessWithPermission> getBusinessWithPermissions(int businessId) async {
try {
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/business/$businessId/info-with-permissions',
);
if (response.data?['success'] == true) {
final data = response.data!['data'] as Map<String, dynamic>;
// تبدیل اطلاعات کسب و کار
final businessInfo = data['business_info'] as Map<String, dynamic>;
final userPermissions = data['user_permissions'] as Map<String, dynamic>? ?? {};
final isOwner = data['is_owner'] as bool? ?? false;
final role = data['role'] as String? ?? 'عضو';
final hasAccess = data['has_access'] as bool? ?? false;
if (!hasAccess) {
throw Exception('دسترسی غیرمجاز به این کسب و کار');
}
return BusinessWithPermission(
id: businessInfo['id'] as int,
name: businessInfo['name'] as String,
businessType: businessInfo['business_type'] as String,
businessField: businessInfo['business_field'] as String,
ownerId: businessInfo['owner_id'] as int,
address: businessInfo['address'] as String?,
phone: businessInfo['phone'] as String?,
mobile: businessInfo['mobile'] as String?,
createdAt: businessInfo['created_at'] as String,
isOwner: isOwner,
role: role,
permissions: userPermissions,
);
} else {
throw Exception('Failed to load business info: ${response.data?['message']}');
}
} on DioException catch (e) {
if (e.response?.statusCode == 403) {
throw Exception('دسترسی غیرمجاز به این کسب و کار');
} else if (e.response?.statusCode == 404) {
throw Exception('کسب و کار یافت نشد');
} else {
throw Exception('خطا در بارگذاری اطلاعات کسب و کار: ${e.message}');
}
} catch (e) {
throw Exception('خطا در بارگذاری اطلاعات کسب و کار: $e');
}
}
} }

View file

@ -0,0 +1,163 @@
import 'package:dio/dio.dart';
import '../core/api_client.dart';
import '../models/person_model.dart';
class PersonService {
final ApiClient _apiClient;
PersonService({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient();
/// دریافت لیست اشخاص یک کسب و کار
Future<Map<String, dynamic>> getPersons({
required int businessId,
int page = 1,
int limit = 20,
String? search,
List<String>? searchFields,
String? sortBy,
bool sortDesc = true,
Map<String, dynamic>? filters,
}) async {
try {
final queryParams = <String, dynamic>{
'take': limit,
'skip': (page - 1) * limit,
'sort_desc': sortDesc,
};
if (search != null && search.isNotEmpty) {
queryParams['search'] = search;
}
if (searchFields != null && searchFields.isNotEmpty) {
queryParams['search_fields'] = searchFields;
}
if (sortBy != null && sortBy.isNotEmpty) {
queryParams['sort_by'] = sortBy;
}
if (filters != null && filters.isNotEmpty) {
queryParams['filters'] = filters;
}
final response = await _apiClient.post(
'/api/v1/persons/businesses/$businessId/persons',
data: queryParams,
);
if (response.statusCode == 200) {
return response.data['data'];
} else {
throw Exception('خطا در دریافت لیست اشخاص');
}
} catch (e) {
throw Exception('خطا در دریافت لیست اشخاص: $e');
}
}
/// دریافت جزئیات یک شخص
Future<Person> getPerson(int personId) async {
try {
final response = await _apiClient.get('/api/v1/persons/persons/$personId');
if (response.statusCode == 200) {
return Person.fromJson(response.data['data']);
} else {
throw Exception('خطا در دریافت جزئیات شخص');
}
} catch (e) {
throw Exception('خطا در دریافت جزئیات شخص: $e');
}
}
/// ایجاد شخص جدید
Future<Person> createPerson({
required int businessId,
required PersonCreateRequest personData,
}) async {
try {
final response = await _apiClient.post(
'/api/v1/persons/businesses/$businessId/persons/create',
data: personData.toJson(),
);
if (response.statusCode == 200) {
return Person.fromJson(response.data['data']);
} else {
throw Exception('خطا در ایجاد شخص');
}
} catch (e) {
throw Exception('خطا در ایجاد شخص: $e');
}
}
/// ویرایش شخص
Future<Person> updatePerson({
required int personId,
required PersonUpdateRequest personData,
}) async {
try {
final response = await _apiClient.put(
'/api/v1/persons/persons/$personId',
data: personData.toJson(),
);
if (response.statusCode == 200) {
return Person.fromJson(response.data['data']);
} else {
throw Exception('خطا در ویرایش شخص');
}
} catch (e) {
throw Exception('خطا در ویرایش شخص: $e');
}
}
/// حذف شخص
Future<bool> deletePerson(int personId) async {
try {
final response = await _apiClient.delete('/api/v1/persons/persons/$personId');
if (response.statusCode == 200) {
return true;
} else {
throw Exception('خطا در حذف شخص');
}
} catch (e) {
throw Exception('خطا در حذف شخص: $e');
}
}
/// دریافت خلاصه اشخاص
Future<Map<String, dynamic>> getPersonsSummary(int businessId) async {
try {
final response = await _apiClient.get(
'/api/v1/persons/businesses/$businessId/persons/summary',
);
if (response.statusCode == 200) {
return response.data['data'];
} else {
throw Exception('خطا در دریافت خلاصه اشخاص');
}
} catch (e) {
throw Exception('خطا در دریافت خلاصه اشخاص: $e');
}
}
/// تبدیل لیست اشخاص از JSON
List<Person> parsePersonsList(Map<String, dynamic> data) {
final List<dynamic> items = data['items'] ?? [];
return items.map((item) => Person.fromJson(item)).toList();
}
/// دریافت اطلاعات صفحهبندی
Map<String, dynamic> getPaginationInfo(Map<String, dynamic> data) {
return data['pagination'] ?? {};
}
/// دریافت اطلاعات جستجو
Map<String, dynamic> getQueryInfo(Map<String, dynamic> data) {
return data['query_info'] ?? {};
}
}

View file

@ -0,0 +1,183 @@
# سیستم مدیریت دسترسی‌ها
این سیستم برای مدیریت دسترسی‌های کاربران در سطح کسب و کار طراحی شده است.
## ویژگی‌ها
- **دسترسی‌های جزئی**: پشتیبانی از دسترسی‌های بسیار جزئی برای هر بخش
- **مدیریت خودکار**: فیلتر کردن منو و دکمه‌ها بر اساس دسترسی‌ها
- **کامپوننت‌های آماده**: ویجت‌های آماده برای بررسی دسترسی‌ها
- **امنیت کامل**: بررسی دسترسی‌ها در هر سطح
## دسترسی‌های موجود
### اشخاص (People)
- `people`: add, view, edit, delete
- `people_receipts`: add, view, edit, delete, draft
- `people_payments`: add, view, edit, delete, draft
### کالا و خدمات (Products & Services)
- `products`: add, view, edit, delete
- `price_lists`: add, view, edit, delete
- `categories`: add, view, edit, delete
- `product_attributes`: add, view, edit, delete
### بانکداری (Banking)
- `bank_accounts`: add, view, edit, delete
- `cash`: add, view, edit, delete
- `petty_cash`: add, view, edit, delete
- `checks`: add, view, edit, delete, collect, transfer, return
- `wallet`: view, charge
- `transfers`: add, view, edit, delete, draft
### فاکتورها و هزینه‌ها (Invoices & Expenses)
- `invoices`: add, view, edit, delete, draft
- `expenses_income`: add, view, edit, delete, draft
### حسابداری (Accounting)
- `accounting_documents`: add, view, edit, delete, draft
- `chart_of_accounts`: add, view, edit, delete
- `opening_balance`: view, edit
### انبارداری (Warehouse)
- `warehouses`: add, view, edit, delete
- `warehouse_transfers`: add, view, edit, delete, draft
### تنظیمات (Settings)
- `settings`: business, print, history, users
- `storage`: view, delete
- `sms`: history, templates
- `marketplace`: view, buy, invoices
## نحوه استفاده
### 1. بررسی دسترسی در AuthStore
```dart
final authStore = Provider.of<AuthStore>(context);
// بررسی دسترسی کلی
if (authStore.canReadSection('people')) {
// نمایش لیست اشخاص
}
// بررسی دسترسی خاص
if (authStore.hasBusinessPermission('people', 'add')) {
// نمایش دکمه اضافه کردن
}
// بررسی دسترسی‌های خاص
if (authStore.canCollectChecks()) {
// نمایش دکمه وصول چک
}
```
### 2. استفاده از کامپوننت‌های آماده
#### PermissionButton
```dart
PermissionButton(
section: 'people',
action: 'add',
authStore: authStore,
child: IconButton(
onPressed: () => _addPerson(),
icon: const Icon(Icons.add),
tooltip: 'اضافه کردن شخص',
),
)
```
#### PermissionWidget
```dart
PermissionWidget(
section: 'settings',
action: 'view',
authStore: authStore,
child: Card(
child: ListTile(
title: Text('تنظیمات'),
onTap: () => _openSettings(),
),
),
)
```
#### AccessDeniedPage
```dart
if (!authStore.canReadSection('people')) {
return AccessDeniedPage(
message: 'شما دسترسی لازم برای مشاهده لیست اشخاص را ندارید',
);
}
```
### 3. فیلتر کردن منو
منوی کسب و کار به صورت خودکار بر اساس دسترسی‌های کاربر فیلتر می‌شود:
```dart
// در BusinessShell
final menuItems = _getFilteredMenuItems(allMenuItems);
```
### 4. API Endpoint
```dart
// دریافت اطلاعات کسب و کار و دسترسی‌ها
final businessData = await businessService.getBusinessWithPermissions(businessId);
await authStore.setCurrentBusiness(businessData);
```
## مثال کامل
```dart
class PersonsPage extends StatelessWidget {
final int businessId;
final AuthStore authStore;
const PersonsPage({
super.key,
required this.businessId,
required this.authStore,
});
@override
Widget build(BuildContext context) {
// بررسی دسترسی خواندن
if (!authStore.canReadSection('people')) {
return AccessDeniedPage(
message: 'شما دسترسی لازم برای مشاهده لیست اشخاص را ندارید',
);
}
return Scaffold(
appBar: AppBar(
title: Text('لیست اشخاص'),
actions: [
// دکمه اضافه کردن فقط در صورت داشتن دسترسی
PermissionButton(
section: 'people',
action: 'add',
authStore: authStore,
child: IconButton(
onPressed: () => _addPerson(),
icon: const Icon(Icons.add),
tooltip: 'اضافه کردن شخص',
),
),
],
),
body: PersonsList(),
);
}
}
```
## نکات مهم
1. **امنیت**: همیشه دسترسی‌ها را در سمت سرور نیز بررسی کنید
2. **عملکرد**: دسترسی‌ها در AuthStore کش می‌شوند
3. **به‌روزرسانی**: دسترسی‌ها هنگام تغییر کسب و کار به‌روزرسانی می‌شوند
4. **مالک کسب و کار**: مالک کسب و کار تمام دسترسی‌ها را دارد

View file

@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
/// صفحه نمایش عدم دسترسی
class AccessDeniedPage extends StatelessWidget {
final String? message;
final String? actionText;
final VoidCallback? onAction;
const AccessDeniedPage({
super.key,
this.message,
this.actionText,
this.onAction,
});
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
backgroundColor: colorScheme.surface,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// آیکون عدم دسترسی
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(60),
),
child: Icon(
Icons.lock_outline,
size: 60,
color: colorScheme.onErrorContainer,
),
),
const SizedBox(height: 32),
// عنوان
Text(
t.accessDenied,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// پیام
Text(
message ?? 'شما دسترسی لازم برای مشاهده این بخش را ندارید',
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// دکمههای عمل
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// دکمه بازگشت
OutlinedButton.icon(
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back),
label: const Text('بازگشت'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
const SizedBox(width: 16),
// دکمه عمل سفارشی
if (actionText != null && onAction != null)
ElevatedButton.icon(
onPressed: onAction,
icon: const Icon(Icons.refresh),
label: Text(actionText!),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
],
),
],
),
),
),
),
);
}
}
/// ویجت کوچک برای نمایش عدم دسترسی
class AccessDeniedWidget extends StatelessWidget {
final String? message;
final IconData? icon;
const AccessDeniedWidget({
super.key,
this.message,
this.icon,
});
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colorScheme.errorContainer,
width: 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon ?? Icons.lock_outline,
size: 48,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
message ?? t.accessDenied,
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
],
),
);
}
}

View file

@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import '../../core/auth_store.dart';
/// کامپوننت برای نمایش دکمهها بر اساس دسترسیها
class PermissionButton extends StatelessWidget {
final String section;
final String action;
final Widget child;
final VoidCallback? onPressed;
final bool showIfNoPermission;
final Widget? fallbackWidget;
final AuthStore authStore;
const PermissionButton({
super.key,
required this.section,
required this.action,
required this.child,
required this.authStore,
this.onPressed,
this.showIfNoPermission = false,
this.fallbackWidget,
});
@override
Widget build(BuildContext context) {
if (!authStore.hasBusinessPermission(section, action)) {
if (showIfNoPermission) {
return child;
}
return fallbackWidget ?? const SizedBox.shrink();
}
return child;
}
}
/// کامپوننت برای نمایش ویجتها بر اساس دسترسیها
class PermissionWidget extends StatelessWidget {
final String section;
final String action;
final Widget child;
final Widget? fallbackWidget;
final AuthStore authStore;
const PermissionWidget({
super.key,
required this.section,
required this.action,
required this.child,
required this.authStore,
this.fallbackWidget,
});
@override
Widget build(BuildContext context) {
if (!authStore.hasBusinessPermission(section, action)) {
return fallbackWidget ?? const SizedBox.shrink();
}
return child;
}
}
/// کامپوننت برای نمایش لیست بر اساس دسترسیها
class PermissionListTile extends StatelessWidget {
final String section;
final String action;
final Widget child;
final VoidCallback? onTap;
final AuthStore authStore;
const PermissionListTile({
super.key,
required this.section,
required this.action,
required this.child,
required this.authStore,
this.onTap,
});
@override
Widget build(BuildContext context) {
if (!authStore.hasBusinessPermission(section, action)) {
return const SizedBox.shrink();
}
return child;
}
}
/// کامپوننت برای نمایش منو بر اساس دسترسیها
class PermissionMenuItem extends StatelessWidget {
final String section;
final String action;
final Widget child;
final AuthStore authStore;
const PermissionMenuItem({
super.key,
required this.section,
required this.action,
required this.child,
required this.authStore,
});
@override
Widget build(BuildContext context) {
if (!authStore.hasBusinessPermission(section, action)) {
return const SizedBox.shrink();
}
return child;
}
}

View file

@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
import '../../core/auth_store.dart';
/// ویجت برای نمایش اطلاعات دسترسیهای کاربر
class PermissionInfoWidget extends StatelessWidget {
final String section;
final bool showActions;
final AuthStore authStore;
const PermissionInfoWidget({
super.key,
required this.section,
required this.authStore,
this.showActions = true,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (authStore.currentBusiness == null) {
return const SizedBox.shrink();
}
final availableActions = authStore.getAvailableActions(section);
final isOwner = authStore.currentBusiness!.isOwner;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// عنوان بخش
Row(
children: [
Icon(
_getSectionIcon(section),
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
_getSectionTitle(section),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const Spacer(),
if (isOwner)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.orange.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.star,
size: 12,
color: Colors.orange,
),
const SizedBox(width: 4),
Text(
'مالک',
style: TextStyle(
color: Colors.orange.shade700,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
const SizedBox(height: 12),
// نمایش دسترسیها
if (showActions) ...[
Text(
'دسترسی‌های موجود:',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: availableActions.map((action) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_getActionTitle(action),
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
);
}).toList(),
),
],
],
),
);
}
IconData _getSectionIcon(String section) {
switch (section) {
case 'people':
return Icons.people;
case 'products':
return Icons.inventory;
case 'price_lists':
return Icons.list_alt;
case 'categories':
return Icons.category;
case 'product_attributes':
return Icons.tune;
case 'bank_accounts':
return Icons.account_balance_wallet;
case 'cash':
return Icons.money;
case 'petty_cash':
return Icons.money;
case 'wallet':
return Icons.wallet;
case 'checks':
return Icons.receipt_long;
case 'transfers':
return Icons.swap_horiz;
case 'invoices':
return Icons.receipt;
case 'expenses_income':
return Icons.account_balance_wallet;
case 'accounting_documents':
return Icons.description;
case 'chart_of_accounts':
return Icons.table_chart;
case 'opening_balance':
return Icons.play_arrow;
case 'reports':
return Icons.assessment;
case 'warehouses':
return Icons.warehouse;
case 'warehouse_transfers':
return Icons.local_shipping;
case 'storage':
return Icons.storage;
case 'settings':
return Icons.settings;
case 'marketplace':
return Icons.store;
default:
return Icons.lock;
}
}
String _getSectionTitle(String section) {
switch (section) {
case 'people':
return 'اشخاص';
case 'products':
return 'کالا و خدمات';
case 'price_lists':
return 'لیست‌های قیمت';
case 'categories':
return 'دسته‌بندی‌ها';
case 'product_attributes':
return 'ویژگی‌های کالا';
case 'bank_accounts':
return 'حساب‌های بانکی';
case 'cash':
return 'صندوق';
case 'petty_cash':
return 'تنخواه گردان';
case 'wallet':
return 'کیف پول';
case 'checks':
return 'چک‌ها';
case 'transfers':
return 'انتقال‌ها';
case 'invoices':
return 'فاکتورها';
case 'expenses_income':
return 'هزینه و درآمد';
case 'accounting_documents':
return 'اسناد حسابداری';
case 'chart_of_accounts':
return 'جدول حساب‌ها';
case 'opening_balance':
return 'تراز افتتاحیه';
case 'reports':
return 'گزارش‌ها';
case 'warehouses':
return 'انبارها';
case 'warehouse_transfers':
return 'حواله‌ها';
case 'storage':
return 'فضای ذخیره‌سازی';
case 'settings':
return 'تنظیمات';
case 'marketplace':
return 'بازار افزونه‌ها';
default:
return 'نامشخص';
}
}
String _getActionTitle(String action) {
switch (action) {
case 'add':
return 'افزودن';
case 'view':
return 'مشاهده';
case 'edit':
return 'ویرایش';
case 'delete':
return 'حذف';
case 'draft':
return 'پیش‌نویس';
case 'collect':
return 'وصول';
case 'transfer':
return 'انتقال';
case 'return':
return 'برگشت';
case 'charge':
return 'شارژ';
default:
return action;
}
}
}

View file

@ -0,0 +1,4 @@
// Export all permission-related widgets
export 'permission_button.dart';
export 'access_denied_page.dart';
export 'permission_info_widget.dart';

View file

@ -0,0 +1,742 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../models/person_model.dart';
import '../../services/person_service.dart';
class PersonFormDialog extends StatefulWidget {
final int businessId;
final Person? person; // null برای افزودن، مقدار برای ویرایش
final VoidCallback? onSuccess;
const PersonFormDialog({
super.key,
required this.businessId,
this.person,
this.onSuccess,
});
@override
State<PersonFormDialog> createState() => _PersonFormDialogState();
}
class _PersonFormDialogState extends State<PersonFormDialog> {
final _formKey = GlobalKey<FormState>();
final _personService = PersonService();
bool _isLoading = false;
// Controllers for basic info
final _aliasNameController = TextEditingController();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _companyNameController = TextEditingController();
final _paymentIdController = TextEditingController();
// Controllers for economic info
final _nationalIdController = TextEditingController();
final _registrationNumberController = TextEditingController();
final _economicIdController = TextEditingController();
// Controllers for contact info
final _countryController = TextEditingController();
final _provinceController = TextEditingController();
final _cityController = TextEditingController();
final _addressController = TextEditingController();
final _postalCodeController = TextEditingController();
final _phoneController = TextEditingController();
final _mobileController = TextEditingController();
final _faxController = TextEditingController();
final _emailController = TextEditingController();
final _websiteController = TextEditingController();
PersonType _selectedPersonType = PersonType.customer;
bool _isActive = true;
// Bank accounts
List<PersonBankAccount> _bankAccounts = [];
@override
void initState() {
super.initState();
_initializeForm();
}
void _initializeForm() {
if (widget.person != null) {
final person = widget.person!;
_aliasNameController.text = person.aliasName;
_firstNameController.text = person.firstName ?? '';
_lastNameController.text = person.lastName ?? '';
_companyNameController.text = person.companyName ?? '';
_paymentIdController.text = person.paymentId ?? '';
_nationalIdController.text = person.nationalId ?? '';
_registrationNumberController.text = person.registrationNumber ?? '';
_economicIdController.text = person.economicId ?? '';
_countryController.text = person.country ?? '';
_provinceController.text = person.province ?? '';
_cityController.text = person.city ?? '';
_addressController.text = person.address ?? '';
_postalCodeController.text = person.postalCode ?? '';
_phoneController.text = person.phone ?? '';
_mobileController.text = person.mobile ?? '';
_faxController.text = person.fax ?? '';
_emailController.text = person.email ?? '';
_websiteController.text = person.website ?? '';
_selectedPersonType = person.personType;
_isActive = person.isActive;
_bankAccounts = List.from(person.bankAccounts);
}
}
@override
void dispose() {
_aliasNameController.dispose();
_firstNameController.dispose();
_lastNameController.dispose();
_companyNameController.dispose();
_paymentIdController.dispose();
_nationalIdController.dispose();
_registrationNumberController.dispose();
_economicIdController.dispose();
_countryController.dispose();
_provinceController.dispose();
_cityController.dispose();
_addressController.dispose();
_postalCodeController.dispose();
_phoneController.dispose();
_mobileController.dispose();
_faxController.dispose();
_emailController.dispose();
_websiteController.dispose();
super.dispose();
}
Future<void> _savePerson() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
});
try {
if (widget.person == null) {
// Create new person
final personData = PersonCreateRequest(
aliasName: _aliasNameController.text.trim(),
firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(),
lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
personType: _selectedPersonType,
companyName: _companyNameController.text.trim().isEmpty ? null : _companyNameController.text.trim(),
paymentId: _paymentIdController.text.trim().isEmpty ? null : _paymentIdController.text.trim(),
nationalId: _nationalIdController.text.trim().isEmpty ? null : _nationalIdController.text.trim(),
registrationNumber: _registrationNumberController.text.trim().isEmpty ? null : _registrationNumberController.text.trim(),
economicId: _economicIdController.text.trim().isEmpty ? null : _economicIdController.text.trim(),
country: _countryController.text.trim().isEmpty ? null : _countryController.text.trim(),
province: _provinceController.text.trim().isEmpty ? null : _provinceController.text.trim(),
city: _cityController.text.trim().isEmpty ? null : _cityController.text.trim(),
address: _addressController.text.trim().isEmpty ? null : _addressController.text.trim(),
postalCode: _postalCodeController.text.trim().isEmpty ? null : _postalCodeController.text.trim(),
phone: _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim(),
mobile: _mobileController.text.trim().isEmpty ? null : _mobileController.text.trim(),
fax: _faxController.text.trim().isEmpty ? null : _faxController.text.trim(),
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(),
bankAccounts: _bankAccounts,
);
await _personService.createPerson(
businessId: widget.businessId,
personData: personData,
);
} else {
// Update existing person
final personData = PersonUpdateRequest(
aliasName: _aliasNameController.text.trim(),
firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(),
lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
personType: _selectedPersonType,
companyName: _companyNameController.text.trim().isEmpty ? null : _companyNameController.text.trim(),
paymentId: _paymentIdController.text.trim().isEmpty ? null : _paymentIdController.text.trim(),
nationalId: _nationalIdController.text.trim().isEmpty ? null : _nationalIdController.text.trim(),
registrationNumber: _registrationNumberController.text.trim().isEmpty ? null : _registrationNumberController.text.trim(),
economicId: _economicIdController.text.trim().isEmpty ? null : _economicIdController.text.trim(),
country: _countryController.text.trim().isEmpty ? null : _countryController.text.trim(),
province: _provinceController.text.trim().isEmpty ? null : _provinceController.text.trim(),
city: _cityController.text.trim().isEmpty ? null : _cityController.text.trim(),
address: _addressController.text.trim().isEmpty ? null : _addressController.text.trim(),
postalCode: _postalCodeController.text.trim().isEmpty ? null : _postalCodeController.text.trim(),
phone: _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim(),
mobile: _mobileController.text.trim().isEmpty ? null : _mobileController.text.trim(),
fax: _faxController.text.trim().isEmpty ? null : _faxController.text.trim(),
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(),
isActive: _isActive,
);
await _personService.updatePerson(
personId: widget.person!.id!,
personData: personData,
);
}
if (mounted) {
Navigator.of(context).pop();
widget.onSuccess?.call();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.person == null
? AppLocalizations.of(context).personCreatedSuccessfully
: AppLocalizations.of(context).personUpdatedSuccessfully),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
void _addBankAccount() {
setState(() {
_bankAccounts.add(PersonBankAccount(
personId: 0, // Will be set when person is created
bankName: '',
isActive: true,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
));
});
}
void _removeBankAccount(int index) {
setState(() {
_bankAccounts.removeAt(index);
});
}
void _updateBankAccount(int index, PersonBankAccount bankAccount) {
setState(() {
_bankAccounts[index] = bankAccount;
});
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final isEditing = widget.person != null;
return Dialog(
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
height: MediaQuery.of(context).size.height * 0.9,
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Header
Row(
children: [
Icon(
isEditing ? Icons.edit : Icons.add,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 8),
Text(
isEditing ? t.editPerson : t.addPerson,
style: Theme.of(context).textTheme.headlineSmall,
),
const Spacer(),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const Divider(),
const SizedBox(height: 16),
// Form
Expanded(
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Basic Information
_buildSectionHeader(t.personBasicInfo),
const SizedBox(height: 16),
_buildBasicInfoFields(t),
const SizedBox(height: 24),
// Economic Information
_buildSectionHeader(t.personEconomicInfo),
const SizedBox(height: 16),
_buildEconomicInfoFields(t),
const SizedBox(height: 24),
// Contact Information
_buildSectionHeader(t.personContactInfo),
const SizedBox(height: 16),
_buildContactInfoFields(t),
const SizedBox(height: 24),
// Bank Accounts
_buildSectionHeader(t.personBankInfo),
const SizedBox(height: 16),
_buildBankAccountsSection(t),
const SizedBox(height: 24),
// Status (only for editing)
if (isEditing) ...[
_buildSectionHeader('وضعیت'),
const SizedBox(height: 16),
SwitchListTile(
title: Text('فعال'),
value: _isActive,
onChanged: (value) {
setState(() {
_isActive = value;
});
},
),
const SizedBox(height: 24),
],
],
),
),
),
),
// Actions
const Divider(),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
child: Text(t.cancel),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isLoading ? null : _savePerson,
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(isEditing ? t.update : t.add),
),
],
),
],
),
),
);
}
Widget _buildSectionHeader(String title) {
return Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
);
}
Widget _buildBasicInfoFields(AppLocalizations t) {
return Column(
children: [
Row(
children: [
Expanded(
child: TextFormField(
controller: _aliasNameController,
decoration: InputDecoration(
labelText: t.personAliasName,
hintText: t.personAliasName,
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return t.personAliasNameRequired;
}
return null;
},
),
),
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<PersonType>(
value: _selectedPersonType,
decoration: InputDecoration(
labelText: t.personType,
),
items: PersonType.values.map((type) {
return DropdownMenuItem(
value: type,
child: Text(type.persianName),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedPersonType = value;
});
}
},
validator: (value) {
if (value == null) {
return t.personTypeRequired;
}
return null;
},
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _firstNameController,
decoration: InputDecoration(
labelText: t.personFirstName,
hintText: t.personFirstName,
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _lastNameController,
decoration: InputDecoration(
labelText: t.personLastName,
hintText: t.personLastName,
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _companyNameController,
decoration: InputDecoration(
labelText: t.personCompanyName,
hintText: t.personCompanyName,
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _paymentIdController,
decoration: InputDecoration(
labelText: t.personPaymentId,
hintText: t.personPaymentId,
),
),
),
],
),
],
);
}
Widget _buildEconomicInfoFields(AppLocalizations t) {
return Column(
children: [
Row(
children: [
Expanded(
child: TextFormField(
controller: _nationalIdController,
decoration: InputDecoration(
labelText: t.personNationalId,
hintText: t.personNationalId,
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _registrationNumberController,
decoration: InputDecoration(
labelText: t.personRegistrationNumber,
hintText: t.personRegistrationNumber,
),
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _economicIdController,
decoration: InputDecoration(
labelText: t.personEconomicId,
hintText: t.personEconomicId,
),
),
],
);
}
Widget _buildContactInfoFields(AppLocalizations t) {
return Column(
children: [
Row(
children: [
Expanded(
child: TextFormField(
controller: _countryController,
decoration: InputDecoration(
labelText: t.personCountry,
hintText: t.personCountry,
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _provinceController,
decoration: InputDecoration(
labelText: t.personProvince,
hintText: t.personProvince,
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _cityController,
decoration: InputDecoration(
labelText: t.personCity,
hintText: t.personCity,
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _postalCodeController,
decoration: InputDecoration(
labelText: t.personPostalCode,
hintText: t.personPostalCode,
),
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _addressController,
decoration: InputDecoration(
labelText: t.personAddress,
hintText: t.personAddress,
),
maxLines: 3,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _phoneController,
decoration: InputDecoration(
labelText: t.personPhone,
hintText: t.personPhone,
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _mobileController,
decoration: InputDecoration(
labelText: t.personMobile,
hintText: t.personMobile,
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _faxController,
decoration: InputDecoration(
labelText: t.personFax,
hintText: t.personFax,
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: t.personEmail,
hintText: t.personEmail,
),
keyboardType: TextInputType.emailAddress,
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _websiteController,
decoration: InputDecoration(
labelText: t.personWebsite,
hintText: t.personWebsite,
),
),
],
);
}
Widget _buildBankAccountsSection(AppLocalizations t) {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
t.personBankAccounts,
style: Theme.of(context).textTheme.titleSmall,
),
ElevatedButton.icon(
onPressed: _addBankAccount,
icon: const Icon(Icons.add),
label: Text(t.addBankAccount),
),
],
),
const SizedBox(height: 16),
if (_bankAccounts.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'هیچ حساب بانکی اضافه نشده است',
style: TextStyle(color: Colors.grey.shade600),
),
),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _bankAccounts.length,
itemBuilder: (context, index) {
return _buildBankAccountCard(t, index);
},
),
],
);
}
Widget _buildBankAccountCard(AppLocalizations t, int index) {
final bankAccount = _bankAccounts[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
Expanded(
child: TextFormField(
initialValue: bankAccount.bankName,
decoration: InputDecoration(
labelText: t.bankName,
hintText: t.bankName,
),
onChanged: (value) {
_updateBankAccount(index, bankAccount.copyWith(bankName: value));
},
),
),
const SizedBox(width: 16),
IconButton(
onPressed: () => _removeBankAccount(index),
icon: const Icon(Icons.delete, color: Colors.red),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
initialValue: bankAccount.accountNumber ?? '',
decoration: InputDecoration(
labelText: t.accountNumber,
hintText: t.accountNumber,
),
onChanged: (value) {
_updateBankAccount(index, bankAccount.copyWith(accountNumber: value.isEmpty ? null : value));
},
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
initialValue: bankAccount.cardNumber ?? '',
decoration: InputDecoration(
labelText: t.cardNumber,
hintText: t.cardNumber,
),
onChanged: (value) {
_updateBankAccount(index, bankAccount.copyWith(cardNumber: value.isEmpty ? null : value));
},
),
),
],
),
const SizedBox(height: 16),
TextFormField(
initialValue: bankAccount.shebaNumber ?? '',
decoration: InputDecoration(
labelText: t.shebaNumber,
hintText: t.shebaNumber,
),
onChanged: (value) {
_updateBankAccount(index, bankAccount.copyWith(shebaNumber: value.isEmpty ? null : value));
},
),
],
),
),
);
}
}