From bd34093dac9e398d046169dbf49c4a409d30b521 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Fri, 26 Sep 2025 23:05:20 +0330 Subject: [PATCH] progress in persons list --- hesabixAPI/adapters/api/v1/persons.py | 46 ++-- .../adapters/api/v1/schema_models/person.py | 10 +- hesabixAPI/adapters/db/models/person.py | 13 +- hesabixAPI/app/core/permissions.py | 21 ++ hesabixAPI/app/services/person_service.py | 213 +++++++++++++--- hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 2 + ...250926_000010_add_person_code_and_types.py | 22 ++ .../20250926_000011_drop_person_is_active.py | 28 +++ .../hesabix_ui/lib/models/person_model.dart | 43 +++- .../lib/pages/business/persons_page.dart | 38 ++- .../data_table/data_table_search_dialog.dart | 4 + .../widgets/data_table/data_table_widget.dart | 136 +++++++++-- .../widgets/person/person_form_dialog.dart | 227 ++++++++++++------ 13 files changed, 637 insertions(+), 166 deletions(-) create mode 100644 hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py create mode 100644 hesabixAPI/migrations/versions/20250926_000011_drop_person_is_active.py diff --git a/hesabixAPI/adapters/api/v1/persons.py b/hesabixAPI/adapters/api/v1/persons.py index 91a773d..5a7783a 100644 --- a/hesabixAPI/adapters/api/v1/persons.py +++ b/hesabixAPI/adapters/api/v1/persons.py @@ -10,7 +10,7 @@ from adapters.api.v1.schema_models.person import ( 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.core.permissions import require_business_management_dep from app.services.person_service import ( create_person, get_person_by_id, get_persons_by_business, update_person, delete_person, get_person_summary @@ -59,13 +59,15 @@ async def create_person_endpoint( person_data: PersonCreateRequest, db: Session = Depends(get_db), auth_context: AuthContext = Depends(get_current_user), - _: None = Depends(require_business_management()) + _: None = Depends(require_business_management_dep), + request: Request = None, ): """ایجاد شخص جدید برای کسب و کار""" result = create_person(db, business_id, person_data) return success_response( + data=format_datetime_fields(result['data'], request), + request=request, message=result['message'], - data=format_datetime_fields(result['data']) ) @@ -103,7 +105,8 @@ async def get_persons_endpoint( business_id: int, query_info: QueryInfo, db: Session = Depends(get_db), - auth_context: AuthContext = Depends(get_current_user) + auth_context: AuthContext = Depends(get_current_user), + request: Request = None, ): """دریافت لیست اشخاص کسب و کار""" query_dict = { @@ -111,17 +114,21 @@ async def get_persons_endpoint( "skip": query_info.skip, "sort_by": query_info.sort_by, "sort_desc": query_info.sort_desc, - "search": query_info.search + "search": query_info.search, + "search_fields": query_info.search_fields, + "filters": query_info.filters, } result = get_persons_by_business(db, business_id, query_dict) # فرمت کردن تاریخ‌ها - for item in result['items']: - item = format_datetime_fields(item) + result['items'] = [ + format_datetime_fields(item, request) for item in result['items'] + ] return success_response( + data=result, + request=request, message="لیست اشخاص با موفقیت دریافت شد", - data=result ) @@ -142,7 +149,8 @@ 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()) + _: None = Depends(require_business_management_dep), + request: Request = None, ): """دریافت جزئیات شخص""" # ابتدا باید business_id را از person دریافت کنیم @@ -155,8 +163,9 @@ async def get_person_endpoint( raise HTTPException(status_code=404, detail="شخص یافت نشد") return success_response( + data=format_datetime_fields(result, request), + request=request, message="جزئیات شخص با موفقیت دریافت شد", - data=format_datetime_fields(result) ) @@ -178,7 +187,8 @@ async def update_person_endpoint( person_data: PersonUpdateRequest, db: Session = Depends(get_db), auth_context: AuthContext = Depends(get_current_user), - _: None = Depends(require_business_management()) + _: None = Depends(require_business_management_dep), + request: Request = None, ): """ویرایش شخص""" # ابتدا باید business_id را از person دریافت کنیم @@ -191,8 +201,9 @@ async def update_person_endpoint( raise HTTPException(status_code=404, detail="شخص یافت نشد") return success_response( + data=format_datetime_fields(result['data'], request), + request=request, message=result['message'], - data=format_datetime_fields(result['data']) ) @@ -213,7 +224,8 @@ 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()) + _: None = Depends(require_business_management_dep), + request: Request = None, ): """حذف شخص""" # ابتدا باید business_id را از person دریافت کنیم @@ -225,7 +237,7 @@ async def delete_person_endpoint( if not success: raise HTTPException(status_code=404, detail="شخص یافت نشد") - return success_response(message="شخص با موفقیت حذف شد") + return success_response(message="شخص با موفقیت حذف شد", request=request) @router.get("/businesses/{business_id}/persons/summary", @@ -242,12 +254,14 @@ 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()) + _: None = Depends(require_business_management_dep), + request: Request = None, ): """دریافت خلاصه اشخاص کسب و کار""" result = get_person_summary(db, business_id) return success_response( + data=result, + request=request, message="خلاصه اشخاص با موفقیت دریافت شد", - data=result ) diff --git a/hesabixAPI/adapters/api/v1/schema_models/person.py b/hesabixAPI/adapters/api/v1/schema_models/person.py index 2ce607b..cffb79d 100644 --- a/hesabixAPI/adapters/api/v1/schema_models/person.py +++ b/hesabixAPI/adapters/api/v1/schema_models/person.py @@ -50,10 +50,12 @@ class PersonBankAccountResponse(BaseModel): class PersonCreateRequest(BaseModel): """درخواست ایجاد شخص جدید""" # اطلاعات پایه + code: Optional[int] = Field(default=None, ge=1, description="کد یکتا در هر کسب و کار (در صورت عدم ارسال، خودکار تولید می‌شود)") 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="نوع شخص") + person_type: Optional[PersonType] = Field(default=None, description="نوع شخص (سازگاری قدیمی)") + person_types: Optional[List[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="شناسه پرداخت") @@ -81,10 +83,12 @@ class PersonCreateRequest(BaseModel): class PersonUpdateRequest(BaseModel): """درخواست ویرایش شخص""" # اطلاعات پایه + code: Optional[int] = Field(default=None, ge=1, description="کد یکتا در هر کسب و کار") 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="نوع شخص") + person_type: Optional[PersonType] = Field(default=None, description="نوع شخص (سازگاری قدیمی)") + person_types: Optional[List[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="شناسه پرداخت") @@ -115,10 +119,12 @@ class PersonResponse(BaseModel): business_id: int = Field(..., description="شناسه کسب و کار") # اطلاعات پایه + code: Optional[int] = Field(default=None, 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="نوع شخص") + person_types: List[str] = Field(default_factory=list, description="انواع شخص") company_name: Optional[str] = Field(default=None, description="نام شرکت") payment_id: Optional[str] = Field(default=None, description="شناسه پرداخت") diff --git a/hesabixAPI/adapters/db/models/person.py b/hesabixAPI/adapters/db/models/person.py index a398889..0a3cfea 100644 --- a/hesabixAPI/adapters/db/models/person.py +++ b/hesabixAPI/adapters/db/models/person.py @@ -3,7 +3,7 @@ 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 import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from adapters.db.session import Base @@ -21,15 +21,20 @@ class PersonType(str, Enum): class Person(Base): __tablename__ = "persons" + __table_args__ = ( + UniqueConstraint('business_id', 'code', name='uq_persons_business_code'), + ) 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) # اطلاعات پایه + code: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="کد یکتا در هر کسب و کار") 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="نوع شخص") + person_types: Mapped[str | None] = mapped_column(Text, nullable=True, comment="لیست انواع شخص به صورت JSON") company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت") payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شناسه پرداخت") @@ -50,9 +55,6 @@ class Person(Base): 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) @@ -74,9 +76,6 @@ class PersonBankAccount(Base): 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) diff --git a/hesabixAPI/app/core/permissions.py b/hesabixAPI/app/core/permissions.py index 6107590..9a07894 100644 --- a/hesabixAPI/app/core/permissions.py +++ b/hesabixAPI/app/core/permissions.py @@ -171,3 +171,24 @@ def require_system_settings(): def require_permission(permission: str): """Decorator عمومی برای بررسی دسترسی - wrapper برای require_app_permission""" return require_app_permission(permission) + + +# ========================= +# FastAPI Dependencies (for Depends) +# ========================= +def require_app_permission_dep(permission: str): + """FastAPI dependency جهت بررسی دسترسی در سطح اپلیکیشن. + + استفاده: + _: None = Depends(require_app_permission_dep("business_management")) + """ + def _dependency(auth_context: AuthContext = Depends(get_current_user)) -> None: + if not auth_context.has_app_permission(permission): + raise ApiError("FORBIDDEN", f"Missing app permission: {permission}", http_status=403) + return _dependency + + +def require_business_management_dep(auth_context: AuthContext = Depends(get_current_user)) -> None: + """FastAPI dependency برای بررسی مجوز مدیریت کسب و کارها.""" + if not auth_context.has_app_permission("business_management"): + raise ApiError("FORBIDDEN", "Missing app permission: business_management", http_status=403) diff --git a/hesabixAPI/app/services/person_service.py b/hesabixAPI/app/services/person_service.py index 6fa0a9d..4d27c3d 100644 --- a/hesabixAPI/app/services/person_service.py +++ b/hesabixAPI/app/services/person_service.py @@ -1,4 +1,7 @@ from typing import List, Optional, Dict, Any +import json +from sqlalchemy.exc import IntegrityError +from app.core.responses import ApiError from sqlalchemy.orm import Session from sqlalchemy import and_, or_, func from adapters.db.models.person import Person, PersonBankAccount, PersonType @@ -10,13 +13,36 @@ from app.core.responses import success_response def create_person(db: Session, business_id: int, person_data: PersonCreateRequest) -> Dict[str, Any]: """ایجاد شخص جدید""" + # محاسبه/اعتبارسنجی کد یکتا + code: Optional[int] = getattr(person_data, 'code', None) + if code is not None: + exists = db.query(Person).filter( + and_(Person.business_id == business_id, Person.code == code) + ).first() + if exists: + raise ApiError("DUPLICATE_PERSON_CODE", "کد شخص تکراری است", http_status=400) + else: + # تولید خودکار کد: بیشینه فعلی + 1 (نسبت به همان کسب و کار) + max_code = db.query(func.max(Person.code)).filter(Person.business_id == business_id).scalar() + code = (max_code or 0) + 1 + + # آماده‌سازی person_types (چندانتخابی) و سازگاری person_type تکی + types_list: List[str] = [] + if getattr(person_data, 'person_types', None): + types_list = [t.value if hasattr(t, 'value') else str(t) for t in person_data.person_types] # type: ignore[attr-defined] + elif getattr(person_data, 'person_type', None): + t = person_data.person_type + types_list = [t.value if hasattr(t, 'value') else str(t)] + # ایجاد شخص person = Person( business_id=business_id, + code=code, alias_name=person_data.alias_name, first_name=person_data.first_name, last_name=person_data.last_name, - person_type=person_data.person_type, + person_type=person_data.person_type or (PersonType(types_list[0]) if types_list else PersonType.CUSTOMER), + person_types=json.dumps(types_list, ensure_ascii=False) if types_list else None, company_name=person_data.company_name, payment_id=person_data.payment_id, national_id=person_data.national_id, @@ -49,7 +75,11 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques ) db.add(bank_account) - db.commit() + try: + db.commit() + except IntegrityError: + db.rollback() + raise ApiError("DUPLICATE_PERSON_CODE", "کد شخص تکراری است", http_status=400) db.refresh(person) return success_response( @@ -84,6 +114,13 @@ def get_persons_by_business( search_conditions = [] for field in query_info['search_fields']: + if field == 'code': + # تبدیل به رشته برای جستجو مانند LIKE + try: + code_int = int(query_info['search']) # type: ignore[arg-type] + search_conditions.append(Person.code == code_int) + except Exception: + pass if field == 'alias_name': search_conditions.append(Person.alias_name.ilike(search_term)) elif field == 'first_name': @@ -105,28 +142,108 @@ def get_persons_by_business( # اعمال فیلترها 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') - + # پشتیبانی از هر دو حالت: دیکشنری یا شیء Pydantic + if isinstance(filter_item, dict): + field = filter_item.get('property') + operator = filter_item.get('operator') + value = filter_item.get('value') + else: + field = getattr(filter_item, 'property', None) + operator = getattr(filter_item, 'operator', None) + value = getattr(filter_item, 'value', None) + + if not field or not operator: + continue + + # کد + if field == 'code': + if operator == '=': + query = query.filter(Person.code == value) + elif operator == 'in' and isinstance(value, list): + query = query.filter(Person.code.in_(value)) + continue + + # نوع شخص تک‌انتخابی if field == 'person_type': if operator == '=': query = query.filter(Person.person_type == value) - elif operator == 'in': + elif operator == 'in' and isinstance(value, list): query = query.filter(Person.person_type.in_(value)) - elif field == 'is_active': + continue + + # انواع شخص چندانتخابی (رشته JSON) + if field == 'person_types': + if operator == '=' and isinstance(value, str): + query = query.filter(Person.person_types.ilike(f'%"{value}"%')) + elif operator == 'in' and isinstance(value, list): + sub_filters = [Person.person_types.ilike(f'%"{v}"%') for v in value] + if sub_filters: + query = query.filter(or_(*sub_filters)) + continue + + # فیلترهای متنی عمومی (حمایت از عملگرهای contains/startsWith/endsWith) + def apply_text_filter(column): + nonlocal query 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}%")) + query = query.filter(column == value) + elif operator == 'like' or operator == '*': + query = query.filter(column.ilike(f"%{value}%")) + elif operator == '*?': # starts with + query = query.filter(column.ilike(f"{value}%")) + elif operator == '?*': # ends with + query = query.filter(column.ilike(f"%{value}")) + + if field == 'country': + apply_text_filter(Person.country) + continue + + if field == 'province': + apply_text_filter(Person.province) + continue + + if field == 'alias_name': + apply_text_filter(Person.alias_name) + continue + + if field == 'first_name': + apply_text_filter(Person.first_name) + continue + + if field == 'last_name': + apply_text_filter(Person.last_name) + continue + + if field == 'company_name': + apply_text_filter(Person.company_name) + continue + + if field == 'mobile': + apply_text_filter(Person.mobile) + continue + + if field == 'email': + apply_text_filter(Person.email) + continue + + if field == 'national_id': + apply_text_filter(Person.national_id) + continue + + if field == 'registration_number': + apply_text_filter(Person.registration_number) + continue + + if field == 'economic_id': + apply_text_filter(Person.economic_id) + continue + + if field == 'city': + apply_text_filter(Person.city) + continue + + if field == 'address': + apply_text_filter(Person.address) + continue # شمارش کل رکوردها total = query.count() @@ -135,7 +252,9 @@ def get_persons_by_business( sort_by = query_info.get('sort_by', 'created_at') sort_desc = query_info.get('sort_desc', True) - if sort_by == 'alias_name': + if sort_by == 'code': + query = query.order_by(Person.code.desc() if sort_desc else Person.code.asc()) + elif 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()) @@ -195,8 +314,35 @@ def update_person( # به‌روزرسانی فیلدها update_data = person_data.dict(exclude_unset=True) - for field, value in update_data.items(): - setattr(person, field, value) + + # مدیریت کد یکتا + if 'code' in update_data and update_data['code'] is not None: + desired_code = update_data['code'] + exists = db.query(Person).filter( + and_(Person.business_id == business_id, Person.code == desired_code, Person.id != person_id) + ).first() + if exists: + raise ValueError("کد شخص تکراری است") + person.code = desired_code + + # مدیریت انواع شخص چندگانه + types_list: Optional[List[str]] = None + if 'person_types' in update_data and update_data['person_types'] is not None: + incoming = update_data['person_types'] or [] + types_list = [t.value if hasattr(t, 'value') else str(t) for t in incoming] + person.person_types = json.dumps(types_list, ensure_ascii=False) if types_list else None + # همگام کردن person_type تکی برای سازگاری + if types_list: + try: + person.person_type = PersonType(types_list[0]) + except Exception: + pass + + # سایر فیلدها + for field in list(update_data.keys()): + if field in {'code', 'person_types'}: + continue + setattr(person, field, update_data[field]) db.commit() db.refresh(person) @@ -227,12 +373,9 @@ 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 + # حذف مفهوم فعال/غیرفعال + active_persons = 0 + inactive_persons = total_persons # تعداد بر اساس نوع by_type = {} @@ -252,13 +395,25 @@ def get_person_summary(db: Session, business_id: int) -> Dict[str, Any]: def _person_to_dict(person: Person) -> Dict[str, Any]: """تبدیل مدل Person به دیکشنری""" + # Parse person_types JSON to list + types_list: List[str] = [] + if person.person_types: + try: + types = json.loads(person.person_types) + if isinstance(types, list): + types_list = [str(x) for x in types] + except Exception: + types_list = [] + return { 'id': person.id, 'business_id': person.business_id, + 'code': person.code, 'alias_name': person.alias_name, 'first_name': person.first_name, 'last_name': person.last_name, 'person_type': person.person_type.value, + 'person_types': types_list, 'company_name': person.company_name, 'payment_id': person.payment_id, 'national_id': person.national_id, @@ -274,7 +429,6 @@ def _person_to_dict(person: Person) -> Dict[str, Any]: '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': [ @@ -285,7 +439,6 @@ def _person_to_dict(person: Person) -> Dict[str, Any]: '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(), } diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index 4d6080b..2740258 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -102,6 +102,8 @@ 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/20250926_000010_add_person_code_and_types.py +migrations/versions/20250926_000011_drop_person_is_active.py migrations/versions/5553f8745c6e_add_support_tables.py tests/__init__.py tests/test_health.py diff --git a/hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py b/hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py new file mode 100644 index 0000000..2badcdf --- /dev/null +++ b/hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py @@ -0,0 +1,22 @@ +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '20250926_000010_add_person_code_and_types' +down_revision = '20250916_000002' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table('persons') as batch_op: + batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('person_types', sa.Text(), nullable=True)) + batch_op.create_unique_constraint('uq_persons_business_code', ['business_id', 'code']) + + +def downgrade() -> None: + with op.batch_alter_table('persons') as batch_op: + batch_op.drop_constraint('uq_persons_business_code', type_='unique') + batch_op.drop_column('person_types') + batch_op.drop_column('code') diff --git a/hesabixAPI/migrations/versions/20250926_000011_drop_person_is_active.py b/hesabixAPI/migrations/versions/20250926_000011_drop_person_is_active.py new file mode 100644 index 0000000..2f6e488 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250926_000011_drop_person_is_active.py @@ -0,0 +1,28 @@ +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '20250926_000011_drop_person_is_active' +down_revision = '20250926_000010_add_person_code_and_types' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table('persons') as batch_op: + try: + batch_op.drop_column('is_active') + except Exception: + pass + with op.batch_alter_table('person_bank_accounts') as batch_op: + try: + batch_op.drop_column('is_active') + except Exception: + pass + + +def downgrade() -> None: + with op.batch_alter_table('persons') as batch_op: + batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'))) + with op.batch_alter_table('person_bank_accounts') as batch_op: + batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'))) diff --git a/hesabixUI/hesabix_ui/lib/models/person_model.dart b/hesabixUI/hesabix_ui/lib/models/person_model.dart index 65858f7..87e35c8 100644 --- a/hesabixUI/hesabix_ui/lib/models/person_model.dart +++ b/hesabixUI/hesabix_ui/lib/models/person_model.dart @@ -97,10 +97,12 @@ enum PersonType { class Person { final int? id; final int businessId; + final int? code; final String aliasName; final String? firstName; final String? lastName; final PersonType personType; + final List personTypes; final String? companyName; final String? paymentId; final String? nationalId; @@ -124,10 +126,12 @@ class Person { Person({ this.id, required this.businessId, + this.code, required this.aliasName, this.firstName, this.lastName, required this.personType, + this.personTypes = const [], this.companyName, this.paymentId, this.nationalId, @@ -150,13 +154,22 @@ class Person { }); factory Person.fromJson(Map json) { + final List types = (json['person_types'] as List?) + ?.map((e) => PersonType.fromString(e.toString())) + .toList() ?? + []; + final PersonType primaryType = types.isNotEmpty + ? types.first + : PersonType.fromString(json['person_type']); return Person( id: json['id'], businessId: json['business_id'], + code: json['code'], aliasName: json['alias_name'], firstName: json['first_name'], lastName: json['last_name'], - personType: PersonType.fromString(json['person_type']), + personType: primaryType, + personTypes: types, companyName: json['company_name'], paymentId: json['payment_id'], nationalId: json['national_id'], @@ -185,10 +198,12 @@ class Person { return { 'id': id, 'business_id': businessId, + 'code': code, 'alias_name': aliasName, 'first_name': firstName, 'last_name': lastName, 'person_type': personType.persianName, + 'person_types': personTypes.map((t) => t.persianName).toList(), 'company_name': companyName, 'payment_id': paymentId, 'national_id': nationalId, @@ -285,9 +300,10 @@ class Person { class PersonCreateRequest { final String aliasName; + final int? code; final String? firstName; final String? lastName; - final PersonType personType; + final List personTypes; final String? companyName; final String? paymentId; final String? nationalId; @@ -307,9 +323,10 @@ class PersonCreateRequest { PersonCreateRequest({ required this.aliasName, + this.code, this.firstName, this.lastName, - required this.personType, + this.personTypes = const [], this.companyName, this.paymentId, this.nationalId, @@ -331,9 +348,10 @@ class PersonCreateRequest { Map toJson() { return { 'alias_name': aliasName, + if (code != null) 'code': code, 'first_name': firstName, 'last_name': lastName, - 'person_type': personType.persianName, + if (personTypes.isNotEmpty) 'person_types': personTypes.map((t) => t.persianName).toList(), 'company_name': companyName, 'payment_id': paymentId, 'national_id': nationalId, @@ -349,16 +367,27 @@ class PersonCreateRequest { 'fax': fax, 'email': email, 'website': website, - 'bank_accounts': bankAccounts.map((ba) => ba.toJson()).toList(), + // Only send fields expected by backend for create + 'bank_accounts': bankAccounts + .where((ba) => (ba.bankName).trim().isNotEmpty) + .map((ba) => { + 'bank_name': ba.bankName, + 'account_number': ba.accountNumber, + 'card_number': ba.cardNumber, + 'sheba_number': ba.shebaNumber, + }) + .toList(), }; } } class PersonUpdateRequest { + final int? code; final String? aliasName; final String? firstName; final String? lastName; final PersonType? personType; + final List? personTypes; final String? companyName; final String? paymentId; final String? nationalId; @@ -377,10 +406,12 @@ class PersonUpdateRequest { final bool? isActive; PersonUpdateRequest({ + this.code, this.aliasName, this.firstName, this.lastName, this.personType, + this.personTypes, this.companyName, this.paymentId, this.nationalId, @@ -402,10 +433,12 @@ class PersonUpdateRequest { Map toJson() { final Map json = {}; + if (code != null) json['code'] = code; 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 (personTypes != null) json['person_types'] = personTypes!.map((t) => t.persianName).toList(); if (companyName != null) json['company_name'] = companyName; if (paymentId != null) json['payment_id'] = paymentId; if (nationalId != null) json['national_id'] = nationalId; diff --git a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart index 865f631..b347138 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart @@ -24,6 +24,7 @@ class PersonsPage extends StatefulWidget { class _PersonsPageState extends State { final _personService = PersonService(); + final GlobalKey _personsTableKey = GlobalKey(); @override Widget build(BuildContext context) { @@ -54,6 +55,7 @@ class _PersonsPageState extends State { ], ), body: DataTableWidget( + key: _personsTableKey, config: _buildDataTableConfig(t), fromJson: Person.fromJson, ), @@ -64,7 +66,16 @@ class _PersonsPageState extends State { return DataTableConfig( endpoint: '/api/v1/persons/businesses/${widget.businessId}/persons', title: t.personsList, + showRowNumbers: true, + enableRowSelection: true, columns: [ + NumberColumn( + 'code', + 'کد شخص', + width: ColumnWidth.small, + formatter: (person) => (person.code?.toString() ?? '-'), + textAlign: TextAlign.center, + ), TextColumn( 'alias_name', t.personAliasName, @@ -87,7 +98,9 @@ class _PersonsPageState extends State { 'person_type', t.personType, width: ColumnWidth.medium, - formatter: (person) => person.personType.persianName, + formatter: (person) => (person.personTypes.isNotEmpty + ? person.personTypes.map((e) => e.persianName).join('، ') + : person.personType.persianName), ), TextColumn( 'company_name', @@ -108,10 +121,10 @@ class _PersonsPageState extends State { formatter: (person) => person.email ?? '-', ), TextColumn( - 'is_active', - 'وضعیت', - width: ColumnWidth.small, - formatter: (person) => person.isActive ? 'فعال' : 'غیرفعال', + 'national_id', + t.personNationalId, + width: ColumnWidth.medium, + formatter: (person) => person.nationalId ?? '-', ), DateColumn( 'created_at', @@ -137,6 +150,7 @@ class _PersonsPageState extends State { ), ], searchFields: [ + 'code', 'alias_name', 'first_name', 'last_name', @@ -147,6 +161,7 @@ class _PersonsPageState extends State { ], filterFields: [ 'person_type', + 'person_types', 'is_active', 'country', 'province', @@ -162,7 +177,12 @@ class _PersonsPageState extends State { builder: (context) => PersonFormDialog( businessId: widget.businessId, onSuccess: () { - // DataTableWidget will automatically refresh + final state = _personsTableKey.currentState; + try { + // Call public refresh() via dynamic to avoid private state typing + // ignore: avoid_dynamic_calls + (state as dynamic)?.refresh(); + } catch (_) {} }, ), ); @@ -175,7 +195,11 @@ class _PersonsPageState extends State { businessId: widget.businessId, person: person, onSuccess: () { - // DataTableWidget will automatically refresh + final state = _personsTableKey.currentState; + try { + // ignore: avoid_dynamic_calls + (state as dynamic)?.refresh(); + } catch (_) {} }, ), ); diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_search_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_search_dialog.dart index 6f1a558..24762a1 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_search_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_search_dialog.dart @@ -53,6 +53,10 @@ class _DataTableSearchDialogState extends State { super.initState(); _controller = TextEditingController(text: widget.searchValue); _selectedType = widget.searchType; + // Enable/disable Apply button reactively on text changes + _controller.addListener(() { + if (mounted) setState(() {}); + }); } @override diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart index b2c7ae4..ac42160 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart @@ -1232,12 +1232,38 @@ class _DataTableWidgetState extends State> { )); } - // Add data columns (use visible columns if column settings are enabled) + // Resolve action column (if defined in config) + ActionColumn? actionColumn; + for (final c in widget.config.columns) { + if (c is ActionColumn) { + actionColumn = c; + break; + } + } + + // Fixed action column immediately after selection and row number columns + if (actionColumn != null) { + columns.add(DataColumn2( + label: Text( + actionColumn.label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + size: ColumnSize.S, + fixedWidth: 80.0, + )); + } + + // Add data columns (use visible columns if column settings are enabled), excluding ActionColumn final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty ? _visibleColumns : widget.config.columns; + final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); - columns.addAll(columnsToShow.map((column) { + columns.addAll(dataColumnsToShow.map((column) { return DataColumn2( label: _ColumnHeaderWithSearch( text: column.label, @@ -1295,7 +1321,22 @@ class _DataTableWidgetState extends State> { )); } - // Add data cells + // 3) Fixed action cell (immediately after selection and row number) + // Resolve action column once (same logic as header) + ActionColumn? actionColumn; + for (final c in widget.config.columns) { + if (c is ActionColumn) { + actionColumn = c; + break; + } + } + if (actionColumn != null) { + cells.add(DataCell( + _buildActionButtons(item, actionColumn), + )); + } + + // 4) Add data cells if (widget.config.customRowBuilder != null) { cells.add(DataCell( widget.config.customRowBuilder!(item) ?? const SizedBox.shrink(), @@ -1304,8 +1345,9 @@ class _DataTableWidgetState extends State> { final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty ? _visibleColumns : widget.config.columns; + final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); - cells.addAll(columnsToShow.map((column) { + cells.addAll(dataColumnsToShow.map((column) { return DataCell( _buildCellContent(item, column, index), ); @@ -1328,18 +1370,49 @@ class _DataTableWidgetState extends State> { } Widget _buildCellContent(dynamic item, DataTableColumn column, int index) { - final value = DataTableUtils.getCellValue(item, column.key); - + // 1) Custom widget builder takes precedence if (column is CustomColumn && column.builder != null) { return column.builder!(item, index); } + // 2) Action column if (column is ActionColumn) { return _buildActionButtons(item, column); } - + + // 3) If a formatter is provided on the column, call it with the full item + // This allows working with strongly-typed objects (not just Map) + if (column is TextColumn && column.formatter != null) { + final text = column.formatter!(item) ?? ''; + return Text( + text, + textAlign: _getTextAlign(column), + maxLines: _getMaxLines(column), + overflow: _getOverflow(column), + ); + } + if (column is NumberColumn && column.formatter != null) { + final text = column.formatter!(item) ?? ''; + return Text( + text, + textAlign: _getTextAlign(column), + maxLines: _getMaxLines(column), + overflow: _getOverflow(column), + ); + } + if (column is DateColumn && column.formatter != null) { + final text = column.formatter!(item) ?? ''; + return Text( + text, + textAlign: _getTextAlign(column), + maxLines: _getMaxLines(column), + overflow: _getOverflow(column), + ); + } + + // 4) Fallback: get property value from Map items by key + final value = DataTableUtils.getCellValue(item, column.key); final formattedValue = DataTableUtils.formatCellValue(value, column); - return Text( formattedValue, textAlign: _getTextAlign(column), @@ -1351,24 +1424,35 @@ class _DataTableWidgetState extends State> { Widget _buildActionButtons(dynamic item, ActionColumn column) { if (column.actions.isEmpty) return const SizedBox.shrink(); - return Row( - mainAxisSize: MainAxisSize.min, - children: column.actions.map((action) { - return IconButton( - onPressed: action.enabled ? () => action.onTap(item) : null, - icon: Icon( - action.icon, - color: action.color, - size: 20, - ), - tooltip: action.label, - style: IconButton.styleFrom( - foregroundColor: action.isDestructive - ? Theme.of(context).colorScheme.error - : action.color, - ), - ); - }).toList(), + return PopupMenuButton( + tooltip: column.label, + icon: const Icon(Icons.more_vert, size: 20), + onSelected: (index) { + final action = column.actions[index]; + if (action.enabled) action.onTap(item); + }, + itemBuilder: (context) { + return List.generate(column.actions.length, (index) { + final action = column.actions[index]; + return PopupMenuItem( + value: index, + enabled: action.enabled, + child: Row( + children: [ + Icon( + action.icon, + color: action.isDestructive + ? Theme.of(context).colorScheme.error + : (action.color ?? Theme.of(context).iconTheme.color), + size: 18, + ), + const SizedBox(width: 8), + Text(action.label), + ], + ), + ); + }); + }, ); } diff --git a/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart index 6af2e25..c51557c 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart @@ -24,6 +24,10 @@ class _PersonFormDialogState extends State { final _personService = PersonService(); bool _isLoading = false; + // Code (unique) controls + final _codeController = TextEditingController(); + bool _autoGenerateCode = true; + // Controllers for basic info final _aliasNameController = TextEditingController(); final _firstNameController = TextEditingController(); @@ -48,7 +52,8 @@ class _PersonFormDialogState extends State { final _emailController = TextEditingController(); final _websiteController = TextEditingController(); - PersonType _selectedPersonType = PersonType.customer; + PersonType _selectedPersonType = PersonType.customer; // legacy single select (for compatibility) + final Set _selectedPersonTypes = {}; bool _isActive = true; // Bank accounts @@ -63,6 +68,10 @@ class _PersonFormDialogState extends State { void _initializeForm() { if (widget.person != null) { final person = widget.person!; + if (person.code != null) { + _codeController.text = person.code!.toString(); + _autoGenerateCode = false; + } _aliasNameController.text = person.aliasName; _firstNameController.text = person.firstName ?? ''; _lastNameController.text = person.lastName ?? ''; @@ -82,6 +91,9 @@ class _PersonFormDialogState extends State { _emailController.text = person.email ?? ''; _websiteController.text = person.website ?? ''; _selectedPersonType = person.personType; + _selectedPersonTypes + ..clear() + ..addAll(person.personTypes.isNotEmpty ? person.personTypes : [person.personType]); _isActive = person.isActive; _bankAccounts = List.from(person.bankAccounts); } @@ -89,6 +101,7 @@ class _PersonFormDialogState extends State { @override void dispose() { + _codeController.dispose(); _aliasNameController.dispose(); _firstNameController.dispose(); _lastNameController.dispose(); @@ -121,10 +134,13 @@ class _PersonFormDialogState extends State { if (widget.person == null) { // Create new person final personData = PersonCreateRequest( + code: _autoGenerateCode + ? null + : (int.tryParse(_codeController.text.trim()) ?? null), aliasName: _aliasNameController.text.trim(), firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(), lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(), - personType: _selectedPersonType, + personTypes: _selectedPersonTypes.toList(), 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(), @@ -150,10 +166,12 @@ class _PersonFormDialogState extends State { } else { // Update existing person final personData = PersonUpdateRequest( + code: (int.tryParse(_codeController.text.trim()) ?? null), aliasName: _aliasNameController.text.trim(), firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(), lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(), - personType: _selectedPersonType, + personType: null, + personTypes: _selectedPersonTypes.isNotEmpty ? _selectedPersonTypes.toList() : null, 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(), @@ -266,53 +284,54 @@ class _PersonFormDialogState extends State { const Divider(), const SizedBox(height: 16), - // Form + // Form with tabs Expanded( child: Form( key: _formKey, - child: SingleChildScrollView( + child: DefaultTabController( + length: 4, 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; - }); - }, + TabBar( + isScrollable: true, + tabs: [ + Tab(text: t.personBasicInfo), + Tab(text: t.personEconomicInfo), + Tab(text: t.personContactInfo), + Tab(text: t.personBankInfo), + ], + ), + const SizedBox(height: 12), + Expanded( + child: TabBarView( + children: [ + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildBasicInfoFields(t), + ), + ), + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildEconomicInfoFields(t), + ), + ), + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildContactInfoFields(t), + ), + ), + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildBankAccountsSection(t), + ), + ), + ], ), - const SizedBox(height: 24), - ], + ), ], ), ), @@ -361,6 +380,61 @@ class _PersonFormDialogState extends State { Widget _buildBasicInfoFields(AppLocalizations t) { return Column( children: [ + Row( + children: [ + Expanded( + child: TextFormField( + controller: _codeController, + readOnly: _autoGenerateCode, + decoration: InputDecoration( + labelText: 'کد شخص (اختیاری)', + hintText: 'کد یکتا (عددی)', + suffixIcon: Container( + margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 6), + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + child: ToggleButtons( + isSelected: [_autoGenerateCode, !_autoGenerateCode], + borderRadius: BorderRadius.circular(6), + constraints: const BoxConstraints(minHeight: 32, minWidth: 64), + onPressed: (index) { + setState(() { + _autoGenerateCode = (index == 0); + }); + }, + children: const [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text('اتوماتیک'), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text('دستی'), + ), + ], + ), + ), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (!_autoGenerateCode) { + if (value == null || value.trim().isEmpty) { + return 'کد شخص الزامی است'; + } + if (int.tryParse(value.trim()) == null) { + return 'کد باید عددی باشد'; + } + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 8), Row( children: [ Expanded( @@ -379,36 +453,10 @@ class _PersonFormDialogState extends State { ), ), 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; - }, - ), - ), + Expanded(child: _buildPersonTypesMultiSelect(t)), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), Row( children: [ Expanded( @@ -432,7 +480,7 @@ class _PersonFormDialogState extends State { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), Row( children: [ Expanded( @@ -460,6 +508,39 @@ class _PersonFormDialogState extends State { ); } + Widget _buildPersonTypesMultiSelect(AppLocalizations t) { + final types = PersonType.values; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text(t.personType), + ), + Wrap( + spacing: 8, + runSpacing: 4, + children: types.map((type) { + final selected = _selectedPersonTypes.contains(type); + return FilterChip( + label: Text(type.persianName), + selected: selected, + onSelected: (v) { + setState(() { + if (v) { + _selectedPersonTypes.add(type); + } else { + _selectedPersonTypes.remove(type); + } + }); + }, + ); + }).toList(), + ), + ], + ); + } + Widget _buildEconomicInfoFields(AppLocalizations t) { return Column( children: [