progress in invoices
This commit is contained in:
parent
09c17b580d
commit
ff968aed7a
|
|
@ -2,4 +2,5 @@ from .health import router as health # noqa: F401
|
||||||
from .categories import router as categories # noqa: F401
|
from .categories import router as categories # noqa: F401
|
||||||
from .products import router as products # noqa: F401
|
from .products import router as products # noqa: F401
|
||||||
from .price_lists import router as price_lists # noqa: F401
|
from .price_lists import router as price_lists # noqa: F401
|
||||||
|
from .invoices import router as invoices # noqa: F401
|
||||||
|
|
||||||
|
|
|
||||||
67
hesabixAPI/adapters/api/v1/invoices.py
Normal file
67
hesabixAPI/adapters/api/v1/invoices.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
from typing import Dict, Any
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from adapters.db.session import get_db
|
||||||
|
from app.core.auth_dependency import get_current_user, AuthContext
|
||||||
|
from app.core.permissions import require_business_access
|
||||||
|
from app.core.responses import success_response
|
||||||
|
from adapters.api.v1.schemas import QueryInfo
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/invoices", tags=["invoices"]) # Stubs only
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/business/{business_id}")
|
||||||
|
@require_business_access("business_id")
|
||||||
|
def create_invoice_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
# Stub only: no implementation yet
|
||||||
|
return success_response(data={}, request=request, message="INVOICE_CREATE_STUB")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/business/{business_id}/{invoice_id}")
|
||||||
|
@require_business_access("business_id")
|
||||||
|
def update_invoice_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
invoice_id: int,
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
# Stub only: no implementation yet
|
||||||
|
return success_response(data={}, request=request, message="INVOICE_UPDATE_STUB")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/business/{business_id}/{invoice_id}")
|
||||||
|
@require_business_access("business_id")
|
||||||
|
def get_invoice_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
invoice_id: int,
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
# Stub only: no implementation yet
|
||||||
|
return success_response(data={"item": None}, request=request, message="INVOICE_GET_STUB")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/business/{business_id}/search")
|
||||||
|
@require_business_access("business_id")
|
||||||
|
def search_invoices_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
query_info: QueryInfo,
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
# Stub only: no implementation yet
|
||||||
|
return success_response(data={"items": [], "total": 0, "take": query_info.take, "skip": query_info.skip}, request=request, message="INVOICE_SEARCH_STUB")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -15,6 +15,7 @@ from adapters.api.v1.categories import router as categories_router
|
||||||
from adapters.api.v1.product_attributes import router as product_attributes_router
|
from adapters.api.v1.product_attributes import router as product_attributes_router
|
||||||
from adapters.api.v1.products import router as products_router
|
from adapters.api.v1.products import router as products_router
|
||||||
from adapters.api.v1.price_lists import router as price_lists_router
|
from adapters.api.v1.price_lists import router as price_lists_router
|
||||||
|
from adapters.api.v1.invoices import router as invoices_router
|
||||||
from adapters.api.v1.persons import router as persons_router
|
from adapters.api.v1.persons import router as persons_router
|
||||||
from adapters.api.v1.customers import router as customers_router
|
from adapters.api.v1.customers import router as customers_router
|
||||||
from adapters.api.v1.bank_accounts import router as bank_accounts_router
|
from adapters.api.v1.bank_accounts import router as bank_accounts_router
|
||||||
|
|
@ -294,6 +295,7 @@ def create_app() -> FastAPI:
|
||||||
application.include_router(product_attributes_router, prefix=settings.api_v1_prefix)
|
application.include_router(product_attributes_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(products_router, prefix=settings.api_v1_prefix)
|
application.include_router(products_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
|
application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
|
||||||
|
application.include_router(invoices_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(persons_router, prefix=settings.api_v1_prefix)
|
application.include_router(persons_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(customers_router, prefix=settings.api_v1_prefix)
|
application.include_router(customers_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix)
|
application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix)
|
||||||
|
|
|
||||||
|
|
@ -65,23 +65,13 @@ class ProductFormController extends ChangeNotifier {
|
||||||
|
|
||||||
void addOrUpdateDraftPriceItem(Map<String, dynamic> item) {
|
void addOrUpdateDraftPriceItem(Map<String, dynamic> item) {
|
||||||
final String key = (
|
final String key = (
|
||||||
(item['price_list_id']?.toString() ?? '') + '|' +
|
'${item['price_list_id']?.toString() ?? ''}|${item['product_id']?.toString() ?? ''}|${item['unit_id']?.toString() ?? 'null'}|${item['currency_id']?.toString() ?? ''}|${item['tier_name']?.toString() ?? ''}|${item['min_qty']?.toString() ?? '0'}'
|
||||||
(item['product_id']?.toString() ?? '') + '|' +
|
|
||||||
(item['unit_id']?.toString() ?? 'null') + '|' +
|
|
||||||
(item['currency_id']?.toString() ?? '') + '|' +
|
|
||||||
(item['tier_name']?.toString() ?? '') + '|' +
|
|
||||||
(item['min_qty']?.toString() ?? '0')
|
|
||||||
);
|
);
|
||||||
int existingIndex = -1;
|
int existingIndex = -1;
|
||||||
for (int i = 0; i < _draftPriceItems.length; i++) {
|
for (int i = 0; i < _draftPriceItems.length; i++) {
|
||||||
final it = _draftPriceItems[i];
|
final it = _draftPriceItems[i];
|
||||||
final itKey = (
|
final itKey = (
|
||||||
(it['price_list_id']?.toString() ?? '') + '|' +
|
'${it['price_list_id']?.toString() ?? ''}|${it['product_id']?.toString() ?? ''}|${it['unit_id']?.toString() ?? 'null'}|${it['currency_id']?.toString() ?? ''}|${it['tier_name']?.toString() ?? ''}|${it['min_qty']?.toString() ?? '0'}'
|
||||||
(it['product_id']?.toString() ?? '') + '|' +
|
|
||||||
(it['unit_id']?.toString() ?? 'null') + '|' +
|
|
||||||
(it['currency_id']?.toString() ?? '') + '|' +
|
|
||||||
(it['tier_name']?.toString() ?? '') + '|' +
|
|
||||||
(it['min_qty']?.toString() ?? '0')
|
|
||||||
);
|
);
|
||||||
if (itKey == key) {
|
if (itKey == key) {
|
||||||
existingIndex = i;
|
existingIndex = i;
|
||||||
|
|
@ -371,8 +361,4 @@ class ProductFormController extends ChangeNotifier {
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import 'pages/business/wallet_page.dart';
|
||||||
import 'pages/business/invoice_page.dart';
|
import 'pages/business/invoice_page.dart';
|
||||||
import 'pages/business/new_invoice_page.dart';
|
import 'pages/business/new_invoice_page.dart';
|
||||||
import 'pages/business/settings_page.dart';
|
import 'pages/business/settings_page.dart';
|
||||||
|
import 'pages/business/reports_page.dart';
|
||||||
import 'pages/business/persons_page.dart';
|
import 'pages/business/persons_page.dart';
|
||||||
import 'pages/business/product_attributes_page.dart';
|
import 'pages/business/product_attributes_page.dart';
|
||||||
import 'pages/business/products_page.dart';
|
import 'pages/business/products_page.dart';
|
||||||
|
|
@ -657,11 +658,33 @@ class _MyAppState extends State<MyApp> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'reports',
|
||||||
|
name: 'business_reports',
|
||||||
|
builder: (context, state) {
|
||||||
|
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||||
|
return BusinessShell(
|
||||||
|
businessId: businessId,
|
||||||
|
authStore: _authStore!,
|
||||||
|
localeController: controller,
|
||||||
|
calendarController: _calendarController!,
|
||||||
|
themeController: themeController,
|
||||||
|
child: ReportsPage(
|
||||||
|
businessId: businessId,
|
||||||
|
authStore: _authStore!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'business_settings',
|
name: 'business_settings',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||||
|
// گارد دسترسی: فقط کاربرانی که دسترسی join دارند
|
||||||
|
if (!_authStore!.hasBusinessPermission('settings', 'join')) {
|
||||||
|
return PermissionGuard.buildAccessDeniedPage();
|
||||||
|
}
|
||||||
return BusinessShell(
|
return BusinessShell(
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
authStore: _authStore!,
|
authStore: _authStore!,
|
||||||
|
|
|
||||||
|
|
@ -1226,7 +1226,15 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// برای کاربران عضو، بررسی دسترسی view
|
// برای کاربران عضو، بررسی دسترسی
|
||||||
|
// تنظیمات: نیازمند دسترسی join
|
||||||
|
if (section == 'settings' && item.label == AppLocalizations.of(context).settings) {
|
||||||
|
final hasJoin = widget.authStore.hasBusinessPermission('settings', 'join');
|
||||||
|
print(' Settings item requires join permission: $hasJoin');
|
||||||
|
return hasJoin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// سایر سکشنها: بررسی دسترسی view
|
||||||
final hasAccess = widget.authStore.canReadSection(section);
|
final hasAccess = widget.authStore.canReadSection(section);
|
||||||
print(' Checking view permission for section "$section": $hasAccess');
|
print(' Checking view permission for section "$section": $hasAccess');
|
||||||
|
|
||||||
|
|
@ -1276,6 +1284,7 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
if (label == t.documents) return 'accounting_documents';
|
if (label == t.documents) return 'accounting_documents';
|
||||||
if (label == t.chartOfAccounts) return 'chart_of_accounts';
|
if (label == t.chartOfAccounts) return 'chart_of_accounts';
|
||||||
if (label == t.openingBalance) return 'opening_balance';
|
if (label == t.openingBalance) return 'opening_balance';
|
||||||
|
if (label == t.reports) return 'reports';
|
||||||
if (label == t.warehouses) return 'warehouses';
|
if (label == t.warehouses) return 'warehouses';
|
||||||
if (label == t.shipments) return 'warehouse_transfers';
|
if (label == t.shipments) return 'warehouse_transfers';
|
||||||
if (label == t.inquiries) return 'reports';
|
if (label == t.inquiries) return 'reports';
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import '../../utils/number_formatters.dart';
|
||||||
import '../../services/currency_service.dart';
|
import '../../services/currency_service.dart';
|
||||||
import '../../core/api_client.dart';
|
import '../../core/api_client.dart';
|
||||||
import '../../models/invoice_transaction.dart';
|
import '../../models/invoice_transaction.dart';
|
||||||
|
import '../../models/invoice_line_item.dart';
|
||||||
|
import '../../services/invoice_service.dart';
|
||||||
|
|
||||||
class NewInvoicePage extends StatefulWidget {
|
class NewInvoicePage extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
|
|
@ -44,7 +46,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
InvoiceType? _selectedInvoiceType;
|
InvoiceType? _selectedInvoiceType;
|
||||||
bool _isDraft = false;
|
bool _isDraft = false;
|
||||||
String? _invoiceNumber;
|
String? _invoiceNumber;
|
||||||
bool _autoGenerateInvoiceNumber = true;
|
final bool _autoGenerateInvoiceNumber = true;
|
||||||
Customer? _selectedCustomer;
|
Customer? _selectedCustomer;
|
||||||
Person? _selectedSeller;
|
Person? _selectedSeller;
|
||||||
double? _commissionPercentage;
|
double? _commissionPercentage;
|
||||||
|
|
@ -71,6 +73,8 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
|
|
||||||
// تراکنشهای فاکتور
|
// تراکنشهای فاکتور
|
||||||
List<InvoiceTransaction> _transactions = [];
|
List<InvoiceTransaction> _transactions = [];
|
||||||
|
// ردیفهای فاکتور برای ساخت payload
|
||||||
|
List<InvoiceLineItem> _lineItems = <InvoiceLineItem>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -280,8 +284,11 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// مشتری
|
// مشتری (برای ضایعات/مصرف مستقیم/تولید مخفی میشود)
|
||||||
CustomerComboboxWidget(
|
if (!(_selectedInvoiceType == InvoiceType.waste ||
|
||||||
|
_selectedInvoiceType == InvoiceType.directConsumption ||
|
||||||
|
_selectedInvoiceType == InvoiceType.production))
|
||||||
|
CustomerComboboxWidget(
|
||||||
selectedCustomer: _selectedCustomer,
|
selectedCustomer: _selectedCustomer,
|
||||||
onCustomerChanged: (customer) {
|
onCustomerChanged: (customer) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -516,7 +523,11 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: CustomerComboboxWidget(
|
child: (_selectedInvoiceType == InvoiceType.waste ||
|
||||||
|
_selectedInvoiceType == InvoiceType.directConsumption ||
|
||||||
|
_selectedInvoiceType == InvoiceType.production)
|
||||||
|
? const SizedBox()
|
||||||
|
: CustomerComboboxWidget(
|
||||||
selectedCustomer: _selectedCustomer,
|
selectedCustomer: _selectedCustomer,
|
||||||
onCustomerChanged: (customer) {
|
onCustomerChanged: (customer) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -528,7 +539,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
label: 'مشتری',
|
label: 'مشتری',
|
||||||
hintText: 'انتخاب مشتری',
|
hintText: 'انتخاب مشتری',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -709,17 +720,136 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _saveInvoice() {
|
Future<void> _saveInvoice() async {
|
||||||
// TODO: پیادهسازی عملیات ذخیره فاکتور
|
final validation = _validateAndBuildPayload();
|
||||||
final printInfo = _printAfterSave ? '\n• چاپ فاکتور: فعال' : '';
|
if (validation is String) {
|
||||||
final taxInfo = _sendToTaxFolder ? '\n• ارسال به کارپوشه مودیان: فعال' : '';
|
_showError(validation);
|
||||||
final transactionInfo = _transactions.isNotEmpty ? '\n• تعداد تراکنشها: ${_transactions.length}' : '';
|
return;
|
||||||
|
}
|
||||||
|
final payload = validation as Map<String, dynamic>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final service = InvoiceService(apiClient: ApiClient());
|
||||||
|
await service.createInvoice(businessId: widget.businessId, payload: payload);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('فاکتور با موفقیت ثبت شد'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_showError('خطا در ذخیره فاکتور: ${e.toString()}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _validateAndBuildPayload() {
|
||||||
|
// اعتبارسنجیهای پایه
|
||||||
|
if (_selectedInvoiceType == null) {
|
||||||
|
return 'نوع فاکتور الزامی است';
|
||||||
|
}
|
||||||
|
if (_invoiceDate == null) {
|
||||||
|
return 'تاریخ فاکتور الزامی است';
|
||||||
|
}
|
||||||
|
if (_selectedCurrencyId == null) {
|
||||||
|
return 'ارز فاکتور الزامی است';
|
||||||
|
}
|
||||||
|
if (_lineItems.isEmpty) {
|
||||||
|
return 'حداقل یک ردیف کالا/خدمت وارد کنید';
|
||||||
|
}
|
||||||
|
// اعتبارسنجی ردیفها
|
||||||
|
for (int i = 0; i < _lineItems.length; i++) {
|
||||||
|
final r = _lineItems[i];
|
||||||
|
if (r.productId == null) {
|
||||||
|
return 'محصول ردیف ${i + 1} انتخاب نشده است';
|
||||||
|
}
|
||||||
|
if ((r.quantity) <= 0) {
|
||||||
|
return 'تعداد ردیف ${i + 1} باید بزرگتر از صفر باشد';
|
||||||
|
}
|
||||||
|
if (r.unitPrice < 0) {
|
||||||
|
return 'قیمت واحد ردیف ${i + 1} نمیتواند منفی باشد';
|
||||||
|
}
|
||||||
|
if (r.discountType == 'percent' && (r.discountValue < 0 || r.discountValue > 100)) {
|
||||||
|
return 'درصد تخفیف ردیف ${i + 1} باید بین 0 تا 100 باشد';
|
||||||
|
}
|
||||||
|
if (r.taxRate < 0 || r.taxRate > 100) {
|
||||||
|
return 'درصد مالیات ردیف ${i + 1} باید بین 0 تا 100 باشد';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final isSalesOrReturn = _selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn;
|
||||||
|
// مشتری برای انواع خاص الزامی نیست
|
||||||
|
final shouldHaveCustomer = !(_selectedInvoiceType == InvoiceType.waste || _selectedInvoiceType == InvoiceType.directConsumption || _selectedInvoiceType == InvoiceType.production);
|
||||||
|
if (shouldHaveCustomer && _selectedCustomer == null) {
|
||||||
|
return 'انتخاب مشتری الزامی است';
|
||||||
|
}
|
||||||
|
|
||||||
|
// اعتبارسنجی کارمزد در حالت فروش
|
||||||
|
if (isSalesOrReturn && _selectedSeller != null && _commissionType != null) {
|
||||||
|
if (_commissionType == CommissionType.percentage) {
|
||||||
|
final p = _commissionPercentage ?? 0;
|
||||||
|
if (p < 0 || p > 100) return 'درصد کارمزد باید بین 0 تا 100 باشد';
|
||||||
|
} else if (_commissionType == CommissionType.amount) {
|
||||||
|
final a = _commissionAmount ?? 0;
|
||||||
|
if (a < 0) return 'مبلغ کارمزد نمیتواند منفی باشد';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ساخت payload
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'type': _selectedInvoiceType!.value,
|
||||||
|
'is_draft': _isDraft,
|
||||||
|
if (_invoiceNumber != null && _invoiceNumber!.trim().isNotEmpty) 'number': _invoiceNumber!.trim(),
|
||||||
|
'invoice_date': _invoiceDate!.toIso8601String(),
|
||||||
|
if (_dueDate != null) 'due_date': _dueDate!.toIso8601String(),
|
||||||
|
'currency_id': _selectedCurrencyId,
|
||||||
|
if (_invoiceTitle != null && _invoiceTitle!.isNotEmpty) 'title': _invoiceTitle,
|
||||||
|
if (_invoiceReference != null && _invoiceReference!.isNotEmpty) 'reference': _invoiceReference,
|
||||||
|
if (_selectedCustomer != null) 'customer_id': _selectedCustomer!.id,
|
||||||
|
if (_selectedSeller?.id != null) 'seller_id': _selectedSeller!.id,
|
||||||
|
if (_commissionType != null) 'commission_type': _commissionType == CommissionType.percentage ? 'percentage' : 'amount',
|
||||||
|
if (_commissionType == CommissionType.percentage && _commissionPercentage != null) 'commission_percentage': _commissionPercentage,
|
||||||
|
if (_commissionType == CommissionType.amount && _commissionAmount != null) 'commission_amount': _commissionAmount,
|
||||||
|
'settings': {
|
||||||
|
'print_after_save': _printAfterSave,
|
||||||
|
'printer': _selectedPrinter,
|
||||||
|
'paper_size': _selectedPaperSize,
|
||||||
|
'is_official_invoice': _isOfficialInvoice,
|
||||||
|
'print_template': _selectedPrintTemplate,
|
||||||
|
'send_to_tax_folder': _sendToTaxFolder,
|
||||||
|
},
|
||||||
|
'transactions': _transactions.map((t) => t.toJson()).toList(),
|
||||||
|
'line_items': _lineItems.map((e) => _serializeLineItem(e)).toList(),
|
||||||
|
'summary': {
|
||||||
|
'subtotal': _sumSubtotal,
|
||||||
|
'discount': _sumDiscount,
|
||||||
|
'tax': _sumTax,
|
||||||
|
'total': _sumTotal,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _serializeLineItem(InvoiceLineItem e) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'product_id': e.productId,
|
||||||
|
'unit': e.selectedUnit ?? e.mainUnit,
|
||||||
|
'quantity': e.quantity,
|
||||||
|
'unit_price': e.unitPrice,
|
||||||
|
'unit_price_source': e.unitPriceSource,
|
||||||
|
'discount_type': e.discountType,
|
||||||
|
'discount_value': e.discountValue,
|
||||||
|
'tax_rate': e.taxRate,
|
||||||
|
if ((e.description ?? '').isNotEmpty) 'description': e.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showError(String message) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('عملیات ذخیره فاکتور به زودی پیادهسازی خواهد شد$printInfo$taxInfo$transactionInfo'),
|
content: Text(message),
|
||||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
duration: const Duration(seconds: 3),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -739,6 +869,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
invoiceType: (_selectedInvoiceType?.value ?? 'sales'),
|
invoiceType: (_selectedInvoiceType?.value ?? 'sales'),
|
||||||
onChanged: (rows) {
|
onChanged: (rows) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_lineItems = rows;
|
||||||
_sumSubtotal = rows.fold<num>(0, (acc, e) => acc + e.subtotal);
|
_sumSubtotal = rows.fold<num>(0, (acc, e) => acc + e.subtotal);
|
||||||
_sumDiscount = rows.fold<num>(0, (acc, e) => acc + e.discountAmount);
|
_sumDiscount = rows.fold<num>(0, (acc, e) => acc + e.discountAmount);
|
||||||
_sumTax = rows.fold<num>(0, (acc, e) => acc + e.taxAmount);
|
_sumTax = rows.fold<num>(0, (acc, e) => acc + e.taxAmount);
|
||||||
|
|
@ -778,6 +909,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
transactions: _transactions,
|
transactions: _transactions,
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
calendarController: widget.calendarController,
|
calendarController: widget.calendarController,
|
||||||
|
invoiceType: _selectedInvoiceType ?? InvoiceType.sales,
|
||||||
onChanged: (transactions) {
|
onChanged: (transactions) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_transactions = transactions;
|
_transactions = transactions;
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
InvoiceType? _selectedInvoiceType;
|
InvoiceType? _selectedInvoiceType;
|
||||||
bool _isDraft = false;
|
bool _isDraft = false;
|
||||||
String? _invoiceNumber;
|
String? _invoiceNumber;
|
||||||
bool _autoGenerateInvoiceNumber = true;
|
final bool _autoGenerateInvoiceNumber = true;
|
||||||
Customer? _selectedCustomer;
|
Customer? _selectedCustomer;
|
||||||
Person? _selectedSeller;
|
Person? _selectedSeller;
|
||||||
double? _commissionPercentage;
|
double? _commissionPercentage;
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ class _PriceListItemsPageState extends State<PriceListItemsPage> {
|
||||||
onChanged: (v) => productId = int.tryParse(v),
|
onChanged: (v) => productId = int.tryParse(v),
|
||||||
),
|
),
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
value: currencyId,
|
initialValue: currencyId,
|
||||||
items: _fallbackCurrencies
|
items: _fallbackCurrencies
|
||||||
.map((c) => DropdownMenuItem<int>(
|
.map((c) => DropdownMenuItem<int>(
|
||||||
value: c['id'] as int,
|
value: c['id'] as int,
|
||||||
|
|
@ -143,7 +143,7 @@ class _PriceListItemsPageState extends State<PriceListItemsPage> {
|
||||||
onChanged: (v) => tierName = v,
|
onChanged: (v) => tierName = v,
|
||||||
),
|
),
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
value: unitId,
|
initialValue: unitId,
|
||||||
items: _fallbackUnits
|
items: _fallbackUnits
|
||||||
.map((u) => DropdownMenuItem<int>(
|
.map((u) => DropdownMenuItem<int>(
|
||||||
value: u['id'] as int,
|
value: u['id'] as int,
|
||||||
|
|
|
||||||
333
hesabixUI/hesabix_ui/lib/pages/business/reports_page.dart
Normal file
333
hesabixUI/hesabix_ui/lib/pages/business/reports_page.dart
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
import '../../core/auth_store.dart';
|
||||||
|
import '../../widgets/permission/access_denied_page.dart';
|
||||||
|
|
||||||
|
class ReportsPage extends StatelessWidget {
|
||||||
|
final int businessId;
|
||||||
|
final AuthStore authStore;
|
||||||
|
|
||||||
|
const ReportsPage({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
required this.authStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
if (!authStore.canReadSection('reports')) {
|
||||||
|
return AccessDeniedPage(message: t.accessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildSection(
|
||||||
|
context,
|
||||||
|
title: 'گزارشات اشخاص',
|
||||||
|
icon: Icons.people_outline,
|
||||||
|
children: [
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'لیست بدهکاران',
|
||||||
|
subtitle: 'نمایش اشخاص با مانده بدهکار',
|
||||||
|
icon: Icons.trending_down,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'لیست بدهکاران'),
|
||||||
|
),
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'لیست بستانکاران',
|
||||||
|
subtitle: 'نمایش اشخاص با مانده بستانکار',
|
||||||
|
icon: Icons.trending_up,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'لیست بستانکاران'),
|
||||||
|
),
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'گزارش تراکنشهای اشخاص',
|
||||||
|
subtitle: 'ریز دریافتها و پرداختها به تفکیک شخص',
|
||||||
|
icon: Icons.receipt_long,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'گزارش تراکنشهای اشخاص'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
_buildSection(
|
||||||
|
context,
|
||||||
|
title: 'گزارشات کالا و خدمات',
|
||||||
|
icon: Icons.inventory_2_outlined,
|
||||||
|
children: [
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'گردش کالا',
|
||||||
|
subtitle: 'ورود، خروج و مانده کالا به تفکیک بازه',
|
||||||
|
icon: Icons.sync_alt,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'گردش کالا'),
|
||||||
|
),
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'کارتکس انبار',
|
||||||
|
subtitle: 'ریز گردش هر کالا (FIFO/LIFO/میانگین)',
|
||||||
|
icon: Icons.storage,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'کارتکس انبار'),
|
||||||
|
),
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'فروش به تفکیک کالا',
|
||||||
|
subtitle: 'عملکرد فروش هر کالا در بازه زمانی',
|
||||||
|
icon: Icons.shopping_cart_checkout,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'فروش به تفکیک کالا'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
_buildSection(
|
||||||
|
context,
|
||||||
|
title: 'گزارشات بانک و صندوق',
|
||||||
|
icon: Icons.account_balance_wallet_outlined,
|
||||||
|
children: [
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'گردش حسابهای بانکی',
|
||||||
|
subtitle: 'برداشت و واریز به تفکیک حساب',
|
||||||
|
icon: Icons.account_balance,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'گردش حسابهای بانکی'),
|
||||||
|
),
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'گردش صندوق و تنخواه',
|
||||||
|
subtitle: 'ریز ورود و خروج وجه نقد',
|
||||||
|
icon: Icons.savings,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'گردش صندوق و تنخواه'),
|
||||||
|
),
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'چکها',
|
||||||
|
subtitle: 'دریافتی، پرداختی، سررسیدها و وضعیتها',
|
||||||
|
icon: Icons.payments_outlined,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'چکها'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
_buildSection(
|
||||||
|
context,
|
||||||
|
title: 'گزارشات فروش',
|
||||||
|
icon: Icons.point_of_sale,
|
||||||
|
children: [
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'فروش روزانه',
|
||||||
|
subtitle: 'عملکرد فروش روزانه و روندها',
|
||||||
|
icon: Icons.today,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'فروش روزانه'),
|
||||||
|
),
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'فروش ماهانه',
|
||||||
|
subtitle: 'مقایسه ماهانه و رشد فروش',
|
||||||
|
icon: Icons.calendar_month,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'فروش ماهانه'),
|
||||||
|
),
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'مشتریان برتر',
|
||||||
|
subtitle: 'رتبهبندی بر اساس مبلغ یا تعداد',
|
||||||
|
icon: Icons.emoji_events_outlined,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'مشتریان برتر'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
_buildSection(
|
||||||
|
context,
|
||||||
|
title: 'گزارشات خرید',
|
||||||
|
icon: Icons.shopping_bag_outlined,
|
||||||
|
children: [
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'خرید روزانه',
|
||||||
|
subtitle: 'عملکرد خرید روزانه و روندها',
|
||||||
|
icon: Icons.today,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'خرید روزانه'),
|
||||||
|
),
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'تامینکنندگان برتر',
|
||||||
|
subtitle: 'رتبهبندی تامینکنندگان بر اساس خرید',
|
||||||
|
icon: Icons.handshake,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'تامینکنندگان برتر'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
_buildSection(
|
||||||
|
context,
|
||||||
|
title: 'گزارشات تولید',
|
||||||
|
icon: Icons.factory_outlined,
|
||||||
|
children: [
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'گزارش مصرف مواد',
|
||||||
|
subtitle: 'مصرف مواد اولیه به ازای محصول',
|
||||||
|
icon: Icons.dataset_outlined,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'گزارش مصرف مواد'),
|
||||||
|
),
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'گزارش تولیدات',
|
||||||
|
subtitle: 'میزان تولید و ضایعات',
|
||||||
|
icon: Icons.precision_manufacturing_outlined,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'گزارش تولیدات'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
_buildSection(
|
||||||
|
context,
|
||||||
|
title: 'حسابداری پایه',
|
||||||
|
icon: Icons.calculate_outlined,
|
||||||
|
children: [
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'تراز آزمایشی',
|
||||||
|
subtitle: 'تراز دو/چهار/شش/هشت ستونی',
|
||||||
|
icon: Icons.grid_on,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'تراز آزمایشی'),
|
||||||
|
),
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'دفتر کل',
|
||||||
|
subtitle: 'گردش حسابها در بازه زمانی',
|
||||||
|
icon: Icons.menu_book_outlined,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'دفتر کل'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
_buildSection(
|
||||||
|
context,
|
||||||
|
title: 'سود و زیان',
|
||||||
|
icon: Icons.assessment_outlined,
|
||||||
|
children: [
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'گزارش سود و زیان دوره',
|
||||||
|
subtitle: 'درآمدها، هزینهها و سود/زیان خالص',
|
||||||
|
icon: Icons.show_chart,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'گزارش سود و زیان دوره'),
|
||||||
|
),
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'سود و زیان تجمیعی',
|
||||||
|
subtitle: 'مقایسه دورهای و تجمیعی',
|
||||||
|
icon: Icons.query_stats,
|
||||||
|
onTap: () => _showComingSoonDialog(context, 'سود و زیان تجمیعی'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSection(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required IconData icon,
|
||||||
|
required List<Widget> children,
|
||||||
|
}) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: cs.primary, size: 24),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: cs.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReportItem(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required IconData icon,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(icon, color: cs.primary),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: cs.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showComingSoonDialog(BuildContext context, String title) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text('این گزارش بهزودی در دسترس خواهد بود.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: Text(t.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,122 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class Error404Page extends StatefulWidget {
|
class Error404Page extends StatelessWidget {
|
||||||
const Error404Page({super.key});
|
const Error404Page({super.key});
|
||||||
|
|
||||||
@override
|
|
||||||
State<Error404Page> createState() => _Error404PageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Error404PageState extends State<Error404Page>
|
|
||||||
with TickerProviderStateMixin {
|
|
||||||
late AnimationController _fadeController;
|
|
||||||
late AnimationController _slideController;
|
|
||||||
late AnimationController _bounceController;
|
|
||||||
late AnimationController _pulseController;
|
|
||||||
late AnimationController _rotateController;
|
|
||||||
|
|
||||||
late Animation<double> _fadeAnimation;
|
|
||||||
late Animation<Offset> _slideAnimation;
|
|
||||||
late Animation<double> _bounceAnimation;
|
|
||||||
late Animation<double> _pulseAnimation;
|
|
||||||
late Animation<double> _rotateAnimation;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
|
|
||||||
// کنترلرهای انیمیشن
|
|
||||||
_fadeController = AnimationController(
|
|
||||||
duration: const Duration(milliseconds: 1200),
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
|
|
||||||
_slideController = AnimationController(
|
|
||||||
duration: const Duration(milliseconds: 1800),
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
|
|
||||||
_bounceController = AnimationController(
|
|
||||||
duration: const Duration(milliseconds: 2500),
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
|
|
||||||
_pulseController = AnimationController(
|
|
||||||
duration: const Duration(milliseconds: 2000),
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
|
|
||||||
_rotateController = AnimationController(
|
|
||||||
duration: const Duration(milliseconds: 3000),
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
|
|
||||||
// انیمیشنها
|
|
||||||
_fadeAnimation = Tween<double>(
|
|
||||||
begin: 0.0,
|
|
||||||
end: 1.0,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _fadeController,
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
));
|
|
||||||
|
|
||||||
_slideAnimation = Tween<Offset>(
|
|
||||||
begin: const Offset(0, 0.5),
|
|
||||||
end: Offset.zero,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _slideController,
|
|
||||||
curve: Curves.elasticOut,
|
|
||||||
));
|
|
||||||
|
|
||||||
_bounceAnimation = Tween<double>(
|
|
||||||
begin: 0.0,
|
|
||||||
end: 1.0,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _bounceController,
|
|
||||||
curve: Curves.elasticOut,
|
|
||||||
));
|
|
||||||
|
|
||||||
_pulseAnimation = Tween<double>(
|
|
||||||
begin: 1.0,
|
|
||||||
end: 1.05,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _pulseController,
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
));
|
|
||||||
|
|
||||||
_rotateAnimation = Tween<double>(
|
|
||||||
begin: 0.0,
|
|
||||||
end: 1.0,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _rotateController,
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
));
|
|
||||||
|
|
||||||
// شروع انیمیشنها
|
|
||||||
_startAnimations();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startAnimations() async {
|
|
||||||
await _fadeController.forward();
|
|
||||||
await _slideController.forward();
|
|
||||||
await _bounceController.forward();
|
|
||||||
|
|
||||||
// انیمیشنهای مداوم
|
|
||||||
_pulseController.repeat(reverse: true);
|
|
||||||
_rotateController.repeat();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_fadeController.dispose();
|
|
||||||
_slideController.dispose();
|
|
||||||
_bounceController.dispose();
|
|
||||||
_pulseController.dispose();
|
|
||||||
_rotateController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -149,177 +36,118 @@ class _Error404PageState extends State<Error404Page>
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// انیمیشن 404 با افکتهای پیشرفته
|
// آیکون 404 ساده
|
||||||
AnimatedBuilder(
|
Container(
|
||||||
animation: Listenable.merge([_bounceAnimation, _pulseAnimation, _rotateAnimation]),
|
width: 220,
|
||||||
builder: (context, child) {
|
height: 220,
|
||||||
return Transform.scale(
|
decoration: BoxDecoration(
|
||||||
scale: _bounceAnimation.value * _pulseAnimation.value,
|
shape: BoxShape.circle,
|
||||||
child: Transform.rotate(
|
gradient: RadialGradient(
|
||||||
angle: _rotateAnimation.value * 0.1,
|
colors: isDark
|
||||||
child: Container(
|
? [
|
||||||
width: 220,
|
const Color(0xFF6366F1).withValues(alpha: 0.4),
|
||||||
height: 220,
|
const Color(0xFF8B5CF6).withValues(alpha: 0.2),
|
||||||
decoration: BoxDecoration(
|
const Color(0xFFEC4899).withValues(alpha: 0.1),
|
||||||
shape: BoxShape.circle,
|
]
|
||||||
gradient: RadialGradient(
|
: [
|
||||||
colors: isDark
|
const Color(0xFF6366F1).withValues(alpha: 0.3),
|
||||||
? [
|
const Color(0xFF8B5CF6).withValues(alpha: 0.15),
|
||||||
const Color(0xFF6366F1).withValues(alpha: 0.4),
|
const Color(0xFFEC4899).withValues(alpha: 0.05),
|
||||||
const Color(0xFF8B5CF6).withValues(alpha: 0.2),
|
|
||||||
const Color(0xFFEC4899).withValues(alpha: 0.1),
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
const Color(0xFF6366F1).withValues(alpha: 0.3),
|
|
||||||
const Color(0xFF8B5CF6).withValues(alpha: 0.15),
|
|
||||||
const Color(0xFFEC4899).withValues(alpha: 0.05),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: isDark
|
|
||||||
? const Color(0xFF6366F1).withValues(alpha: 0.3)
|
|
||||||
: const Color(0xFF4F46E5).withValues(alpha: 0.2),
|
|
||||||
blurRadius: 30,
|
|
||||||
spreadRadius: 5,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Stack(
|
boxShadow: [
|
||||||
alignment: Alignment.center,
|
BoxShadow(
|
||||||
children: [
|
color: isDark
|
||||||
// حلقههای متحرک
|
? const Color(0xFF6366F1).withValues(alpha: 0.3)
|
||||||
...List.generate(3, (index) {
|
: const Color(0xFF4F46E5).withValues(alpha: 0.2),
|
||||||
return AnimatedBuilder(
|
blurRadius: 30,
|
||||||
animation: _rotateAnimation,
|
spreadRadius: 5,
|
||||||
builder: (context, child) {
|
|
||||||
return Transform.rotate(
|
|
||||||
angle: _rotateAnimation.value * (2 * 3.14159) * (index + 1) * 0.3,
|
|
||||||
child: Container(
|
|
||||||
width: 180 - (index * 20),
|
|
||||||
height: 180 - (index * 20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: isDark
|
|
||||||
? const Color(0xFF6366F1).withValues(alpha: 0.3 - (index * 0.1))
|
|
||||||
: const Color(0xFF4F46E5).withValues(alpha: 0.2 - (index * 0.05)),
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
// متن 404
|
|
||||||
Text(
|
|
||||||
'404',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 80,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: isDark
|
|
||||||
? const Color(0xFF6366F1)
|
|
||||||
: const Color(0xFF4F46E5),
|
|
||||||
shadows: [
|
|
||||||
Shadow(
|
|
||||||
color: isDark
|
|
||||||
? const Color(0xFF6366F1).withValues(alpha: 0.6)
|
|
||||||
: const Color(0xFF4F46E5).withValues(alpha: 0.4),
|
|
||||||
blurRadius: 25,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
},
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'404',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 80,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isDark
|
||||||
|
? const Color(0xFF6366F1)
|
||||||
|
: const Color(0xFF4F46E5),
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: isDark
|
||||||
|
? const Color(0xFF6366F1).withValues(alpha: 0.6)
|
||||||
|
: const Color(0xFF4F46E5).withValues(alpha: 0.4),
|
||||||
|
blurRadius: 25,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 50),
|
const SizedBox(height: 50),
|
||||||
|
|
||||||
// متن اصلی با انیمیشن
|
// متن اصلی
|
||||||
FadeTransition(
|
Column(
|
||||||
opacity: _fadeAnimation,
|
children: [
|
||||||
child: SlideTransition(
|
// عنوان اصلی
|
||||||
position: _slideAnimation,
|
Text(
|
||||||
child: Column(
|
'صفحه مورد نظر یافت نشد',
|
||||||
children: [
|
style: TextStyle(
|
||||||
// عنوان اصلی
|
fontSize: 36,
|
||||||
Text(
|
fontWeight: FontWeight.bold,
|
||||||
'صفحه مورد نظر یافت نشد',
|
color: isDark ? Colors.white : const Color(0xFF1E293B),
|
||||||
style: TextStyle(
|
height: 1.2,
|
||||||
fontSize: 36,
|
letterSpacing: 0.5,
|
||||||
fontWeight: FontWeight.bold,
|
),
|
||||||
color: isDark ? Colors.white : const Color(0xFF1E293B),
|
textAlign: TextAlign.center,
|
||||||
height: 1.2,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// توضیحات
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
||||||
child: Text(
|
|
||||||
'متأسفانه صفحهای که به دنبال آن هستید وجود ندارد یا حذف شده است. لطفاً آدرس را بررسی کنید یا از دکمههای زیر استفاده کنید.',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
color: isDark
|
|
||||||
? Colors.grey[300]
|
|
||||||
: const Color(0xFF64748B),
|
|
||||||
height: 1.6,
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 60),
|
|
||||||
|
|
||||||
// دکمه بازگشت
|
|
||||||
AnimatedBuilder(
|
|
||||||
animation: _fadeAnimation,
|
|
||||||
builder: (context, child) {
|
|
||||||
return Transform.translate(
|
|
||||||
offset: Offset(0, 30 * (1 - _fadeAnimation.value)),
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// همیشه سعی کن به صفحه قبلی برگردی
|
|
||||||
if (Navigator.canPop(context)) {
|
|
||||||
Navigator.pop(context);
|
|
||||||
} else {
|
|
||||||
// اگر نمیتونی pop کنی، به root برگرد
|
|
||||||
context.go('/');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.arrow_back_ios, size: 20),
|
|
||||||
label: const Text('بازگشت به صفحه قبلی'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: const Color(0xFF6366F1),
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 32,
|
|
||||||
vertical: 20,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
elevation: 6,
|
|
||||||
shadowColor: const Color(0xFF6366F1).withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// توضیحات
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Text(
|
||||||
|
'متأسفانه صفحهای که به دنبال آن هستید وجود ندارد یا حذف شده است. لطفاً آدرس را بررسی کنید یا از دکمه زیر استفاده کنید.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
color: isDark
|
||||||
|
? Colors.grey[300]
|
||||||
|
: const Color(0xFF64748B),
|
||||||
|
height: 1.6,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 60),
|
||||||
|
|
||||||
|
// دکمه صفحه نخست
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
context.go('/');
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.home, size: 20),
|
||||||
|
label: const Text('صفحه نخست'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF6366F1),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 32,
|
||||||
|
vertical: 20,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
elevation: 6,
|
||||||
|
shadowColor: const Color(0xFF6366F1).withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
||||||
final BusinessData _businessData = BusinessData();
|
final BusinessData _businessData = BusinessData();
|
||||||
int _currentStep = 0;
|
int _currentStep = 0;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
int _fiscalTabIndex = 0;
|
final int _fiscalTabIndex = 0;
|
||||||
late TextEditingController _fiscalTitleController;
|
late TextEditingController _fiscalTitleController;
|
||||||
List<Map<String, dynamic>> _currencies = [];
|
List<Map<String, dynamic>> _currencies = [];
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
||||||
}
|
}
|
||||||
final fiscal = _businessData.fiscalYears[_fiscalTabIndex];
|
final fiscal = _businessData.fiscalYears[_fiscalTabIndex];
|
||||||
|
|
||||||
String _autoTitle() {
|
String autoTitle() {
|
||||||
final isJalali = widget.calendarController.isJalali;
|
final isJalali = widget.calendarController.isJalali;
|
||||||
final end = fiscal.endDate;
|
final end = fiscal.endDate;
|
||||||
if (end == null) return fiscal.title;
|
if (end == null) return fiscal.title;
|
||||||
|
|
@ -139,7 +139,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
||||||
final s = fiscal.startDate!;
|
final s = fiscal.startDate!;
|
||||||
fiscal.endDate = DateTime(s.year + 1, s.month, s.day);
|
fiscal.endDate = DateTime(s.year + 1, s.month, s.day);
|
||||||
}
|
}
|
||||||
fiscal.title = _autoTitle();
|
fiscal.title = autoTitle();
|
||||||
_fiscalTitleController.text = fiscal.title;
|
_fiscalTitleController.text = fiscal.title;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -157,7 +157,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
||||||
setState(() {
|
setState(() {
|
||||||
fiscal.endDate = d;
|
fiscal.endDate = d;
|
||||||
if (fiscal.title.trim().isEmpty || fiscal.title.startsWith('سال مالی منتهی به')) {
|
if (fiscal.title.trim().isEmpty || fiscal.title.startsWith('سال مالی منتهی به')) {
|
||||||
fiscal.title = _autoTitle();
|
fiscal.title = autoTitle();
|
||||||
_fiscalTitleController.text = fiscal.title;
|
_fiscalTitleController.text = fiscal.title;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1524,7 +1524,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
value: _businessData.defaultCurrencyId,
|
initialValue: _businessData.defaultCurrencyId,
|
||||||
items: _currencies.map((c) {
|
items: _currencies.map((c) {
|
||||||
return DropdownMenuItem<int>(
|
return DropdownMenuItem<int>(
|
||||||
value: c['id'] as int,
|
value: c['id'] as int,
|
||||||
|
|
|
||||||
62
hesabixUI/hesabix_ui/lib/services/invoice_service.dart
Normal file
62
hesabixUI/hesabix_ui/lib/services/invoice_service.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import '../core/api_client.dart';
|
||||||
|
|
||||||
|
class InvoiceService {
|
||||||
|
final ApiClient _api;
|
||||||
|
|
||||||
|
InvoiceService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient();
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> createInvoice({
|
||||||
|
required int businessId,
|
||||||
|
required Map<String, dynamic> payload,
|
||||||
|
}) async {
|
||||||
|
final res = await _api.post<Map<String, dynamic>>(
|
||||||
|
'/api/v1/invoices/business/$businessId',
|
||||||
|
data: payload,
|
||||||
|
);
|
||||||
|
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> updateInvoice({
|
||||||
|
required int businessId,
|
||||||
|
required int invoiceId,
|
||||||
|
required Map<String, dynamic> payload,
|
||||||
|
}) async {
|
||||||
|
final res = await _api.put<Map<String, dynamic>>(
|
||||||
|
'/api/v1/invoices/business/$businessId/$invoiceId',
|
||||||
|
data: payload,
|
||||||
|
);
|
||||||
|
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getInvoice({
|
||||||
|
required int businessId,
|
||||||
|
required int invoiceId,
|
||||||
|
}) async {
|
||||||
|
final res = await _api.get<Map<String, dynamic>>(
|
||||||
|
'/api/v1/invoices/business/$businessId/$invoiceId',
|
||||||
|
);
|
||||||
|
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> searchInvoices({
|
||||||
|
required int businessId,
|
||||||
|
int page = 1,
|
||||||
|
int limit = 20,
|
||||||
|
String? search,
|
||||||
|
Map<String, dynamic>? filters,
|
||||||
|
}) async {
|
||||||
|
final body = {
|
||||||
|
'take': limit,
|
||||||
|
'skip': (page - 1) * limit,
|
||||||
|
if (search != null && search.isNotEmpty) 'search': search,
|
||||||
|
if (filters != null) 'filters': filters,
|
||||||
|
};
|
||||||
|
final res = await _api.post<Map<String, dynamic>>(
|
||||||
|
'/api/v1/invoices/business/$businessId/search',
|
||||||
|
data: body,
|
||||||
|
);
|
||||||
|
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -26,6 +26,10 @@ class PersonService {
|
||||||
|
|
||||||
if (search != null && search.isNotEmpty) {
|
if (search != null && search.isNotEmpty) {
|
||||||
queryParams['search'] = search;
|
queryParams['search'] = search;
|
||||||
|
// اگر search_fields مشخص نشده، فیلدهای پیشفرض را استفاده کن
|
||||||
|
if (searchFields == null || searchFields.isEmpty) {
|
||||||
|
queryParams['search_fields'] = ['alias_name', 'first_name', 'last_name', 'company_name', 'mobile', 'email'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchFields != null && searchFields.isNotEmpty) {
|
if (searchFields != null && searchFields.isNotEmpty) {
|
||||||
|
|
@ -37,22 +41,55 @@ class PersonService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters != null && filters.isNotEmpty) {
|
if (filters != null && filters.isNotEmpty) {
|
||||||
// تبدیل Map به لیست برای API
|
// تبدیل Map به لیست برای API با پشتیبانی از person_type و person_types
|
||||||
final filtersList = filters.entries.map((e) => {
|
final List<Map<String, dynamic>> filtersList = <Map<String, dynamic>>[];
|
||||||
'property': e.key,
|
filters.forEach((key, value) {
|
||||||
'operator': 'in', // برای فیلترهای چندتایی از عملگر 'in' استفاده میکنیم
|
// یکسانسازی: اگر person_type ارسال شود، به person_types (لیستی) تبدیل میکنیم
|
||||||
'value': e.value,
|
if (key == 'person_type') {
|
||||||
}).toList();
|
final List<dynamic> values = value is List ? List<dynamic>.from(value) : <dynamic>[value];
|
||||||
|
filtersList.add({
|
||||||
|
'property': 'person_types',
|
||||||
|
'operator': 'in',
|
||||||
|
'value': values,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key == 'person_types') {
|
||||||
|
final List<dynamic> values = value is List ? List<dynamic>.from(value) : <dynamic>[value];
|
||||||
|
filtersList.add({
|
||||||
|
'property': 'person_types',
|
||||||
|
'operator': 'in',
|
||||||
|
'value': values,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// سایر فیلترها: اگر مقدار لیست باشد از in، در غیر این صورت از = استفاده میکنیم
|
||||||
|
final bool isList = value is List;
|
||||||
|
filtersList.add({
|
||||||
|
'property': key,
|
||||||
|
'operator': isList ? 'in' : '=',
|
||||||
|
'value': value,
|
||||||
|
});
|
||||||
|
});
|
||||||
queryParams['filters'] = filtersList;
|
queryParams['filters'] = filtersList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug: نمایش پارامترهای ارسالی
|
||||||
|
print('PersonService API Call:');
|
||||||
|
print('URL: /api/v1/persons/businesses/$businessId/persons');
|
||||||
|
print('Data: $queryParams');
|
||||||
|
|
||||||
final response = await _apiClient.post(
|
final response = await _apiClient.post(
|
||||||
'/api/v1/persons/businesses/$businessId/persons',
|
'/api/v1/persons/businesses/$businessId/persons',
|
||||||
data: queryParams,
|
data: queryParams,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
return response.data['data'];
|
final data = response.data['data'];
|
||||||
|
print('PersonService Response Data: $data');
|
||||||
|
return data;
|
||||||
} else {
|
} else {
|
||||||
throw Exception('خطا در دریافت لیست اشخاص');
|
throw Exception('خطا در دریافت لیست اشخاص');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ class PriceListService {
|
||||||
final qp = <String, String>{};
|
final qp = <String, String>{};
|
||||||
if (productId != null) qp['product_id'] = '$productId';
|
if (productId != null) qp['product_id'] = '$productId';
|
||||||
if (currencyId != null) qp['currency_id'] = '$currencyId';
|
if (currencyId != null) qp['currency_id'] = '$currencyId';
|
||||||
final query = qp.isEmpty ? '' : ('?' + qp.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&'));
|
final query = qp.isEmpty ? '' : ('?${qp.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}');
|
||||||
final res = await _api.get<Map<String, dynamic>>('/api/v1/price-lists/business/$businessId/$priceListId/items$query');
|
final res = await _api.get<Map<String, dynamic>>('/api/v1/price-lists/business/$businessId/$priceListId/items$query');
|
||||||
final data = res.data?['data'];
|
final data = res.data?['data'];
|
||||||
final items = (data is Map<String, dynamic>) ? data['items'] : null;
|
final items = (data is Map<String, dynamic>) ? data['items'] : null;
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ class ProductFormValidator {
|
||||||
errors['basePurchasePrice'] = 'قیمت خرید نمیتواند منفی باشد';
|
errors['basePurchasePrice'] = 'قیمت خرید نمیتواند منفی باشد';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.unitConversionFactor != null && formData.unitConversionFactor! <= 0) {
|
if (formData.unitConversionFactor <= 0) {
|
||||||
errors['unitConversionFactor'] = 'ضریب تبدیل باید بزرگتر از صفر باشد';
|
errors['unitConversionFactor'] = 'ضریب تبدیل باید بزرگتر از صفر باشد';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,10 @@ class CategoryPickerField extends FormField<int?> {
|
||||||
required this.businessId,
|
required this.businessId,
|
||||||
required List<Map<String, dynamic>> categoriesTree,
|
required List<Map<String, dynamic>> categoriesTree,
|
||||||
required ValueChanged<int?> onChanged,
|
required ValueChanged<int?> onChanged,
|
||||||
int? initialValue,
|
super.initialValue,
|
||||||
String? label,
|
String? label,
|
||||||
String? Function(int?)? validator,
|
super.validator,
|
||||||
}) : super(
|
}) : super(
|
||||||
initialValue: initialValue,
|
|
||||||
validator: validator,
|
|
||||||
builder: (state) {
|
builder: (state) {
|
||||||
final context = state.context;
|
final context = state.context;
|
||||||
final t = AppLocalizations.of(context);
|
final t = AppLocalizations.of(context);
|
||||||
|
|
@ -263,8 +261,11 @@ class _CategoryPickerDialogState extends State<_CategoryPickerDialog> {
|
||||||
'children': <Map<String, dynamic>>[],
|
'children': <Map<String, dynamic>>[],
|
||||||
};
|
};
|
||||||
byId[nid] = existing;
|
byId[nid] = existing;
|
||||||
if (parent == null) roots.add(existing);
|
if (parent == null) {
|
||||||
else (parent['children'] as List).add(existing);
|
roots.add(existing);
|
||||||
|
} else {
|
||||||
|
(parent['children'] as List).add(existing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
parent = existing;
|
parent = existing;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1397,7 +1397,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
thumbVisibility: true,
|
thumbVisibility: true,
|
||||||
child: DataTableTheme(
|
child: DataTableTheme(
|
||||||
data: DataTableThemeData(
|
data: DataTableThemeData(
|
||||||
headingRowColor: MaterialStatePropertyAll(
|
headingRowColor: WidgetStatePropertyAll(
|
||||||
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
|
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
headingTextStyle: theme.textTheme.titleSmall?.copyWith(
|
headingTextStyle: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../services/bank_account_service.dart';
|
||||||
|
|
||||||
|
class BankAccountOption {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
const BankAccountOption(this.id, this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
class BankAccountComboboxWidget extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final String? selectedAccountId;
|
||||||
|
final ValueChanged<BankAccountOption?> onChanged;
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
const BankAccountComboboxWidget({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
required this.onChanged,
|
||||||
|
this.selectedAccountId,
|
||||||
|
this.label = 'بانک',
|
||||||
|
this.hintText = 'جستوجو و انتخاب بانک',
|
||||||
|
this.isRequired = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BankAccountComboboxWidget> createState() => _BankAccountComboboxWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
|
||||||
|
final BankAccountService _service = BankAccountService();
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
int _seq = 0;
|
||||||
|
String _latestQuery = '';
|
||||||
|
void Function(void Function())? _setModalState;
|
||||||
|
|
||||||
|
List<BankAccountOption> _items = <BankAccountOption>[];
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isSearching = false;
|
||||||
|
bool _hasSearched = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
await _performSearch('');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged(String q) {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performSearch(String query) async {
|
||||||
|
final int seq = ++_seq;
|
||||||
|
_latestQuery = query;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = true;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = true;
|
||||||
|
_hasSearched = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await _service.list(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
queryInfo: {
|
||||||
|
'take': query.isEmpty ? 50 : 20,
|
||||||
|
'skip': 0,
|
||||||
|
if (query.isNotEmpty) 'search': query,
|
||||||
|
if (query.isNotEmpty)
|
||||||
|
'search_fields': ['code', 'name', 'branch', 'account_number', 'sheba_number', 'card_number', 'owner_name', 'pos_number', 'payment_id'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (seq != _seq || query != _latestQuery) return;
|
||||||
|
// پشتیبانی از هر دو ساختار: data.items و items سطح بالا
|
||||||
|
final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null)
|
||||||
|
? (res['data'] as Map)['items']
|
||||||
|
: res['items'];
|
||||||
|
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
|
||||||
|
final m = Map<String, dynamic>.from(e as Map);
|
||||||
|
return BankAccountOption('${m['id']}', (m['name']?.toString() ?? 'نامشخص'));
|
||||||
|
}).toList();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_items = items;
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = false;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
} catch (e) {
|
||||||
|
if (seq != _seq || query != _latestQuery) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_items = <BankAccountOption>[];
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = false;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در دریافت لیست بانکها: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openPicker() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setModalState) {
|
||||||
|
_setModalState = setModalState;
|
||||||
|
return _BankPickerBottomSheet(
|
||||||
|
label: widget.label,
|
||||||
|
hintText: widget.hintText,
|
||||||
|
items: _items,
|
||||||
|
searchController: _searchController,
|
||||||
|
isLoading: _isLoading,
|
||||||
|
isSearching: _isSearching,
|
||||||
|
hasSearched: _hasSearched,
|
||||||
|
onSearchChanged: _onSearchChanged,
|
||||||
|
onSelected: (opt) {
|
||||||
|
widget.onChanged(opt);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final selected = _items.firstWhere(
|
||||||
|
(e) => e.id == widget.selectedAccountId,
|
||||||
|
orElse: () => const BankAccountOption('', ''),
|
||||||
|
);
|
||||||
|
final text = (widget.selectedAccountId != null && widget.selectedAccountId!.isNotEmpty)
|
||||||
|
? (selected.name.isNotEmpty ? selected.name : widget.hintText)
|
||||||
|
: widget.hintText;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: _openPicker,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.5)),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.account_balance, color: theme.colorScheme.primary, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.arrow_drop_down, color: theme.colorScheme.onSurface.withValues(alpha: 0.6)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BankPickerBottomSheet extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
final List<BankAccountOption> items;
|
||||||
|
final TextEditingController searchController;
|
||||||
|
final bool isLoading;
|
||||||
|
final bool isSearching;
|
||||||
|
final bool hasSearched;
|
||||||
|
final ValueChanged<String> onSearchChanged;
|
||||||
|
final ValueChanged<BankAccountOption?> onSelected;
|
||||||
|
|
||||||
|
const _BankPickerBottomSheet({
|
||||||
|
required this.label,
|
||||||
|
required this.hintText,
|
||||||
|
required this.items,
|
||||||
|
required this.searchController,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.isSearching,
|
||||||
|
required this.hasSearched,
|
||||||
|
required this.onSearchChanged,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@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(label, 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: searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hintText,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
suffixIcon: isSearching
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: onSearchChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: items.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.account_balance, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.5)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
hasSearched ? 'بانکی با این مشخصات یافت نشد' : 'بانکی ثبت نشده است',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final it = items[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: colorScheme.primaryContainer,
|
||||||
|
child: Icon(Icons.account_balance, color: colorScheme.onPrimaryContainer),
|
||||||
|
),
|
||||||
|
title: Text(it.name),
|
||||||
|
onTap: () => onSelected(it),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../services/cash_register_service.dart';
|
||||||
|
|
||||||
|
class CashRegisterOption {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
const CashRegisterOption(this.id, this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CashRegisterComboboxWidget extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final String? selectedRegisterId;
|
||||||
|
final ValueChanged<CashRegisterOption?> onChanged;
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
const CashRegisterComboboxWidget({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
required this.onChanged,
|
||||||
|
this.selectedRegisterId,
|
||||||
|
this.label = 'صندوق',
|
||||||
|
this.hintText = 'جستوجو و انتخاب صندوق',
|
||||||
|
this.isRequired = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CashRegisterComboboxWidget> createState() => _CashRegisterComboboxWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CashRegisterComboboxWidgetState extends State<CashRegisterComboboxWidget> {
|
||||||
|
final CashRegisterService _service = CashRegisterService();
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
int _seq = 0;
|
||||||
|
String _latestQuery = '';
|
||||||
|
void Function(void Function())? _setModalState;
|
||||||
|
|
||||||
|
List<CashRegisterOption> _items = <CashRegisterOption>[];
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isSearching = false;
|
||||||
|
bool _hasSearched = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
await _performSearch('');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged(String q) {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performSearch(String query) async {
|
||||||
|
final int seq = ++_seq;
|
||||||
|
_latestQuery = query;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = true;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = true;
|
||||||
|
_hasSearched = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await _service.list(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
queryInfo: {
|
||||||
|
'take': query.isEmpty ? 50 : 20,
|
||||||
|
'skip': 0,
|
||||||
|
if (query.isNotEmpty) 'search': query,
|
||||||
|
if (query.isNotEmpty) 'search_fields': ['name', 'code', 'description', 'payment_switch_number', 'payment_terminal_number', 'merchant_id'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (seq != _seq || query != _latestQuery) return;
|
||||||
|
final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null)
|
||||||
|
? (res['data'] as Map)['items']
|
||||||
|
: res['items'];
|
||||||
|
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
|
||||||
|
final m = Map<String, dynamic>.from(e as Map);
|
||||||
|
return CashRegisterOption('${m['id']}', (m['name']?.toString() ?? 'نامشخص'));
|
||||||
|
}).toList();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_items = items;
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = false;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
} catch (e) {
|
||||||
|
if (seq != _seq || query != _latestQuery) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_items = <CashRegisterOption>[];
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = false;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در دریافت لیست صندوقها: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openPicker() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setModalState) {
|
||||||
|
_setModalState = setModalState;
|
||||||
|
return _CashRegisterPickerBottomSheet(
|
||||||
|
label: widget.label,
|
||||||
|
hintText: widget.hintText,
|
||||||
|
items: _items,
|
||||||
|
searchController: _searchController,
|
||||||
|
isLoading: _isLoading,
|
||||||
|
isSearching: _isSearching,
|
||||||
|
hasSearched: _hasSearched,
|
||||||
|
onSearchChanged: _onSearchChanged,
|
||||||
|
onSelected: (opt) {
|
||||||
|
widget.onChanged(opt);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final selected = _items.firstWhere(
|
||||||
|
(e) => e.id == widget.selectedRegisterId,
|
||||||
|
orElse: () => const CashRegisterOption('', ''),
|
||||||
|
);
|
||||||
|
final text = (widget.selectedRegisterId != null && widget.selectedRegisterId!.isNotEmpty)
|
||||||
|
? (selected.name.isNotEmpty ? selected.name : widget.hintText)
|
||||||
|
: widget.hintText;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: _openPicker,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.5)),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.point_of_sale, color: theme.colorScheme.primary, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.arrow_drop_down, color: theme.colorScheme.onSurface.withValues(alpha: 0.6)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CashRegisterPickerBottomSheet extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
final List<CashRegisterOption> items;
|
||||||
|
final TextEditingController searchController;
|
||||||
|
final bool isLoading;
|
||||||
|
final bool isSearching;
|
||||||
|
final bool hasSearched;
|
||||||
|
final ValueChanged<String> onSearchChanged;
|
||||||
|
final ValueChanged<CashRegisterOption?> onSelected;
|
||||||
|
|
||||||
|
const _CashRegisterPickerBottomSheet({
|
||||||
|
required this.label,
|
||||||
|
required this.hintText,
|
||||||
|
required this.items,
|
||||||
|
required this.searchController,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.isSearching,
|
||||||
|
required this.hasSearched,
|
||||||
|
required this.onSearchChanged,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@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(label, 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: searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hintText,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
suffixIcon: isSearching
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: onSearchChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: items.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.point_of_sale, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.5)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
hasSearched ? 'صندوقی با این مشخصات یافت نشد' : 'صندوقی ثبت نشده است',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final it = items[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: colorScheme.primaryContainer,
|
||||||
|
child: Icon(Icons.point_of_sale, color: colorScheme.onPrimaryContainer),
|
||||||
|
),
|
||||||
|
title: Text(it.name),
|
||||||
|
onTap: () => onSelected(it),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
|
||||||
class CodeFieldWidget extends StatefulWidget {
|
class CodeFieldWidget extends StatefulWidget {
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ class _CommissionTypeSelectorState extends State<CommissionTypeSelector> {
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return DropdownButtonFormField<CommissionType>(
|
return DropdownButtonFormField<CommissionType>(
|
||||||
value: _selectedType,
|
initialValue: _selectedType,
|
||||||
onChanged: (CommissionType? newValue) {
|
onChanged: (CommissionType? newValue) {
|
||||||
if (newValue != null) {
|
if (newValue != null) {
|
||||||
_selectType(newValue);
|
_selectType(newValue);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,26 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../../models/invoice_transaction.dart';
|
import '../../models/invoice_transaction.dart';
|
||||||
|
import '../../models/person_model.dart';
|
||||||
import '../../core/date_utils.dart';
|
import '../../core/date_utils.dart';
|
||||||
import '../../core/calendar_controller.dart';
|
import '../../core/calendar_controller.dart';
|
||||||
import '../../utils/number_formatters.dart';
|
import '../../utils/number_formatters.dart';
|
||||||
|
import '../../services/bank_account_service.dart';
|
||||||
|
import '../../services/cash_register_service.dart';
|
||||||
|
import '../../services/petty_cash_service.dart';
|
||||||
|
import '../../services/person_service.dart';
|
||||||
|
import 'person_combobox_widget.dart';
|
||||||
|
import 'bank_account_combobox_widget.dart';
|
||||||
|
import 'cash_register_combobox_widget.dart';
|
||||||
|
import 'petty_cash_combobox_widget.dart';
|
||||||
|
import '../../models/invoice_type_model.dart';
|
||||||
|
|
||||||
class InvoiceTransactionsWidget extends StatefulWidget {
|
class InvoiceTransactionsWidget extends StatefulWidget {
|
||||||
final List<InvoiceTransaction> transactions;
|
final List<InvoiceTransaction> transactions;
|
||||||
final ValueChanged<List<InvoiceTransaction>> onChanged;
|
final ValueChanged<List<InvoiceTransaction>> onChanged;
|
||||||
final int businessId;
|
final int businessId;
|
||||||
final CalendarController calendarController;
|
final CalendarController calendarController;
|
||||||
|
final InvoiceType invoiceType;
|
||||||
|
|
||||||
const InvoiceTransactionsWidget({
|
const InvoiceTransactionsWidget({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -17,6 +28,7 @@ class InvoiceTransactionsWidget extends StatefulWidget {
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
required this.businessId,
|
required this.businessId,
|
||||||
required this.calendarController,
|
required this.calendarController,
|
||||||
|
required this.invoiceType,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -305,6 +317,7 @@ class _InvoiceTransactionsWidgetState extends State<InvoiceTransactionsWidget> {
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
calendarController: widget.calendarController,
|
calendarController: widget.calendarController,
|
||||||
|
invoiceType: widget.invoiceType,
|
||||||
onSave: (newTransaction) {
|
onSave: (newTransaction) {
|
||||||
if (index != null) {
|
if (index != null) {
|
||||||
// ویرایش تراکنش موجود
|
// ویرایش تراکنش موجود
|
||||||
|
|
@ -328,12 +341,14 @@ class TransactionDialog extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
final CalendarController calendarController;
|
final CalendarController calendarController;
|
||||||
final ValueChanged<InvoiceTransaction> onSave;
|
final ValueChanged<InvoiceTransaction> onSave;
|
||||||
|
final InvoiceType invoiceType;
|
||||||
|
|
||||||
const TransactionDialog({
|
const TransactionDialog({
|
||||||
super.key,
|
super.key,
|
||||||
this.transaction,
|
this.transaction,
|
||||||
required this.businessId,
|
required this.businessId,
|
||||||
required this.calendarController,
|
required this.calendarController,
|
||||||
|
required this.invoiceType,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -351,6 +366,12 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
final _commissionController = TextEditingController();
|
final _commissionController = TextEditingController();
|
||||||
final _descriptionController = TextEditingController();
|
final _descriptionController = TextEditingController();
|
||||||
|
|
||||||
|
// سرویسها
|
||||||
|
final BankAccountService _bankService = BankAccountService();
|
||||||
|
final CashRegisterService _cashRegisterService = CashRegisterService();
|
||||||
|
final PettyCashService _pettyCashService = PettyCashService();
|
||||||
|
final PersonService _personService = PersonService();
|
||||||
|
|
||||||
// فیلدهای خاص هر نوع تراکنش
|
// فیلدهای خاص هر نوع تراکنش
|
||||||
String? _selectedBankId;
|
String? _selectedBankId;
|
||||||
String? _selectedCashRegisterId;
|
String? _selectedCashRegisterId;
|
||||||
|
|
@ -358,6 +379,14 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
String? _selectedCheckId;
|
String? _selectedCheckId;
|
||||||
String? _selectedPersonId;
|
String? _selectedPersonId;
|
||||||
String? _selectedAccountId;
|
String? _selectedAccountId;
|
||||||
|
|
||||||
|
// لیستهای داده
|
||||||
|
List<Map<String, dynamic>> _banks = [];
|
||||||
|
List<Map<String, dynamic>> _cashRegisters = [];
|
||||||
|
List<Map<String, dynamic>> _pettyCashList = [];
|
||||||
|
List<Map<String, dynamic>> _persons = [];
|
||||||
|
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -375,6 +404,53 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
_selectedCheckId = widget.transaction?.checkId;
|
_selectedCheckId = widget.transaction?.checkId;
|
||||||
_selectedPersonId = widget.transaction?.personId;
|
_selectedPersonId = widget.transaction?.personId;
|
||||||
_selectedAccountId = widget.transaction?.accountId;
|
_selectedAccountId = widget.transaction?.accountId;
|
||||||
|
|
||||||
|
// لود کردن دادهها از دیتابیس
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadData() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// لود کردن بانکها
|
||||||
|
final bankResponse = await _bankService.list(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
queryInfo: {'take': 100, 'skip': 0},
|
||||||
|
);
|
||||||
|
_banks = (bankResponse['items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
|
|
||||||
|
// لود کردن صندوقها
|
||||||
|
final cashRegisterResponse = await _cashRegisterService.list(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
queryInfo: {'take': 100, 'skip': 0},
|
||||||
|
);
|
||||||
|
_cashRegisters = (cashRegisterResponse['items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
|
|
||||||
|
// لود کردن تنخواهگردانها
|
||||||
|
final pettyCashResponse = await _pettyCashService.list(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
queryInfo: {'take': 100, 'skip': 0},
|
||||||
|
);
|
||||||
|
_pettyCashList = (pettyCashResponse['items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
|
|
||||||
|
// لود کردن اشخاص
|
||||||
|
final personResponse = await _personService.getPersons(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
limit: 100,
|
||||||
|
);
|
||||||
|
_persons = (personResponse['items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
// در صورت خطا، لیستها خالی باقی میمانند
|
||||||
|
print('خطا در لود کردن دادهها: $e');
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -447,7 +523,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
labelText: 'نوع تراکنش *',
|
labelText: 'نوع تراکنش *',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
items: TransactionType.allTypes.map((type) {
|
items: _availableTransactionTypes().map((type) {
|
||||||
return DropdownMenuItem(
|
return DropdownMenuItem(
|
||||||
value: type,
|
value: type,
|
||||||
child: Text(type.label),
|
child: Text(type.label),
|
||||||
|
|
@ -464,7 +540,10 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// فیلدهای خاص بر اساس نوع تراکنش
|
// فیلدهای خاص بر اساس نوع تراکنش
|
||||||
_buildTypeSpecificFields(),
|
if (_isLoading)
|
||||||
|
const Center(child: CircularProgressIndicator())
|
||||||
|
else
|
||||||
|
_buildTypeSpecificFields(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// تاریخ تراکنش
|
// تاریخ تراکنش
|
||||||
|
|
@ -595,61 +674,56 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<TransactionType> _availableTransactionTypes() {
|
||||||
|
// خرج چک فقط برای خرید یا برگشت از فروش نمایش داده شود
|
||||||
|
final showCheckExpense = widget.invoiceType == InvoiceType.purchase || widget.invoiceType == InvoiceType.salesReturn;
|
||||||
|
final all = TransactionType.allTypes;
|
||||||
|
if (showCheckExpense) return all;
|
||||||
|
return all.where((t) => t != TransactionType.checkExpense).toList();
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildBankFields() {
|
Widget _buildBankFields() {
|
||||||
return DropdownButtonFormField<String>(
|
return BankAccountComboboxWidget(
|
||||||
initialValue: _selectedBankId,
|
businessId: widget.businessId,
|
||||||
decoration: const InputDecoration(
|
selectedAccountId: _selectedBankId,
|
||||||
labelText: 'بانک *',
|
onChanged: (opt) {
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(value: 'bank1', child: Text('بانک ملی')),
|
|
||||||
DropdownMenuItem(value: 'bank2', child: Text('بانک صادرات')),
|
|
||||||
DropdownMenuItem(value: 'bank3', child: Text('بانک ملت')),
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedBankId = value;
|
_selectedBankId = opt?.id;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
label: 'بانک *',
|
||||||
|
hintText: 'جستوجو و انتخاب بانک',
|
||||||
|
isRequired: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCashRegisterFields() {
|
Widget _buildCashRegisterFields() {
|
||||||
return DropdownButtonFormField<String>(
|
return CashRegisterComboboxWidget(
|
||||||
initialValue: _selectedCashRegisterId,
|
businessId: widget.businessId,
|
||||||
decoration: const InputDecoration(
|
selectedRegisterId: _selectedCashRegisterId,
|
||||||
labelText: 'صندوق *',
|
onChanged: (opt) {
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(value: 'cash1', child: Text('صندوق اصلی')),
|
|
||||||
DropdownMenuItem(value: 'cash2', child: Text('صندوق فرعی')),
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCashRegisterId = value;
|
_selectedCashRegisterId = opt?.id;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
label: 'صندوق *',
|
||||||
|
hintText: 'جستوجو و انتخاب صندوق',
|
||||||
|
isRequired: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPettyCashFields() {
|
Widget _buildPettyCashFields() {
|
||||||
return DropdownButtonFormField<String>(
|
return PettyCashComboboxWidget(
|
||||||
initialValue: _selectedPettyCashId,
|
businessId: widget.businessId,
|
||||||
decoration: const InputDecoration(
|
selectedPettyCashId: _selectedPettyCashId,
|
||||||
labelText: 'تنخواهگردان *',
|
onChanged: (opt) {
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(value: 'petty1', child: Text('تنخواهگردان اصلی')),
|
|
||||||
DropdownMenuItem(value: 'petty2', child: Text('تنخواهگردان فرعی')),
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedPettyCashId = value;
|
_selectedPettyCashId = opt?.id;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
label: 'تنخواهگردان *',
|
||||||
|
hintText: 'جستوجو و انتخاب تنخواهگردان',
|
||||||
|
isRequired: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -692,21 +766,30 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPersonFields() {
|
Widget _buildPersonFields() {
|
||||||
return DropdownButtonFormField<String>(
|
// پیدا کردن شخص انتخاب شده از لیست
|
||||||
initialValue: _selectedPersonId,
|
Person? selectedPerson;
|
||||||
decoration: const InputDecoration(
|
if (_selectedPersonId != null) {
|
||||||
labelText: 'شخص *',
|
try {
|
||||||
border: OutlineInputBorder(),
|
final personData = _persons.firstWhere(
|
||||||
),
|
(p) => p['id']?.toString() == _selectedPersonId,
|
||||||
items: const [
|
);
|
||||||
DropdownMenuItem(value: 'person1', child: Text('احمد محمدی')),
|
selectedPerson = Person.fromJson(personData);
|
||||||
DropdownMenuItem(value: 'person2', child: Text('فاطمه احمدی')),
|
} catch (e) {
|
||||||
],
|
selectedPerson = null;
|
||||||
onChanged: (value) {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PersonComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedPerson: selectedPerson,
|
||||||
|
onChanged: (person) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedPersonId = value;
|
_selectedPersonId = person?.id?.toString();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
label: 'شخص *',
|
||||||
|
hintText: 'انتخاب شخص',
|
||||||
|
isRequired: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -780,28 +863,30 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _getBankName(String? id) {
|
String? _getBankName(String? id) {
|
||||||
switch (id) {
|
if (id == null) return null;
|
||||||
case 'bank1': return 'بانک ملی';
|
final bank = _banks.firstWhere(
|
||||||
case 'bank2': return 'بانک صادرات';
|
(b) => b['id']?.toString() == id,
|
||||||
case 'bank3': return 'بانک ملت';
|
orElse: () => <String, dynamic>{},
|
||||||
default: return null;
|
);
|
||||||
}
|
return bank['name']?.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _getCashRegisterName(String? id) {
|
String? _getCashRegisterName(String? id) {
|
||||||
switch (id) {
|
if (id == null) return null;
|
||||||
case 'cash1': return 'صندوق اصلی';
|
final cashRegister = _cashRegisters.firstWhere(
|
||||||
case 'cash2': return 'صندوق فرعی';
|
(c) => c['id']?.toString() == id,
|
||||||
default: return null;
|
orElse: () => <String, dynamic>{},
|
||||||
}
|
);
|
||||||
|
return cashRegister['name']?.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _getPettyCashName(String? id) {
|
String? _getPettyCashName(String? id) {
|
||||||
switch (id) {
|
if (id == null) return null;
|
||||||
case 'petty1': return 'تنخواهگردان اصلی';
|
final pettyCash = _pettyCashList.firstWhere(
|
||||||
case 'petty2': return 'تنخواهگردان فرعی';
|
(p) => p['id']?.toString() == id,
|
||||||
default: return null;
|
orElse: () => <String, dynamic>{},
|
||||||
}
|
);
|
||||||
|
return pettyCash['name']?.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _getCheckNumber(String? id) {
|
String? _getCheckNumber(String? id) {
|
||||||
|
|
@ -813,11 +898,12 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _getPersonName(String? id) {
|
String? _getPersonName(String? id) {
|
||||||
switch (id) {
|
if (id == null) return null;
|
||||||
case 'person1': return 'احمد محمدی';
|
final person = _persons.firstWhere(
|
||||||
case 'person2': return 'فاطمه احمدی';
|
(p) => p['id']?.toString() == id,
|
||||||
default: return null;
|
orElse: () => <String, dynamic>{},
|
||||||
}
|
);
|
||||||
|
return person['name']?.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _getAccountName(String? id) {
|
String? _getAccountName(String? id) {
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ class _InvoiceTypeComboboxState extends State<InvoiceTypeCombobox> {
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return DropdownButtonFormField<InvoiceType>(
|
return DropdownButtonFormField<InvoiceType>(
|
||||||
value: _selectedType,
|
initialValue: _selectedType,
|
||||||
onChanged: (InvoiceType? newValue) {
|
onChanged: (InvoiceType? newValue) {
|
||||||
if (newValue != null) {
|
if (newValue != null) {
|
||||||
_selectType(newValue);
|
_selectType(newValue);
|
||||||
|
|
|
||||||
|
|
@ -241,9 +241,7 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
..._rows.asMap().entries.map((e) => _buildCompactRow(context, e.key, e.value)).toList(),
|
..._rows.asMap().entries.map((e) => _buildCompactRow(context, e.key, e.value)),
|
||||||
const Divider(height: 1),
|
|
||||||
_buildFooter(context),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -418,13 +416,23 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Flexible(
|
Flexible(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: Align(
|
child: SizedBox(
|
||||||
alignment: Alignment.centerRight,
|
height: 36,
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: 'مبلغ کل این ردیف',
|
message: 'مبلغ کل این ردیف',
|
||||||
child: Text(
|
child: InputDecorator(
|
||||||
formatWithThousands(item.total, decimalPlaces: 0),
|
decoration: const InputDecoration(
|
||||||
style: theme.textTheme.bodyMedium,
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Text(
|
||||||
|
formatWithThousands(item.total, decimalPlaces: 0),
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -449,11 +457,12 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
_updateRow(index, item.copyWith(description: v));
|
_updateRow(index, item.copyWith(description: v));
|
||||||
},
|
},
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
isDense: true,
|
isDense: true,
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
hintText: 'شرح (اختیاری)'
|
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
),
|
hintText: 'شرح (اختیاری)'
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -509,26 +518,7 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Widget _buildFooter(BuildContext context) {
|
// فوتر جمعها حذف شد؛ جمعها در صفحهٔ والد نمایش داده میشوند
|
||||||
final sumSubtotal = _rows.fold<num>(0, (acc, e) => acc + e.subtotal);
|
|
||||||
final sumDiscount = _rows.fold<num>(0, (acc, e) => acc + e.discountAmount);
|
|
||||||
final sumTax = _rows.fold<num>(0, (acc, e) => acc + e.taxAmount);
|
|
||||||
final sumTotal = _rows.fold<num>(0, (acc, e) => acc + e.total);
|
|
||||||
final style = Theme.of(context).textTheme.bodyLarge;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Spacer(),
|
|
||||||
SizedBox(width: 140, child: Text('جمع مبلغ: ${formatWithThousands(sumSubtotal, decimalPlaces: 0)}', style: style)),
|
|
||||||
SizedBox(width: 120, child: Text('جمع تخفیف: ${formatWithThousands(sumDiscount, decimalPlaces: 0)}', style: style)),
|
|
||||||
SizedBox(width: 120, child: Text('جمع مالیات: ${formatWithThousands(sumTax, decimalPlaces: 0)}', style: style)),
|
|
||||||
SizedBox(width: 140, child: Align(alignment: Alignment.centerRight, child: Text('جمع کل: ${formatWithThousands(sumTotal, decimalPlaces: 0)}', style: style))),
|
|
||||||
const SizedBox(width: 40),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showUnitSelectorDialog(InvoiceLineItem item, ValueChanged<String?> onChanged) {
|
void _showUnitSelectorDialog(InvoiceLineItem item, ValueChanged<String?> onChanged) {
|
||||||
showDialog(
|
showDialog(
|
||||||
|
|
@ -625,7 +615,7 @@ class _DiscountCellState extends State<_DiscountCell> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
String _typeLabel(String t) => t == 'percent' ? 'درصد' : 'مبلغ';
|
String typeLabel(String t) => t == 'percent' ? 'درصد' : 'مبلغ';
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 36,
|
height: 36,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
|
@ -647,7 +637,7 @@ class _DiscountCellState extends State<_DiscountCell> {
|
||||||
else
|
else
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsetsDirectional.only(end: 4),
|
padding: const EdgeInsetsDirectional.only(end: 4),
|
||||||
child: Text(_typeLabel(_type), style: theme.textTheme.bodySmall),
|
child: Text(typeLabel(_type), style: theme.textTheme.bodySmall),
|
||||||
),
|
),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
tooltip: 'نوع تخفیف',
|
tooltip: 'نوع تخفیف',
|
||||||
|
|
@ -694,11 +684,13 @@ class _TaxCell extends StatefulWidget {
|
||||||
class _TaxCellState extends State<_TaxCell> {
|
class _TaxCellState extends State<_TaxCell> {
|
||||||
late TextEditingController _controller;
|
late TextEditingController _controller;
|
||||||
bool _isUserTyping = false;
|
bool _isUserTyping = false;
|
||||||
|
late TextEditingController _amountCtrl;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = TextEditingController(text: widget.rate.toString());
|
_controller = TextEditingController(text: widget.rate.toString());
|
||||||
|
_amountCtrl = TextEditingController(text: formatWithThousands(widget.taxAmount, decimalPlaces: 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -708,11 +700,15 @@ class _TaxCellState extends State<_TaxCell> {
|
||||||
if (oldWidget.rate != widget.rate && !_isUserTyping) {
|
if (oldWidget.rate != widget.rate && !_isUserTyping) {
|
||||||
_controller.text = widget.rate.toString();
|
_controller.text = widget.rate.toString();
|
||||||
}
|
}
|
||||||
|
if (oldWidget.taxAmount != widget.taxAmount) {
|
||||||
|
_amountCtrl.text = formatWithThousands(widget.taxAmount, decimalPlaces: 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
|
_amountCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -722,6 +718,7 @@ class _TaxCellState extends State<_TaxCell> {
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 70,
|
width: 70,
|
||||||
|
height: 36,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
|
@ -735,18 +732,29 @@ class _TaxCellState extends State<_TaxCell> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder(), suffixText: '%'),
|
decoration: const InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
suffixText: '%',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
child: TextFormField(
|
||||||
border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)),
|
controller: _amountCtrl,
|
||||||
borderRadius: BorderRadius.circular(8),
|
readOnly: true,
|
||||||
|
enableInteractiveSelection: false,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(formatWithThousands(widget.taxAmount, decimalPlaces: 0)),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -779,7 +787,7 @@ class _UnitPriceCell extends StatefulWidget {
|
||||||
|
|
||||||
class _UnitPriceCellState extends State<_UnitPriceCell> {
|
class _UnitPriceCellState extends State<_UnitPriceCell> {
|
||||||
late TextEditingController _ctrl;
|
late TextEditingController _ctrl;
|
||||||
bool _loading = false;
|
final bool _loading = false;
|
||||||
final PriceListService _pls = PriceListService(apiClient: ApiClient());
|
final PriceListService _pls = PriceListService(apiClient: ApiClient());
|
||||||
late FocusNode _focusNode;
|
late FocusNode _focusNode;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,446 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../services/person_service.dart';
|
||||||
|
import '../../models/person_model.dart';
|
||||||
|
|
||||||
|
class PersonComboboxWidget extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final Person? selectedPerson;
|
||||||
|
final ValueChanged<Person?> onChanged;
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
final bool isRequired;
|
||||||
|
final List<String>? personTypes; // فیلتر بر اساس نوع شخص (مثل ['فروشنده', 'بازاریاب'])
|
||||||
|
final String? searchHint;
|
||||||
|
|
||||||
|
const PersonComboboxWidget({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
required this.onChanged,
|
||||||
|
this.selectedPerson,
|
||||||
|
this.label = 'شخص',
|
||||||
|
this.hintText = 'جستوجو و انتخاب شخص',
|
||||||
|
this.isRequired = false,
|
||||||
|
this.personTypes,
|
||||||
|
this.searchHint,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PersonComboboxWidget> createState() => _PersonComboboxWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonComboboxWidgetState extends State<PersonComboboxWidget> {
|
||||||
|
final PersonService _personService = PersonService();
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
int _searchSeq = 0; // برای جلوگیری از نمایش نتایج قدیمی
|
||||||
|
String _latestQuery = '';
|
||||||
|
void Function(void Function())? _setModalState;
|
||||||
|
|
||||||
|
List<Person> _persons = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isSearching = false;
|
||||||
|
bool _hasSearched = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_searchController.text = widget.selectedPerson?.displayName ?? '';
|
||||||
|
_loadRecentPersons();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRecentPersons() async {
|
||||||
|
// استفاده از مسیر واحد جستوجو با کوئری خالی
|
||||||
|
await _performSearch('');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged(String query) {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
|
||||||
|
_performSearch(query.trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performSearch(String query) async {
|
||||||
|
final int seq = ++_searchSeq;
|
||||||
|
_latestQuery = query;
|
||||||
|
|
||||||
|
// حالت لودینگ بسته به خالی بودن کوئری
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = true;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = true;
|
||||||
|
_hasSearched = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Debug: نمایش پارامترهای جستوجو
|
||||||
|
print('PersonComboboxWidget - _performSearch:');
|
||||||
|
print('seq: $seq');
|
||||||
|
print('query: $query');
|
||||||
|
print('businessId: ${widget.businessId}');
|
||||||
|
print('personTypes: ${widget.personTypes}');
|
||||||
|
|
||||||
|
final response = await _personService.getPersons(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
search: query.isEmpty ? null : query,
|
||||||
|
limit: query.isEmpty ? 10 : 20,
|
||||||
|
filters: widget.personTypes != null && widget.personTypes!.isNotEmpty
|
||||||
|
? {'person_types': widget.personTypes}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// پاسخ کهنه را نادیده بگیر
|
||||||
|
if (seq != _searchSeq || query != _latestQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final persons = _personService.parsePersonsList(response);
|
||||||
|
print('Search returned ${persons.length} persons');
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_persons = persons;
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = false;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// پاسخ کهنه را نادیده بگیر
|
||||||
|
if (seq != _searchSeq || query != _latestQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
print('Error in _performSearch: $e');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_persons = [];
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = false;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_showErrorSnackBar('خطا در جستوجو: $e');
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showErrorSnackBar(String message) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectPerson(Person? person) {
|
||||||
|
if (person == null) {
|
||||||
|
_searchController.clear();
|
||||||
|
widget.onChanged(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_searchController.text = person.displayName;
|
||||||
|
widget.onChanged(person);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPersonPicker() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setModalState) {
|
||||||
|
_setModalState = setModalState;
|
||||||
|
return _PersonPickerBottomSheet(
|
||||||
|
persons: _persons,
|
||||||
|
selectedPerson: widget.selectedPerson,
|
||||||
|
onPersonSelected: _selectPerson,
|
||||||
|
searchController: _searchController,
|
||||||
|
onSearchChanged: (query) {
|
||||||
|
_onSearchChanged(query);
|
||||||
|
setModalState(() {});
|
||||||
|
},
|
||||||
|
isLoading: _isLoading,
|
||||||
|
isSearching: _isSearching,
|
||||||
|
hasSearched: _hasSearched,
|
||||||
|
label: widget.label,
|
||||||
|
searchHint: widget.searchHint ?? 'جستوجو در اشخاص...',
|
||||||
|
personTypes: widget.personTypes,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
final displayText = widget.selectedPerson?.displayName ?? widget.hintText;
|
||||||
|
final isSelected = widget.selectedPerson != null;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: _showPersonPicker,
|
||||||
|
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: Text(
|
||||||
|
displayText,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onSurface
|
||||||
|
: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.selectedPerson != null)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _selectPerson(null),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: Icon(
|
||||||
|
Icons.clear,
|
||||||
|
color: colorScheme.error,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(
|
||||||
|
Icons.arrow_drop_down,
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonPickerBottomSheet extends StatefulWidget {
|
||||||
|
final List<Person> persons;
|
||||||
|
final Person? selectedPerson;
|
||||||
|
final Function(Person?) onPersonSelected;
|
||||||
|
final TextEditingController searchController;
|
||||||
|
final Function(String) onSearchChanged;
|
||||||
|
final bool isLoading;
|
||||||
|
final bool isSearching;
|
||||||
|
final bool hasSearched;
|
||||||
|
final String label;
|
||||||
|
final String searchHint;
|
||||||
|
final List<String>? personTypes;
|
||||||
|
|
||||||
|
const _PersonPickerBottomSheet({
|
||||||
|
required this.persons,
|
||||||
|
required this.selectedPerson,
|
||||||
|
required this.onPersonSelected,
|
||||||
|
required this.searchController,
|
||||||
|
required this.onSearchChanged,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.isSearching,
|
||||||
|
required this.hasSearched,
|
||||||
|
required this.label,
|
||||||
|
required this.searchHint,
|
||||||
|
this.personTypes,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PersonPickerBottomSheet> createState() => _PersonPickerBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonPickerBottomSheetState extends State<_PersonPickerBottomSheet> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.7,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// هدر
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.label,
|
||||||
|
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: widget.searchHint,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
suffixIcon: widget.isSearching
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: widget.onSearchChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// لیست اشخاص
|
||||||
|
Expanded(
|
||||||
|
child: _buildPersonsList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPersonsList() {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
if (widget.isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.persons.isEmpty) {
|
||||||
|
return 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(
|
||||||
|
widget.hasSearched
|
||||||
|
? 'شخصی با این مشخصات یافت نشد'
|
||||||
|
: 'هیچ شخصی ثبت نشده است',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.personTypes != null && widget.personTypes!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'فیلتر: ${widget.personTypes!.join(', ')}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: widget.persons.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final person = widget.persons[index];
|
||||||
|
final isSelected = widget.selectedPerson?.id == person.id;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: colorScheme.primaryContainer,
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(person.displayName),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (person.personTypes.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
person.personTypes.first.persianName,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (person.phone != null)
|
||||||
|
Text(
|
||||||
|
'تلفن: ${person.phone}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (person.email != null)
|
||||||
|
Text(
|
||||||
|
'ایمیل: ${person.email}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: isSelected
|
||||||
|
? Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
widget.onPersonSelected(person);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../services/petty_cash_service.dart';
|
||||||
|
|
||||||
|
class PettyCashOption {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
const PettyCashOption(this.id, this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
class PettyCashComboboxWidget extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final String? selectedPettyCashId;
|
||||||
|
final ValueChanged<PettyCashOption?> onChanged;
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
const PettyCashComboboxWidget({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
required this.onChanged,
|
||||||
|
this.selectedPettyCashId,
|
||||||
|
this.label = 'تنخواهگردان',
|
||||||
|
this.hintText = 'جستوجو و انتخاب تنخواهگردان',
|
||||||
|
this.isRequired = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PettyCashComboboxWidget> createState() => _PettyCashComboboxWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PettyCashComboboxWidgetState extends State<PettyCashComboboxWidget> {
|
||||||
|
final PettyCashService _service = PettyCashService();
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
int _seq = 0;
|
||||||
|
String _latestQuery = '';
|
||||||
|
void Function(void Function())? _setModalState;
|
||||||
|
|
||||||
|
List<PettyCashOption> _items = <PettyCashOption>[];
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isSearching = false;
|
||||||
|
bool _hasSearched = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
await _performSearch('');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged(String q) {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performSearch(String query) async {
|
||||||
|
final int seq = ++_seq;
|
||||||
|
_latestQuery = query;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = true;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = true;
|
||||||
|
_hasSearched = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await _service.list(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
queryInfo: {
|
||||||
|
'take': query.isEmpty ? 50 : 20,
|
||||||
|
'skip': 0,
|
||||||
|
if (query.isNotEmpty) 'search': query,
|
||||||
|
if (query.isNotEmpty) 'search_fields': ['name', 'code', 'description'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (seq != _seq || query != _latestQuery) return;
|
||||||
|
final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null)
|
||||||
|
? (res['data'] as Map)['items']
|
||||||
|
: res['items'];
|
||||||
|
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
|
||||||
|
final m = Map<String, dynamic>.from(e as Map);
|
||||||
|
return PettyCashOption('${m['id']}', (m['name']?.toString() ?? 'نامشخص'));
|
||||||
|
}).toList();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_items = items;
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = false;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
} catch (e) {
|
||||||
|
if (seq != _seq || query != _latestQuery) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_items = <PettyCashOption>[];
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = false;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در دریافت لیست تنخواهگردانها: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openPicker() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setModalState) {
|
||||||
|
_setModalState = setModalState;
|
||||||
|
return _PettyCashPickerBottomSheet(
|
||||||
|
label: widget.label,
|
||||||
|
hintText: widget.hintText,
|
||||||
|
items: _items,
|
||||||
|
searchController: _searchController,
|
||||||
|
isLoading: _isLoading,
|
||||||
|
isSearching: _isSearching,
|
||||||
|
hasSearched: _hasSearched,
|
||||||
|
onSearchChanged: _onSearchChanged,
|
||||||
|
onSelected: (opt) {
|
||||||
|
widget.onChanged(opt);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final selected = _items.firstWhere(
|
||||||
|
(e) => e.id == widget.selectedPettyCashId,
|
||||||
|
orElse: () => const PettyCashOption('', ''),
|
||||||
|
);
|
||||||
|
final text = (widget.selectedPettyCashId != null && widget.selectedPettyCashId!.isNotEmpty)
|
||||||
|
? (selected.name.isNotEmpty ? selected.name : widget.hintText)
|
||||||
|
: widget.hintText;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: _openPicker,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.5)),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.wallet, color: theme.colorScheme.primary, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.arrow_drop_down, color: theme.colorScheme.onSurface.withValues(alpha: 0.6)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PettyCashPickerBottomSheet extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
final List<PettyCashOption> items;
|
||||||
|
final TextEditingController searchController;
|
||||||
|
final bool isLoading;
|
||||||
|
final bool isSearching;
|
||||||
|
final bool hasSearched;
|
||||||
|
final ValueChanged<String> onSearchChanged;
|
||||||
|
final ValueChanged<PettyCashOption?> onSelected;
|
||||||
|
|
||||||
|
const _PettyCashPickerBottomSheet({
|
||||||
|
required this.label,
|
||||||
|
required this.hintText,
|
||||||
|
required this.items,
|
||||||
|
required this.searchController,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.isSearching,
|
||||||
|
required this.hasSearched,
|
||||||
|
required this.onSearchChanged,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@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(label, 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: searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hintText,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
suffixIcon: isSearching
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: onSearchChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: items.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.wallet, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.5)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
hasSearched ? 'تنخواهگردانی با این مشخصات یافت نشد' : 'تنخواهگردانی ثبت نشده است',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final it = items[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: colorScheme.primaryContainer,
|
||||||
|
child: Icon(Icons.wallet, color: colorScheme.onPrimaryContainer),
|
||||||
|
),
|
||||||
|
title: Text(it.name),
|
||||||
|
onTap: () => onSelected(it),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,7 +67,7 @@ class _PriceListComboboxWidgetState extends State<PriceListComboboxWidget> {
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: _selected != null ? (_selected!['name']?.toString() ?? '') : widget.hintText,
|
message: _selected != null ? (_selected!['name']?.toString() ?? '') : widget.hintText,
|
||||||
child: DropdownButtonFormField<int>(
|
child: DropdownButtonFormField<int>(
|
||||||
value: _selected != null ? (_selected!['id'] as int) : null,
|
initialValue: _selected != null ? (_selected!['id'] as int) : null,
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
items: _items
|
items: _items
|
||||||
.map((e) => DropdownMenuItem<int>(
|
.map((e) => DropdownMenuItem<int>(
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_searchCtrl.text = widget.selectedProduct != null
|
_searchCtrl.text = widget.selectedProduct != null
|
||||||
? ((widget.selectedProduct!['code']?.toString() ?? '') + ' - ' + (widget.selectedProduct!['name']?.toString() ?? ''))
|
? ('${widget.selectedProduct!['code']?.toString() ?? ''} - ${widget.selectedProduct!['name']?.toString() ?? ''}')
|
||||||
: '';
|
: '';
|
||||||
_loadRecent();
|
_loadRecent();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ class _SellerPickerWidgetState extends State<SellerPickerWidget> {
|
||||||
List<Person> _sellers = [];
|
List<Person> _sellers = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isSearching = false;
|
bool _isSearching = false;
|
||||||
|
int _searchSeq = 0; // برای جلوگیری از نمایش نتایج قدیمی
|
||||||
|
String _latestQuery = '';
|
||||||
|
void Function(void Function())? _setModalState;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -57,7 +60,8 @@ class _SellerPickerWidgetState extends State<SellerPickerWidget> {
|
||||||
final response = await _personService.getPersons(
|
final response = await _personService.getPersons(
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
filters: {
|
filters: {
|
||||||
'person_types': ['فروشنده', 'بازاریاب'], // فقط فروشنده و بازاریاب
|
// یکسانسازی با API: استفاده از person_types (لیستی از مقادیر)
|
||||||
|
'person_types': ['فروشنده', 'بازاریاب'],
|
||||||
},
|
},
|
||||||
limit: 100, // دریافت همه فروشندگان/بازاریابها
|
limit: 100, // دریافت همه فروشندگان/بازاریابها
|
||||||
);
|
);
|
||||||
|
|
@ -86,40 +90,62 @@ class _SellerPickerWidgetState extends State<SellerPickerWidget> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _searchSellers(String query) async {
|
Future<void> _searchSellers(String query) async {
|
||||||
if (query.isEmpty) {
|
final int seq = ++_searchSeq;
|
||||||
_loadSellers();
|
_latestQuery = query;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSearching = true;
|
if (query.isEmpty) {
|
||||||
|
_isLoading = true; // برای نمایش لودینگ مرکزی هنگام پاککردن کوئری
|
||||||
|
} else {
|
||||||
|
_isSearching = true; // برای نمایش اسپینر کوچک کنار فیلد جستوجو
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _personService.getPersons(
|
final response = await _personService.getPersons(
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
search: query,
|
search: query.isEmpty ? null : query,
|
||||||
filters: {
|
filters: {
|
||||||
'person_types': ['فروشنده', 'بازاریاب'],
|
'person_types': ['فروشنده', 'بازاریاب'],
|
||||||
},
|
},
|
||||||
limit: 50,
|
limit: query.isEmpty ? 100 : 50,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// پاسخ کهنه را نادیده بگیر
|
||||||
|
if (seq != _searchSeq || query != _latestQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final sellers = _personService.parsePersonsList(response);
|
final sellers = _personService.parsePersonsList(response);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_sellers = sellers;
|
_sellers = sellers;
|
||||||
_isSearching = false;
|
if (query.isEmpty) {
|
||||||
|
_isLoading = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// پاسخ کهنه را نادیده بگیر
|
||||||
|
if (seq != _searchSeq || query != _latestQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSearching = false;
|
if (query.isEmpty) {
|
||||||
|
_isLoading = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('خطا در جستوجو: $e'),
|
content: Text('خطا در جستوجو: $e'),
|
||||||
|
|
@ -134,16 +160,21 @@ class _SellerPickerWidgetState extends State<SellerPickerWidget> {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (context) => _SellerPickerBottomSheet(
|
builder: (context) => StatefulBuilder(
|
||||||
sellers: _sellers,
|
builder: (context, setModalState) {
|
||||||
selectedSeller: widget.selectedSeller,
|
_setModalState = setModalState;
|
||||||
onSellerSelected: (seller) {
|
return _SellerPickerBottomSheet(
|
||||||
widget.onSellerChanged(seller);
|
sellers: _sellers,
|
||||||
Navigator.pop(context);
|
selectedSeller: widget.selectedSeller,
|
||||||
|
onSellerSelected: (seller) {
|
||||||
|
widget.onSellerChanged(seller);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
searchController: _searchController,
|
||||||
|
onSearchChanged: _searchSellers,
|
||||||
|
isLoading: _isLoading || _isSearching,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
searchController: _searchController,
|
|
||||||
onSearchChanged: _searchSellers,
|
|
||||||
isLoading: _isLoading || _isSearching,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<String>(
|
child: DropdownButtonFormField<String>(
|
||||||
value: _matchBy,
|
initialValue: _matchBy,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
items: [
|
items: [
|
||||||
DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')),
|
DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')),
|
||||||
|
|
@ -208,7 +208,7 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<String>(
|
child: DropdownButtonFormField<String>(
|
||||||
value: _conflictPolicy,
|
initialValue: _conflictPolicy,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
items: [
|
items: [
|
||||||
DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')),
|
DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')),
|
||||||
|
|
|
||||||
|
|
@ -514,7 +514,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
||||||
return 'مقدار نامعتبر';
|
return 'مقدار نامعتبر';
|
||||||
}
|
}
|
||||||
if (parsed < 0) {
|
if (parsed < 0) {
|
||||||
return 'مقدار نمی\تواند منفی باشد';
|
return 'مقدار نمیتواند منفی باشد';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -526,7 +526,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
||||||
return 'مقدار نامعتبر';
|
return 'مقدار نامعتبر';
|
||||||
}
|
}
|
||||||
if (parsed < 0) {
|
if (parsed < 0) {
|
||||||
return 'مقدار نمی\تواند منفی باشد';
|
return 'مقدار نمیتواند منفی باشد';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
@ -611,7 +611,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
||||||
const Text('ارز'),
|
const Text('ارز'),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
DropdownButtonFormField<int?>(
|
DropdownButtonFormField<int?>(
|
||||||
value: _selectedCurrencyIds.isNotEmpty ? _selectedCurrencyIds.first : null,
|
initialValue: _selectedCurrencyIds.isNotEmpty ? _selectedCurrencyIds.first : null,
|
||||||
items: [
|
items: [
|
||||||
const DropdownMenuItem<int?>(
|
const DropdownMenuItem<int?>(
|
||||||
value: null,
|
value: null,
|
||||||
|
|
@ -643,7 +643,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
||||||
const Text('لیست قیمت'),
|
const Text('لیست قیمت'),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
DropdownButtonFormField<int?>(
|
DropdownButtonFormField<int?>(
|
||||||
value: _selectedPriceListIds.isNotEmpty ? _selectedPriceListIds.first : null,
|
initialValue: _selectedPriceListIds.isNotEmpty ? _selectedPriceListIds.first : null,
|
||||||
items: [
|
items: [
|
||||||
const DropdownMenuItem<int?>(
|
const DropdownMenuItem<int?>(
|
||||||
value: null,
|
value: null,
|
||||||
|
|
@ -675,7 +675,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
||||||
const Text('نوع آیتم'),
|
const Text('نوع آیتم'),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
DropdownButtonFormField<String?>(
|
DropdownButtonFormField<String?>(
|
||||||
value: _selectedItemTypes.isNotEmpty ? _selectedItemTypes.first : null,
|
initialValue: _selectedItemTypes.isNotEmpty ? _selectedItemTypes.first : null,
|
||||||
items: const [
|
items: const [
|
||||||
DropdownMenuItem<String?>(
|
DropdownMenuItem<String?>(
|
||||||
value: null,
|
value: null,
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ class _ProductImportDialogState extends State<ProductImportDialog> {
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<String>(
|
child: DropdownButtonFormField<String>(
|
||||||
value: _matchBy,
|
initialValue: _matchBy,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
items: [
|
items: [
|
||||||
DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')),
|
DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')),
|
||||||
|
|
@ -206,7 +206,7 @@ class _ProductImportDialogState extends State<ProductImportDialog> {
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<String>(
|
child: DropdownButtonFormField<String>(
|
||||||
value: _conflictPolicy,
|
initialValue: _conflictPolicy,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
items: [
|
items: [
|
||||||
DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')),
|
DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')),
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ class ProductPricingInventorySection extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -290,7 +290,7 @@ class ProductPricingInventorySection extends StatelessWidget {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
value: priceListId,
|
initialValue: priceListId,
|
||||||
items: priceLists
|
items: priceLists
|
||||||
.map((pl) => DropdownMenuItem<int>(
|
.map((pl) => DropdownMenuItem<int>(
|
||||||
value: (pl['id'] as num).toInt(),
|
value: (pl['id'] as num).toInt(),
|
||||||
|
|
@ -303,7 +303,7 @@ class ProductPricingInventorySection extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
value: currencyId,
|
initialValue: currencyId,
|
||||||
items: currencies
|
items: currencies
|
||||||
.map((c) => DropdownMenuItem<int>(
|
.map((c) => DropdownMenuItem<int>(
|
||||||
value: (c['id'] as num).toInt(),
|
value: (c['id'] as num).toInt(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue