progress in invoice

This commit is contained in:
Hesabix 2025-10-05 02:33:08 +03:30
parent 6c1606fe24
commit 0edff7d020
36 changed files with 4463 additions and 131 deletions

View 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": "دسترسی مجاز است"}

View file

@ -34,12 +34,7 @@ class Person(Base):
alias_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام مستعار (الزامی)")
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام")
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام خانوادگی")
person_type: Mapped[PersonType] = mapped_column(
SQLEnum(PersonType, 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")
person_types: Mapped[str] = mapped_column(Text, nullable=False, comment="لیست انواع شخص به صورت JSON")
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت")
payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شناسه پرداخت")
# سهام

View file

@ -212,3 +212,10 @@ def require_business_management_dep(auth_context: AuthContext = Depends(get_curr
"""FastAPI dependency برای بررسی مجوز مدیریت کسب و کارها."""
if not auth_context.has_app_permission("business_management"):
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

View file

@ -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.price_lists import router as price_lists_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.cash_registers import router as cash_registers_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(price_lists_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(cash_registers_router, prefix=settings.api_v1_prefix)
application.include_router(petty_cash_router, prefix=settings.api_v1_prefix)

View file

@ -68,7 +68,6 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques
first_name=person_data.first_name,
last_name=person_data.last_name,
# ذخیره مقدار 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,
company_name=person_data.company_name,
payment_id=person_data.payment_id,
@ -198,14 +197,6 @@ def get_persons_by_business(
query = query.filter(Person.code.in_(value))
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)
if field == 'person_types':
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())
elif sort_by == 'last_name':
query = query.order_by(Person.last_name.desc() if sort_desc else Person.last_name.asc())
elif sort_by == 'person_type':
query = query.order_by(Person.person_type.desc() if sort_desc else Person.person_type.asc())
# person_type sorting removed - use person_types instead
elif sort_by == 'created_at':
query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc())
elif sort_by == 'updated_at':
@ -367,23 +357,7 @@ def update_person(
types_list = [t.value if hasattr(t, 'value') else str(t) for t in incoming]
person.person_types = json.dumps(types_list, ensure_ascii=False) if types_list else None
# همگام کردن person_type تکی برای سازگاری
if types_list:
# مقدار 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)
# person_type handling removed - only person_types is used now
# اگر شخص سهامدار شد، share_count معتبر باشد
resulting_types: List[str] = []
@ -394,7 +368,7 @@ def update_person(
resulting_types = [str(x) for x in tmp]
except Exception:
resulting_types = []
if (person.person_type == 'سهامدار') or ('سهامدار' in resulting_types):
if 'سهامدار' in resulting_types:
sc_val2 = update_data.get('share_count', person.share_count)
if sc_val2 is None or (isinstance(sc_val2, int) and sc_val2 <= 0):
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 = {}
for person_type in PersonType:
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()
by_type[person_type.value] = count
@ -473,7 +447,6 @@ def _person_to_dict(person: Person) -> Dict[str, Any]:
'alias_name': person.alias_name,
'first_name': person.first_name,
'last_name': person.last_name,
'person_type': person.person_type.value,
'person_types': types_list,
'company_name': person.company_name,
'payment_id': person.payment_id,
@ -514,3 +487,51 @@ def _person_to_dict(person: Person) -> Dict[str, Any]:
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()

View file

@ -12,6 +12,7 @@ adapters/api/v1/businesses.py
adapters/api/v1/cash_registers.py
adapters/api/v1/categories.py
adapters/api/v1/currencies.py
adapters/api/v1/customers.py
adapters/api/v1/health.py
adapters/api/v1/persons.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/9f9786ae7191_create_tax_units_table.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/d3e84892c1c2_sync_person_type_enum_values_callable_.py
migrations/versions/f876bfa36805_merge_multiple_heads.py

View file

@ -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='نوع شخص'
)
)

View file

@ -652,6 +652,7 @@ class _MyAppState extends State<MyApp> {
child: NewInvoicePage(
businessId: businessId,
authStore: _authStore!,
calendarController: _calendarController!,
),
);
},

View 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;
}

View 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;
}

View file

@ -102,7 +102,6 @@ class Person {
final String aliasName;
final String? firstName;
final String? lastName;
final PersonType personType;
final List<PersonType> personTypes;
final String? companyName;
final String? paymentId;
@ -140,8 +139,7 @@ class Person {
required this.aliasName,
this.firstName,
this.lastName,
required this.personType,
this.personTypes = const [],
required this.personTypes,
this.companyName,
this.paymentId,
this.nationalId,
@ -176,9 +174,6 @@ class Person {
?.map((e) => PersonType.fromString(e.toString()))
.toList() ??
[];
final PersonType primaryType = types.isNotEmpty
? types.first
: PersonType.fromString(json['person_type']);
return Person(
id: json['id'],
businessId: json['business_id'],
@ -186,7 +181,6 @@ class Person {
aliasName: json['alias_name'],
firstName: json['first_name'],
lastName: json['last_name'],
personType: primaryType,
personTypes: types,
companyName: json['company_name'],
paymentId: json['payment_id'],
@ -228,7 +222,6 @@ class Person {
'alias_name': aliasName,
'first_name': firstName,
'last_name': lastName,
'person_type': personType.persianName,
'person_types': personTypes.map((t) => t.persianName).toList(),
'company_name': companyName,
'payment_id': paymentId,
@ -266,7 +259,7 @@ class Person {
String? aliasName,
String? firstName,
String? lastName,
PersonType? personType,
List<PersonType>? personTypes,
String? companyName,
String? paymentId,
String? nationalId,
@ -293,7 +286,7 @@ class Person {
aliasName: aliasName ?? this.aliasName,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
personType: personType ?? this.personType,
personTypes: personTypes ?? this.personTypes,
companyName: companyName ?? this.companyName,
paymentId: paymentId ?? this.paymentId,
nationalId: nationalId ?? this.nationalId,
@ -444,7 +437,6 @@ class PersonUpdateRequest {
final String? aliasName;
final String? firstName;
final String? lastName;
final PersonType? personType;
final List<PersonType>? personTypes;
final String? companyName;
final String? paymentId;
@ -476,7 +468,6 @@ class PersonUpdateRequest {
this.aliasName,
this.firstName,
this.lastName,
this.personType,
this.personTypes,
this.companyName,
this.paymentId,
@ -511,7 +502,6 @@ class PersonUpdateRequest {
if (aliasName != null) json['alias_name'] = aliasName;
if (firstName != null) json['first_name'] = firstName;
if (lastName != null) json['last_name'] = lastName;
if (personType != null) json['person_type'] = personType!.persianName;
if (personTypes != null) json['person_types'] = personTypes!.map((t) => t.persianName).toList();
if (companyName != null) json['company_name'] = companyName;
if (paymentId != null) json['payment_id'] = paymentId;

View file

@ -948,6 +948,9 @@ class _BusinessShellState extends State<BusinessShell> {
showAddBankAccountDialog();
} else if (item.label == t.cashBox) {
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) {
showAddPersonDialog();
} else if (item.label == t.invoice) {
// Navigate to add invoice
context.go('/business/${widget.businessId}/invoice/new');
}
},
child: Container(

View file

@ -34,20 +34,20 @@ class _InvoicePageState extends State<InvoicePage> {
Icon(
Icons.receipt,
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),
Text(
t.invoice,
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),
Text(
'صفحه فاکتور در حال توسعه است',
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),
),
),
],

View file

@ -1,23 +1,68 @@
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> {
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);
@ -29,53 +74,830 @@ class _NewInvoicePageState extends State<NewInvoicePage> {
return Scaffold(
appBar: AppBar(
title: Text(t.addInvoice),
backgroundColor: Theme.of(context).colorScheme.surface,
foregroundColor: Theme.of(context).colorScheme.onSurface,
elevation: 0,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_circle_outline,
size: 80,
color: Theme.of(context).colorScheme.primary.withOpacity(0.6),
toolbarHeight: 56,
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(
icon: Icon(Icons.info_outline),
text: 'اطلاعات فاکتور',
),
const SizedBox(height: 24),
Text(
t.addInvoice,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
Tab(
icon: Icon(Icons.inventory_2_outlined),
text: 'کالاها و خدمات',
),
const SizedBox(height: 16),
Text(
'فرم ایجاد فاکتور جدید در حال توسعه است',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
),
Tab(
icon: Icon(Icons.receipt_long_outlined),
text: 'تراکنش‌ها',
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: () {
// 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),
),
Tab(
icon: Icon(Icons.settings_outlined),
text: 'تنظیمات',
),
],
),
),
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,
),
),
],
),
);
}
}

View file

@ -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,
),
),
],
),
);
}
}

View file

@ -103,9 +103,7 @@ class _PersonsPageState extends State<PersonsPage> {
FilterOption(value: 'فروشنده', label: t.personTypeSeller),
FilterOption(value: 'سهامدار', label: 'سهامدار'),
],
formatter: (person) => (person.personTypes.isNotEmpty
? person.personTypes.map((e) => e.persianName).join('، ')
: person.personType.persianName),
formatter: (person) => person.personTypes.map((e) => e.persianName).join('، '),
),
TextColumn(
'company_name',

View file

@ -169,8 +169,8 @@ class _PriceListsPageState extends State<PriceListsPage> {
child: ListTile(
leading: CircleAvatar(
backgroundColor: isActive
? Theme.of(context).primaryColor.withOpacity(0.1)
: Colors.grey.withOpacity(0.1),
? Theme.of(context).primaryColor.withValues(alpha: 0.1)
: Colors.grey.withValues(alpha: 0.1),
child: Icon(
Icons.price_change,
color: isActive
@ -193,7 +193,7 @@ class _PriceListsPageState extends State<PriceListsPage> {
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.2),
color: Colors.grey.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
@ -392,9 +392,9 @@ class _PriceListsPageState extends State<PriceListsPage> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
color: Colors.red.withValues(alpha: 0.1),
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(
children: [

View file

@ -34,20 +34,20 @@ class _WalletPageState extends State<WalletPage> {
Icon(
Icons.wallet,
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),
Text(
t.wallet,
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),
Text(
'صفحه کیف پول در حال توسعه است',
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),
),
),
],

View 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;
}
}
}

View file

@ -37,7 +37,13 @@ class PersonService {
}
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(

View file

@ -137,7 +137,7 @@ class _CurrencyPickerWidgetState extends State<CurrencyPickerWidget> {
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
color: Theme.of(context).primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(

View file

@ -40,7 +40,7 @@ class CombinedUserMenuButton extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 8),
),
@ -53,7 +53,7 @@ class CombinedUserMenuButton extends StatelessWidget {
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: cs.primaryContainer.withOpacity(0.1),
color: cs.primaryContainer.withValues(alpha: 0.1),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
@ -106,10 +106,10 @@ class CombinedUserMenuButton extends StatelessWidget {
Container(
width: double.infinity,
decoration: BoxDecoration(
color: cs.primaryContainer.withOpacity(0.1),
color: cs.primaryContainer.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: cs.primary.withOpacity(0.2),
color: cs.primary.withValues(alpha: 0.2),
width: 1,
),
),
@ -175,7 +175,7 @@ class CombinedUserMenuButton extends StatelessWidget {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withOpacity(0.3),
color: cs.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
@ -242,10 +242,10 @@ class CombinedUserMenuButton extends StatelessWidget {
Container(
width: double.infinity,
decoration: BoxDecoration(
color: cs.errorContainer.withOpacity(0.1),
color: cs.errorContainer.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: cs.error.withOpacity(0.2),
color: cs.error.withValues(alpha: 0.2),
width: 1,
),
),
@ -342,7 +342,7 @@ class CombinedUserMenuButton extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 8),
),
@ -355,7 +355,7 @@ class CombinedUserMenuButton extends StatelessWidget {
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: cs.errorContainer.withOpacity(0.1),
color: cs.errorContainer.withValues(alpha: 0.1),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),

View 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;
},
);
}
}

View file

@ -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('');
}
}

View file

@ -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;
},
);
}
}

View file

@ -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;
}
}
}

View file

@ -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),
);
},
),
),
],
),
);
}
}

View file

@ -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,
),
),
),
],
),
),
),
],
],
),
);
}
}

View file

@ -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,
),
),
],
),
),
);
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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),
);
},
),
),
],
),
);
}
}

View file

@ -100,10 +100,10 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
_faxController.text = person.fax ?? '';
_emailController.text = person.email ?? '';
_websiteController.text = person.website ?? '';
_selectedPersonType = person.personType;
_selectedPersonType = person.personTypes.isNotEmpty ? person.personTypes.first : PersonType.customer;
_selectedPersonTypes
..clear()
..addAll(person.personTypes.isNotEmpty ? person.personTypes : [person.personType]);
..addAll(person.personTypes);
_isActive = person.isActive;
_bankAccounts = List.from(person.bankAccounts);
// مقدار اولیه سهام
@ -230,7 +230,6 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
aliasName: _aliasNameController.text.trim(),
firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(),
lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
personType: null,
personTypes: _selectedPersonTypes.isNotEmpty ? _selectedPersonTypes.toList() : null,
companyName: _companyNameController.text.trim().isEmpty ? null : _companyNameController.text.trim(),
paymentId: _paymentIdController.text.trim().isEmpty ? null : _paymentIdController.text.trim(),

View file

@ -172,9 +172,9 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
color: Colors.orange.withValues(alpha: 0.1),
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(
children: [
@ -273,7 +273,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
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,
end: Alignment.centerLeft,
),
@ -284,7 +284,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
width: 40,
height: 40,
decoration: BoxDecoration(
color: onPrimary.withOpacity(0.15),
color: onPrimary.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(Icons.price_change, color: onPrimary),
@ -300,7 +300,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
),
Text(
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(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
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(
mainAxisAlignment: MainAxisAlignment.end,
@ -881,7 +881,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
width: 28,
height: 28,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.10),
color: theme.colorScheme.primary.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(icon, size: 16, color: theme.colorScheme.primary),

View file

@ -310,7 +310,7 @@ class ProductBasicInfoSection extends StatelessWidget {
width: isSelected ? 2 : 1,
),
color: isSelected
? Theme.of(context).primaryColor.withOpacity(0.05)
? Theme.of(context).primaryColor.withValues(alpha: 0.05)
: Theme.of(context).cardColor,
),
child: Column(
@ -336,7 +336,7 @@ class ProductBasicInfoSection extends StatelessWidget {
Text(
subtitle,
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,
),

View file

@ -226,9 +226,9 @@ class ProductPricingInventorySection extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
color: Colors.blue.withValues(alpha: 0.1),
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(
children: [