diff --git a/hesabixAPI/adapters/api/v1/business_dashboard.py b/hesabixAPI/adapters/api/v1/business_dashboard.py index ebd0ed9..e71075d 100644 --- a/hesabixAPI/adapters/api/v1/business_dashboard.py +++ b/hesabixAPI/adapters/api/v1/business_dashboard.py @@ -192,3 +192,101 @@ def get_business_statistics( stats_data = get_business_statistics(db, business_id, ctx) formatted_data = format_datetime_fields(stats_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) diff --git a/hesabixAPI/adapters/api/v1/business_users.py b/hesabixAPI/adapters/api/v1/business_users.py index a2c0bb3..3599c4c 100644 --- a/hesabixAPI/adapters/api/v1/business_users.py +++ b/hesabixAPI/adapters/api/v1/business_users.py @@ -270,7 +270,7 @@ def add_user( permission_obj = permission_repo.create_or_update( user_id=user.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}") diff --git a/hesabixAPI/adapters/api/v1/businesses.py b/hesabixAPI/adapters/api/v1/businesses.py index d186a15..8d93b81 100644 --- a/hesabixAPI/adapters/api/v1/businesses.py +++ b/hesabixAPI/adapters/api/v1/businesses.py @@ -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.permissions import require_business_management 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 ) @@ -116,8 +116,8 @@ def list_user_businesses( sort_desc: bool = True, search: str = None ) -> dict: - """لیست کسب و کارهای کاربر""" - owner_id = ctx.get_user_id() + """لیست کسب و کارهای کاربر (مالک + عضو)""" + user_id = ctx.get_user_id() query_dict = { "take": take, "skip": skip, @@ -125,34 +125,10 @@ def list_user_businesses( "sort_desc": sort_desc, "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 یک dict با کلید items است، آن را استخراج کنیم - 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) + return success_response(formatted_data, request) @router.post("/{business_id}/details", diff --git a/hesabixAPI/adapters/api/v1/persons.py b/hesabixAPI/adapters/api/v1/persons.py new file mode 100644 index 0000000..91a773d --- /dev/null +++ b/hesabixAPI/adapters/api/v1/persons.py @@ -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 + ) diff --git a/hesabixAPI/adapters/api/v1/schema_models/person.py b/hesabixAPI/adapters/api/v1/schema_models/person.py new file mode 100644 index 0000000..2ce607b --- /dev/null +++ b/hesabixAPI/adapters/api/v1/schema_models/person.py @@ -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="تعداد اشخاص غیرفعال") diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py index 0e06545..b505ba6 100644 --- a/hesabixAPI/adapters/db/models/__init__.py +++ b/hesabixAPI/adapters/db/models/__init__.py @@ -7,6 +7,7 @@ from .captcha import Captcha # noqa: F401 from .password_reset import PasswordReset # noqa: F401 from .business import Business # noqa: F401 from .business_permission import BusinessPermission # noqa: F401 +from .person import Person, PersonBankAccount # noqa: F401 # Business user models removed - using business_permissions instead # Import support models diff --git a/hesabixAPI/adapters/db/models/business.py b/hesabixAPI/adapters/db/models/business.py index 93a946c..b3813e2 100644 --- a/hesabixAPI/adapters/db/models/business.py +++ b/hesabixAPI/adapters/db/models/business.py @@ -54,5 +54,5 @@ class Business(Base): 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 - using business_permissions instead - # users = relationship("BusinessUser", back_populates="business", cascade="all, delete-orphan") + # Relationships + persons: Mapped[list["Person"]] = relationship("Person", back_populates="business", cascade="all, delete-orphan") diff --git a/hesabixAPI/adapters/db/models/person.py b/hesabixAPI/adapters/db/models/person.py new file mode 100644 index 0000000..a398889 --- /dev/null +++ b/hesabixAPI/adapters/db/models/person.py @@ -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") diff --git a/hesabixAPI/adapters/db/repositories/business_permission_repo.py b/hesabixAPI/adapters/db/repositories/business_permission_repo.py index d5138da..f66cd32 100644 --- a/hesabixAPI/adapters/db/repositories/business_permission_repo.py +++ b/hesabixAPI/adapters/db/repositories/business_permission_repo.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Optional -from sqlalchemy import select, and_ +from sqlalchemy import select, and_, text from sqlalchemy.orm import Session 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) 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 \ No newline at end of file diff --git a/hesabixAPI/app/core/auth_dependency.py b/hesabixAPI/app/core/auth_dependency.py index 6a5dae1..ed68fc4 100644 --- a/hesabixAPI/app/core/auth_dependency.py +++ b/hesabixAPI/app/core/auth_dependency.py @@ -247,6 +247,42 @@ class AuthContext: logger.info(f"Business access check: {business_id} == {self.business_id} = {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: """تبدیل به dictionary برای استفاده در API""" return { diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index e6d693a..7c6b342 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -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.business_dashboard import router as business_dashboard_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.operator import router as support_operator_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(business_dashboard_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 application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support") diff --git a/hesabixAPI/app/services/business_service.py b/hesabixAPI/app/services/business_service.py index e70d2d3..9fc62b1 100644 --- a/hesabixAPI/app/services/business_service.py +++ b/hesabixAPI/app/services/business_service.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Session from sqlalchemy import select, and_, func 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.api.v1.schemas import ( 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]]: """ویرایش کسب و کار""" business_repo = BusinessRepository(db) diff --git a/hesabixAPI/app/services/person_service.py b/hesabixAPI/app/services/person_service.py new file mode 100644 index 0000000..6fa0a9d --- /dev/null +++ b/hesabixAPI/app/services/person_service.py @@ -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 + ] + } diff --git a/hesabixAPI/docs/JOIN_PERMISSION_IMPLEMENTATION_SUMMARY.md b/hesabixAPI/docs/JOIN_PERMISSION_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..d951418 --- /dev/null +++ b/hesabixAPI/docs/JOIN_PERMISSION_IMPLEMENTATION_SUMMARY.md @@ -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 های موجود همچنان کار می‌کنند diff --git a/hesabixAPI/docs/JOIN_PERMISSION_SYSTEM.md b/hesabixAPI/docs/JOIN_PERMISSION_SYSTEM.md new file mode 100644 index 0000000..51829e5 --- /dev/null +++ b/hesabixAPI/docs/JOIN_PERMISSION_SYSTEM.md @@ -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) +# حالا کاربر هم کسب و کارهای مالک و هم کسب و کارهای عضو را می‌بیند +``` diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index cbe31ed..4d6080b 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -8,6 +8,7 @@ adapters/api/v1/business_dashboard.py adapters/api/v1/business_users.py adapters/api/v1/businesses.py adapters/api/v1/health.py +adapters/api/v1/persons.py adapters/api/v1/schemas.py adapters/api/v1/users.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/email.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/categories.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/file_storage.py adapters/db/models/password_reset.py +adapters/db/models/person.py adapters/db/models/user.py adapters/db/models/support/__init__.py adapters/db/models/support/category.py @@ -75,6 +78,7 @@ app/services/business_service.py app/services/captcha_service.py app/services/email_service.py app/services/file_storage_service.py +app/services/person_service.py app/services/query_service.py app/services/pdf/__init__.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_000008_add_email_config_table.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/20250916_000002_add_referral_fields.py migrations/versions/5553f8745c6e_add_support_tables.py diff --git a/hesabixAPI/migrations/versions/20250120_000001_add_persons_tables.py b/hesabixAPI/migrations/versions/20250120_000001_add_persons_tables.py new file mode 100644 index 0000000..9b0e1e8 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250120_000001_add_persons_tables.py @@ -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 ### diff --git a/hesabixAPI/migrations/versions/20250120_000002_add_join_permission.py b/hesabixAPI/migrations/versions/20250120_000002_add_join_permission.py new file mode 100644 index 0000000..9d75a0e --- /dev/null +++ b/hesabixAPI/migrations/versions/20250120_000002_add_join_permission.py @@ -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 diff --git a/hesabixAPI/scripts/grant_operator_permission.py b/hesabixAPI/scripts/grant_operator_permission.py index 88f7a96..313525d 100644 --- a/hesabixAPI/scripts/grant_operator_permission.py +++ b/hesabixAPI/scripts/grant_operator_permission.py @@ -8,6 +8,7 @@ import os sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from sqlalchemy.orm import Session +from sqlalchemy import text from adapters.db.session import get_db from adapters.db.models.user import User @@ -82,7 +83,7 @@ def list_operators(): try: operators = db.query(User).filter( - User.app_permissions['support_operator'].astext == 'true' + text("app_permissions->>'support_operator' = 'true'") ).all() if not operators: diff --git a/hesabixUI/hesabix_ui/lib/core/auth_store.dart b/hesabixUI/hesabix_ui/lib/core/auth_store.dart index dc4e214..7a23c7f 100644 --- a/hesabixUI/hesabix_ui/lib/core/auth_store.dart +++ b/hesabixUI/hesabix_ui/lib/core/auth_store.dart @@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; import 'api_client.dart'; +import '../models/business_dashboard_models.dart'; class AuthStore with ChangeNotifier { static const _kApiKey = 'auth_api_key'; @@ -11,12 +12,15 @@ class AuthStore with ChangeNotifier { static const _kAppPermissions = 'app_permissions'; static const _kIsSuperAdmin = 'is_superadmin'; static const _kLastUrl = 'last_url'; + static const _kCurrentBusiness = 'current_business'; final FlutterSecureStorage _secure = const FlutterSecureStorage(); String? _apiKey; String? _deviceId; Map? _appPermissions; bool _isSuperAdmin = false; + BusinessWithPermission? _currentBusiness; + Map? _businessPermissions; String? get apiKey => _apiKey; String get deviceId => _deviceId ?? ''; @@ -24,6 +28,8 @@ class AuthStore with ChangeNotifier { bool get isSuperAdmin => _isSuperAdmin; int? _currentUserId; int? get currentUserId => _currentUserId; + BusinessWithPermission? get currentBusiness => _currentBusiness; + Map? get businessPermissions => _businessPermissions; Future load() async { final prefs = await SharedPreferences.getInstance(); @@ -234,6 +240,147 @@ class AuthStore with ChangeNotifier { await prefs.remove(_kLastUrl); } catch (_) {} } + + // مدیریت کسب و کار فعلی + Future setCurrentBusiness(BusinessWithPermission business) async { + _currentBusiness = business; + _businessPermissions = business.permissions; + notifyListeners(); + + // ذخیره در حافظه محلی + await _saveCurrentBusiness(); + } + + Future clearCurrentBusiness() async { + _currentBusiness = null; + _businessPermissions = null; + notifyListeners(); + + // پاک کردن از حافظه محلی + await _clearCurrentBusiness(); + } + + Future _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 _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?; + 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 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?; + if (sectionPerms == null) return ['view']; + + return sectionPerms.keys.where((key) => sectionPerms[key] == true).toList(); + } } diff --git a/hesabixUI/hesabix_ui/lib/examples/permission_usage_example.dart b/hesabixUI/hesabix_ui/lib/examples/permission_usage_example.dart new file mode 100644 index 0000000..c670928 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/examples/permission_usage_example.dart @@ -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('باز کردن پنل مدیریت'); + } +} diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index aaa158f..6958216 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -491,6 +491,7 @@ "peopleList": "People List", "receipts": "Receipts", "payments": "Payments", + "receiptsAndPayments": "Receipts and Payments", "productsAndServices": "Products and Services", "products": "Products", "services": "Services", @@ -777,6 +778,65 @@ "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.", "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" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index 162d559..41b635d 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -490,6 +490,7 @@ "peopleList": "لیست اشخاص", "receipts": "دریافت‌ها", "payments": "پرداخت‌ها", + "receiptsAndPayments": "دریافت و پرداخت", "productsAndServices": "کالا و خدمات", "products": "کالاها", "services": "خدمات", @@ -776,6 +777,65 @@ "dataBackupDialogContent": "در این بخش می‌توانید از تمام اطلاعات کسب و کار نسخه پشتیبان تهیه کنید.", "dataRestoreDialogContent": "در این بخش می‌توانید اطلاعات را از نسخه پشتیبان قبلی بازیابی کنید.", "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": "ویرایش" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index ff62f00..fe87f90 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -2756,6 +2756,12 @@ abstract class AppLocalizations { /// **'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. /// /// In en, this message translates to: @@ -3323,7 +3329,7 @@ abstract class AppLocalizations { /// No description provided for @addPerson. /// /// In en, this message translates to: - /// **'Add New Person'** + /// **'Add Person'** String get addPerson; /// No description provided for @viewPeople. @@ -4219,6 +4225,348 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Account Management'** 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 diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart index 959f2c1..63d71fc 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -1379,6 +1379,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get payments => 'Payments'; + @override + String get receiptsAndPayments => 'Receipts and Payments'; + @override String get productsAndServices => 'Products and Services'; @@ -1665,7 +1668,7 @@ class AppLocalizationsEn extends AppLocalizations { String get draft => 'Manage Drafts'; @override - String get addPerson => 'Add New Person'; + String get addPerson => 'Add Person'; @override String get viewPeople => 'View People List'; @@ -2126,4 +2129,177 @@ class AppLocalizationsEn extends AppLocalizations { @override 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'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index 4eb86f9..6304e29 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -1368,6 +1368,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get payments => 'پرداخت‌ها'; + @override + String get receiptsAndPayments => 'دریافت و پرداخت'; + @override String get productsAndServices => 'کالا و خدمات'; @@ -1655,7 +1658,7 @@ class AppLocalizationsFa extends AppLocalizations { String get draft => 'مدیریت پیش‌نویس‌ها'; @override - String get addPerson => 'افزودن شخص جدید'; + String get addPerson => 'افزودن شخص'; @override String get viewPeople => 'مشاهده لیست اشخاص'; @@ -2112,4 +2115,175 @@ class AppLocalizationsFa extends AppLocalizations { @override 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 => 'ویرایش'; } diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index 786eb60..4ad909d 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -23,6 +23,8 @@ import 'pages/business/business_shell.dart'; import 'pages/business/dashboard/business_dashboard_page.dart'; import 'pages/business/users_permissions_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/calendar_controller.dart'; import 'core/api_client.dart'; @@ -516,10 +518,35 @@ class _MyAppState extends State { ); }, ), + 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.) ], ), + // صفحه 404 برای مسیرهای نامعتبر + GoRoute( + path: '/404', + name: 'error_404', + builder: (context, state) => const Error404Page(), + ), ], + errorBuilder: (context, state) => const Error404Page(), ); return AnimatedBuilder( diff --git a/hesabixUI/hesabix_ui/lib/models/person_model.dart b/hesabixUI/hesabix_ui/lib/models/person_model.dart index e69de29..65858f7 100644 --- a/hesabixUI/hesabix_ui/lib/models/person_model.dart +++ b/hesabixUI/hesabix_ui/lib/models/person_model.dart @@ -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 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 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 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 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?) + ?.map((ba) => PersonBankAccount.fromJson(ba)) + .toList() ?? [], + ); + } + + Map 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? 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 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 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 toJson() { + final Map 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; + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index d7c72aa..5731d2c 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -5,6 +5,9 @@ import '../../core/locale_controller.dart'; import '../../core/calendar_controller.dart'; import '../../theme/theme_controller.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'; class BusinessShell extends StatefulWidget { @@ -31,11 +34,11 @@ class BusinessShell extends StatefulWidget { class _BusinessShellState extends State { int _hoverIndex = -1; - bool _isPeopleExpanded = false; bool _isProductsAndServicesExpanded = false; bool _isBankingExpanded = false; bool _isAccountingMenuExpanded = false; bool _isWarehouseManagementExpanded = false; + final BusinessDashboardService _businessService = BusinessDashboardService(ApiClient()); @override void initState() { @@ -46,6 +49,29 @@ class _BusinessShellState extends State { setState(() {}); } }); + + // بارگذاری اطلاعات کسب و کار و دسترسی‌ها + _loadBusinessInfo(); + } + + Future _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 @@ -68,7 +94,7 @@ class _BusinessShellState extends State { final t = AppLocalizations.of(context); // ساختار متمرکز منو - final menuItems = <_MenuItem>[ + final allMenuItems = <_MenuItem>[ _MenuItem( label: t.businessDashboard, icon: Icons.dashboard_outlined, @@ -87,33 +113,9 @@ class _BusinessShellState extends State { label: t.people, icon: Icons.people, selectedIcon: Icons.people, - path: null, // برای منوی بازشونده - type: _MenuItemType.expandable, - children: [ - _MenuItem( - label: t.peopleList, - icon: Icons.list, - selectedIcon: Icons.list, - path: '/business/${widget.businessId}/people-list', - type: _MenuItemType.simple, - ), - _MenuItem( - label: t.receipts, - icon: Icons.receipt, - selectedIcon: Icons.receipt, - path: '/business/${widget.businessId}/receipts', - type: _MenuItemType.simple, - hasAddButton: true, - ), - _MenuItem( - label: t.payments, - icon: Icons.payment, - selectedIcon: Icons.payment, - path: '/business/${widget.businessId}/payments', - type: _MenuItemType.simple, - hasAddButton: true, - ), - ], + path: '/business/${widget.businessId}/persons', + type: _MenuItemType.simple, + hasAddButton: true, ), _MenuItem( label: t.productsAndServices, @@ -203,14 +205,6 @@ class _BusinessShellState extends State { type: _MenuItemType.simple, 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( @@ -228,6 +222,14 @@ class _BusinessShellState extends State { type: _MenuItemType.simple, 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( label: t.expenseAndIncome, icon: Icons.account_balance_wallet, @@ -236,6 +238,22 @@ class _BusinessShellState extends State { type: _MenuItemType.simple, 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( label: t.accountingMenu, icon: Icons.calculate, @@ -243,14 +261,6 @@ class _BusinessShellState extends State { path: null, // برای منوی بازشونده type: _MenuItemType.expandable, children: [ - _MenuItem( - label: t.documents, - icon: Icons.description, - selectedIcon: Icons.description, - path: '/business/${widget.businessId}/documents', - type: _MenuItemType.simple, - hasAddButton: true, - ), _MenuItem( label: t.chartOfAccounts, icon: Icons.table_chart, @@ -373,6 +383,9 @@ class _BusinessShellState extends State { ), ]; + // فیلتر کردن منو بر اساس دسترسی‌ها + final menuItems = _getFilteredMenuItems(allMenuItems); + int selectedIndex = 0; for (int i = 0; i < menuItems.length; i++) { final item = menuItems[i]; @@ -387,11 +400,10 @@ class _BusinessShellState extends State { if (child.path != null && location.startsWith(child.path!)) { selectedIndex = i; // تنظیم وضعیت باز بودن منو - if (i == 2) _isPeopleExpanded = true; // اشخاص در ایندکس 2 - if (i == 3) _isProductsAndServicesExpanded = true; // کالا و خدمات در ایندکس 3 - if (i == 4) _isBankingExpanded = true; // بانکداری در ایندکس 4 - if (i == 6) _isAccountingMenuExpanded = true; // حسابداری در ایندکس 6 - if (i == 8) _isWarehouseManagementExpanded = true; // انبارداری در ایندکس 8 + if (i == 2) _isProductsAndServicesExpanded = true; // کالا و خدمات در ایندکس 2 + if (i == 3) _isBankingExpanded = true; // بانکداری در ایندکس 3 + if (i == 5) _isAccountingMenuExpanded = true; // حسابداری در ایندکس 5 + if (i == 7) _isWarehouseManagementExpanded = true; // انبارداری در ایندکس 7 break; } } @@ -413,7 +425,6 @@ class _BusinessShellState extends State { } } else if (item.type == _MenuItemType.expandable) { // تغییر وضعیت باز/بسته بودن منو - if (item.label == t.people) _isPeopleExpanded = !_isPeopleExpanded; if (item.label == t.productsAndServices) _isProductsAndServicesExpanded = !_isProductsAndServicesExpanded; if (item.label == t.banking) _isBankingExpanded = !_isBankingExpanded; if (item.label == t.accountingMenu) _isAccountingMenuExpanded = !_isAccountingMenuExpanded; @@ -449,8 +460,20 @@ class _BusinessShellState extends State { context.go('/login'); } + Future _showAddPersonDialog() async { + final result = await showDialog( + 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) { - if (item.label == t.people) return _isPeopleExpanded; if (item.label == t.productsAndServices) return _isProductsAndServicesExpanded; if (item.label == t.banking) return _isBankingExpanded; if (item.label == t.accountingMenu) return _isAccountingMenuExpanded; @@ -622,10 +645,9 @@ class _BusinessShellState extends State { GestureDetector( onTap: () { // Navigate to add new item - if (child.label == t.receipts) { - // Navigate to add receipt - } else if (child.label == t.payments) { - // Navigate to add payment + if (child.label == t.personsList) { + // Navigate to add person + _showAddPersonDialog(); } else if (child.label == t.products) { // Navigate to add product } else if (child.label == t.priceLists) { @@ -644,14 +666,10 @@ class _BusinessShellState extends State { // Navigate to add wallet } else if (child.label == t.checks) { // Navigate to add check - } else if (child.label == t.transfers) { - // Navigate to add transfer } else if (child.label == t.invoice) { // Navigate to add invoice } else if (child.label == t.expenseAndIncome) { // Navigate to add expense/income - } else if (child.label == t.documents) { - // Navigate to add document } else if (child.label == t.warehouses) { // Navigate to add warehouse } else if (child.label == t.shipments) { @@ -733,7 +751,6 @@ class _BusinessShellState extends State { onTap: () { if (item.type == _MenuItemType.expandable) { setState(() { - if (item.label == t.people) _isPeopleExpanded = !_isPeopleExpanded; if (item.label == t.productsAndServices) _isProductsAndServicesExpanded = !_isProductsAndServicesExpanded; if (item.label == t.banking) _isBankingExpanded = !_isBankingExpanded; if (item.label == t.accountingMenu) _isAccountingMenuExpanded = !_isAccountingMenuExpanded; @@ -781,8 +798,17 @@ class _BusinessShellState extends State { GestureDetector( onTap: () { // 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 + } 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) { // Navigate to add expense/income } else if (item.label == t.reports) { @@ -892,7 +918,6 @@ class _BusinessShellState extends State { initiallyExpanded: isExpanded(item), onExpansionChanged: (expanded) { setState(() { - if (item.label == t.people) _isPeopleExpanded = expanded; if (item.label == t.productsAndServices) _isProductsAndServicesExpanded = expanded; if (item.label == t.banking) _isBankingExpanded = expanded; if (item.label == t.accountingMenu) _isAccountingMenuExpanded = expanded; @@ -906,11 +931,7 @@ class _BusinessShellState extends State { onTap: () { context.pop(); // Navigate to add new item - if (child.label == t.receipts) { - // Navigate to add receipt - } else if (child.label == t.payments) { - // Navigate to add payment - } else if (child.label == t.products) { + if (child.label == t.products) { // Navigate to add product } else if (child.label == t.priceLists) { // Navigate to add price list @@ -928,14 +949,10 @@ class _BusinessShellState extends State { // Navigate to add wallet } else if (child.label == t.checks) { // Navigate to add check - } else if (child.label == t.transfers) { - // Navigate to add transfer } else if (child.label == t.invoice) { // Navigate to add invoice } else if (child.label == t.expenseAndIncome) { // Navigate to add expense/income - } else if (child.label == t.documents) { - // Navigate to add document } else if (child.label == t.warehouses) { // Navigate to add warehouse } else if (child.label == t.shipments) { @@ -980,6 +997,67 @@ class _BusinessShellState extends State { 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 } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart new file mode 100644 index 0000000..865f631 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart @@ -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 createState() => _PersonsPageState(); +} + +class _PersonsPageState extends State { + 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( + config: _buildDataTableConfig(t), + fromJson: Person.fromJson, + ), + ); + } + + DataTableConfig _buildDataTableConfig(AppLocalizations t) { + return DataTableConfig( + 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 _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, + ), + ); + } + } + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/error_404_page.dart b/hesabixUI/hesabix_ui/lib/pages/error_404_page.dart new file mode 100644 index 0000000..96a8005 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/error_404_page.dart @@ -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 createState() => _Error404PageState(); +} + +class _Error404PageState extends State + with TickerProviderStateMixin { + late AnimationController _fadeController; + late AnimationController _slideController; + late AnimationController _bounceController; + late AnimationController _pulseController; + late AnimationController _rotateController; + + late Animation _fadeAnimation; + late Animation _slideAnimation; + late Animation _bounceAnimation; + late Animation _pulseAnimation; + late Animation _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( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _fadeController, + curve: Curves.easeInOut, + )); + + _slideAnimation = Tween( + begin: const Offset(0, 0.5), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _slideController, + curve: Curves.elasticOut, + )); + + _bounceAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _bounceController, + curve: Curves.elasticOut, + )); + + _pulseAnimation = Tween( + begin: 1.0, + end: 1.05, + ).animate(CurvedAnimation( + parent: _pulseController, + curve: Curves.easeInOut, + )); + + _rotateAnimation = Tween( + 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), + ), + ), + ); + }, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + +} diff --git a/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart b/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart index c46cd2d..5e36d38 100644 --- a/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart @@ -85,8 +85,8 @@ class BusinessDashboardService { /// دریافت لیست کسب و کارهای کاربر (مالک + عضو) Future> getUserBusinesses() async { try { - // دریافت کسب و کارهای مالک با POST request - final ownedResponse = await _apiClient.post>( + // دریافت کسب و کارهای کاربر (مالک + عضو) با POST request + final response = await _apiClient.post>( '/api/v1/businesses/list', data: { 'take': 100, @@ -99,32 +99,13 @@ class BusinessDashboardService { List businesses = []; - if (ownedResponse.data?['success'] == true) { - final ownedItems = ownedResponse.data!['data']['items'] as List; + if (response.data?['success'] == true) { + final items = response.data!['data']['items'] as List; businesses.addAll( - ownedItems.map((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: {}, - ); - }), + items.map((item) => BusinessWithPermission.fromJson(item)), ); } - // TODO: در آینده می‌توان کسب و کارهای عضو را نیز اضافه کرد - // از API endpoint جدید برای کسب و کارهای عضو - return businesses; } on DioException catch (e) { if (e.response?.statusCode == 401) { @@ -136,4 +117,55 @@ class BusinessDashboardService { throw Exception('خطا در بارگذاری کسب و کارها: $e'); } } + + /// دریافت اطلاعات کسب و کار همراه با دسترسی‌های کاربر + Future getBusinessWithPermissions(int businessId) async { + try { + final response = await _apiClient.post>( + '/api/v1/business/$businessId/info-with-permissions', + ); + + if (response.data?['success'] == true) { + final data = response.data!['data'] as Map; + + // تبدیل اطلاعات کسب و کار + final businessInfo = data['business_info'] as Map; + final userPermissions = data['user_permissions'] as Map? ?? {}; + 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'); + } + } } diff --git a/hesabixUI/hesabix_ui/lib/services/person_service.dart b/hesabixUI/hesabix_ui/lib/services/person_service.dart index e69de29..8149b40 100644 --- a/hesabixUI/hesabix_ui/lib/services/person_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/person_service.dart @@ -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> getPersons({ + required int businessId, + int page = 1, + int limit = 20, + String? search, + List? searchFields, + String? sortBy, + bool sortDesc = true, + Map? filters, + }) async { + try { + final queryParams = { + '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 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 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 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 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> 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 parsePersonsList(Map data) { + final List items = data['items'] ?? []; + return items.map((item) => Person.fromJson(item)).toList(); + } + + /// دریافت اطلاعات صفحه‌بندی + Map getPaginationInfo(Map data) { + return data['pagination'] ?? {}; + } + + /// دریافت اطلاعات جستجو + Map getQueryInfo(Map data) { + return data['query_info'] ?? {}; + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/permission/README.md b/hesabixUI/hesabix_ui/lib/widgets/permission/README.md new file mode 100644 index 0000000..775781c --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/permission/README.md @@ -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(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. **مالک کسب و کار**: مالک کسب و کار تمام دسترسی‌ها را دارد diff --git a/hesabixUI/hesabix_ui/lib/widgets/permission/access_denied_page.dart b/hesabixUI/hesabix_ui/lib/widgets/permission/access_denied_page.dart new file mode 100644 index 0000000..be2e392 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/permission/access_denied_page.dart @@ -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, + ), + ], + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/permission/permission_button.dart b/hesabixUI/hesabix_ui/lib/widgets/permission/permission_button.dart new file mode 100644 index 0000000..9793758 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/permission/permission_button.dart @@ -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; + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/permission/permission_info_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/permission/permission_info_widget.dart new file mode 100644 index 0000000..f3b5ee1 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/permission/permission_info_widget.dart @@ -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; + } + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/permission/permission_widgets.dart b/hesabixUI/hesabix_ui/lib/widgets/permission/permission_widgets.dart new file mode 100644 index 0000000..6ef26d1 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/permission/permission_widgets.dart @@ -0,0 +1,4 @@ +// Export all permission-related widgets +export 'permission_button.dart'; +export 'access_denied_page.dart'; +export 'permission_info_widget.dart'; diff --git a/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart new file mode 100644 index 0000000..6af2e25 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart @@ -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 createState() => _PersonFormDialogState(); +} + +class _PersonFormDialogState extends State { + final _formKey = GlobalKey(); + 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 _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 _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( + 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)); + }, + ), + ], + ), + ), + ); + } +}