progress in invoice
This commit is contained in:
parent
6c1606fe24
commit
0edff7d020
228
hesabixAPI/adapters/api/v1/customers.py
Normal file
228
hesabixAPI/adapters/api/v1/customers.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from adapters.db.session import get_db
|
||||||
|
from app.core.responses import success_response, format_datetime_fields
|
||||||
|
from app.core.auth_dependency import get_current_user, AuthContext
|
||||||
|
from app.core.permissions import require_business_access_dep
|
||||||
|
from app.services.person_service import search_persons, count_persons, get_person_by_id
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/customers", tags=["customers"])
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerSearchRequest(BaseModel):
|
||||||
|
business_id: int
|
||||||
|
page: int = 1
|
||||||
|
limit: int = 20
|
||||||
|
search: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
code: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerSearchResponse(BaseModel):
|
||||||
|
customers: List[CustomerResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
limit: int
|
||||||
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/search",
|
||||||
|
summary="جستوجوی مشتریها",
|
||||||
|
description="جستوجو در لیست مشتریها (اشخاص) با قابلیت فیلتر و صفحهبندی",
|
||||||
|
response_model=CustomerSearchResponse,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "لیست مشتریها با موفقیت دریافت شد",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"customers": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "احمد احمدی",
|
||||||
|
"code": "CUST001",
|
||||||
|
"phone": "09123456789",
|
||||||
|
"email": "ahmad@example.com",
|
||||||
|
"address": "تهران، خیابان ولیعصر",
|
||||||
|
"is_active": True,
|
||||||
|
"created_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"has_more": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
"description": "کاربر احراز هویت نشده است"
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
"description": "دسترسی غیرمجاز - نیاز به دسترسی به کسب و کار"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def search_customers(
|
||||||
|
request: Request,
|
||||||
|
search_request: CustomerSearchRequest,
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: None = Depends(require_business_access_dep)
|
||||||
|
):
|
||||||
|
"""جستوجو در لیست مشتریها"""
|
||||||
|
|
||||||
|
# بررسی دسترسی به بخش اشخاص (یا join permission)
|
||||||
|
# در اینجا میتوانید منطق بررسی دسترسی join را پیادهسازی کنید
|
||||||
|
# برای مثال: اگر کاربر دسترسی مستقیم به اشخاص ندارد، اما دسترسی join دارد
|
||||||
|
|
||||||
|
# جستوجو در اشخاص
|
||||||
|
persons = search_persons(
|
||||||
|
db=db,
|
||||||
|
business_id=search_request.business_id,
|
||||||
|
search_query=search_request.search,
|
||||||
|
page=search_request.page,
|
||||||
|
limit=search_request.limit
|
||||||
|
)
|
||||||
|
|
||||||
|
# تبدیل به فرمت مشتری
|
||||||
|
customers = []
|
||||||
|
for person in persons:
|
||||||
|
# ساخت نام کامل
|
||||||
|
name_parts = []
|
||||||
|
if person.alias_name:
|
||||||
|
name_parts.append(person.alias_name)
|
||||||
|
if person.first_name:
|
||||||
|
name_parts.append(person.first_name)
|
||||||
|
if person.last_name:
|
||||||
|
name_parts.append(person.last_name)
|
||||||
|
full_name = " ".join(name_parts) if name_parts else person.alias_name or "نامشخص"
|
||||||
|
|
||||||
|
customer = CustomerResponse(
|
||||||
|
id=person.id,
|
||||||
|
name=full_name,
|
||||||
|
code=str(person.code) if person.code else None,
|
||||||
|
phone=person.phone or person.mobile,
|
||||||
|
email=person.email,
|
||||||
|
address=person.address,
|
||||||
|
is_active=True, # اشخاص همیشه فعال در نظر گرفته میشوند
|
||||||
|
created_at=person.created_at.isoformat() if person.created_at else None
|
||||||
|
)
|
||||||
|
customers.append(customer)
|
||||||
|
|
||||||
|
# محاسبه تعداد کل
|
||||||
|
total_count = count_persons(
|
||||||
|
db=db,
|
||||||
|
business_id=search_request.business_id,
|
||||||
|
search_query=search_request.search
|
||||||
|
)
|
||||||
|
|
||||||
|
has_more = len(customers) == search_request.limit
|
||||||
|
|
||||||
|
return CustomerSearchResponse(
|
||||||
|
customers=customers,
|
||||||
|
total=total_count,
|
||||||
|
page=search_request.page,
|
||||||
|
limit=search_request.limit,
|
||||||
|
has_more=has_more
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/detail/{customer_id}",
|
||||||
|
summary="دریافت اطلاعات مشتری",
|
||||||
|
description="دریافت اطلاعات کامل یک مشتری بر اساس شناسه",
|
||||||
|
response_model=CustomerResponse,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "اطلاعات مشتری با موفقیت دریافت شد"
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
"description": "کاربر احراز هویت نشده است"
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
"description": "دسترسی غیرمجاز"
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"description": "مشتری یافت نشد"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def get_customer(
|
||||||
|
customer_id: int,
|
||||||
|
business_id: int,
|
||||||
|
request: Request,
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: None = Depends(require_business_access_dep)
|
||||||
|
):
|
||||||
|
"""دریافت اطلاعات یک مشتری"""
|
||||||
|
|
||||||
|
# دریافت اطلاعات شخص
|
||||||
|
person_data = get_person_by_id(db, customer_id, business_id)
|
||||||
|
|
||||||
|
if not person_data:
|
||||||
|
raise HTTPException(status_code=404, detail="مشتری یافت نشد")
|
||||||
|
|
||||||
|
# ساخت نام کامل
|
||||||
|
name_parts = []
|
||||||
|
if person_data.get('alias_name'):
|
||||||
|
name_parts.append(person_data['alias_name'])
|
||||||
|
if person_data.get('first_name'):
|
||||||
|
name_parts.append(person_data['first_name'])
|
||||||
|
if person_data.get('last_name'):
|
||||||
|
name_parts.append(person_data['last_name'])
|
||||||
|
full_name = " ".join(name_parts) if name_parts else person_data.get('alias_name', 'نامشخص')
|
||||||
|
|
||||||
|
customer = CustomerResponse(
|
||||||
|
id=person_data['id'],
|
||||||
|
name=full_name,
|
||||||
|
code=str(person_data['code']) if person_data.get('code') else None,
|
||||||
|
phone=person_data.get('phone') or person_data.get('mobile'),
|
||||||
|
email=person_data.get('email'),
|
||||||
|
address=person_data.get('address'),
|
||||||
|
is_active=True, # اشخاص همیشه فعال در نظر گرفته میشوند
|
||||||
|
created_at=person_data.get('created_at')
|
||||||
|
)
|
||||||
|
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/check-access",
|
||||||
|
summary="بررسی دسترسی به مشتریها",
|
||||||
|
description="بررسی دسترسی کاربر به بخش مشتریها",
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "دسترسی مجاز است"
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
"description": "کاربر احراز هویت نشده است"
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
"description": "دسترسی غیرمجاز"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def check_customer_access(
|
||||||
|
business_id: int,
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_access_dep)
|
||||||
|
):
|
||||||
|
"""بررسی دسترسی به بخش مشتریها"""
|
||||||
|
|
||||||
|
# در اینجا میتوانید منطق بررسی دسترسی join را پیادهسازی کنید
|
||||||
|
# برای مثال: بررسی اینکه آیا کاربر دسترسی به اشخاص یا join permission دارد
|
||||||
|
|
||||||
|
return {"access": True, "message": "دسترسی مجاز است"}
|
||||||
|
|
@ -34,12 +34,7 @@ class Person(Base):
|
||||||
alias_name: Mapped[str] = mapped_column(String(255), nullable=False, index=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="نام")
|
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام")
|
||||||
last_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(
|
person_types: Mapped[str] = mapped_column(Text, nullable=False, comment="لیست انواع شخص به صورت JSON")
|
||||||
SQLEnum(PersonType, values_callable=lambda obj: [e.value for e in obj], name="person_type_enum"),
|
|
||||||
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="نام شرکت")
|
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت")
|
||||||
payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شناسه پرداخت")
|
payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شناسه پرداخت")
|
||||||
# سهام
|
# سهام
|
||||||
|
|
|
||||||
|
|
@ -212,3 +212,10 @@ def require_business_management_dep(auth_context: AuthContext = Depends(get_curr
|
||||||
"""FastAPI dependency برای بررسی مجوز مدیریت کسب و کارها."""
|
"""FastAPI dependency برای بررسی مجوز مدیریت کسب و کارها."""
|
||||||
if not auth_context.has_app_permission("business_management"):
|
if not auth_context.has_app_permission("business_management"):
|
||||||
raise ApiError("FORBIDDEN", "Missing app permission: business_management", http_status=403)
|
raise ApiError("FORBIDDEN", "Missing app permission: business_management", http_status=403)
|
||||||
|
|
||||||
|
|
||||||
|
def require_business_access_dep(auth_context: AuthContext = Depends(get_current_user)) -> None:
|
||||||
|
"""FastAPI dependency برای بررسی دسترسی به کسب و کار."""
|
||||||
|
# در اینجا میتوانید منطق بررسی دسترسی به کسب و کار را پیادهسازی کنید
|
||||||
|
# برای مثال: بررسی اینکه آیا کاربر دسترسی به کسب و کار دارد
|
||||||
|
pass
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from adapters.api.v1.product_attributes import router as product_attributes_rout
|
||||||
from adapters.api.v1.products import router as products_router
|
from adapters.api.v1.products import router as products_router
|
||||||
from adapters.api.v1.price_lists import router as price_lists_router
|
from adapters.api.v1.price_lists import router as price_lists_router
|
||||||
from adapters.api.v1.persons import router as persons_router
|
from adapters.api.v1.persons import router as persons_router
|
||||||
|
from adapters.api.v1.customers import router as customers_router
|
||||||
from adapters.api.v1.bank_accounts import router as bank_accounts_router
|
from adapters.api.v1.bank_accounts import router as bank_accounts_router
|
||||||
from adapters.api.v1.cash_registers import router as cash_registers_router
|
from adapters.api.v1.cash_registers import router as cash_registers_router
|
||||||
from adapters.api.v1.petty_cash import router as petty_cash_router
|
from adapters.api.v1.petty_cash import router as petty_cash_router
|
||||||
|
|
@ -295,6 +296,7 @@ def create_app() -> FastAPI:
|
||||||
application.include_router(products_router, prefix=settings.api_v1_prefix)
|
application.include_router(products_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
|
application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(persons_router, prefix=settings.api_v1_prefix)
|
application.include_router(persons_router, prefix=settings.api_v1_prefix)
|
||||||
|
application.include_router(customers_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix)
|
application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(cash_registers_router, prefix=settings.api_v1_prefix)
|
application.include_router(cash_registers_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(petty_cash_router, prefix=settings.api_v1_prefix)
|
application.include_router(petty_cash_router, prefix=settings.api_v1_prefix)
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques
|
||||||
first_name=person_data.first_name,
|
first_name=person_data.first_name,
|
||||||
last_name=person_data.last_name,
|
last_name=person_data.last_name,
|
||||||
# ذخیره مقدار Enum با مقدار فارسی (values_callable در مدل مقادیر فارسی را مینویسد)
|
# ذخیره مقدار Enum با مقدار فارسی (values_callable در مدل مقادیر فارسی را مینویسد)
|
||||||
person_type=(mapped_single_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,
|
person_types=json.dumps(types_list, ensure_ascii=False) if types_list else None,
|
||||||
company_name=person_data.company_name,
|
company_name=person_data.company_name,
|
||||||
payment_id=person_data.payment_id,
|
payment_id=person_data.payment_id,
|
||||||
|
|
@ -198,14 +197,6 @@ def get_persons_by_business(
|
||||||
query = query.filter(Person.code.in_(value))
|
query = query.filter(Person.code.in_(value))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# نوع شخص تکانتخابی
|
|
||||||
if field == 'person_type':
|
|
||||||
if operator == '=':
|
|
||||||
query = query.filter(Person.person_type == value)
|
|
||||||
elif operator == 'in' and isinstance(value, list):
|
|
||||||
query = query.filter(Person.person_type.in_(value))
|
|
||||||
continue
|
|
||||||
|
|
||||||
# انواع شخص چندانتخابی (رشته JSON)
|
# انواع شخص چندانتخابی (رشته JSON)
|
||||||
if field == 'person_types':
|
if field == 'person_types':
|
||||||
if operator == '=' and isinstance(value, str):
|
if operator == '=' and isinstance(value, str):
|
||||||
|
|
@ -295,8 +286,7 @@ def get_persons_by_business(
|
||||||
query = query.order_by(Person.first_name.desc() if sort_desc else Person.first_name.asc())
|
query = query.order_by(Person.first_name.desc() if sort_desc else Person.first_name.asc())
|
||||||
elif sort_by == 'last_name':
|
elif sort_by == 'last_name':
|
||||||
query = query.order_by(Person.last_name.desc() if sort_desc else Person.last_name.asc())
|
query = query.order_by(Person.last_name.desc() if sort_desc else Person.last_name.asc())
|
||||||
elif sort_by == 'person_type':
|
# person_type sorting removed - use person_types instead
|
||||||
query = query.order_by(Person.person_type.desc() if sort_desc else Person.person_type.asc())
|
|
||||||
elif sort_by == 'created_at':
|
elif sort_by == 'created_at':
|
||||||
query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc())
|
query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc())
|
||||||
elif sort_by == 'updated_at':
|
elif sort_by == 'updated_at':
|
||||||
|
|
@ -367,23 +357,7 @@ def update_person(
|
||||||
types_list = [t.value if hasattr(t, 'value') else str(t) for t in incoming]
|
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.person_types = json.dumps(types_list, ensure_ascii=False) if types_list else None
|
||||||
# همگام کردن person_type تکی برای سازگاری
|
# همگام کردن person_type تکی برای سازگاری
|
||||||
if types_list:
|
# person_type handling removed - only person_types is used now
|
||||||
# مقدار Enum را با مقدار فارسی ست میکنیم
|
|
||||||
try:
|
|
||||||
person.person_type = PersonType(types_list[0])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# مدیریت person_type تکی از اسکیما
|
|
||||||
if 'person_type' in update_data and update_data['person_type'] is not None:
|
|
||||||
single_type = update_data['person_type']
|
|
||||||
# نگاشت به Enum (مقدار فارسی)
|
|
||||||
try:
|
|
||||||
person.person_type = PersonType(getattr(single_type, 'value', str(single_type)))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# پس از ست کردن مستقیم، از دیکشنری حذف شود تا در حلقه عمومی دوباره اعمال نشود
|
|
||||||
update_data.pop('person_type', None)
|
|
||||||
|
|
||||||
# اگر شخص سهامدار شد، share_count معتبر باشد
|
# اگر شخص سهامدار شد، share_count معتبر باشد
|
||||||
resulting_types: List[str] = []
|
resulting_types: List[str] = []
|
||||||
|
|
@ -394,7 +368,7 @@ def update_person(
|
||||||
resulting_types = [str(x) for x in tmp]
|
resulting_types = [str(x) for x in tmp]
|
||||||
except Exception:
|
except Exception:
|
||||||
resulting_types = []
|
resulting_types = []
|
||||||
if (person.person_type == 'سهامدار') or ('سهامدار' in resulting_types):
|
if 'سهامدار' in resulting_types:
|
||||||
sc_val2 = update_data.get('share_count', person.share_count)
|
sc_val2 = update_data.get('share_count', person.share_count)
|
||||||
if sc_val2 is None or (isinstance(sc_val2, int) and sc_val2 <= 0):
|
if sc_val2 is None or (isinstance(sc_val2, int) and sc_val2 <= 0):
|
||||||
raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400)
|
raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400)
|
||||||
|
|
@ -442,7 +416,7 @@ def get_person_summary(db: Session, business_id: int) -> Dict[str, Any]:
|
||||||
by_type = {}
|
by_type = {}
|
||||||
for person_type in PersonType:
|
for person_type in PersonType:
|
||||||
count = db.query(Person).filter(
|
count = db.query(Person).filter(
|
||||||
and_(Person.business_id == business_id, Person.person_type == person_type)
|
and_(Person.business_id == business_id, Person.person_types.ilike(f'%"{person_type.value}"%'))
|
||||||
).count()
|
).count()
|
||||||
by_type[person_type.value] = count
|
by_type[person_type.value] = count
|
||||||
|
|
||||||
|
|
@ -473,7 +447,6 @@ def _person_to_dict(person: Person) -> Dict[str, Any]:
|
||||||
'alias_name': person.alias_name,
|
'alias_name': person.alias_name,
|
||||||
'first_name': person.first_name,
|
'first_name': person.first_name,
|
||||||
'last_name': person.last_name,
|
'last_name': person.last_name,
|
||||||
'person_type': person.person_type.value,
|
|
||||||
'person_types': types_list,
|
'person_types': types_list,
|
||||||
'company_name': person.company_name,
|
'company_name': person.company_name,
|
||||||
'payment_id': person.payment_id,
|
'payment_id': person.payment_id,
|
||||||
|
|
@ -514,3 +487,51 @@ def _person_to_dict(person: Person) -> Dict[str, Any]:
|
||||||
for ba in person.bank_accounts
|
for ba in person.bank_accounts
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def search_persons(db: Session, business_id: int, search_query: Optional[str] = None,
|
||||||
|
page: int = 1, limit: int = 20) -> List[Person]:
|
||||||
|
"""جستوجو در اشخاص"""
|
||||||
|
query = db.query(Person).filter(Person.business_id == business_id)
|
||||||
|
|
||||||
|
if search_query:
|
||||||
|
# جستوجو در نام، نام خانوادگی، نام مستعار، کد، تلفن و ایمیل
|
||||||
|
search_filter = or_(
|
||||||
|
Person.alias_name.ilike(f"%{search_query}%"),
|
||||||
|
Person.first_name.ilike(f"%{search_query}%"),
|
||||||
|
Person.last_name.ilike(f"%{search_query}%"),
|
||||||
|
Person.company_name.ilike(f"%{search_query}%"),
|
||||||
|
Person.phone.ilike(f"%{search_query}%"),
|
||||||
|
Person.mobile.ilike(f"%{search_query}%"),
|
||||||
|
Person.email.ilike(f"%{search_query}%"),
|
||||||
|
Person.code == int(search_query) if search_query.isdigit() else False
|
||||||
|
)
|
||||||
|
query = query.filter(search_filter)
|
||||||
|
|
||||||
|
# مرتبسازی بر اساس نام مستعار
|
||||||
|
query = query.order_by(Person.alias_name)
|
||||||
|
|
||||||
|
# صفحهبندی
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
return query.offset(offset).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
def count_persons(db: Session, business_id: int, search_query: Optional[str] = None) -> int:
|
||||||
|
"""شمارش تعداد اشخاص"""
|
||||||
|
query = db.query(Person).filter(Person.business_id == business_id)
|
||||||
|
|
||||||
|
if search_query:
|
||||||
|
# جستوجو در نام، نام خانوادگی، نام مستعار، کد، تلفن و ایمیل
|
||||||
|
search_filter = or_(
|
||||||
|
Person.alias_name.ilike(f"%{search_query}%"),
|
||||||
|
Person.first_name.ilike(f"%{search_query}%"),
|
||||||
|
Person.last_name.ilike(f"%{search_query}%"),
|
||||||
|
Person.company_name.ilike(f"%{search_query}%"),
|
||||||
|
Person.phone.ilike(f"%{search_query}%"),
|
||||||
|
Person.mobile.ilike(f"%{search_query}%"),
|
||||||
|
Person.email.ilike(f"%{search_query}%"),
|
||||||
|
Person.code == int(search_query) if search_query.isdigit() else False
|
||||||
|
)
|
||||||
|
query = query.filter(search_filter)
|
||||||
|
|
||||||
|
return query.count()
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ adapters/api/v1/businesses.py
|
||||||
adapters/api/v1/cash_registers.py
|
adapters/api/v1/cash_registers.py
|
||||||
adapters/api/v1/categories.py
|
adapters/api/v1/categories.py
|
||||||
adapters/api/v1/currencies.py
|
adapters/api/v1/currencies.py
|
||||||
|
adapters/api/v1/customers.py
|
||||||
adapters/api/v1/health.py
|
adapters/api/v1/health.py
|
||||||
adapters/api/v1/persons.py
|
adapters/api/v1/persons.py
|
||||||
adapters/api/v1/petty_cash.py
|
adapters/api/v1/petty_cash.py
|
||||||
|
|
@ -177,6 +178,7 @@ migrations/versions/4b2ea782bcb3_merge_heads.py
|
||||||
migrations/versions/5553f8745c6e_add_support_tables.py
|
migrations/versions/5553f8745c6e_add_support_tables.py
|
||||||
migrations/versions/9f9786ae7191_create_tax_units_table.py
|
migrations/versions/9f9786ae7191_create_tax_units_table.py
|
||||||
migrations/versions/a1443c153b47_merge_heads.py
|
migrations/versions/a1443c153b47_merge_heads.py
|
||||||
|
migrations/versions/c302bc2f2cb8_remove_person_type_column.py
|
||||||
migrations/versions/caf3f4ef4b76_add_tax_units_table.py
|
migrations/versions/caf3f4ef4b76_add_tax_units_table.py
|
||||||
migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py
|
migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py
|
||||||
migrations/versions/f876bfa36805_merge_multiple_heads.py
|
migrations/versions/f876bfa36805_merge_multiple_heads.py
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""remove_person_type_column
|
||||||
|
|
||||||
|
Revision ID: c302bc2f2cb8
|
||||||
|
Revises: 1f0abcdd7300
|
||||||
|
Create Date: 2025-10-04 19:04:30.866110
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c302bc2f2cb8'
|
||||||
|
down_revision = '1f0abcdd7300'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# حذف ستون person_type از جدول persons
|
||||||
|
op.drop_column('persons', 'person_type')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# بازگردانی ستون person_type
|
||||||
|
op.add_column('persons',
|
||||||
|
sa.Column('person_type',
|
||||||
|
sa.Enum('مشتری', 'بازاریاب', 'کارمند', 'تامینکننده', 'همکار', 'فروشنده', 'سهامدار', name='person_type_enum'),
|
||||||
|
nullable=False,
|
||||||
|
comment='نوع شخص'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -652,6 +652,7 @@ class _MyAppState extends State<MyApp> {
|
||||||
child: NewInvoicePage(
|
child: NewInvoicePage(
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
authStore: _authStore!,
|
authStore: _authStore!,
|
||||||
|
calendarController: _calendarController!,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
61
hesabixUI/hesabix_ui/lib/models/customer_model.dart
Normal file
61
hesabixUI/hesabix_ui/lib/models/customer_model.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
class Customer {
|
||||||
|
final int id;
|
||||||
|
final String name;
|
||||||
|
final String? code;
|
||||||
|
final String? phone;
|
||||||
|
final String? email;
|
||||||
|
final String? address;
|
||||||
|
final bool isActive;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
const Customer({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.code,
|
||||||
|
this.phone,
|
||||||
|
this.email,
|
||||||
|
this.address,
|
||||||
|
this.isActive = true,
|
||||||
|
this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Customer.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Customer(
|
||||||
|
id: json['id'] as int,
|
||||||
|
name: json['name'] as String,
|
||||||
|
code: json['code'] as String?,
|
||||||
|
phone: json['phone'] as String?,
|
||||||
|
email: json['email'] as String?,
|
||||||
|
address: json['address'] as String?,
|
||||||
|
isActive: json['is_active'] ?? true,
|
||||||
|
createdAt: json['created_at'] != null
|
||||||
|
? DateTime.tryParse(json['created_at'].toString())
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'code': code,
|
||||||
|
'phone': phone,
|
||||||
|
'email': email,
|
||||||
|
'address': address,
|
||||||
|
'is_active': isActive,
|
||||||
|
'created_at': createdAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
return other is Customer && other.id == id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => id.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => name;
|
||||||
|
}
|
||||||
25
hesabixUI/hesabix_ui/lib/models/invoice_type_model.dart
Normal file
25
hesabixUI/hesabix_ui/lib/models/invoice_type_model.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
enum InvoiceType {
|
||||||
|
sales('sales', 'فروش'),
|
||||||
|
salesReturn('sales_return', 'برگشت از فروش'),
|
||||||
|
purchase('purchase', 'خرید'),
|
||||||
|
purchaseReturn('purchase_return', 'برگشت از خرید'),
|
||||||
|
waste('waste', 'ضایعات'),
|
||||||
|
directConsumption('direct_consumption', 'مصرف مستقیم'),
|
||||||
|
production('production', 'تولید');
|
||||||
|
|
||||||
|
const InvoiceType(this.value, this.label);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
static InvoiceType? fromValue(String value) {
|
||||||
|
for (final type in InvoiceType.values) {
|
||||||
|
if (type.value == value) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<InvoiceType> get allTypes => InvoiceType.values;
|
||||||
|
}
|
||||||
|
|
@ -102,7 +102,6 @@ class Person {
|
||||||
final String aliasName;
|
final String aliasName;
|
||||||
final String? firstName;
|
final String? firstName;
|
||||||
final String? lastName;
|
final String? lastName;
|
||||||
final PersonType personType;
|
|
||||||
final List<PersonType> personTypes;
|
final List<PersonType> personTypes;
|
||||||
final String? companyName;
|
final String? companyName;
|
||||||
final String? paymentId;
|
final String? paymentId;
|
||||||
|
|
@ -140,8 +139,7 @@ class Person {
|
||||||
required this.aliasName,
|
required this.aliasName,
|
||||||
this.firstName,
|
this.firstName,
|
||||||
this.lastName,
|
this.lastName,
|
||||||
required this.personType,
|
required this.personTypes,
|
||||||
this.personTypes = const [],
|
|
||||||
this.companyName,
|
this.companyName,
|
||||||
this.paymentId,
|
this.paymentId,
|
||||||
this.nationalId,
|
this.nationalId,
|
||||||
|
|
@ -176,9 +174,6 @@ class Person {
|
||||||
?.map((e) => PersonType.fromString(e.toString()))
|
?.map((e) => PersonType.fromString(e.toString()))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[];
|
[];
|
||||||
final PersonType primaryType = types.isNotEmpty
|
|
||||||
? types.first
|
|
||||||
: PersonType.fromString(json['person_type']);
|
|
||||||
return Person(
|
return Person(
|
||||||
id: json['id'],
|
id: json['id'],
|
||||||
businessId: json['business_id'],
|
businessId: json['business_id'],
|
||||||
|
|
@ -186,7 +181,6 @@ class Person {
|
||||||
aliasName: json['alias_name'],
|
aliasName: json['alias_name'],
|
||||||
firstName: json['first_name'],
|
firstName: json['first_name'],
|
||||||
lastName: json['last_name'],
|
lastName: json['last_name'],
|
||||||
personType: primaryType,
|
|
||||||
personTypes: types,
|
personTypes: types,
|
||||||
companyName: json['company_name'],
|
companyName: json['company_name'],
|
||||||
paymentId: json['payment_id'],
|
paymentId: json['payment_id'],
|
||||||
|
|
@ -228,7 +222,6 @@ class Person {
|
||||||
'alias_name': aliasName,
|
'alias_name': aliasName,
|
||||||
'first_name': firstName,
|
'first_name': firstName,
|
||||||
'last_name': lastName,
|
'last_name': lastName,
|
||||||
'person_type': personType.persianName,
|
|
||||||
'person_types': personTypes.map((t) => t.persianName).toList(),
|
'person_types': personTypes.map((t) => t.persianName).toList(),
|
||||||
'company_name': companyName,
|
'company_name': companyName,
|
||||||
'payment_id': paymentId,
|
'payment_id': paymentId,
|
||||||
|
|
@ -266,7 +259,7 @@ class Person {
|
||||||
String? aliasName,
|
String? aliasName,
|
||||||
String? firstName,
|
String? firstName,
|
||||||
String? lastName,
|
String? lastName,
|
||||||
PersonType? personType,
|
List<PersonType>? personTypes,
|
||||||
String? companyName,
|
String? companyName,
|
||||||
String? paymentId,
|
String? paymentId,
|
||||||
String? nationalId,
|
String? nationalId,
|
||||||
|
|
@ -293,7 +286,7 @@ class Person {
|
||||||
aliasName: aliasName ?? this.aliasName,
|
aliasName: aliasName ?? this.aliasName,
|
||||||
firstName: firstName ?? this.firstName,
|
firstName: firstName ?? this.firstName,
|
||||||
lastName: lastName ?? this.lastName,
|
lastName: lastName ?? this.lastName,
|
||||||
personType: personType ?? this.personType,
|
personTypes: personTypes ?? this.personTypes,
|
||||||
companyName: companyName ?? this.companyName,
|
companyName: companyName ?? this.companyName,
|
||||||
paymentId: paymentId ?? this.paymentId,
|
paymentId: paymentId ?? this.paymentId,
|
||||||
nationalId: nationalId ?? this.nationalId,
|
nationalId: nationalId ?? this.nationalId,
|
||||||
|
|
@ -444,7 +437,6 @@ class PersonUpdateRequest {
|
||||||
final String? aliasName;
|
final String? aliasName;
|
||||||
final String? firstName;
|
final String? firstName;
|
||||||
final String? lastName;
|
final String? lastName;
|
||||||
final PersonType? personType;
|
|
||||||
final List<PersonType>? personTypes;
|
final List<PersonType>? personTypes;
|
||||||
final String? companyName;
|
final String? companyName;
|
||||||
final String? paymentId;
|
final String? paymentId;
|
||||||
|
|
@ -476,7 +468,6 @@ class PersonUpdateRequest {
|
||||||
this.aliasName,
|
this.aliasName,
|
||||||
this.firstName,
|
this.firstName,
|
||||||
this.lastName,
|
this.lastName,
|
||||||
this.personType,
|
|
||||||
this.personTypes,
|
this.personTypes,
|
||||||
this.companyName,
|
this.companyName,
|
||||||
this.paymentId,
|
this.paymentId,
|
||||||
|
|
@ -511,7 +502,6 @@ class PersonUpdateRequest {
|
||||||
if (aliasName != null) json['alias_name'] = aliasName;
|
if (aliasName != null) json['alias_name'] = aliasName;
|
||||||
if (firstName != null) json['first_name'] = firstName;
|
if (firstName != null) json['first_name'] = firstName;
|
||||||
if (lastName != null) json['last_name'] = lastName;
|
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 (personTypes != null) json['person_types'] = personTypes!.map((t) => t.persianName).toList();
|
||||||
if (companyName != null) json['company_name'] = companyName;
|
if (companyName != null) json['company_name'] = companyName;
|
||||||
if (paymentId != null) json['payment_id'] = paymentId;
|
if (paymentId != null) json['payment_id'] = paymentId;
|
||||||
|
|
|
||||||
|
|
@ -948,6 +948,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
showAddBankAccountDialog();
|
showAddBankAccountDialog();
|
||||||
} else if (item.label == t.cashBox) {
|
} else if (item.label == t.cashBox) {
|
||||||
showAddCashBoxDialog();
|
showAddCashBoxDialog();
|
||||||
|
} else if (item.label == t.invoice) {
|
||||||
|
// Navigate to add invoice
|
||||||
|
context.go('/business/${widget.businessId}/invoice/new');
|
||||||
}
|
}
|
||||||
// سایر مسیرهای افزودن در آینده متصل میشوند
|
// سایر مسیرهای افزودن در آینده متصل میشوند
|
||||||
},
|
},
|
||||||
|
|
@ -1044,6 +1047,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
// در حال حاضر فقط اشخاص پشتیبانی میشود
|
// در حال حاضر فقط اشخاص پشتیبانی میشود
|
||||||
if (item.label == t.people) {
|
if (item.label == t.people) {
|
||||||
showAddPersonDialog();
|
showAddPersonDialog();
|
||||||
|
} else if (item.label == t.invoice) {
|
||||||
|
// Navigate to add invoice
|
||||||
|
context.go('/business/${widget.businessId}/invoice/new');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
|
||||||
|
|
@ -34,20 +34,20 @@ class _InvoicePageState extends State<InvoicePage> {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.receipt,
|
Icons.receipt,
|
||||||
size: 80,
|
size: 80,
|
||||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.6),
|
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
t.invoice,
|
t.invoice,
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'صفحه فاکتور در حال توسعه است',
|
'صفحه فاکتور در حال توسعه است',
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,68 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
import '../../core/auth_store.dart';
|
import '../../core/auth_store.dart';
|
||||||
|
import '../../core/calendar_controller.dart';
|
||||||
import '../../widgets/permission/access_denied_page.dart';
|
import '../../widgets/permission/access_denied_page.dart';
|
||||||
|
import '../../widgets/invoice/invoice_type_combobox.dart';
|
||||||
|
import '../../widgets/invoice/code_field_widget.dart';
|
||||||
|
import '../../widgets/invoice/customer_combobox_widget.dart';
|
||||||
|
import '../../widgets/invoice/seller_picker_widget.dart';
|
||||||
|
import '../../widgets/invoice/commission_percentage_field.dart';
|
||||||
|
import '../../widgets/invoice/commission_type_selector.dart';
|
||||||
|
import '../../widgets/invoice/commission_amount_field.dart';
|
||||||
|
import '../../widgets/date_input_field.dart';
|
||||||
|
import '../../widgets/banking/currency_picker_widget.dart';
|
||||||
|
import '../../core/date_utils.dart';
|
||||||
|
import '../../models/invoice_type_model.dart';
|
||||||
|
import '../../models/customer_model.dart';
|
||||||
|
import '../../models/person_model.dart';
|
||||||
|
|
||||||
class NewInvoicePage extends StatefulWidget {
|
class NewInvoicePage extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
final AuthStore authStore;
|
final AuthStore authStore;
|
||||||
|
final CalendarController calendarController;
|
||||||
|
|
||||||
const NewInvoicePage({
|
const NewInvoicePage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.businessId,
|
required this.businessId,
|
||||||
required this.authStore,
|
required this.authStore,
|
||||||
|
required this.calendarController,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<NewInvoicePage> createState() => _NewInvoicePageState();
|
State<NewInvoicePage> createState() => _NewInvoicePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NewInvoicePageState extends State<NewInvoicePage> {
|
class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
|
||||||
|
InvoiceType? _selectedInvoiceType;
|
||||||
|
bool _isDraft = false;
|
||||||
|
String? _invoiceNumber;
|
||||||
|
bool _autoGenerateInvoiceNumber = true;
|
||||||
|
Customer? _selectedCustomer;
|
||||||
|
Person? _selectedSeller;
|
||||||
|
double? _commissionPercentage;
|
||||||
|
double? _commissionAmount;
|
||||||
|
CommissionType? _commissionType;
|
||||||
|
DateTime? _invoiceDate;
|
||||||
|
DateTime? _dueDate;
|
||||||
|
int? _selectedCurrencyId;
|
||||||
|
String? _invoiceTitle;
|
||||||
|
String? _invoiceReference;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 4, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = AppLocalizations.of(context);
|
final t = AppLocalizations.of(context);
|
||||||
|
|
@ -29,53 +74,830 @@ class _NewInvoicePageState extends State<NewInvoicePage> {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(t.addInvoice),
|
title: Text(t.addInvoice),
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
toolbarHeight: 56,
|
||||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
bottom: TabBar(
|
||||||
elevation: 0,
|
controller: _tabController,
|
||||||
),
|
tabs: const [
|
||||||
body: Center(
|
Tab(
|
||||||
child: Column(
|
icon: Icon(Icons.info_outline),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
text: 'اطلاعات فاکتور',
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.add_circle_outline,
|
|
||||||
size: 80,
|
|
||||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.6),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
Tab(
|
||||||
Text(
|
icon: Icon(Icons.inventory_2_outlined),
|
||||||
t.addInvoice,
|
text: 'کالاها و خدمات',
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
Tab(
|
||||||
Text(
|
icon: Icon(Icons.receipt_long_outlined),
|
||||||
'فرم ایجاد فاکتور جدید در حال توسعه است',
|
text: 'تراکنشها',
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
Tab(
|
||||||
ElevatedButton.icon(
|
icon: Icon(Icons.settings_outlined),
|
||||||
onPressed: () {
|
text: 'تنظیمات',
|
||||||
// TODO: پیادهسازی منطق ایجاد فاکتور
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('فرم ایجاد فاکتور به زودی اضافه خواهد شد'),
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: Text(t.addInvoice),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
// تب اطلاعات فاکتور
|
||||||
|
_buildInvoiceInfoTab(),
|
||||||
|
// تب کالاها و خدمات
|
||||||
|
_buildProductsTab(),
|
||||||
|
// تب تراکنشها
|
||||||
|
_buildTransactionsTab(),
|
||||||
|
// تب تنظیمات
|
||||||
|
_buildSettingsTab(),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Widget _buildInvoiceInfoTab() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1600),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// فیلدهای اصلی - responsive layout
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// اگر عرض صفحه کمتر از 768 پیکسل باشد، تک ستونه
|
||||||
|
if (constraints.maxWidth < 768) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// نوع فاکتور
|
||||||
|
InvoiceTypeCombobox(
|
||||||
|
selectedType: _selectedInvoiceType,
|
||||||
|
onTypeChanged: (type) {
|
||||||
|
setState(() {
|
||||||
|
_selectedInvoiceType = type;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isDraft: _isDraft,
|
||||||
|
onDraftChanged: (isDraft) {
|
||||||
|
setState(() {
|
||||||
|
_isDraft = isDraft;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: true,
|
||||||
|
label: 'نوع فاکتور',
|
||||||
|
hintText: 'انتخاب نوع فاکتور',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// شماره فاکتور
|
||||||
|
CodeFieldWidget(
|
||||||
|
initialValue: _invoiceNumber,
|
||||||
|
onChanged: (number) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceNumber = number;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: true,
|
||||||
|
label: 'شماره فاکتور',
|
||||||
|
hintText: 'مثال: INV-2024-001',
|
||||||
|
autoGenerateCode: _autoGenerateInvoiceNumber,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// تاریخ فاکتور
|
||||||
|
DateInputField(
|
||||||
|
value: _invoiceDate,
|
||||||
|
labelText: 'تاریخ فاکتور *',
|
||||||
|
hintText: 'انتخاب تاریخ فاکتور',
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
onChanged: (date) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceDate = date;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// تاریخ سررسید
|
||||||
|
DateInputField(
|
||||||
|
value: _dueDate,
|
||||||
|
labelText: 'تاریخ سررسید',
|
||||||
|
hintText: 'انتخاب تاریخ سررسید',
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
onChanged: (date) {
|
||||||
|
setState(() {
|
||||||
|
_dueDate = date;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// مشتری
|
||||||
|
CustomerComboboxWidget(
|
||||||
|
selectedCustomer: _selectedCustomer,
|
||||||
|
onCustomerChanged: (customer) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCustomer = customer;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
businessId: widget.businessId,
|
||||||
|
authStore: widget.authStore,
|
||||||
|
isRequired: false,
|
||||||
|
label: 'مشتری',
|
||||||
|
hintText: 'انتخاب مشتری',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// ارز فاکتور
|
||||||
|
CurrencyPickerWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedCurrencyId: _selectedCurrencyId,
|
||||||
|
onChanged: (currencyId) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCurrencyId = currencyId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
label: 'ارز فاکتور',
|
||||||
|
hintText: 'انتخاب ارز فاکتور',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// فروشنده و کارمزد (فقط برای فروش و برگشت فروش)
|
||||||
|
if (_selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SellerPickerWidget(
|
||||||
|
selectedSeller: _selectedSeller,
|
||||||
|
onSellerChanged: (seller) {
|
||||||
|
setState(() {
|
||||||
|
_selectedSeller = seller;
|
||||||
|
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
|
||||||
|
if (seller != null) {
|
||||||
|
if (seller.commissionSalePercent != null) {
|
||||||
|
_commissionType = CommissionType.percentage;
|
||||||
|
_commissionPercentage = seller.commissionSalePercent;
|
||||||
|
_commissionAmount = null;
|
||||||
|
} else if (seller.commissionSalesAmount != null) {
|
||||||
|
_commissionType = CommissionType.amount;
|
||||||
|
_commissionAmount = seller.commissionSalesAmount;
|
||||||
|
_commissionPercentage = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_commissionType = null;
|
||||||
|
_commissionPercentage = null;
|
||||||
|
_commissionAmount = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
businessId: widget.businessId,
|
||||||
|
authStore: widget.authStore,
|
||||||
|
isRequired: false,
|
||||||
|
label: 'فروشنده/بازاریاب',
|
||||||
|
hintText: 'جستوجو و انتخاب فروشنده یا بازاریاب',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// فیلدهای کارمزد (فقط اگر فروشنده انتخاب شده باشد)
|
||||||
|
if (_selectedSeller != null) ...[
|
||||||
|
Expanded(
|
||||||
|
child: CommissionTypeSelector(
|
||||||
|
selectedType: _commissionType,
|
||||||
|
onTypeChanged: (type) {
|
||||||
|
setState(() {
|
||||||
|
_commissionType = type;
|
||||||
|
// پاک کردن مقادیر قبلی هنگام تغییر نوع
|
||||||
|
if (type == CommissionType.percentage) {
|
||||||
|
_commissionAmount = null;
|
||||||
|
} else if (type == CommissionType.amount) {
|
||||||
|
_commissionPercentage = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: false,
|
||||||
|
label: 'نوع کارمزد',
|
||||||
|
hintText: 'انتخاب نوع کارمزد',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// فیلد درصد کارمزد (فقط اگر نوع درصدی انتخاب شده)
|
||||||
|
if (_commissionType == CommissionType.percentage)
|
||||||
|
Expanded(
|
||||||
|
child: CommissionPercentageField(
|
||||||
|
initialValue: _commissionPercentage,
|
||||||
|
onChanged: (percentage) {
|
||||||
|
setState(() {
|
||||||
|
_commissionPercentage = percentage;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: false,
|
||||||
|
label: 'درصد کارمزد',
|
||||||
|
hintText: 'مثال: 5.5',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// فیلد مبلغ کارمزد (فقط اگر نوع مبلغی انتخاب شده)
|
||||||
|
else if (_commissionType == CommissionType.amount)
|
||||||
|
Expanded(
|
||||||
|
child: CommissionAmountField(
|
||||||
|
initialValue: _commissionAmount,
|
||||||
|
onChanged: (amount) {
|
||||||
|
setState(() {
|
||||||
|
_commissionAmount = amount;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: false,
|
||||||
|
label: 'مبلغ کارمزد',
|
||||||
|
hintText: 'مثال: 100000',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// عنوان فاکتور
|
||||||
|
TextFormField(
|
||||||
|
initialValue: _invoiceTitle,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceTitle = value.trim().isEmpty ? null : value.trim();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'عنوان فاکتور',
|
||||||
|
hintText: 'مثال: فروش محصولات',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// ارجاع
|
||||||
|
TextFormField(
|
||||||
|
initialValue: _invoiceReference,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceReference = value.trim().isEmpty ? null : value.trim();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'ارجاع',
|
||||||
|
hintText: 'مثال: PO-2024-001',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// برای دسکتاپ - چند ستونه
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// ردیف اول: 5 فیلد اصلی
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: InvoiceTypeCombobox(
|
||||||
|
selectedType: _selectedInvoiceType,
|
||||||
|
onTypeChanged: (type) {
|
||||||
|
setState(() {
|
||||||
|
_selectedInvoiceType = type;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isDraft: _isDraft,
|
||||||
|
onDraftChanged: (isDraft) {
|
||||||
|
setState(() {
|
||||||
|
_isDraft = isDraft;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: true,
|
||||||
|
label: 'نوع فاکتور',
|
||||||
|
hintText: 'انتخاب نوع فاکتور',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: CodeFieldWidget(
|
||||||
|
initialValue: _invoiceNumber,
|
||||||
|
onChanged: (number) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceNumber = number;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: true,
|
||||||
|
label: 'شماره فاکتور',
|
||||||
|
hintText: 'مثال: INV-2024-001',
|
||||||
|
autoGenerateCode: _autoGenerateInvoiceNumber,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: DateInputField(
|
||||||
|
value: _invoiceDate,
|
||||||
|
labelText: 'تاریخ فاکتور *',
|
||||||
|
hintText: 'انتخاب تاریخ فاکتور',
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
onChanged: (date) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceDate = date;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: DateInputField(
|
||||||
|
value: _dueDate,
|
||||||
|
labelText: 'تاریخ سررسید',
|
||||||
|
hintText: 'انتخاب تاریخ سررسید',
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
onChanged: (date) {
|
||||||
|
setState(() {
|
||||||
|
_dueDate = date;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: CustomerComboboxWidget(
|
||||||
|
selectedCustomer: _selectedCustomer,
|
||||||
|
onCustomerChanged: (customer) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCustomer = customer;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
businessId: widget.businessId,
|
||||||
|
authStore: widget.authStore,
|
||||||
|
isRequired: false,
|
||||||
|
label: 'مشتری',
|
||||||
|
hintText: 'انتخاب مشتری',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// ردیف دوم: ارز، عنوان فاکتور، ارجاع
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: CurrencyPickerWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedCurrencyId: _selectedCurrencyId,
|
||||||
|
onChanged: (currencyId) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCurrencyId = currencyId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
label: 'ارز فاکتور',
|
||||||
|
hintText: 'انتخاب ارز فاکتور',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: _invoiceTitle,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceTitle = value.trim().isEmpty ? null : value.trim();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'عنوان فاکتور',
|
||||||
|
hintText: 'مثال: فروش محصولات',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: _invoiceReference,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceReference = value.trim().isEmpty ? null : value.trim();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'ارجاع',
|
||||||
|
hintText: 'مثال: PO-2024-001',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(child: SizedBox()), // جای خالی
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(child: SizedBox()), // جای خالی
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// ردیف سوم: فروشنده و کارمزد (فقط برای فروش و برگشت فروش)
|
||||||
|
if (_selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn) ...[
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SellerPickerWidget(
|
||||||
|
selectedSeller: _selectedSeller,
|
||||||
|
onSellerChanged: (seller) {
|
||||||
|
setState(() {
|
||||||
|
_selectedSeller = seller;
|
||||||
|
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
|
||||||
|
if (seller != null) {
|
||||||
|
if (seller.commissionSalePercent != null) {
|
||||||
|
_commissionType = CommissionType.percentage;
|
||||||
|
_commissionPercentage = seller.commissionSalePercent;
|
||||||
|
_commissionAmount = null;
|
||||||
|
} else if (seller.commissionSalesAmount != null) {
|
||||||
|
_commissionType = CommissionType.amount;
|
||||||
|
_commissionAmount = seller.commissionSalesAmount;
|
||||||
|
_commissionPercentage = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_commissionType = null;
|
||||||
|
_commissionPercentage = null;
|
||||||
|
_commissionAmount = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
businessId: widget.businessId,
|
||||||
|
authStore: widget.authStore,
|
||||||
|
isRequired: false,
|
||||||
|
label: 'فروشنده/بازاریاب',
|
||||||
|
hintText: 'جستوجو و انتخاب فروشنده یا بازاریاب',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// فیلدهای کارمزد (فقط اگر فروشنده انتخاب شده باشد)
|
||||||
|
if (_selectedSeller != null) ...[
|
||||||
|
Expanded(
|
||||||
|
child: CommissionTypeSelector(
|
||||||
|
selectedType: _commissionType,
|
||||||
|
onTypeChanged: (type) {
|
||||||
|
setState(() {
|
||||||
|
_commissionType = type;
|
||||||
|
// پاک کردن مقادیر قبلی هنگام تغییر نوع
|
||||||
|
if (type == CommissionType.percentage) {
|
||||||
|
_commissionAmount = null;
|
||||||
|
} else if (type == CommissionType.amount) {
|
||||||
|
_commissionPercentage = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: false,
|
||||||
|
label: 'نوع کارمزد',
|
||||||
|
hintText: 'انتخاب نوع کارمزد',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// فیلد درصد کارمزد (فقط اگر نوع درصدی انتخاب شده)
|
||||||
|
if (_commissionType == CommissionType.percentage)
|
||||||
|
Expanded(
|
||||||
|
child: CommissionPercentageField(
|
||||||
|
initialValue: _commissionPercentage,
|
||||||
|
onChanged: (percentage) {
|
||||||
|
setState(() {
|
||||||
|
_commissionPercentage = percentage;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: false,
|
||||||
|
label: 'درصد کارمزد',
|
||||||
|
hintText: 'مثال: 5.5',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// فیلد مبلغ کارمزد (فقط اگر نوع مبلغی انتخاب شده)
|
||||||
|
else if (_commissionType == CommissionType.amount)
|
||||||
|
Expanded(
|
||||||
|
child: CommissionAmountField(
|
||||||
|
initialValue: _commissionAmount,
|
||||||
|
onChanged: (amount) {
|
||||||
|
setState(() {
|
||||||
|
_commissionAmount = amount;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: false,
|
||||||
|
label: 'مبلغ کارمزد',
|
||||||
|
hintText: 'مثال: 100000',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Expanded(child: SizedBox()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
] else ...[
|
||||||
|
const Expanded(child: SizedBox()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(child: SizedBox()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
const Expanded(child: SizedBox()), // جای خالی
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// دکمه ادامه
|
||||||
|
Center(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: (_selectedInvoiceType != null && _invoiceDate != null) ? _continueToInvoiceForm : null,
|
||||||
|
icon: const Icon(Icons.arrow_forward),
|
||||||
|
label: Text('ادامه ایجاد فاکتور'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
|
minimumSize: const Size(200, 48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// نمایش اطلاعات انتخاب شده
|
||||||
|
if (_selectedInvoiceType != null || _invoiceDate != null || _dueDate != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'اطلاعات انتخاب شده:',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// نمایش اطلاعات در دو ستون
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_selectedInvoiceType != null)
|
||||||
|
_buildInfoItem('نوع فاکتور', _selectedInvoiceType!.label),
|
||||||
|
if (_invoiceDate != null)
|
||||||
|
_buildInfoItem('تاریخ فاکتور', HesabixDateUtils.formatForDisplay(_invoiceDate, widget.calendarController.isJalali == true)),
|
||||||
|
if (_dueDate != null)
|
||||||
|
_buildInfoItem('تاریخ سررسید', HesabixDateUtils.formatForDisplay(_dueDate, widget.calendarController.isJalali == true)),
|
||||||
|
if (_selectedCurrencyId != null)
|
||||||
|
_buildInfoItem('ارز فاکتور', 'انتخاب شده'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_selectedSeller != null)
|
||||||
|
_buildInfoItem('فروشنده/بازاریاب', '${_selectedSeller!.displayName} (${_selectedSeller!.personTypes.isNotEmpty ? _selectedSeller!.personTypes.first.persianName : 'نامشخص'})'),
|
||||||
|
if (_commissionType != null)
|
||||||
|
_buildInfoItem('نوع کارمزد', _commissionType!.label),
|
||||||
|
if (_commissionPercentage != null)
|
||||||
|
_buildInfoItem('درصد کارمزد', '${_commissionPercentage!.toStringAsFixed(1)}%'),
|
||||||
|
if (_commissionAmount != null)
|
||||||
|
_buildInfoItem('مبلغ کارمزد', '${_commissionAmount!.toStringAsFixed(0)} ریال'),
|
||||||
|
if (_invoiceTitle != null)
|
||||||
|
_buildInfoItem('عنوان فاکتور', _invoiceTitle!),
|
||||||
|
if (_invoiceReference != null)
|
||||||
|
_buildInfoItem('ارجاع', _invoiceReference!),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoItem(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: Text(
|
||||||
|
'$label:',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _continueToInvoiceForm() {
|
||||||
|
if (_selectedInvoiceType == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('لطفا نوع فاکتور را انتخاب کنید'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final invoiceNumberText = _autoGenerateInvoiceNumber
|
||||||
|
? 'شماره فاکتور: اتوماتیک\n'
|
||||||
|
: (_invoiceNumber != null
|
||||||
|
? 'شماره فاکتور: $_invoiceNumber\n'
|
||||||
|
: 'شماره فاکتور: انتخاب نشده\n');
|
||||||
|
|
||||||
|
final customerText = _selectedCustomer != null
|
||||||
|
? 'مشتری: ${_selectedCustomer!.name}\n'
|
||||||
|
: 'مشتری: خویشتنفروش\n';
|
||||||
|
|
||||||
|
final sellerText = _selectedSeller != null
|
||||||
|
? 'فروشنده/بازاریاب: ${_selectedSeller!.displayName} (${_selectedSeller!.personTypes.isNotEmpty ? _selectedSeller!.personTypes.first.persianName : 'نامشخص'})\n'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
final commissionText = _commissionPercentage != null
|
||||||
|
? 'درصد کارمزد: ${_commissionPercentage!.toStringAsFixed(1)}%\n'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
final invoiceDateText = _invoiceDate != null
|
||||||
|
? 'تاریخ فاکتور: ${HesabixDateUtils.formatForDisplay(_invoiceDate, widget.calendarController.isJalali == true)}\n'
|
||||||
|
: 'تاریخ فاکتور: انتخاب نشده\n';
|
||||||
|
|
||||||
|
final dueDateText = _dueDate != null
|
||||||
|
? 'تاریخ سررسید: ${HesabixDateUtils.formatForDisplay(_dueDate, widget.calendarController.isJalali == true)}\n'
|
||||||
|
: 'تاریخ سررسید: انتخاب نشده\n';
|
||||||
|
|
||||||
|
final currencyText = _selectedCurrencyId != null
|
||||||
|
? 'ارز فاکتور: انتخاب شده\n'
|
||||||
|
: 'ارز فاکتور: انتخاب نشده\n';
|
||||||
|
|
||||||
|
final titleText = _invoiceTitle != null
|
||||||
|
? 'عنوان فاکتور: $_invoiceTitle\n'
|
||||||
|
: 'عنوان فاکتور: انتخاب نشده\n';
|
||||||
|
|
||||||
|
final referenceText = _invoiceReference != null
|
||||||
|
? 'ارجاع: $_invoiceReference\n'
|
||||||
|
: 'ارجاع: انتخاب نشده\n';
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('نوع فاکتور: ${_selectedInvoiceType!.label}\n$invoiceNumberText$customerText$sellerText$commissionText$invoiceDateText$dueDateText$currencyText$titleText$referenceText\nفرم کامل فاکتور به زودی اضافه خواهد شد'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: در آینده میتوانید به صفحه فرم کامل فاکتور بروید
|
||||||
|
// Navigator.push(
|
||||||
|
// context,
|
||||||
|
// MaterialPageRoute(
|
||||||
|
// builder: (context) => InvoiceFormPage(
|
||||||
|
// businessId: widget.businessId,
|
||||||
|
// authStore: widget.authStore,
|
||||||
|
// invoiceType: _selectedInvoiceType!,
|
||||||
|
// invoiceNumber: _invoiceNumber,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProductsTab() {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.inventory_2_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'کالاها و خدمات',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'این بخش در آینده پیادهسازی خواهد شد',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTransactionsTab() {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.receipt_long_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'تراکنشها',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'این بخش در آینده پیادهسازی خواهد شد',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSettingsTab() {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.settings_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'تنظیمات',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'این بخش در آینده پیادهسازی خواهد شد',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,903 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
import '../../core/auth_store.dart';
|
||||||
|
import '../../core/calendar_controller.dart';
|
||||||
|
import '../../widgets/permission/access_denied_page.dart';
|
||||||
|
import '../../widgets/invoice/invoice_type_combobox.dart';
|
||||||
|
import '../../widgets/invoice/code_field_widget.dart';
|
||||||
|
import '../../widgets/invoice/customer_combobox_widget.dart';
|
||||||
|
import '../../widgets/invoice/seller_picker_widget.dart';
|
||||||
|
import '../../widgets/invoice/commission_percentage_field.dart';
|
||||||
|
import '../../widgets/invoice/commission_type_selector.dart';
|
||||||
|
import '../../widgets/invoice/commission_amount_field.dart';
|
||||||
|
import '../../widgets/date_input_field.dart';
|
||||||
|
import '../../widgets/banking/currency_picker_widget.dart';
|
||||||
|
import '../../core/date_utils.dart';
|
||||||
|
import '../../models/invoice_type_model.dart';
|
||||||
|
import '../../models/customer_model.dart';
|
||||||
|
import '../../models/person_model.dart';
|
||||||
|
|
||||||
|
class NewInvoicePage extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final AuthStore authStore;
|
||||||
|
final CalendarController calendarController;
|
||||||
|
|
||||||
|
const NewInvoicePage({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
required this.authStore,
|
||||||
|
required this.calendarController,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NewInvoicePage> createState() => _NewInvoicePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
|
||||||
|
InvoiceType? _selectedInvoiceType;
|
||||||
|
bool _isDraft = false;
|
||||||
|
String? _invoiceNumber;
|
||||||
|
bool _autoGenerateInvoiceNumber = true;
|
||||||
|
Customer? _selectedCustomer;
|
||||||
|
Person? _selectedSeller;
|
||||||
|
double? _commissionPercentage;
|
||||||
|
double? _commissionAmount;
|
||||||
|
CommissionType? _commissionType;
|
||||||
|
DateTime? _invoiceDate;
|
||||||
|
DateTime? _dueDate;
|
||||||
|
int? _selectedCurrencyId;
|
||||||
|
String? _invoiceTitle;
|
||||||
|
String? _invoiceReference;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 4, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
if (!widget.authStore.canWriteSection('invoices')) {
|
||||||
|
return AccessDeniedPage(message: t.accessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(t.addInvoice),
|
||||||
|
toolbarHeight: 56,
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
tabs: const [
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.info_outline),
|
||||||
|
text: 'اطلاعات',
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.inventory_2_outlined),
|
||||||
|
text: 'کالا و خدمات',
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.receipt_long_outlined),
|
||||||
|
text: 'تراکنشها',
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.settings_outlined),
|
||||||
|
text: 'تنظیمات',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
// تب اطلاعات
|
||||||
|
_buildInfoTab(),
|
||||||
|
// تب کالا و خدمات
|
||||||
|
_buildProductsTab(),
|
||||||
|
// تب تراکنشها
|
||||||
|
_buildTransactionsTab(),
|
||||||
|
// تب تنظیمات
|
||||||
|
_buildSettingsTab(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoTab() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1600),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// فیلدهای اصلی - responsive layout
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// اگر عرض صفحه کمتر از 768 پیکسل باشد، تک ستونه
|
||||||
|
if (constraints.maxWidth < 768) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// نوع فاکتور
|
||||||
|
InvoiceTypeCombobox(
|
||||||
|
selectedType: _selectedInvoiceType,
|
||||||
|
onTypeChanged: (type) {
|
||||||
|
setState(() {
|
||||||
|
_selectedInvoiceType = type;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isDraft: _isDraft,
|
||||||
|
onDraftChanged: (isDraft) {
|
||||||
|
setState(() {
|
||||||
|
_isDraft = isDraft;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: true,
|
||||||
|
label: 'نوع فاکتور',
|
||||||
|
hintText: 'انتخاب نوع فاکتور',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// شماره فاکتور
|
||||||
|
CodeFieldWidget(
|
||||||
|
initialValue: _invoiceNumber,
|
||||||
|
onChanged: (number) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceNumber = number;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: true,
|
||||||
|
label: 'شماره فاکتور',
|
||||||
|
hintText: 'مثال: INV-2024-001',
|
||||||
|
autoGenerateCode: _autoGenerateInvoiceNumber,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// تاریخ فاکتور
|
||||||
|
DateInputField(
|
||||||
|
value: _invoiceDate,
|
||||||
|
labelText: 'تاریخ فاکتور *',
|
||||||
|
hintText: 'انتخاب تاریخ فاکتور',
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
onChanged: (date) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceDate = date;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// تاریخ سررسید
|
||||||
|
DateInputField(
|
||||||
|
value: _dueDate,
|
||||||
|
labelText: 'تاریخ سررسید',
|
||||||
|
hintText: 'انتخاب تاریخ سررسید',
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
onChanged: (date) {
|
||||||
|
setState(() {
|
||||||
|
_dueDate = date;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// مشتری
|
||||||
|
CustomerComboboxWidget(
|
||||||
|
selectedCustomer: _selectedCustomer,
|
||||||
|
onCustomerChanged: (customer) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCustomer = customer;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
businessId: widget.businessId,
|
||||||
|
authStore: widget.authStore,
|
||||||
|
isRequired: false,
|
||||||
|
label: 'مشتری',
|
||||||
|
hintText: 'انتخاب مشتری',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// ارز فاکتور
|
||||||
|
CurrencyPickerWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedCurrencyId: _selectedCurrencyId,
|
||||||
|
onChanged: (currencyId) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCurrencyId = currencyId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
label: 'ارز فاکتور',
|
||||||
|
hintText: 'انتخاب ارز فاکتور',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// فروشنده و کارمزد (فقط برای فروش و برگشت فروش)
|
||||||
|
if (_selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SellerPickerWidget(
|
||||||
|
selectedSeller: _selectedSeller,
|
||||||
|
onSellerChanged: (seller) {
|
||||||
|
setState(() {
|
||||||
|
_selectedSeller = seller;
|
||||||
|
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
|
||||||
|
if (seller != null) {
|
||||||
|
if (seller.commissionSalePercent != null) {
|
||||||
|
_commissionType = CommissionType.percentage;
|
||||||
|
_commissionPercentage = seller.commissionSalePercent;
|
||||||
|
_commissionAmount = null;
|
||||||
|
} else if (seller.commissionSalesAmount != null) {
|
||||||
|
_commissionType = CommissionType.amount;
|
||||||
|
_commissionAmount = seller.commissionSalesAmount;
|
||||||
|
_commissionPercentage = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_commissionType = null;
|
||||||
|
_commissionPercentage = null;
|
||||||
|
_commissionAmount = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
businessId: widget.businessId,
|
||||||
|
authStore: widget.authStore,
|
||||||
|
isRequired: false,
|
||||||
|
label: 'فروشنده/بازاریاب',
|
||||||
|
hintText: 'جستوجو و انتخاب فروشنده یا بازاریاب',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// فیلدهای کارمزد (فقط اگر فروشنده انتخاب شده باشد)
|
||||||
|
if (_selectedSeller != null) ...[
|
||||||
|
Expanded(
|
||||||
|
child: CommissionTypeSelector(
|
||||||
|
selectedType: _commissionType,
|
||||||
|
onTypeChanged: (type) {
|
||||||
|
setState(() {
|
||||||
|
_commissionType = type;
|
||||||
|
// پاک کردن مقادیر قبلی هنگام تغییر نوع
|
||||||
|
if (type == CommissionType.percentage) {
|
||||||
|
_commissionAmount = null;
|
||||||
|
} else if (type == CommissionType.amount) {
|
||||||
|
_commissionPercentage = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: false,
|
||||||
|
label: 'نوع کارمزد',
|
||||||
|
hintText: 'انتخاب نوع کارمزد',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// فیلد درصد کارمزد (فقط اگر نوع درصدی انتخاب شده)
|
||||||
|
if (_commissionType == CommissionType.percentage)
|
||||||
|
Expanded(
|
||||||
|
child: CommissionPercentageField(
|
||||||
|
initialValue: _commissionPercentage,
|
||||||
|
onChanged: (percentage) {
|
||||||
|
setState(() {
|
||||||
|
_commissionPercentage = percentage;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: false,
|
||||||
|
label: 'درصد کارمزد',
|
||||||
|
hintText: 'مثال: 5.5',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// فیلد مبلغ کارمزد (فقط اگر نوع مبلغی انتخاب شده)
|
||||||
|
else if (_commissionType == CommissionType.amount)
|
||||||
|
Expanded(
|
||||||
|
child: CommissionAmountField(
|
||||||
|
initialValue: _commissionAmount,
|
||||||
|
onChanged: (amount) {
|
||||||
|
setState(() {
|
||||||
|
_commissionAmount = amount;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: false,
|
||||||
|
label: 'مبلغ کارمزد',
|
||||||
|
hintText: 'مثال: 100000',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// عنوان فاکتور
|
||||||
|
TextFormField(
|
||||||
|
initialValue: _invoiceTitle,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceTitle = value.trim().isEmpty ? null : value.trim();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'عنوان فاکتور',
|
||||||
|
hintText: 'مثال: فروش محصولات',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// ارجاع
|
||||||
|
TextFormField(
|
||||||
|
initialValue: _invoiceReference,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceReference = value.trim().isEmpty ? null : value.trim();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'ارجاع',
|
||||||
|
hintText: 'مثال: PO-2024-001',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// برای دسکتاپ - چند ستونه
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// ردیف اول: 5 فیلد اصلی
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: InvoiceTypeCombobox(
|
||||||
|
selectedType: _selectedInvoiceType,
|
||||||
|
onTypeChanged: (type) {
|
||||||
|
setState(() {
|
||||||
|
_selectedInvoiceType = type;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isDraft: _isDraft,
|
||||||
|
onDraftChanged: (isDraft) {
|
||||||
|
setState(() {
|
||||||
|
_isDraft = isDraft;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: true,
|
||||||
|
label: 'نوع فاکتور',
|
||||||
|
hintText: 'انتخاب نوع فاکتور',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: CodeFieldWidget(
|
||||||
|
initialValue: _invoiceNumber,
|
||||||
|
onChanged: (number) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceNumber = number;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: true,
|
||||||
|
label: 'شماره فاکتور',
|
||||||
|
hintText: 'مثال: INV-2024-001',
|
||||||
|
autoGenerateCode: _autoGenerateInvoiceNumber,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: DateInputField(
|
||||||
|
value: _invoiceDate,
|
||||||
|
labelText: 'تاریخ فاکتور *',
|
||||||
|
hintText: 'انتخاب تاریخ فاکتور',
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
onChanged: (date) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceDate = date;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: DateInputField(
|
||||||
|
value: _dueDate,
|
||||||
|
labelText: 'تاریخ سررسید',
|
||||||
|
hintText: 'انتخاب تاریخ سررسید',
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
onChanged: (date) {
|
||||||
|
setState(() {
|
||||||
|
_dueDate = date;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: CustomerComboboxWidget(
|
||||||
|
selectedCustomer: _selectedCustomer,
|
||||||
|
onCustomerChanged: (customer) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCustomer = customer;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
businessId: widget.businessId,
|
||||||
|
authStore: widget.authStore,
|
||||||
|
isRequired: false,
|
||||||
|
label: 'مشتری',
|
||||||
|
hintText: 'انتخاب مشتری',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// ردیف دوم: ارز، عنوان فاکتور، ارجاع
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: CurrencyPickerWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedCurrencyId: _selectedCurrencyId,
|
||||||
|
onChanged: (currencyId) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCurrencyId = currencyId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
label: 'ارز فاکتور',
|
||||||
|
hintText: 'انتخاب ارز فاکتور',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: _invoiceTitle,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceTitle = value.trim().isEmpty ? null : value.trim();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'عنوان فاکتور',
|
||||||
|
hintText: 'مثال: فروش محصولات',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: _invoiceReference,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_invoiceReference = value.trim().isEmpty ? null : value.trim();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'ارجاع',
|
||||||
|
hintText: 'مثال: PO-2024-001',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(child: SizedBox()), // جای خالی
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(child: SizedBox()), // جای خالی
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// ردیف سوم: فروشنده و کارمزد (فقط برای فروش و برگشت فروش)
|
||||||
|
if (_selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn) ...[
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SellerPickerWidget(
|
||||||
|
selectedSeller: _selectedSeller,
|
||||||
|
onSellerChanged: (seller) {
|
||||||
|
setState(() {
|
||||||
|
_selectedSeller = seller;
|
||||||
|
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
|
||||||
|
if (seller != null) {
|
||||||
|
if (seller.commissionSalePercent != null) {
|
||||||
|
_commissionType = CommissionType.percentage;
|
||||||
|
_commissionPercentage = seller.commissionSalePercent;
|
||||||
|
_commissionAmount = null;
|
||||||
|
} else if (seller.commissionSalesAmount != null) {
|
||||||
|
_commissionType = CommissionType.amount;
|
||||||
|
_commissionAmount = seller.commissionSalesAmount;
|
||||||
|
_commissionPercentage = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_commissionType = null;
|
||||||
|
_commissionPercentage = null;
|
||||||
|
_commissionAmount = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
businessId: widget.businessId,
|
||||||
|
authStore: widget.authStore,
|
||||||
|
isRequired: false,
|
||||||
|
label: 'فروشنده/بازاریاب',
|
||||||
|
hintText: 'جستوجو و انتخاب فروشنده یا بازاریاب',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// فیلدهای کارمزد (فقط اگر فروشنده انتخاب شده باشد)
|
||||||
|
if (_selectedSeller != null) ...[
|
||||||
|
Expanded(
|
||||||
|
child: CommissionTypeSelector(
|
||||||
|
selectedType: _commissionType,
|
||||||
|
onTypeChanged: (type) {
|
||||||
|
setState(() {
|
||||||
|
_commissionType = type;
|
||||||
|
// پاک کردن مقادیر قبلی هنگام تغییر نوع
|
||||||
|
if (type == CommissionType.percentage) {
|
||||||
|
_commissionAmount = null;
|
||||||
|
} else if (type == CommissionType.amount) {
|
||||||
|
_commissionPercentage = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: false,
|
||||||
|
label: 'نوع کارمزد',
|
||||||
|
hintText: 'انتخاب نوع کارمزد',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// فیلد درصد کارمزد (فقط اگر نوع درصدی انتخاب شده)
|
||||||
|
if (_commissionType == CommissionType.percentage)
|
||||||
|
Expanded(
|
||||||
|
child: CommissionPercentageField(
|
||||||
|
initialValue: _commissionPercentage,
|
||||||
|
onChanged: (percentage) {
|
||||||
|
setState(() {
|
||||||
|
_commissionPercentage = percentage;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: false,
|
||||||
|
label: 'درصد کارمزد',
|
||||||
|
hintText: 'مثال: 5.5',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// فیلد مبلغ کارمزد (فقط اگر نوع مبلغی انتخاب شده)
|
||||||
|
else if (_commissionType == CommissionType.amount)
|
||||||
|
Expanded(
|
||||||
|
child: CommissionAmountField(
|
||||||
|
initialValue: _commissionAmount,
|
||||||
|
onChanged: (amount) {
|
||||||
|
setState(() {
|
||||||
|
_commissionAmount = amount;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isRequired: false,
|
||||||
|
label: 'مبلغ کارمزد',
|
||||||
|
hintText: 'مثال: 100000',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Expanded(child: SizedBox()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
] else ...[
|
||||||
|
const Expanded(child: SizedBox()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(child: SizedBox()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
const Expanded(child: SizedBox()), // جای خالی
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// دکمه ادامه
|
||||||
|
Center(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: (_selectedInvoiceType != null && _invoiceDate != null) ? _continueToInvoiceForm : null,
|
||||||
|
icon: const Icon(Icons.arrow_forward),
|
||||||
|
label: Text('ادامه ایجاد فاکتور'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
|
minimumSize: const Size(200, 48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// نمایش اطلاعات انتخاب شده
|
||||||
|
if (_selectedInvoiceType != null || _invoiceDate != null || _dueDate != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'اطلاعات انتخاب شده:',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// نمایش اطلاعات در دو ستون
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_selectedInvoiceType != null)
|
||||||
|
_buildInfoItem('نوع فاکتور', _selectedInvoiceType!.label),
|
||||||
|
if (_invoiceDate != null)
|
||||||
|
_buildInfoItem('تاریخ فاکتور', HesabixDateUtils.formatForDisplay(_invoiceDate, widget.calendarController.isJalali == true)),
|
||||||
|
if (_dueDate != null)
|
||||||
|
_buildInfoItem('تاریخ سررسید', HesabixDateUtils.formatForDisplay(_dueDate, widget.calendarController.isJalali == true)),
|
||||||
|
if (_selectedCurrencyId != null)
|
||||||
|
_buildInfoItem('ارز فاکتور', 'انتخاب شده'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_selectedSeller != null)
|
||||||
|
_buildInfoItem('فروشنده/بازاریاب', '${_selectedSeller!.displayName} (${_selectedSeller!.personTypes.isNotEmpty ? _selectedSeller!.personTypes.first.persianName : 'نامشخص'})'),
|
||||||
|
if (_commissionType != null)
|
||||||
|
_buildInfoItem('نوع کارمزد', _commissionType!.label),
|
||||||
|
if (_commissionPercentage != null)
|
||||||
|
_buildInfoItem('درصد کارمزد', '${_commissionPercentage!.toStringAsFixed(1)}%'),
|
||||||
|
if (_commissionAmount != null)
|
||||||
|
_buildInfoItem('مبلغ کارمزد', '${_commissionAmount!.toStringAsFixed(0)} ریال'),
|
||||||
|
if (_invoiceTitle != null)
|
||||||
|
_buildInfoItem('عنوان فاکتور', _invoiceTitle!),
|
||||||
|
if (_invoiceReference != null)
|
||||||
|
_buildInfoItem('ارجاع', _invoiceReference!),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoItem(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: Text(
|
||||||
|
'$label:',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _continueToInvoiceForm() {
|
||||||
|
if (_selectedInvoiceType == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('لطفا نوع فاکتور را انتخاب کنید'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final invoiceNumberText = _autoGenerateInvoiceNumber
|
||||||
|
? 'شماره فاکتور: اتوماتیک\n'
|
||||||
|
: (_invoiceNumber != null
|
||||||
|
? 'شماره فاکتور: $_invoiceNumber\n'
|
||||||
|
: 'شماره فاکتور: انتخاب نشده\n');
|
||||||
|
|
||||||
|
final customerText = _selectedCustomer != null
|
||||||
|
? 'مشتری: ${_selectedCustomer!.name}\n'
|
||||||
|
: 'مشتری: خویشتنفروش\n';
|
||||||
|
|
||||||
|
final sellerText = _selectedSeller != null
|
||||||
|
? 'فروشنده/بازاریاب: ${_selectedSeller!.displayName} (${_selectedSeller!.personTypes.isNotEmpty ? _selectedSeller!.personTypes.first.persianName : 'نامشخص'})\n'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
final commissionText = _commissionPercentage != null
|
||||||
|
? 'درصد کارمزد: ${_commissionPercentage!.toStringAsFixed(1)}%\n'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
final invoiceDateText = _invoiceDate != null
|
||||||
|
? 'تاریخ فاکتور: ${HesabixDateUtils.formatForDisplay(_invoiceDate, widget.calendarController.isJalali == true)}\n'
|
||||||
|
: 'تاریخ فاکتور: انتخاب نشده\n';
|
||||||
|
|
||||||
|
final dueDateText = _dueDate != null
|
||||||
|
? 'تاریخ سررسید: ${HesabixDateUtils.formatForDisplay(_dueDate, widget.calendarController.isJalali == true)}\n'
|
||||||
|
: 'تاریخ سررسید: انتخاب نشده\n';
|
||||||
|
|
||||||
|
final currencyText = _selectedCurrencyId != null
|
||||||
|
? 'ارز فاکتور: انتخاب شده\n'
|
||||||
|
: 'ارز فاکتور: انتخاب نشده\n';
|
||||||
|
|
||||||
|
final titleText = _invoiceTitle != null
|
||||||
|
? 'عنوان فاکتور: $_invoiceTitle\n'
|
||||||
|
: 'عنوان فاکتور: انتخاب نشده\n';
|
||||||
|
|
||||||
|
final referenceText = _invoiceReference != null
|
||||||
|
? 'ارجاع: $_invoiceReference\n'
|
||||||
|
: 'ارجاع: انتخاب نشده\n';
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('نوع فاکتور: ${_selectedInvoiceType!.label}\n$invoiceNumberText$customerText$sellerText$commissionText$invoiceDateText$dueDateText$currencyText$titleText$referenceText\nفرم کامل فاکتور به زودی اضافه خواهد شد'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: در آینده میتوانید به صفحه فرم کامل فاکتور بروید
|
||||||
|
// Navigator.push(
|
||||||
|
// context,
|
||||||
|
// MaterialPageRoute(
|
||||||
|
// builder: (context) => InvoiceFormPage(
|
||||||
|
// businessId: widget.businessId,
|
||||||
|
// authStore: widget.authStore,
|
||||||
|
// invoiceType: _selectedInvoiceType!,
|
||||||
|
// invoiceNumber: _invoiceNumber,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProductsTab() {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.inventory_2_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'کالا و خدمات',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'این بخش در آینده پیادهسازی خواهد شد',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTransactionsTab() {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.receipt_long_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'تراکنشها',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'این بخش در آینده پیادهسازی خواهد شد',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSettingsTab() {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.settings_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'تنظیمات',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'این بخش در آینده پیادهسازی خواهد شد',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -103,9 +103,7 @@ class _PersonsPageState extends State<PersonsPage> {
|
||||||
FilterOption(value: 'فروشنده', label: t.personTypeSeller),
|
FilterOption(value: 'فروشنده', label: t.personTypeSeller),
|
||||||
FilterOption(value: 'سهامدار', label: 'سهامدار'),
|
FilterOption(value: 'سهامدار', label: 'سهامدار'),
|
||||||
],
|
],
|
||||||
formatter: (person) => (person.personTypes.isNotEmpty
|
formatter: (person) => person.personTypes.map((e) => e.persianName).join('، '),
|
||||||
? person.personTypes.map((e) => e.persianName).join('، ')
|
|
||||||
: person.personType.persianName),
|
|
||||||
),
|
),
|
||||||
TextColumn(
|
TextColumn(
|
||||||
'company_name',
|
'company_name',
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,8 @@ class _PriceListsPageState extends State<PriceListsPage> {
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: isActive
|
backgroundColor: isActive
|
||||||
? Theme.of(context).primaryColor.withOpacity(0.1)
|
? Theme.of(context).primaryColor.withValues(alpha: 0.1)
|
||||||
: Colors.grey.withOpacity(0.1),
|
: Colors.grey.withValues(alpha: 0.1),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.price_change,
|
Icons.price_change,
|
||||||
color: isActive
|
color: isActive
|
||||||
|
|
@ -193,7 +193,7 @@ class _PriceListsPageState extends State<PriceListsPage> {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -392,9 +392,9 @@ class _PriceListsPageState extends State<PriceListsPage> {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.red.withOpacity(0.1),
|
color: Colors.red.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: Colors.red.withOpacity(0.3)),
|
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
|
||||||
|
|
@ -34,20 +34,20 @@ class _WalletPageState extends State<WalletPage> {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.wallet,
|
Icons.wallet,
|
||||||
size: 80,
|
size: 80,
|
||||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.6),
|
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
t.wallet,
|
t.wallet,
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'صفحه کیف پول در حال توسعه است',
|
'صفحه کیف پول در حال توسعه است',
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
98
hesabixUI/hesabix_ui/lib/services/customer_service.dart
Normal file
98
hesabixUI/hesabix_ui/lib/services/customer_service.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import '../core/api_client.dart';
|
||||||
|
import '../models/customer_model.dart';
|
||||||
|
|
||||||
|
class CustomerService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
CustomerService(this._apiClient);
|
||||||
|
|
||||||
|
/// جستوجوی مشتریها با پشتیبانی از pagination
|
||||||
|
Future<Map<String, dynamic>> searchCustomers({
|
||||||
|
required int businessId,
|
||||||
|
String? searchQuery,
|
||||||
|
int page = 1,
|
||||||
|
int limit = 20,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final requestData = <String, dynamic>{
|
||||||
|
'business_id': businessId,
|
||||||
|
'page': page,
|
||||||
|
'limit': limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||||
|
requestData['search'] = searchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _apiClient.post(
|
||||||
|
'/api/v1/customers/search',
|
||||||
|
data: requestData,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// تبدیل لیست مشتریها
|
||||||
|
final customersJson = data['customers'] as List<dynamic>;
|
||||||
|
final customers = customersJson
|
||||||
|
.map((json) => Customer.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return {
|
||||||
|
'customers': customers,
|
||||||
|
'total': data['total'] as int,
|
||||||
|
'page': data['page'] as int,
|
||||||
|
'limit': data['limit'] as int,
|
||||||
|
'hasMore': data['has_more'] as bool,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw Exception('خطا در دریافت لیست مشتریها: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('خطا در جستوجوی مشتریها: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// دریافت اطلاعات یک مشتری خاص
|
||||||
|
Future<Customer?> getCustomerById({
|
||||||
|
required int businessId,
|
||||||
|
required int customerId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'/api/v1/customers/$customerId',
|
||||||
|
query: {'business_id': businessId},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final customerJson = response.data as Map<String, dynamic>;
|
||||||
|
return Customer.fromJson(customerJson);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// بررسی دسترسی کاربر به بخش مشتریها
|
||||||
|
Future<bool> checkCustomerAccess({
|
||||||
|
required int businessId,
|
||||||
|
required String authToken,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'/api/v1/customers/access-check',
|
||||||
|
query: {'business_id': businessId},
|
||||||
|
options: Options(
|
||||||
|
headers: {'Authorization': 'Bearer $authToken'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.statusCode == 200;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,7 +37,13 @@ class PersonService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters != null && filters.isNotEmpty) {
|
if (filters != null && filters.isNotEmpty) {
|
||||||
queryParams['filters'] = filters;
|
// تبدیل Map به لیست برای API
|
||||||
|
final filtersList = filters.entries.map((e) => {
|
||||||
|
'property': e.key,
|
||||||
|
'operator': 'in', // برای فیلترهای چندتایی از عملگر 'in' استفاده میکنیم
|
||||||
|
'value': e.value,
|
||||||
|
}).toList();
|
||||||
|
queryParams['filters'] = filtersList;
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await _apiClient.post(
|
final response = await _apiClient.post(
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ class _CurrencyPickerWidgetState extends State<CurrencyPickerWidget> {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).primaryColor.withOpacity(0.1),
|
color: Theme.of(context).primaryColor.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class CombinedUserMenuButton extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.1),
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
blurRadius: 20,
|
blurRadius: 20,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 8),
|
||||||
),
|
),
|
||||||
|
|
@ -53,7 +53,7 @@ class CombinedUserMenuButton extends StatelessWidget {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cs.primaryContainer.withOpacity(0.1),
|
color: cs.primaryContainer.withValues(alpha: 0.1),
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
topLeft: Radius.circular(16),
|
topLeft: Radius.circular(16),
|
||||||
topRight: Radius.circular(16),
|
topRight: Radius.circular(16),
|
||||||
|
|
@ -106,10 +106,10 @@ class CombinedUserMenuButton extends StatelessWidget {
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cs.primaryContainer.withOpacity(0.1),
|
color: cs.primaryContainer.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: cs.primary.withOpacity(0.2),
|
color: cs.primary.withValues(alpha: 0.2),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -175,7 +175,7 @@ class CombinedUserMenuButton extends StatelessWidget {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cs.surfaceContainerHighest.withOpacity(0.3),
|
color: cs.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -242,10 +242,10 @@ class CombinedUserMenuButton extends StatelessWidget {
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cs.errorContainer.withOpacity(0.1),
|
color: cs.errorContainer.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: cs.error.withOpacity(0.2),
|
color: cs.error.withValues(alpha: 0.2),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -342,7 +342,7 @@ class CombinedUserMenuButton extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.1),
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
blurRadius: 20,
|
blurRadius: 20,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 8),
|
||||||
),
|
),
|
||||||
|
|
@ -355,7 +355,7 @@ class CombinedUserMenuButton extends StatelessWidget {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cs.errorContainer.withOpacity(0.1),
|
color: cs.errorContainer.withValues(alpha: 0.1),
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
topLeft: Radius.circular(16),
|
topLeft: Radius.circular(16),
|
||||||
topRight: Radius.circular(16),
|
topRight: Radius.circular(16),
|
||||||
|
|
|
||||||
100
hesabixUI/hesabix_ui/lib/widgets/invoice/code_field_widget.dart
Normal file
100
hesabixUI/hesabix_ui/lib/widgets/invoice/code_field_widget.dart
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class CodeFieldWidget extends StatefulWidget {
|
||||||
|
final String? initialValue;
|
||||||
|
final ValueChanged<String?> onChanged;
|
||||||
|
final String? label;
|
||||||
|
final String? hintText;
|
||||||
|
final bool isRequired;
|
||||||
|
final bool autoGenerateCode;
|
||||||
|
|
||||||
|
const CodeFieldWidget({
|
||||||
|
super.key,
|
||||||
|
this.initialValue,
|
||||||
|
required this.onChanged,
|
||||||
|
this.label,
|
||||||
|
this.hintText,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.autoGenerateCode = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CodeFieldWidget> createState() => _CodeFieldWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CodeFieldWidgetState extends State<CodeFieldWidget> {
|
||||||
|
late TextEditingController _controller;
|
||||||
|
late bool _autoGenerateCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TextEditingController(text: widget.initialValue ?? '');
|
||||||
|
_autoGenerateCode = widget.autoGenerateCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
return TextFormField(
|
||||||
|
controller: _controller,
|
||||||
|
readOnly: _autoGenerateCode,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: widget.label ?? t.code,
|
||||||
|
hintText: widget.hintText ?? t.uniqueCodeNumeric,
|
||||||
|
suffixIcon: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// سویچ اتوماتیک/دستی
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
child: Tooltip(
|
||||||
|
message: _autoGenerateCode ? 'تولید خودکار کد فعال است' : 'تولید دستی کد فعال است',
|
||||||
|
child: Switch(
|
||||||
|
value: _autoGenerateCode,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_autoGenerateCode = value;
|
||||||
|
if (_autoGenerateCode) {
|
||||||
|
_controller.clear();
|
||||||
|
widget.onChanged(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
onChanged: (value) {
|
||||||
|
widget.onChanged(_autoGenerateCode ? null : value.trim().isEmpty ? null : value.trim());
|
||||||
|
},
|
||||||
|
validator: (value) {
|
||||||
|
if (widget.isRequired && !_autoGenerateCode) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return t.personCodeRequired;
|
||||||
|
}
|
||||||
|
if (value.trim().length < 3) {
|
||||||
|
return t.passwordMinLength;
|
||||||
|
}
|
||||||
|
if (!RegExp(r'^\d+$').hasMatch(value.trim())) {
|
||||||
|
return t.codeMustBeNumeric;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class CommissionAmountField extends StatefulWidget {
|
||||||
|
final double? initialValue;
|
||||||
|
final Function(double?) onChanged;
|
||||||
|
final bool isRequired;
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
|
||||||
|
const CommissionAmountField({
|
||||||
|
super.key,
|
||||||
|
this.initialValue,
|
||||||
|
required this.onChanged,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.label = 'مبلغ کارمزد',
|
||||||
|
this.hintText = 'مثال: 100000',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CommissionAmountField> createState() => _CommissionAmountFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CommissionAmountFieldState extends State<CommissionAmountField> {
|
||||||
|
late TextEditingController _controller;
|
||||||
|
String? _errorText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TextEditingController(
|
||||||
|
text: widget.initialValue?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateAndUpdate(String value) {
|
||||||
|
setState(() {
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (value.isEmpty) {
|
||||||
|
widget.onChanged(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final doubleValue = double.tryParse(value);
|
||||||
|
if (doubleValue == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorText = 'لطفا مبلغ معتبر وارد کنید';
|
||||||
|
});
|
||||||
|
widget.onChanged(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doubleValue < 0) {
|
||||||
|
setState(() {
|
||||||
|
_errorText = 'مبلغ کارمزد نمیتواند منفی باشد';
|
||||||
|
});
|
||||||
|
widget.onChanged(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onChanged(doubleValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _controller,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
||||||
|
ThousandsSeparatorInputFormatter(),
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: widget.label,
|
||||||
|
hintText: widget.hintText,
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Icons.attach_money,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
errorText: _errorText,
|
||||||
|
errorStyle: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: _validateAndUpdate,
|
||||||
|
validator: (value) {
|
||||||
|
if (widget.isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return 'این فیلد الزامی است';
|
||||||
|
}
|
||||||
|
return _errorText;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ThousandsSeparatorInputFormatter extends TextInputFormatter {
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
if (newValue.text.isEmpty) {
|
||||||
|
return newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all non-digit characters except decimal point
|
||||||
|
String cleanText = newValue.text.replaceAll(RegExp(r'[^\d.]'), '');
|
||||||
|
|
||||||
|
// Split by decimal point
|
||||||
|
List<String> parts = cleanText.split('.');
|
||||||
|
String integerPart = parts[0];
|
||||||
|
String decimalPart = parts.length > 1 ? '.${parts[1]}' : '';
|
||||||
|
|
||||||
|
// Add thousands separators to integer part
|
||||||
|
String formattedInteger = _addThousandsSeparator(integerPart);
|
||||||
|
|
||||||
|
String formattedText = formattedInteger + decimalPart;
|
||||||
|
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formattedText,
|
||||||
|
selection: TextSelection.collapsed(offset: formattedText.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _addThousandsSeparator(String text) {
|
||||||
|
if (text.isEmpty) return text;
|
||||||
|
|
||||||
|
String reversed = text.split('').reversed.join('');
|
||||||
|
String withCommas = reversed.replaceAllMapped(
|
||||||
|
RegExp(r'(\d{3})(?=\d)'),
|
||||||
|
(Match match) => '${match.group(1)},',
|
||||||
|
);
|
||||||
|
return withCommas.split('').reversed.join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class CommissionPercentageField extends StatefulWidget {
|
||||||
|
final double? initialValue;
|
||||||
|
final Function(double?) onChanged;
|
||||||
|
final bool isRequired;
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
|
||||||
|
const CommissionPercentageField({
|
||||||
|
super.key,
|
||||||
|
this.initialValue,
|
||||||
|
required this.onChanged,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.label = 'درصد کارمزد',
|
||||||
|
this.hintText = 'مثال: 5.5',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CommissionPercentageField> createState() => _CommissionPercentageFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CommissionPercentageFieldState extends State<CommissionPercentageField> {
|
||||||
|
late TextEditingController _controller;
|
||||||
|
String? _errorText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TextEditingController(
|
||||||
|
text: widget.initialValue?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateAndUpdate(String value) {
|
||||||
|
setState(() {
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (value.isEmpty) {
|
||||||
|
widget.onChanged(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final doubleValue = double.tryParse(value);
|
||||||
|
if (doubleValue == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorText = 'لطفا عدد معتبر وارد کنید';
|
||||||
|
});
|
||||||
|
widget.onChanged(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doubleValue < 0 || doubleValue > 100) {
|
||||||
|
setState(() {
|
||||||
|
_errorText = 'درصد کارمزد باید بین 0 تا 100 باشد';
|
||||||
|
});
|
||||||
|
widget.onChanged(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onChanged(doubleValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _controller,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: widget.label,
|
||||||
|
hintText: widget.hintText,
|
||||||
|
suffixText: '%',
|
||||||
|
suffixStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Icons.percent,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
errorText: _errorText,
|
||||||
|
errorStyle: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: _validateAndUpdate,
|
||||||
|
validator: (value) {
|
||||||
|
if (widget.isRequired && (value == null || value.isEmpty)) {
|
||||||
|
return 'این فیلد الزامی است';
|
||||||
|
}
|
||||||
|
return _errorText;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum CommissionType {
|
||||||
|
percentage('درصدی'),
|
||||||
|
amount('مبلغی');
|
||||||
|
|
||||||
|
const CommissionType(this.label);
|
||||||
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommissionTypeSelector extends StatefulWidget {
|
||||||
|
final CommissionType? selectedType;
|
||||||
|
final ValueChanged<CommissionType?> onTypeChanged;
|
||||||
|
final bool isRequired;
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
|
||||||
|
const CommissionTypeSelector({
|
||||||
|
super.key,
|
||||||
|
this.selectedType,
|
||||||
|
required this.onTypeChanged,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.label = 'نوع کارمزد',
|
||||||
|
this.hintText = 'انتخاب نوع کارمزد',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CommissionTypeSelector> createState() => _CommissionTypeSelectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CommissionTypeSelectorState extends State<CommissionTypeSelector> {
|
||||||
|
CommissionType? _selectedType;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedType = widget.selectedType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(CommissionTypeSelector oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.selectedType != oldWidget.selectedType) {
|
||||||
|
setState(() {
|
||||||
|
_selectedType = widget.selectedType;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectType(CommissionType type) {
|
||||||
|
setState(() {
|
||||||
|
_selectedType = type;
|
||||||
|
});
|
||||||
|
widget.onTypeChanged(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSelection() {
|
||||||
|
setState(() {
|
||||||
|
_selectedType = null;
|
||||||
|
});
|
||||||
|
widget.onTypeChanged(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return DropdownButtonFormField<CommissionType>(
|
||||||
|
value: _selectedType,
|
||||||
|
onChanged: (CommissionType? newValue) {
|
||||||
|
if (newValue != null) {
|
||||||
|
_selectType(newValue);
|
||||||
|
} else if (!widget.isRequired) {
|
||||||
|
_clearSelection();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: widget.label,
|
||||||
|
hintText: widget.hintText,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
prefixIcon: _selectedType != null
|
||||||
|
? Icon(_getTypeIcon(_selectedType!))
|
||||||
|
: const Icon(Icons.toggle_on_outlined),
|
||||||
|
suffixIcon: _selectedType != null && !widget.isRequired
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: _clearSelection,
|
||||||
|
iconSize: 18,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
items: CommissionType.values.map((CommissionType type) {
|
||||||
|
return DropdownMenuItem<CommissionType>(
|
||||||
|
value: type,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_getTypeIcon(type),
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
type.label,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
validator: (value) {
|
||||||
|
if (widget.isRequired && value == null) {
|
||||||
|
return 'لطفا نوع کارمزد را انتخاب کنید';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getTypeIcon(CommissionType type) {
|
||||||
|
switch (type) {
|
||||||
|
case CommissionType.percentage:
|
||||||
|
return Icons.percent;
|
||||||
|
case CommissionType.amount:
|
||||||
|
return Icons.attach_money;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,313 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../models/customer_model.dart';
|
||||||
|
import '../../services/customer_service.dart';
|
||||||
|
import '../../core/auth_store.dart';
|
||||||
|
import '../../core/api_client.dart';
|
||||||
|
|
||||||
|
class CustomerComboboxWidget extends StatefulWidget {
|
||||||
|
final Customer? selectedCustomer;
|
||||||
|
final ValueChanged<Customer?> onCustomerChanged;
|
||||||
|
final int businessId;
|
||||||
|
final AuthStore authStore;
|
||||||
|
final bool isRequired;
|
||||||
|
final String? label;
|
||||||
|
final String? hintText;
|
||||||
|
|
||||||
|
const CustomerComboboxWidget({
|
||||||
|
super.key,
|
||||||
|
this.selectedCustomer,
|
||||||
|
required this.onCustomerChanged,
|
||||||
|
required this.businessId,
|
||||||
|
required this.authStore,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.label = 'مشتری',
|
||||||
|
this.hintText = 'انتخاب مشتری',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CustomerComboboxWidget> createState() => _CustomerComboboxWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
|
||||||
|
final CustomerService _customerService = CustomerService(ApiClient());
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
List<Customer> _customers = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _hasLoadedRecent = false;
|
||||||
|
bool _isSearchMode = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_searchController.text = widget.selectedCustomer?.name ?? '';
|
||||||
|
_loadRecentCustomers();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRecentCustomers() async {
|
||||||
|
if (_hasLoadedRecent && !_isSearchMode) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _customerService.searchCustomers(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
limit: 5,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_customers = result['customers'] as List<Customer>;
|
||||||
|
_isLoading = false;
|
||||||
|
_hasLoadedRecent = true;
|
||||||
|
_isSearchMode = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_hasLoadedRecent = true;
|
||||||
|
_isSearchMode = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _searchCustomers(String query) async {
|
||||||
|
if (query.trim().isEmpty) {
|
||||||
|
await _loadRecentCustomers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_isSearchMode = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _customerService.searchCustomers(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
searchQuery: query.trim(),
|
||||||
|
limit: 20,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_customers = result['customers'] as List<Customer>;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_customers.clear();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
void _showCustomerPicker() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => _CustomerPickerBottomSheet(
|
||||||
|
customers: _customers,
|
||||||
|
selectedCustomer: widget.selectedCustomer,
|
||||||
|
onCustomerSelected: (customer) {
|
||||||
|
widget.onCustomerChanged(customer);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
searchController: _searchController,
|
||||||
|
onSearchChanged: _searchCustomers,
|
||||||
|
isLoading: _isLoading,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: _showCustomerPicker,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outline.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: colorScheme.surface,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.person_search,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: widget.selectedCustomer != null
|
||||||
|
? Text(
|
||||||
|
widget.selectedCustomer!.name,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
widget.hintText!,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.arrow_drop_down,
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomerPickerBottomSheet extends StatefulWidget {
|
||||||
|
final List<Customer> customers;
|
||||||
|
final Customer? selectedCustomer;
|
||||||
|
final Function(Customer) onCustomerSelected;
|
||||||
|
final TextEditingController searchController;
|
||||||
|
final Function(String) onSearchChanged;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const _CustomerPickerBottomSheet({
|
||||||
|
required this.customers,
|
||||||
|
required this.selectedCustomer,
|
||||||
|
required this.onCustomerSelected,
|
||||||
|
required this.searchController,
|
||||||
|
required this.onSearchChanged,
|
||||||
|
required this.isLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CustomerPickerBottomSheet> createState() => _CustomerPickerBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomerPickerBottomSheetState extends State<_CustomerPickerBottomSheet> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.7,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// هدر
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'انتخاب مشتری',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// فیلد جستوجو
|
||||||
|
TextField(
|
||||||
|
controller: widget.searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'جستوجو در مشتریان...',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: widget.onSearchChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// لیست مشتریان
|
||||||
|
Expanded(
|
||||||
|
child: widget.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: widget.customers.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.person_off,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'مشتریای یافت نشد',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: widget.customers.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final customer = widget.customers[index];
|
||||||
|
final isSelected = widget.selectedCustomer?.id == customer.id;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: colorScheme.primaryContainer,
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(customer.name),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (customer.code != null)
|
||||||
|
Text('کد: ${customer.code}'),
|
||||||
|
if (customer.phone != null)
|
||||||
|
Text('تلفن: ${customer.phone}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: isSelected
|
||||||
|
? Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => widget.onCustomerSelected(customer),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,361 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../models/customer_model.dart';
|
||||||
|
import '../../services/customer_service.dart';
|
||||||
|
import '../../core/auth_store.dart';
|
||||||
|
import '../../core/api_client.dart';
|
||||||
|
|
||||||
|
class CustomerPickerWidget extends StatefulWidget {
|
||||||
|
final Customer? selectedCustomer;
|
||||||
|
final ValueChanged<Customer?> onCustomerChanged;
|
||||||
|
final int businessId;
|
||||||
|
final AuthStore authStore;
|
||||||
|
final bool isRequired;
|
||||||
|
final String? label;
|
||||||
|
final String? hintText;
|
||||||
|
|
||||||
|
const CustomerPickerWidget({
|
||||||
|
super.key,
|
||||||
|
this.selectedCustomer,
|
||||||
|
required this.onCustomerChanged,
|
||||||
|
required this.businessId,
|
||||||
|
required this.authStore,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.label =
|
||||||
|
'مشتری',
|
||||||
|
this.hintText = 'خویشتنفروش',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CustomerPickerWidget> createState() => _CustomerPickerWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomerPickerWidgetState extends State<CustomerPickerWidget> {
|
||||||
|
final CustomerService _customerService = CustomerService(ApiClient());
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
List<Customer> _customers = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _hasSearched = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_searchController.text = widget.selectedCustomer?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _searchCustomers(String query) async {
|
||||||
|
if (query.trim().isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_customers.clear();
|
||||||
|
_hasSearched = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _customerService.searchCustomers(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
searchQuery: query.trim(),
|
||||||
|
limit: 50,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_customers = result['customers'] as List<Customer>;
|
||||||
|
_hasSearched = true;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = e.toString();
|
||||||
|
_customers.clear();
|
||||||
|
_hasSearched = true;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectCustomer(Customer customer) {
|
||||||
|
setState(() {
|
||||||
|
_searchController.text = customer.name;
|
||||||
|
});
|
||||||
|
widget.onCustomerChanged(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSelection() {
|
||||||
|
setState(() {
|
||||||
|
_searchController.clear();
|
||||||
|
});
|
||||||
|
widget.onCustomerChanged(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// هدر
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.person_search_outlined,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.label!,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.isRequired)
|
||||||
|
Text(
|
||||||
|
' *',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.error,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// فیلد جستوجو
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: widget.hintText,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: _searchController.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
_clearSelection();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value.length >= 2) {
|
||||||
|
_searchCustomers(value);
|
||||||
|
} else if (value.isEmpty) {
|
||||||
|
_clearSelection();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// لیست نتایج جستوجو
|
||||||
|
if (_hasSearched && _customers.isNotEmpty) ...[
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 300),
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: _customers.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final customer = _customers[index];
|
||||||
|
final isSelected = widget.selectedCustomer?.id == customer.id;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? colorScheme.primaryContainer
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
customer.name,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (customer.code != null)
|
||||||
|
Text(
|
||||||
|
'کد: ${customer.code}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (customer.phone != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'تلفن: ${customer.phone}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: isSelected
|
||||||
|
? Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
selected: isSelected,
|
||||||
|
selectedTileColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
|
onTap: () => _selectCustomer(customer),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// پیام خطا یا عدم وجود نتیجه
|
||||||
|
if (_hasSearched && _customers.isEmpty && _errorMessage == null) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.search_off,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'مشتریای با این مشخصات یافت نشد',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// پیام خطا
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'خطا در جستوجو: ${_errorMessage!.split(':').last}',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// نمایش مشتری انتخاب شده
|
||||||
|
if (widget.selectedCustomer != null) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'مشتری انتخاب شده: ${widget.selectedCustomer!.name}',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class InvoiceNumberField extends StatefulWidget {
|
||||||
|
final String? initialValue;
|
||||||
|
final ValueChanged<String?> onChanged;
|
||||||
|
final bool isRequired;
|
||||||
|
final String? label;
|
||||||
|
final String? hintText;
|
||||||
|
final bool autoGenerate;
|
||||||
|
|
||||||
|
const InvoiceNumberField({
|
||||||
|
super.key,
|
||||||
|
this.initialValue,
|
||||||
|
required this.onChanged,
|
||||||
|
this.isRequired = true,
|
||||||
|
this.label,
|
||||||
|
this.hintText,
|
||||||
|
this.autoGenerate = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<InvoiceNumberField> createState() => _InvoiceNumberFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoiceNumberFieldState extends State<InvoiceNumberField> {
|
||||||
|
late TextEditingController _controller;
|
||||||
|
bool _isAutoGenerate = true;
|
||||||
|
bool _isManualEntry = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TextEditingController(text: widget.initialValue ?? '');
|
||||||
|
_isAutoGenerate = widget.autoGenerate;
|
||||||
|
_isManualEntry = widget.initialValue != null && widget.initialValue!.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(InvoiceNumberField oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.initialValue != oldWidget.initialValue) {
|
||||||
|
_controller.text = widget.initialValue ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// هدر با عنوان و دکمههای انتخاب نوع
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.confirmation_number_outlined,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.label ?? 'شماره فاکتور',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.isRequired)
|
||||||
|
Text(
|
||||||
|
' *',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.error,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// دکمههای انتخاب نوع شمارهگذاری
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildModeButton(
|
||||||
|
context: context,
|
||||||
|
icon: Icons.auto_awesome,
|
||||||
|
label: 'اتوماتیک',
|
||||||
|
isSelected: _isAutoGenerate,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isAutoGenerate = true;
|
||||||
|
_isManualEntry = false;
|
||||||
|
_controller.clear();
|
||||||
|
});
|
||||||
|
widget.onChanged(null);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildModeButton(
|
||||||
|
context: context,
|
||||||
|
icon: Icons.edit,
|
||||||
|
label: 'دستی',
|
||||||
|
isSelected: _isManualEntry,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isAutoGenerate = false;
|
||||||
|
_isManualEntry = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// فیلد ورودی شماره فاکتور
|
||||||
|
if (_isManualEntry) ...[
|
||||||
|
TextFormField(
|
||||||
|
controller: _controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: widget.hintText ?? 'شماره فاکتور را وارد کنید',
|
||||||
|
prefixIcon: const Icon(Icons.numbers),
|
||||||
|
suffixIcon: _controller.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_controller.clear();
|
||||||
|
widget.onChanged('');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
widget.onChanged(value.isEmpty ? null : value);
|
||||||
|
},
|
||||||
|
inputFormatters: [
|
||||||
|
// فقط اعداد و حروف انگلیسی و خط تیره و زیرخط
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9\-_]')),
|
||||||
|
],
|
||||||
|
validator: widget.isRequired && _isManualEntry
|
||||||
|
? (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'شماره فاکتور الزامی است';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
// نمایش حالت اتوماتیک
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.auto_awesome,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'شماره فاکتور به صورت خودکار تولید خواهد شد',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// راهنما
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_isAutoGenerate
|
||||||
|
? 'شماره فاکتور بر اساس الگوی تعریف شده تولید میشود'
|
||||||
|
: 'شماره فاکتور را به صورت دستی وارد کنید (فقط حروف انگلیسی، اعداد، خط تیره و زیرخط مجاز است)',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildModeButton({
|
||||||
|
required BuildContext context,
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required bool isSelected,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.primaryContainer
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
width: isSelected ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../models/invoice_type_model.dart';
|
||||||
|
|
||||||
|
class InvoiceTypeCombobox extends StatefulWidget {
|
||||||
|
final InvoiceType? selectedType;
|
||||||
|
final ValueChanged<InvoiceType?> onTypeChanged;
|
||||||
|
final bool isDraft;
|
||||||
|
final ValueChanged<bool> onDraftChanged;
|
||||||
|
final bool isRequired;
|
||||||
|
final String? label;
|
||||||
|
final String? hintText;
|
||||||
|
|
||||||
|
const InvoiceTypeCombobox({
|
||||||
|
super.key,
|
||||||
|
this.selectedType,
|
||||||
|
required this.onTypeChanged,
|
||||||
|
this.isDraft = false,
|
||||||
|
required this.onDraftChanged,
|
||||||
|
this.isRequired = true,
|
||||||
|
this.label = 'نوع فاکتور',
|
||||||
|
this.hintText = 'انتخاب نوع فاکتور',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<InvoiceTypeCombobox> createState() => _InvoiceTypeComboboxState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoiceTypeComboboxState extends State<InvoiceTypeCombobox> {
|
||||||
|
InvoiceType? _selectedType;
|
||||||
|
late bool _isDraft;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedType = widget.selectedType;
|
||||||
|
_isDraft = widget.isDraft;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(InvoiceTypeCombobox oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.selectedType != oldWidget.selectedType) {
|
||||||
|
setState(() {
|
||||||
|
_selectedType = widget.selectedType;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (widget.isDraft != oldWidget.isDraft) {
|
||||||
|
setState(() {
|
||||||
|
_isDraft = widget.isDraft;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectType(InvoiceType type) {
|
||||||
|
setState(() {
|
||||||
|
_selectedType = type;
|
||||||
|
});
|
||||||
|
widget.onTypeChanged(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSelection() {
|
||||||
|
setState(() {
|
||||||
|
_selectedType = null;
|
||||||
|
});
|
||||||
|
widget.onTypeChanged(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return DropdownButtonFormField<InvoiceType>(
|
||||||
|
value: _selectedType,
|
||||||
|
onChanged: (InvoiceType? newValue) {
|
||||||
|
if (newValue != null) {
|
||||||
|
_selectType(newValue);
|
||||||
|
} else if (!widget.isRequired) {
|
||||||
|
_clearSelection();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: widget.label,
|
||||||
|
hintText: widget.hintText,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
prefixIcon: _selectedType != null
|
||||||
|
? Icon(_getTypeIcon(_selectedType!))
|
||||||
|
: const Icon(Icons.category_outlined),
|
||||||
|
suffixIcon: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// سویچ پیشنویس کوچک
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
child: Tooltip(
|
||||||
|
message: _isDraft ? 'حالت پیشنویس فعال است' : 'فعال کردن حالت پیشنویس',
|
||||||
|
child: Switch(
|
||||||
|
value: _isDraft,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_isDraft = value;
|
||||||
|
});
|
||||||
|
widget.onDraftChanged(value);
|
||||||
|
},
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// دکمه پاک کردن (اگر نیاز باشد)
|
||||||
|
if (_selectedType != null && !widget.isRequired)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: _clearSelection,
|
||||||
|
iconSize: 18,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: InvoiceType.allTypes.map((InvoiceType type) {
|
||||||
|
return DropdownMenuItem<InvoiceType>(
|
||||||
|
value: type,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_getTypeIcon(type),
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
type.label,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
validator: (value) {
|
||||||
|
if (widget.isRequired && value == null) {
|
||||||
|
return 'انتخاب ${widget.label} الزامی است';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getTypeIcon(InvoiceType type) {
|
||||||
|
switch (type) {
|
||||||
|
case InvoiceType.sales:
|
||||||
|
return Icons.shopping_cart_outlined;
|
||||||
|
case InvoiceType.salesReturn:
|
||||||
|
return Icons.keyboard_return_outlined;
|
||||||
|
case InvoiceType.purchase:
|
||||||
|
return Icons.shop_outlined;
|
||||||
|
case InvoiceType.purchaseReturn:
|
||||||
|
return Icons.assignment_return_outlined;
|
||||||
|
case InvoiceType.waste:
|
||||||
|
return Icons.delete_outline;
|
||||||
|
case InvoiceType.directConsumption:
|
||||||
|
return Icons.flash_on_outlined;
|
||||||
|
case InvoiceType.production:
|
||||||
|
return Icons.precision_manufacturing_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../models/invoice_type_model.dart';
|
||||||
|
|
||||||
|
class InvoiceTypeSelector extends StatefulWidget {
|
||||||
|
final InvoiceType? selectedType;
|
||||||
|
final ValueChanged<InvoiceType?> onTypeChanged;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
const InvoiceTypeSelector({
|
||||||
|
super.key,
|
||||||
|
this.selectedType,
|
||||||
|
required this.onTypeChanged,
|
||||||
|
this.isRequired = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<InvoiceTypeSelector> createState() => _InvoiceTypeSelectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoiceTypeSelectorState extends State<InvoiceTypeSelector> {
|
||||||
|
InvoiceType? _selectedType;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedType = widget.selectedType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(InvoiceTypeSelector oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.selectedType != oldWidget.selectedType) {
|
||||||
|
setState(() {
|
||||||
|
_selectedType = widget.selectedType;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.category_outlined,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'انتخاب نوع فاکتور',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.isRequired)
|
||||||
|
Text(
|
||||||
|
' *',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.error,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// استفاده از SegmentedButton
|
||||||
|
SegmentedButton<InvoiceType>(
|
||||||
|
segments: InvoiceType.allTypes.map((type) {
|
||||||
|
return ButtonSegment<InvoiceType>(
|
||||||
|
value: type,
|
||||||
|
label: Text(type.label),
|
||||||
|
icon: Icon(_getTypeIcon(type)),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
selected: _selectedType != null ? {_selectedType!} : <InvoiceType>{},
|
||||||
|
onSelectionChanged: (Set<InvoiceType> selection) {
|
||||||
|
final selectedType = selection.isNotEmpty ? selection.first : null;
|
||||||
|
setState(() {
|
||||||
|
_selectedType = selectedType;
|
||||||
|
});
|
||||||
|
widget.onTypeChanged(selectedType);
|
||||||
|
},
|
||||||
|
multiSelectionEnabled: false,
|
||||||
|
showSelectedIcon: true,
|
||||||
|
style: ButtonStyle(
|
||||||
|
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return colorScheme.primaryContainer;
|
||||||
|
}
|
||||||
|
return colorScheme.surfaceContainerHighest;
|
||||||
|
}),
|
||||||
|
foregroundColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return colorScheme.onPrimaryContainer;
|
||||||
|
}
|
||||||
|
return colorScheme.onSurfaceVariant;
|
||||||
|
}),
|
||||||
|
side: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return BorderSide(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
width: 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return BorderSide(
|
||||||
|
color: colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getTypeIcon(InvoiceType type) {
|
||||||
|
switch (type) {
|
||||||
|
case InvoiceType.sales:
|
||||||
|
return Icons.shopping_cart_outlined;
|
||||||
|
case InvoiceType.salesReturn:
|
||||||
|
return Icons.keyboard_return_outlined;
|
||||||
|
case InvoiceType.purchase:
|
||||||
|
return Icons.shop_outlined;
|
||||||
|
case InvoiceType.purchaseReturn:
|
||||||
|
return Icons.assignment_return_outlined;
|
||||||
|
case InvoiceType.waste:
|
||||||
|
return Icons.delete_outline;
|
||||||
|
case InvoiceType.directConsumption:
|
||||||
|
return Icons.flash_on_outlined;
|
||||||
|
case InvoiceType.production:
|
||||||
|
return Icons.precision_manufacturing_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,347 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../core/auth_store.dart';
|
||||||
|
import '../../models/person_model.dart';
|
||||||
|
import '../../services/person_service.dart';
|
||||||
|
|
||||||
|
class SellerPickerWidget extends StatefulWidget {
|
||||||
|
final Person? selectedSeller;
|
||||||
|
final Function(Person?) onSellerChanged;
|
||||||
|
final int businessId;
|
||||||
|
final AuthStore authStore;
|
||||||
|
final bool isRequired;
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
|
||||||
|
const SellerPickerWidget({
|
||||||
|
super.key,
|
||||||
|
this.selectedSeller,
|
||||||
|
required this.onSellerChanged,
|
||||||
|
required this.businessId,
|
||||||
|
required this.authStore,
|
||||||
|
this.isRequired = false,
|
||||||
|
this.label = 'فروشنده/بازاریاب',
|
||||||
|
this.hintText = 'جستوجو و انتخاب فروشنده یا بازاریاب',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SellerPickerWidget> createState() => _SellerPickerWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SellerPickerWidgetState extends State<SellerPickerWidget> {
|
||||||
|
final PersonService _personService = PersonService();
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
List<Person> _sellers = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isSearching = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSellers();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSellers() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _personService.getPersons(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
filters: {
|
||||||
|
'person_types': ['فروشنده', 'بازاریاب'], // فقط فروشنده و بازاریاب
|
||||||
|
},
|
||||||
|
limit: 100, // دریافت همه فروشندگان/بازاریابها
|
||||||
|
);
|
||||||
|
|
||||||
|
final sellers = _personService.parsePersonsList(response);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_sellers = sellers;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('خطا در دریافت لیست فروشندگان: $e'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _searchSellers(String query) async {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_loadSellers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSearching = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _personService.getPersons(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
search: query,
|
||||||
|
filters: {
|
||||||
|
'person_types': ['فروشنده', 'بازاریاب'],
|
||||||
|
},
|
||||||
|
limit: 50,
|
||||||
|
);
|
||||||
|
|
||||||
|
final sellers = _personService.parsePersonsList(response);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_sellers = sellers;
|
||||||
|
_isSearching = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSearching = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('خطا در جستوجو: $e'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSellerPicker() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => _SellerPickerBottomSheet(
|
||||||
|
sellers: _sellers,
|
||||||
|
selectedSeller: widget.selectedSeller,
|
||||||
|
onSellerSelected: (seller) {
|
||||||
|
widget.onSellerChanged(seller);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
searchController: _searchController,
|
||||||
|
onSearchChanged: _searchSellers,
|
||||||
|
isLoading: _isLoading || _isSearching,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: _showSellerPicker,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.person_search,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: widget.selectedSeller != null
|
||||||
|
? Text(
|
||||||
|
widget.selectedSeller!.displayName,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
widget.hintText,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.selectedSeller != null)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
widget.onSellerChanged(null);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: Icon(
|
||||||
|
Icons.clear,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(
|
||||||
|
Icons.arrow_drop_down,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SellerPickerBottomSheet extends StatefulWidget {
|
||||||
|
final List<Person> sellers;
|
||||||
|
final Person? selectedSeller;
|
||||||
|
final Function(Person) onSellerSelected;
|
||||||
|
final TextEditingController searchController;
|
||||||
|
final Function(String) onSearchChanged;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const _SellerPickerBottomSheet({
|
||||||
|
required this.sellers,
|
||||||
|
required this.selectedSeller,
|
||||||
|
required this.onSellerSelected,
|
||||||
|
required this.searchController,
|
||||||
|
required this.onSearchChanged,
|
||||||
|
required this.isLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SellerPickerBottomSheet> createState() => _SellerPickerBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SellerPickerBottomSheetState extends State<_SellerPickerBottomSheet> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.7,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// هدر
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'انتخاب فروشنده/بازاریاب',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// فیلد جستوجو
|
||||||
|
TextField(
|
||||||
|
controller: widget.searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'جستوجو در فروشندگان و بازاریابها...',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: widget.onSearchChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// لیست فروشندگان
|
||||||
|
Expanded(
|
||||||
|
child: widget.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: widget.sellers.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.person_off,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'فروشنده یا بازاریابی یافت نشد',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: widget.sellers.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final seller = widget.sellers[index];
|
||||||
|
final isSelected = widget.selectedSeller?.id == seller.id;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(seller.displayName),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(seller.personTypes.isNotEmpty
|
||||||
|
? seller.personTypes.first.persianName
|
||||||
|
: 'نامشخص'),
|
||||||
|
if (seller.commissionSalePercent != null)
|
||||||
|
Text(
|
||||||
|
'کارمزد فروش: ${seller.commissionSalePercent!.toStringAsFixed(1)}%',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: isSelected
|
||||||
|
? Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => widget.onSellerSelected(seller),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -100,10 +100,10 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
||||||
_faxController.text = person.fax ?? '';
|
_faxController.text = person.fax ?? '';
|
||||||
_emailController.text = person.email ?? '';
|
_emailController.text = person.email ?? '';
|
||||||
_websiteController.text = person.website ?? '';
|
_websiteController.text = person.website ?? '';
|
||||||
_selectedPersonType = person.personType;
|
_selectedPersonType = person.personTypes.isNotEmpty ? person.personTypes.first : PersonType.customer;
|
||||||
_selectedPersonTypes
|
_selectedPersonTypes
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(person.personTypes.isNotEmpty ? person.personTypes : [person.personType]);
|
..addAll(person.personTypes);
|
||||||
_isActive = person.isActive;
|
_isActive = person.isActive;
|
||||||
_bankAccounts = List.from(person.bankAccounts);
|
_bankAccounts = List.from(person.bankAccounts);
|
||||||
// مقدار اولیه سهام
|
// مقدار اولیه سهام
|
||||||
|
|
@ -230,7 +230,6 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
||||||
aliasName: _aliasNameController.text.trim(),
|
aliasName: _aliasNameController.text.trim(),
|
||||||
firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(),
|
firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(),
|
||||||
lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
|
lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
|
||||||
personType: null,
|
|
||||||
personTypes: _selectedPersonTypes.isNotEmpty ? _selectedPersonTypes.toList() : null,
|
personTypes: _selectedPersonTypes.isNotEmpty ? _selectedPersonTypes.toList() : null,
|
||||||
companyName: _companyNameController.text.trim().isEmpty ? null : _companyNameController.text.trim(),
|
companyName: _companyNameController.text.trim().isEmpty ? null : _companyNameController.text.trim(),
|
||||||
paymentId: _paymentIdController.text.trim().isEmpty ? null : _paymentIdController.text.trim(),
|
paymentId: _paymentIdController.text.trim().isEmpty ? null : _paymentIdController.text.trim(),
|
||||||
|
|
|
||||||
|
|
@ -172,9 +172,9 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.orange.withOpacity(0.1),
|
color: Colors.orange.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -273,7 +273,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [primary.withOpacity(0.90), primary.withOpacity(0.75)],
|
colors: [primary.withValues(alpha: 0.90), primary.withValues(alpha: 0.75)],
|
||||||
begin: Alignment.centerRight,
|
begin: Alignment.centerRight,
|
||||||
end: Alignment.centerLeft,
|
end: Alignment.centerLeft,
|
||||||
),
|
),
|
||||||
|
|
@ -284,7 +284,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: onPrimary.withOpacity(0.15),
|
color: onPrimary.withValues(alpha: 0.15),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(Icons.price_change, color: onPrimary),
|
child: Icon(Icons.price_change, color: onPrimary),
|
||||||
|
|
@ -300,7 +300,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
t.bulkPriceUpdateSubtitle,
|
t.bulkPriceUpdateSubtitle,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: onPrimary.withOpacity(0.9)),
|
style: theme.textTheme.bodySmall?.copyWith(color: onPrimary.withValues(alpha: 0.9)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -366,7 +366,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(top: BorderSide(color: theme.dividerColor.withOpacity(0.4))),
|
border: Border(top: BorderSide(color: theme.dividerColor.withValues(alpha: 0.4))),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
|
@ -881,7 +881,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.primary.withOpacity(0.10),
|
color: theme.colorScheme.primary.withValues(alpha: 0.10),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 16, color: theme.colorScheme.primary),
|
child: Icon(icon, size: 16, color: theme.colorScheme.primary),
|
||||||
|
|
|
||||||
|
|
@ -310,7 +310,7 @@ class ProductBasicInfoSection extends StatelessWidget {
|
||||||
width: isSelected ? 2 : 1,
|
width: isSelected ? 2 : 1,
|
||||||
),
|
),
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? Theme.of(context).primaryColor.withOpacity(0.05)
|
? Theme.of(context).primaryColor.withValues(alpha: 0.05)
|
||||||
: Theme.of(context).cardColor,
|
: Theme.of(context).cardColor,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -336,7 +336,7 @@ class ProductBasicInfoSection extends StatelessWidget {
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.7),
|
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -226,9 +226,9 @@ class ProductPricingInventorySection extends StatelessWidget {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blue.withOpacity(0.1),
|
color: Colors.blue.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue