From ff968aed7abc62d8be219060a2794f96635d098e Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Sat, 11 Oct 2025 02:13:18 +0330 Subject: [PATCH] progress in invoices --- hesabixAPI/adapters/api/v1/__init__.py | 1 + hesabixAPI/adapters/api/v1/invoices.py | 67 +++ hesabixAPI/app/main.py | 2 + .../controllers/product_form_controller.dart | 18 +- hesabixUI/hesabix_ui/lib/main.dart | 23 + .../lib/pages/business/business_shell.dart | 11 +- .../lib/pages/business/new_invoice_page.dart | 160 ++++++- .../business/new_invoice_page_backup.dart | 2 +- .../pages/business/price_list_items_page.dart | 4 +- .../lib/pages/business/reports_page.dart | 333 +++++++++++++ .../hesabix_ui/lib/pages/error_404_page.dart | 384 +++++---------- .../lib/pages/profile/new_business_page.dart | 10 +- .../lib/services/invoice_service.dart | 62 +++ .../lib/services/person_service.dart | 51 +- .../lib/services/price_list_service.dart | 2 +- .../lib/utils/product_form_validator.dart | 2 +- .../category/category_picker_field.dart | 13 +- .../widgets/data_table/data_table_widget.dart | 2 +- .../invoice/bank_account_combobox_widget.dart | 290 ++++++++++++ .../cash_register_combobox_widget.dart | 288 +++++++++++ .../widgets/invoice/code_field_widget.dart | 1 - .../invoice/commission_type_selector.dart | 2 +- .../invoice/invoice_transactions_widget.dart | 230 ++++++--- .../invoice/invoice_type_combobox.dart | 2 +- .../lib/widgets/invoice/line_items_table.dart | 94 ++-- .../invoice/person_combobox_widget.dart | 446 ++++++++++++++++++ .../invoice/petty_cash_combobox_widget.dart | 288 +++++++++++ .../invoice/price_list_combobox_widget.dart | 2 +- .../invoice/product_combobox_widget.dart | 2 +- .../widgets/invoice/seller_picker_widget.dart | 71 ++- .../widgets/person/person_import_dialog.dart | 4 +- .../product/bulk_price_update_dialog.dart | 10 +- .../product/product_import_dialog.dart | 4 +- .../product_pricing_inventory_section.dart | 6 +- 34 files changed, 2402 insertions(+), 485 deletions(-) create mode 100644 hesabixAPI/adapters/api/v1/invoices.py create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/reports_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/invoice_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/invoice/bank_account_combobox_widget.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/invoice/cash_register_combobox_widget.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/invoice/person_combobox_widget.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/invoice/petty_cash_combobox_widget.dart diff --git a/hesabixAPI/adapters/api/v1/__init__.py b/hesabixAPI/adapters/api/v1/__init__.py index d8d30c8..9d0abf8 100644 --- a/hesabixAPI/adapters/api/v1/__init__.py +++ b/hesabixAPI/adapters/api/v1/__init__.py @@ -2,4 +2,5 @@ from .health import router as health # noqa: F401 from .categories import router as categories # noqa: F401 from .products import router as products # noqa: F401 from .price_lists import router as price_lists # noqa: F401 +from .invoices import router as invoices # noqa: F401 diff --git a/hesabixAPI/adapters/api/v1/invoices.py b/hesabixAPI/adapters/api/v1/invoices.py new file mode 100644 index 0000000..312fdc8 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/invoices.py @@ -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") + + diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index 3b721e9..b517d91 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -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.products import router as products_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.customers import router as customers_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(products_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(customers_router, prefix=settings.api_v1_prefix) application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix) diff --git a/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart b/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart index 3365d85..e0c7b81 100644 --- a/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart +++ b/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart @@ -65,23 +65,13 @@ class ProductFormController extends ChangeNotifier { void addOrUpdateDraftPriceItem(Map item) { final String key = ( - (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['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'}' ); int existingIndex = -1; for (int i = 0; i < _draftPriceItems.length; i++) { final it = _draftPriceItems[i]; final itKey = ( - (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['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'}' ); if (itKey == key) { existingIndex = i; @@ -371,8 +361,4 @@ class ProductFormController extends ChangeNotifier { _errorMessage = null; } - @override - void dispose() { - super.dispose(); - } } diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index ad53110..bb4102c 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -28,6 +28,7 @@ import 'pages/business/wallet_page.dart'; import 'pages/business/invoice_page.dart'; import 'pages/business/new_invoice_page.dart'; import 'pages/business/settings_page.dart'; +import 'pages/business/reports_page.dart'; import 'pages/business/persons_page.dart'; import 'pages/business/product_attributes_page.dart'; import 'pages/business/products_page.dart'; @@ -657,11 +658,33 @@ class _MyAppState extends State { ); }, ), + 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( path: 'settings', name: 'business_settings', builder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); + // گارد دسترسی: فقط کاربرانی که دسترسی join دارند + if (!_authStore!.hasBusinessPermission('settings', 'join')) { + return PermissionGuard.buildAccessDeniedPage(); + } return BusinessShell( businessId: businessId, authStore: _authStore!, diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index 3d14d05..572ec8d 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -1226,7 +1226,15 @@ class _BusinessShellState extends State { 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); print(' Checking view permission for section "$section": $hasAccess'); @@ -1276,6 +1284,7 @@ class _BusinessShellState extends State { if (label == t.documents) return 'accounting_documents'; if (label == t.chartOfAccounts) return 'chart_of_accounts'; if (label == t.openingBalance) return 'opening_balance'; + if (label == t.reports) return 'reports'; if (label == t.warehouses) return 'warehouses'; if (label == t.shipments) return 'warehouse_transfers'; if (label == t.inquiries) return 'reports'; diff --git a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart index 8f7a9a0..fb64c94 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart @@ -21,6 +21,8 @@ import '../../utils/number_formatters.dart'; import '../../services/currency_service.dart'; import '../../core/api_client.dart'; import '../../models/invoice_transaction.dart'; +import '../../models/invoice_line_item.dart'; +import '../../services/invoice_service.dart'; class NewInvoicePage extends StatefulWidget { final int businessId; @@ -44,7 +46,7 @@ class _NewInvoicePageState extends State with SingleTickerProvid InvoiceType? _selectedInvoiceType; bool _isDraft = false; String? _invoiceNumber; - bool _autoGenerateInvoiceNumber = true; + final bool _autoGenerateInvoiceNumber = true; Customer? _selectedCustomer; Person? _selectedSeller; double? _commissionPercentage; @@ -71,6 +73,8 @@ class _NewInvoicePageState extends State with SingleTickerProvid // تراکنش‌های فاکتور List _transactions = []; + // ردیف‌های فاکتور برای ساخت payload + List _lineItems = []; @override void initState() { @@ -280,8 +284,11 @@ class _NewInvoicePageState extends State with SingleTickerProvid ), const SizedBox(height: 16), - // مشتری - CustomerComboboxWidget( + // مشتری (برای ضایعات/مصرف مستقیم/تولید مخفی می‌شود) + if (!(_selectedInvoiceType == InvoiceType.waste || + _selectedInvoiceType == InvoiceType.directConsumption || + _selectedInvoiceType == InvoiceType.production)) + CustomerComboboxWidget( selectedCustomer: _selectedCustomer, onCustomerChanged: (customer) { setState(() { @@ -516,7 +523,11 @@ class _NewInvoicePageState extends State with SingleTickerProvid ), const SizedBox(width: 12), Expanded( - child: CustomerComboboxWidget( + child: (_selectedInvoiceType == InvoiceType.waste || + _selectedInvoiceType == InvoiceType.directConsumption || + _selectedInvoiceType == InvoiceType.production) + ? const SizedBox() + : CustomerComboboxWidget( selectedCustomer: _selectedCustomer, onCustomerChanged: (customer) { setState(() { @@ -528,7 +539,7 @@ class _NewInvoicePageState extends State with SingleTickerProvid isRequired: false, label: 'مشتری', hintText: 'انتخاب مشتری', - ), + ), ), ], ), @@ -709,17 +720,136 @@ class _NewInvoicePageState extends State with SingleTickerProvid ); } - void _saveInvoice() { - // TODO: پیاده‌سازی عملیات ذخیره فاکتور - final printInfo = _printAfterSave ? '\n• چاپ فاکتور: فعال' : ''; - final taxInfo = _sendToTaxFolder ? '\n• ارسال به کارپوشه مودیان: فعال' : ''; - final transactionInfo = _transactions.isNotEmpty ? '\n• تعداد تراکنش‌ها: ${_transactions.length}' : ''; - + Future _saveInvoice() async { + final validation = _validateAndBuildPayload(); + if (validation is String) { + _showError(validation); + return; + } + final payload = validation as Map; + + 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 = { + '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 _serializeLineItem(InvoiceLineItem e) { + return { + '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( SnackBar( - content: Text('عملیات ذخیره فاکتور به زودی پیاده‌سازی خواهد شد$printInfo$taxInfo$transactionInfo'), - backgroundColor: Theme.of(context).colorScheme.primary, - duration: const Duration(seconds: 3), + content: Text(message), + backgroundColor: Theme.of(context).colorScheme.error, ), ); } @@ -739,6 +869,7 @@ class _NewInvoicePageState extends State with SingleTickerProvid invoiceType: (_selectedInvoiceType?.value ?? 'sales'), onChanged: (rows) { setState(() { + _lineItems = rows; _sumSubtotal = rows.fold(0, (acc, e) => acc + e.subtotal); _sumDiscount = rows.fold(0, (acc, e) => acc + e.discountAmount); _sumTax = rows.fold(0, (acc, e) => acc + e.taxAmount); @@ -778,6 +909,7 @@ class _NewInvoicePageState extends State with SingleTickerProvid transactions: _transactions, businessId: widget.businessId, calendarController: widget.calendarController, + invoiceType: _selectedInvoiceType ?? InvoiceType.sales, onChanged: (transactions) { setState(() { _transactions = transactions; diff --git a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page_backup.dart b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page_backup.dart index 64ad2d6..d4bd731 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page_backup.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page_backup.dart @@ -39,7 +39,7 @@ class _NewInvoicePageState extends State with SingleTickerProvid InvoiceType? _selectedInvoiceType; bool _isDraft = false; String? _invoiceNumber; - bool _autoGenerateInvoiceNumber = true; + final bool _autoGenerateInvoiceNumber = true; Customer? _selectedCustomer; Person? _selectedSeller; double? _commissionPercentage; diff --git a/hesabixUI/hesabix_ui/lib/pages/business/price_list_items_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/price_list_items_page.dart index 2487a4e..9b708f4 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/price_list_items_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/price_list_items_page.dart @@ -125,7 +125,7 @@ class _PriceListItemsPageState extends State { onChanged: (v) => productId = int.tryParse(v), ), DropdownButtonFormField( - value: currencyId, + initialValue: currencyId, items: _fallbackCurrencies .map((c) => DropdownMenuItem( value: c['id'] as int, @@ -143,7 +143,7 @@ class _PriceListItemsPageState extends State { onChanged: (v) => tierName = v, ), DropdownButtonFormField( - value: unitId, + initialValue: unitId, items: _fallbackUnits .map((u) => DropdownMenuItem( value: u['id'] as int, diff --git a/hesabixUI/hesabix_ui/lib/pages/business/reports_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/reports_page.dart new file mode 100644 index 0000000..1521994 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/reports_page.dart @@ -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 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), + ), + ], + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/error_404_page.dart b/hesabixUI/hesabix_ui/lib/pages/error_404_page.dart index 96a8005..456d351 100644 --- a/hesabixUI/hesabix_ui/lib/pages/error_404_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/error_404_page.dart @@ -1,122 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -class Error404Page extends StatefulWidget { +class Error404Page extends StatelessWidget { const Error404Page({super.key}); - @override - State createState() => _Error404PageState(); -} - -class _Error404PageState extends State - with TickerProviderStateMixin { - late AnimationController _fadeController; - late AnimationController _slideController; - late AnimationController _bounceController; - late AnimationController _pulseController; - late AnimationController _rotateController; - - late Animation _fadeAnimation; - late Animation _slideAnimation; - late Animation _bounceAnimation; - late Animation _pulseAnimation; - late Animation _rotateAnimation; - - @override - void initState() { - super.initState(); - - // کنترلرهای انیمیشن - _fadeController = AnimationController( - duration: const Duration(milliseconds: 1200), - vsync: this, - ); - - _slideController = AnimationController( - duration: const Duration(milliseconds: 1800), - vsync: this, - ); - - _bounceController = AnimationController( - duration: const Duration(milliseconds: 2500), - vsync: this, - ); - - _pulseController = AnimationController( - duration: const Duration(milliseconds: 2000), - vsync: this, - ); - - _rotateController = AnimationController( - duration: const Duration(milliseconds: 3000), - vsync: this, - ); - - // انیمیشن‌ها - _fadeAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _fadeController, - curve: Curves.easeInOut, - )); - - _slideAnimation = Tween( - begin: const Offset(0, 0.5), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _slideController, - curve: Curves.elasticOut, - )); - - _bounceAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _bounceController, - curve: Curves.elasticOut, - )); - - _pulseAnimation = Tween( - begin: 1.0, - end: 1.05, - ).animate(CurvedAnimation( - parent: _pulseController, - curve: Curves.easeInOut, - )); - - _rotateAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _rotateController, - curve: Curves.easeInOut, - )); - - // شروع انیمیشن‌ها - _startAnimations(); - } - - void _startAnimations() async { - await _fadeController.forward(); - await _slideController.forward(); - await _bounceController.forward(); - - // انیمیشن‌های مداوم - _pulseController.repeat(reverse: true); - _rotateController.repeat(); - } - - @override - void dispose() { - _fadeController.dispose(); - _slideController.dispose(); - _bounceController.dispose(); - _pulseController.dispose(); - _rotateController.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -149,177 +36,118 @@ class _Error404PageState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - // انیمیشن 404 با افکت‌های پیشرفته - AnimatedBuilder( - animation: Listenable.merge([_bounceAnimation, _pulseAnimation, _rotateAnimation]), - builder: (context, child) { - return Transform.scale( - scale: _bounceAnimation.value * _pulseAnimation.value, - child: Transform.rotate( - angle: _rotateAnimation.value * 0.1, - child: Container( - width: 220, - height: 220, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: isDark - ? [ - const Color(0xFF6366F1).withValues(alpha: 0.4), - const Color(0xFF8B5CF6).withValues(alpha: 0.2), - const Color(0xFFEC4899).withValues(alpha: 0.1), - ] - : [ - const Color(0xFF6366F1).withValues(alpha: 0.3), - const Color(0xFF8B5CF6).withValues(alpha: 0.15), - const Color(0xFFEC4899).withValues(alpha: 0.05), - ], - ), - boxShadow: [ - BoxShadow( - color: isDark - ? const Color(0xFF6366F1).withValues(alpha: 0.3) - : const Color(0xFF4F46E5).withValues(alpha: 0.2), - blurRadius: 30, - spreadRadius: 5, - ), + // آیکون 404 ساده + Container( + width: 220, + height: 220, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: isDark + ? [ + const Color(0xFF6366F1).withValues(alpha: 0.4), + const Color(0xFF8B5CF6).withValues(alpha: 0.2), + const Color(0xFFEC4899).withValues(alpha: 0.1), + ] + : [ + const Color(0xFF6366F1).withValues(alpha: 0.3), + const Color(0xFF8B5CF6).withValues(alpha: 0.15), + const Color(0xFFEC4899).withValues(alpha: 0.05), ], - ), - child: Stack( - alignment: Alignment.center, - children: [ - // حلقه‌های متحرک - ...List.generate(3, (index) { - return AnimatedBuilder( - animation: _rotateAnimation, - builder: (context, child) { - return Transform.rotate( - angle: _rotateAnimation.value * (2 * 3.14159) * (index + 1) * 0.3, - child: Container( - width: 180 - (index * 20), - height: 180 - (index * 20), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: isDark - ? const Color(0xFF6366F1).withValues(alpha: 0.3 - (index * 0.1)) - : const Color(0xFF4F46E5).withValues(alpha: 0.2 - (index * 0.05)), - width: 2, - ), - ), - ), - ); - }, - ); - }), - // متن 404 - Text( - '404', - style: TextStyle( - fontSize: 80, - fontWeight: FontWeight.bold, - color: isDark - ? const Color(0xFF6366F1) - : const Color(0xFF4F46E5), - shadows: [ - Shadow( - color: isDark - ? const Color(0xFF6366F1).withValues(alpha: 0.6) - : const Color(0xFF4F46E5).withValues(alpha: 0.4), - blurRadius: 25, - ), - ], - ), - ), - ], - ), - ), + ), + boxShadow: [ + BoxShadow( + color: isDark + ? const Color(0xFF6366F1).withValues(alpha: 0.3) + : const Color(0xFF4F46E5).withValues(alpha: 0.2), + blurRadius: 30, + spreadRadius: 5, ), - ); - }, + ], + ), + 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), - // متن اصلی با انیمیشن - FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: _slideAnimation, - child: Column( - children: [ - // عنوان اصلی - Text( - 'صفحه مورد نظر یافت نشد', - style: TextStyle( - fontSize: 36, - fontWeight: FontWeight.bold, - color: isDark ? Colors.white : const Color(0xFF1E293B), - height: 1.2, - letterSpacing: 0.5, - ), - textAlign: TextAlign.center, - ), - - const SizedBox(height: 20), - - // توضیحات - Container( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - 'متأسفانه صفحه‌ای که به دنبال آن هستید وجود ندارد یا حذف شده است. لطفاً آدرس را بررسی کنید یا از دکمه‌های زیر استفاده کنید.', - style: TextStyle( - fontSize: 18, - color: isDark - ? Colors.grey[300] - : const Color(0xFF64748B), - height: 1.6, - letterSpacing: 0.3, - ), - textAlign: TextAlign.center, - ), - ), - - const SizedBox(height: 60), - - // دکمه بازگشت - AnimatedBuilder( - animation: _fadeAnimation, - builder: (context, child) { - return Transform.translate( - offset: Offset(0, 30 * (1 - _fadeAnimation.value)), - child: ElevatedButton.icon( - onPressed: () { - // همیشه سعی کن به صفحه قبلی برگردی - if (Navigator.canPop(context)) { - Navigator.pop(context); - } else { - // اگر نمی‌تونی pop کنی، به root برگرد - context.go('/'); - } - }, - icon: const Icon(Icons.arrow_back_ios, size: 20), - label: const Text('بازگشت به صفحه قبلی'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6366F1), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 20, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - elevation: 6, - shadowColor: const Color(0xFF6366F1).withValues(alpha: 0.4), - ), - ), - ); - }, - ), - ], + // متن اصلی + Column( + children: [ + // عنوان اصلی + Text( + 'صفحه مورد نظر یافت نشد', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : const Color(0xFF1E293B), + height: 1.2, + letterSpacing: 0.5, + ), + textAlign: TextAlign.center, ), - ), + + const SizedBox(height: 20), + + // توضیحات + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + 'متأسفانه صفحه‌ای که به دنبال آن هستید وجود ندارد یا حذف شده است. لطفاً آدرس را بررسی کنید یا از دکمه زیر استفاده کنید.', + style: TextStyle( + fontSize: 18, + color: isDark + ? Colors.grey[300] + : const Color(0xFF64748B), + height: 1.6, + letterSpacing: 0.3, + ), + textAlign: TextAlign.center, + ), + ), + + const SizedBox(height: 60), + + // دکمه صفحه نخست + 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), + ), + ), + ], ), ], ), diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart index a92df5b..bd68342 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart @@ -21,7 +21,7 @@ class _NewBusinessPageState extends State { final BusinessData _businessData = BusinessData(); int _currentStep = 0; bool _isLoading = false; - int _fiscalTabIndex = 0; + final int _fiscalTabIndex = 0; late TextEditingController _fiscalTitleController; List> _currencies = []; @@ -87,7 +87,7 @@ class _NewBusinessPageState extends State { } final fiscal = _businessData.fiscalYears[_fiscalTabIndex]; - String _autoTitle() { + String autoTitle() { final isJalali = widget.calendarController.isJalali; final end = fiscal.endDate; if (end == null) return fiscal.title; @@ -139,7 +139,7 @@ class _NewBusinessPageState extends State { final s = fiscal.startDate!; fiscal.endDate = DateTime(s.year + 1, s.month, s.day); } - fiscal.title = _autoTitle(); + fiscal.title = autoTitle(); _fiscalTitleController.text = fiscal.title; } }); @@ -157,7 +157,7 @@ class _NewBusinessPageState extends State { setState(() { fiscal.endDate = d; if (fiscal.title.trim().isEmpty || fiscal.title.startsWith('سال مالی منتهی به')) { - fiscal.title = _autoTitle(); + fiscal.title = autoTitle(); _fiscalTitleController.text = fiscal.title; } }); @@ -1524,7 +1524,7 @@ class _NewBusinessPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ DropdownButtonFormField( - value: _businessData.defaultCurrencyId, + initialValue: _businessData.defaultCurrencyId, items: _currencies.map((c) { return DropdownMenuItem( value: c['id'] as int, diff --git a/hesabixUI/hesabix_ui/lib/services/invoice_service.dart b/hesabixUI/hesabix_ui/lib/services/invoice_service.dart new file mode 100644 index 0000000..03e7711 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/invoice_service.dart @@ -0,0 +1,62 @@ +import '../core/api_client.dart'; + +class InvoiceService { + final ApiClient _api; + + InvoiceService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient(); + + Future> createInvoice({ + required int businessId, + required Map payload, + }) async { + final res = await _api.post>( + '/api/v1/invoices/business/$businessId', + data: payload, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future> updateInvoice({ + required int businessId, + required int invoiceId, + required Map payload, + }) async { + final res = await _api.put>( + '/api/v1/invoices/business/$businessId/$invoiceId', + data: payload, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future> getInvoice({ + required int businessId, + required int invoiceId, + }) async { + final res = await _api.get>( + '/api/v1/invoices/business/$businessId/$invoiceId', + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future> searchInvoices({ + required int businessId, + int page = 1, + int limit = 20, + String? search, + Map? 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>( + '/api/v1/invoices/business/$businessId/search', + data: body, + ); + return Map.from(res.data?['data'] ?? const {}); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/person_service.dart b/hesabixUI/hesabix_ui/lib/services/person_service.dart index b318a54..b3b11b4 100644 --- a/hesabixUI/hesabix_ui/lib/services/person_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/person_service.dart @@ -26,6 +26,10 @@ class PersonService { if (search != null && search.isNotEmpty) { 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) { @@ -37,22 +41,55 @@ class PersonService { } if (filters != null && filters.isNotEmpty) { - // تبدیل Map به لیست برای API - final filtersList = filters.entries.map((e) => { - 'property': e.key, - 'operator': 'in', // برای فیلترهای چندتایی از عملگر 'in' استفاده می‌کنیم - 'value': e.value, - }).toList(); + // تبدیل Map به لیست برای API با پشتیبانی از person_type و person_types + final List> filtersList = >[]; + filters.forEach((key, value) { + // یکسان‌سازی: اگر person_type ارسال شود، به person_types (لیستی) تبدیل می‌کنیم + if (key == 'person_type') { + final List values = value is List ? List.from(value) : [value]; + filtersList.add({ + 'property': 'person_types', + 'operator': 'in', + 'value': values, + }); + return; + } + + if (key == 'person_types') { + final List values = value is List ? List.from(value) : [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; } + // Debug: نمایش پارامترهای ارسالی + print('PersonService API Call:'); + print('URL: /api/v1/persons/businesses/$businessId/persons'); + print('Data: $queryParams'); + final response = await _apiClient.post( '/api/v1/persons/businesses/$businessId/persons', data: queryParams, ); if (response.statusCode == 200) { - return response.data['data']; + final data = response.data['data']; + print('PersonService Response Data: $data'); + return data; } else { throw Exception('خطا در دریافت لیست اشخاص'); } diff --git a/hesabixUI/hesabix_ui/lib/services/price_list_service.dart b/hesabixUI/hesabix_ui/lib/services/price_list_service.dart index dc2ccb6..e097bc7 100644 --- a/hesabixUI/hesabix_ui/lib/services/price_list_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/price_list_service.dart @@ -32,7 +32,7 @@ class PriceListService { final qp = {}; if (productId != null) qp['product_id'] = '$productId'; 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>('/api/v1/price-lists/business/$businessId/$priceListId/items$query'); final data = res.data?['data']; final items = (data is Map) ? data['items'] : null; diff --git a/hesabixUI/hesabix_ui/lib/utils/product_form_validator.dart b/hesabixUI/hesabix_ui/lib/utils/product_form_validator.dart index cac8a32..ca27f3e 100644 --- a/hesabixUI/hesabix_ui/lib/utils/product_form_validator.dart +++ b/hesabixUI/hesabix_ui/lib/utils/product_form_validator.dart @@ -109,7 +109,7 @@ class ProductFormValidator { errors['basePurchasePrice'] = 'قیمت خرید نمی‌تواند منفی باشد'; } - if (formData.unitConversionFactor != null && formData.unitConversionFactor! <= 0) { + if (formData.unitConversionFactor <= 0) { errors['unitConversionFactor'] = 'ضریب تبدیل باید بزرگتر از صفر باشد'; } diff --git a/hesabixUI/hesabix_ui/lib/widgets/category/category_picker_field.dart b/hesabixUI/hesabix_ui/lib/widgets/category/category_picker_field.dart index c599243..0e3cde9 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/category/category_picker_field.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/category/category_picker_field.dart @@ -10,12 +10,10 @@ class CategoryPickerField extends FormField { required this.businessId, required List> categoriesTree, required ValueChanged onChanged, - int? initialValue, + super.initialValue, String? label, - String? Function(int?)? validator, + super.validator, }) : super( - initialValue: initialValue, - validator: validator, builder: (state) { final context = state.context; final t = AppLocalizations.of(context); @@ -263,8 +261,11 @@ class _CategoryPickerDialogState extends State<_CategoryPickerDialog> { 'children': >[], }; byId[nid] = existing; - if (parent == null) roots.add(existing); - else (parent['children'] as List).add(existing); + if (parent == null) { + roots.add(existing); + } else { + (parent['children'] as List).add(existing); + } } parent = existing; } diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart index eb8a286..294943f 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart @@ -1397,7 +1397,7 @@ class _DataTableWidgetState extends State> { thumbVisibility: true, child: DataTableTheme( data: DataTableThemeData( - headingRowColor: MaterialStatePropertyAll( + headingRowColor: WidgetStatePropertyAll( theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), ), headingTextStyle: theme.textTheme.titleSmall?.copyWith( diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/bank_account_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/bank_account_combobox_widget.dart new file mode 100644 index 0000000..9fe2819 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/bank_account_combobox_widget.dart @@ -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 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 createState() => _BankAccountComboboxWidgetState(); +} + +class _BankAccountComboboxWidgetState extends State { + final BankAccountService _service = BankAccountService(); + final TextEditingController _searchController = TextEditingController(); + Timer? _debounceTimer; + int _seq = 0; + String _latestQuery = ''; + void Function(void Function())? _setModalState; + + List _items = []; + 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 _load() async { + await _performSearch(''); + } + + void _onSearchChanged(String q) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim())); + } + + Future _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? ?? const [])).map((e) { + final m = Map.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 = []; + 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 items; + final TextEditingController searchController; + final bool isLoading; + final bool isSearching; + final bool hasSearched; + final ValueChanged onSearchChanged; + final ValueChanged 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), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/cash_register_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/cash_register_combobox_widget.dart new file mode 100644 index 0000000..c24ad29 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/cash_register_combobox_widget.dart @@ -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 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 createState() => _CashRegisterComboboxWidgetState(); +} + +class _CashRegisterComboboxWidgetState extends State { + final CashRegisterService _service = CashRegisterService(); + final TextEditingController _searchController = TextEditingController(); + Timer? _debounceTimer; + int _seq = 0; + String _latestQuery = ''; + void Function(void Function())? _setModalState; + + List _items = []; + 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 _load() async { + await _performSearch(''); + } + + void _onSearchChanged(String q) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim())); + } + + Future _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? ?? const [])).map((e) { + final m = Map.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 = []; + 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 items; + final TextEditingController searchController; + final bool isLoading; + final bool isSearching; + final bool hasSearched; + final ValueChanged onSearchChanged; + final ValueChanged 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), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/code_field_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/code_field_widget.dart index 4ecc12c..147a3e2 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/code_field_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/code_field_widget.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; class CodeFieldWidget extends StatefulWidget { diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/commission_type_selector.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/commission_type_selector.dart index 4b0ad0e..7aa9e5a 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/commission_type_selector.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/commission_type_selector.dart @@ -67,7 +67,7 @@ class _CommissionTypeSelectorState extends State { final colorScheme = theme.colorScheme; return DropdownButtonFormField( - value: _selectedType, + initialValue: _selectedType, onChanged: (CommissionType? newValue) { if (newValue != null) { _selectType(newValue); diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart index d779242..254e94f 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart @@ -1,15 +1,26 @@ import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; import '../../models/invoice_transaction.dart'; +import '../../models/person_model.dart'; import '../../core/date_utils.dart'; import '../../core/calendar_controller.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 { final List transactions; final ValueChanged> onChanged; final int businessId; final CalendarController calendarController; + final InvoiceType invoiceType; const InvoiceTransactionsWidget({ super.key, @@ -17,6 +28,7 @@ class InvoiceTransactionsWidget extends StatefulWidget { required this.onChanged, required this.businessId, required this.calendarController, + required this.invoiceType, }); @override @@ -305,6 +317,7 @@ class _InvoiceTransactionsWidgetState extends State { transaction: transaction, businessId: widget.businessId, calendarController: widget.calendarController, + invoiceType: widget.invoiceType, onSave: (newTransaction) { if (index != null) { // ویرایش تراکنش موجود @@ -328,12 +341,14 @@ class TransactionDialog extends StatefulWidget { final int businessId; final CalendarController calendarController; final ValueChanged onSave; + final InvoiceType invoiceType; const TransactionDialog({ super.key, this.transaction, required this.businessId, required this.calendarController, + required this.invoiceType, required this.onSave, }); @@ -351,6 +366,12 @@ class _TransactionDialogState extends State { final _commissionController = TextEditingController(); final _descriptionController = TextEditingController(); + // سرویس‌ها + final BankAccountService _bankService = BankAccountService(); + final CashRegisterService _cashRegisterService = CashRegisterService(); + final PettyCashService _pettyCashService = PettyCashService(); + final PersonService _personService = PersonService(); + // فیلدهای خاص هر نوع تراکنش String? _selectedBankId; String? _selectedCashRegisterId; @@ -358,6 +379,14 @@ class _TransactionDialogState extends State { String? _selectedCheckId; String? _selectedPersonId; String? _selectedAccountId; + + // لیست‌های داده + List> _banks = []; + List> _cashRegisters = []; + List> _pettyCashList = []; + List> _persons = []; + + bool _isLoading = false; @override void initState() { @@ -375,6 +404,53 @@ class _TransactionDialogState extends State { _selectedCheckId = widget.transaction?.checkId; _selectedPersonId = widget.transaction?.personId; _selectedAccountId = widget.transaction?.accountId; + + // لود کردن داده‌ها از دیتابیس + _loadData(); + } + + Future _loadData() async { + setState(() { + _isLoading = true; + }); + + try { + // لود کردن بانک‌ها + final bankResponse = await _bankService.list( + businessId: widget.businessId, + queryInfo: {'take': 100, 'skip': 0}, + ); + _banks = (bankResponse['items'] as List?)?.cast>() ?? []; + + // لود کردن صندوق‌ها + final cashRegisterResponse = await _cashRegisterService.list( + businessId: widget.businessId, + queryInfo: {'take': 100, 'skip': 0}, + ); + _cashRegisters = (cashRegisterResponse['items'] as List?)?.cast>() ?? []; + + // لود کردن تنخواهگردان‌ها + final pettyCashResponse = await _pettyCashService.list( + businessId: widget.businessId, + queryInfo: {'take': 100, 'skip': 0}, + ); + _pettyCashList = (pettyCashResponse['items'] as List?)?.cast>() ?? []; + + // لود کردن اشخاص + final personResponse = await _personService.getPersons( + businessId: widget.businessId, + limit: 100, + ); + _persons = (personResponse['items'] as List?)?.cast>() ?? []; + + } catch (e) { + // در صورت خطا، لیست‌ها خالی باقی می‌مانند + print('خطا در لود کردن داده‌ها: $e'); + } finally { + setState(() { + _isLoading = false; + }); + } } @override @@ -447,7 +523,7 @@ class _TransactionDialogState extends State { labelText: 'نوع تراکنش *', border: OutlineInputBorder(), ), - items: TransactionType.allTypes.map((type) { + items: _availableTransactionTypes().map((type) { return DropdownMenuItem( value: type, child: Text(type.label), @@ -464,7 +540,10 @@ class _TransactionDialogState extends State { const SizedBox(height: 16), // فیلدهای خاص بر اساس نوع تراکنش - _buildTypeSpecificFields(), + if (_isLoading) + const Center(child: CircularProgressIndicator()) + else + _buildTypeSpecificFields(), const SizedBox(height: 16), // تاریخ تراکنش @@ -595,61 +674,56 @@ class _TransactionDialogState extends State { } } + List _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() { - return DropdownButtonFormField( - initialValue: _selectedBankId, - decoration: const InputDecoration( - labelText: 'بانک *', - border: OutlineInputBorder(), - ), - items: const [ - DropdownMenuItem(value: 'bank1', child: Text('بانک ملی')), - DropdownMenuItem(value: 'bank2', child: Text('بانک صادرات')), - DropdownMenuItem(value: 'bank3', child: Text('بانک ملت')), - ], - onChanged: (value) { + return BankAccountComboboxWidget( + businessId: widget.businessId, + selectedAccountId: _selectedBankId, + onChanged: (opt) { setState(() { - _selectedBankId = value; + _selectedBankId = opt?.id; }); }, + label: 'بانک *', + hintText: 'جست‌وجو و انتخاب بانک', + isRequired: true, ); } Widget _buildCashRegisterFields() { - return DropdownButtonFormField( - initialValue: _selectedCashRegisterId, - decoration: const InputDecoration( - labelText: 'صندوق *', - border: OutlineInputBorder(), - ), - items: const [ - DropdownMenuItem(value: 'cash1', child: Text('صندوق اصلی')), - DropdownMenuItem(value: 'cash2', child: Text('صندوق فرعی')), - ], - onChanged: (value) { + return CashRegisterComboboxWidget( + businessId: widget.businessId, + selectedRegisterId: _selectedCashRegisterId, + onChanged: (opt) { setState(() { - _selectedCashRegisterId = value; + _selectedCashRegisterId = opt?.id; }); }, + label: 'صندوق *', + hintText: 'جست‌وجو و انتخاب صندوق', + isRequired: true, ); } Widget _buildPettyCashFields() { - return DropdownButtonFormField( - initialValue: _selectedPettyCashId, - decoration: const InputDecoration( - labelText: 'تنخواهگردان *', - border: OutlineInputBorder(), - ), - items: const [ - DropdownMenuItem(value: 'petty1', child: Text('تنخواهگردان اصلی')), - DropdownMenuItem(value: 'petty2', child: Text('تنخواهگردان فرعی')), - ], - onChanged: (value) { + return PettyCashComboboxWidget( + businessId: widget.businessId, + selectedPettyCashId: _selectedPettyCashId, + onChanged: (opt) { setState(() { - _selectedPettyCashId = value; + _selectedPettyCashId = opt?.id; }); }, + label: 'تنخواهگردان *', + hintText: 'جست‌وجو و انتخاب تنخواه‌گردان', + isRequired: true, ); } @@ -692,21 +766,30 @@ class _TransactionDialogState extends State { } Widget _buildPersonFields() { - return DropdownButtonFormField( - initialValue: _selectedPersonId, - decoration: const InputDecoration( - labelText: 'شخص *', - border: OutlineInputBorder(), - ), - items: const [ - DropdownMenuItem(value: 'person1', child: Text('احمد محمدی')), - DropdownMenuItem(value: 'person2', child: Text('فاطمه احمدی')), - ], - onChanged: (value) { + // پیدا کردن شخص انتخاب شده از لیست + Person? selectedPerson; + if (_selectedPersonId != null) { + try { + final personData = _persons.firstWhere( + (p) => p['id']?.toString() == _selectedPersonId, + ); + selectedPerson = Person.fromJson(personData); + } catch (e) { + selectedPerson = null; + } + } + + return PersonComboboxWidget( + businessId: widget.businessId, + selectedPerson: selectedPerson, + onChanged: (person) { setState(() { - _selectedPersonId = value; + _selectedPersonId = person?.id?.toString(); }); }, + label: 'شخص *', + hintText: 'انتخاب شخص', + isRequired: true, ); } @@ -780,28 +863,30 @@ class _TransactionDialogState extends State { } String? _getBankName(String? id) { - switch (id) { - case 'bank1': return 'بانک ملی'; - case 'bank2': return 'بانک صادرات'; - case 'bank3': return 'بانک ملت'; - default: return null; - } + if (id == null) return null; + final bank = _banks.firstWhere( + (b) => b['id']?.toString() == id, + orElse: () => {}, + ); + return bank['name']?.toString(); } String? _getCashRegisterName(String? id) { - switch (id) { - case 'cash1': return 'صندوق اصلی'; - case 'cash2': return 'صندوق فرعی'; - default: return null; - } + if (id == null) return null; + final cashRegister = _cashRegisters.firstWhere( + (c) => c['id']?.toString() == id, + orElse: () => {}, + ); + return cashRegister['name']?.toString(); } String? _getPettyCashName(String? id) { - switch (id) { - case 'petty1': return 'تنخواهگردان اصلی'; - case 'petty2': return 'تنخواهگردان فرعی'; - default: return null; - } + if (id == null) return null; + final pettyCash = _pettyCashList.firstWhere( + (p) => p['id']?.toString() == id, + orElse: () => {}, + ); + return pettyCash['name']?.toString(); } String? _getCheckNumber(String? id) { @@ -813,11 +898,12 @@ class _TransactionDialogState extends State { } String? _getPersonName(String? id) { - switch (id) { - case 'person1': return 'احمد محمدی'; - case 'person2': return 'فاطمه احمدی'; - default: return null; - } + if (id == null) return null; + final person = _persons.firstWhere( + (p) => p['id']?.toString() == id, + orElse: () => {}, + ); + return person['name']?.toString(); } String? _getAccountName(String? id) { diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_type_combobox.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_type_combobox.dart index 474cfa1..b132f9f 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_type_combobox.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_type_combobox.dart @@ -71,7 +71,7 @@ class _InvoiceTypeComboboxState extends State { final colorScheme = theme.colorScheme; return DropdownButtonFormField( - value: _selectedType, + initialValue: _selectedType, onChanged: (InvoiceType? newValue) { if (newValue != null) { _selectType(newValue); diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart index 539bbe7..05b78e6 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart @@ -241,9 +241,7 @@ class _InvoiceLineItemsTableState extends State { ), ) else - ..._rows.asMap().entries.map((e) => _buildCompactRow(context, e.key, e.value)).toList(), - const Divider(height: 1), - _buildFooter(context), + ..._rows.asMap().entries.map((e) => _buildCompactRow(context, e.key, e.value)), ], ), ), @@ -418,13 +416,23 @@ class _InvoiceLineItemsTableState extends State { const SizedBox(width: 8), Flexible( flex: 2, - child: Align( - alignment: Alignment.centerRight, + child: SizedBox( + height: 36, child: Tooltip( message: 'مبلغ کل این ردیف', - child: Text( - formatWithThousands(item.total, decimalPlaces: 0), - style: theme.textTheme.bodyMedium, + child: InputDecorator( + decoration: const InputDecoration( + 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 { onChanged: (v) { _updateRow(index, item.copyWith(description: v)); }, - decoration: const InputDecoration( - isDense: true, - border: OutlineInputBorder(), - hintText: 'شرح (اختیاری)' - ), + decoration: const InputDecoration( + isDense: true, + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + hintText: 'شرح (اختیاری)' + ), ), ), ), @@ -509,26 +518,7 @@ class _InvoiceLineItemsTableState extends State { } - Widget _buildFooter(BuildContext context) { - final sumSubtotal = _rows.fold(0, (acc, e) => acc + e.subtotal); - final sumDiscount = _rows.fold(0, (acc, e) => acc + e.discountAmount); - final sumTax = _rows.fold(0, (acc, e) => acc + e.taxAmount); - final sumTotal = _rows.fold(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 onChanged) { showDialog( @@ -625,7 +615,7 @@ class _DiscountCellState extends State<_DiscountCell> { @override Widget build(BuildContext context) { final theme = Theme.of(context); - String _typeLabel(String t) => t == 'percent' ? 'درصد' : 'مبلغ'; + String typeLabel(String t) => t == 'percent' ? 'درصد' : 'مبلغ'; return SizedBox( height: 36, child: TextFormField( @@ -647,7 +637,7 @@ class _DiscountCellState extends State<_DiscountCell> { else Padding( padding: const EdgeInsetsDirectional.only(end: 4), - child: Text(_typeLabel(_type), style: theme.textTheme.bodySmall), + child: Text(typeLabel(_type), style: theme.textTheme.bodySmall), ), PopupMenuButton( tooltip: 'نوع تخفیف', @@ -694,11 +684,13 @@ class _TaxCell extends StatefulWidget { class _TaxCellState extends State<_TaxCell> { late TextEditingController _controller; bool _isUserTyping = false; + late TextEditingController _amountCtrl; @override void initState() { super.initState(); _controller = TextEditingController(text: widget.rate.toString()); + _amountCtrl = TextEditingController(text: formatWithThousands(widget.taxAmount, decimalPlaces: 0)); } @override @@ -708,11 +700,15 @@ class _TaxCellState extends State<_TaxCell> { if (oldWidget.rate != widget.rate && !_isUserTyping) { _controller.text = widget.rate.toString(); } + if (oldWidget.taxAmount != widget.taxAmount) { + _amountCtrl.text = formatWithThousands(widget.taxAmount, decimalPlaces: 0); + } } @override void dispose() { _controller.dispose(); + _amountCtrl.dispose(); super.dispose(); } @@ -722,6 +718,7 @@ class _TaxCellState extends State<_TaxCell> { children: [ SizedBox( width: 70, + height: 36, child: TextFormField( controller: _controller, 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), Expanded( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), - decoration: BoxDecoration( - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - borderRadius: BorderRadius.circular(8), + child: SizedBox( + height: 36, + child: TextFormField( + controller: _amountCtrl, + 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> { late TextEditingController _ctrl; - bool _loading = false; + final bool _loading = false; final PriceListService _pls = PriceListService(apiClient: ApiClient()); late FocusNode _focusNode; diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/person_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/person_combobox_widget.dart new file mode 100644 index 0000000..5af096d --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/person_combobox_widget.dart @@ -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 onChanged; + final String label; + final String hintText; + final bool isRequired; + final List? 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 createState() => _PersonComboboxWidgetState(); +} + +class _PersonComboboxWidgetState extends State { + final PersonService _personService = PersonService(); + final TextEditingController _searchController = TextEditingController(); + Timer? _debounceTimer; + int _searchSeq = 0; // برای جلوگیری از نمایش نتایج قدیمی + String _latestQuery = ''; + void Function(void Function())? _setModalState; + + List _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 _loadRecentPersons() async { + // استفاده از مسیر واحد جست‌وجو با کوئری خالی + await _performSearch(''); + } + + void _onSearchChanged(String query) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 500), () { + _performSearch(query.trim()); + }); + } + + Future _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 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? 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); + }, + ); + }, + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/petty_cash_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/petty_cash_combobox_widget.dart new file mode 100644 index 0000000..f8840ac --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/petty_cash_combobox_widget.dart @@ -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 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 createState() => _PettyCashComboboxWidgetState(); +} + +class _PettyCashComboboxWidgetState extends State { + final PettyCashService _service = PettyCashService(); + final TextEditingController _searchController = TextEditingController(); + Timer? _debounceTimer; + int _seq = 0; + String _latestQuery = ''; + void Function(void Function())? _setModalState; + + List _items = []; + 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 _load() async { + await _performSearch(''); + } + + void _onSearchChanged(String q) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim())); + } + + Future _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? ?? const [])).map((e) { + final m = Map.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 = []; + 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 items; + final TextEditingController searchController; + final bool isLoading; + final bool isSearching; + final bool hasSearched; + final ValueChanged onSearchChanged; + final ValueChanged 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), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/price_list_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/price_list_combobox_widget.dart index 945db94..264498f 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/price_list_combobox_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/price_list_combobox_widget.dart @@ -67,7 +67,7 @@ class _PriceListComboboxWidgetState extends State { child: Tooltip( message: _selected != null ? (_selected!['name']?.toString() ?? '') : widget.hintText, child: DropdownButtonFormField( - value: _selected != null ? (_selected!['id'] as int) : null, + initialValue: _selected != null ? (_selected!['id'] as int) : null, isExpanded: true, items: _items .map((e) => DropdownMenuItem( diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart index 6402344..d407c5a 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart @@ -34,7 +34,7 @@ class _ProductComboboxWidgetState extends State { void initState() { super.initState(); _searchCtrl.text = widget.selectedProduct != null - ? ((widget.selectedProduct!['code']?.toString() ?? '') + ' - ' + (widget.selectedProduct!['name']?.toString() ?? '')) + ? ('${widget.selectedProduct!['code']?.toString() ?? ''} - ${widget.selectedProduct!['name']?.toString() ?? ''}') : ''; _loadRecent(); } diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/seller_picker_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/seller_picker_widget.dart index 4897bca..da389e5 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/seller_picker_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/seller_picker_widget.dart @@ -33,6 +33,9 @@ class _SellerPickerWidgetState extends State { List _sellers = []; bool _isLoading = false; bool _isSearching = false; + int _searchSeq = 0; // برای جلوگیری از نمایش نتایج قدیمی + String _latestQuery = ''; + void Function(void Function())? _setModalState; @override void initState() { @@ -57,7 +60,8 @@ class _SellerPickerWidgetState extends State { final response = await _personService.getPersons( businessId: widget.businessId, filters: { - 'person_types': ['فروشنده', 'بازاریاب'], // فقط فروشنده و بازاریاب + // یکسان‌سازی با API: استفاده از person_types (لیستی از مقادیر) + 'person_types': ['فروشنده', 'بازاریاب'], }, limit: 100, // دریافت همه فروشندگان/بازاریاب‌ها ); @@ -86,40 +90,62 @@ class _SellerPickerWidgetState extends State { } Future _searchSellers(String query) async { - if (query.isEmpty) { - _loadSellers(); - return; - } + final int seq = ++_searchSeq; + _latestQuery = query; if (!mounted) return; - + setState(() { - _isSearching = true; + if (query.isEmpty) { + _isLoading = true; // برای نمایش لودینگ مرکزی هنگام پاک‌کردن کوئری + } else { + _isSearching = true; // برای نمایش اسپینر کوچک کنار فیلد جست‌وجو + } }); + _setModalState?.call(() {}); try { final response = await _personService.getPersons( businessId: widget.businessId, - search: query, + search: query.isEmpty ? null : query, filters: { 'person_types': ['فروشنده', 'بازاریاب'], }, - limit: 50, + limit: query.isEmpty ? 100 : 50, ); + // پاسخ کهنه را نادیده بگیر + if (seq != _searchSeq || query != _latestQuery) { + return; + } + final sellers = _personService.parsePersonsList(response); if (mounted) { setState(() { _sellers = sellers; - _isSearching = false; + if (query.isEmpty) { + _isLoading = false; + } else { + _isSearching = false; + } }); + _setModalState?.call(() {}); } } catch (e) { + // پاسخ کهنه را نادیده بگیر + if (seq != _searchSeq || query != _latestQuery) { + return; + } if (mounted) { setState(() { - _isSearching = false; + if (query.isEmpty) { + _isLoading = false; + } else { + _isSearching = false; + } }); + _setModalState?.call(() {}); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('خطا در جست‌وجو: $e'), @@ -134,16 +160,21 @@ class _SellerPickerWidgetState extends State { showModalBottomSheet( context: context, isScrollControlled: true, - builder: (context) => _SellerPickerBottomSheet( - sellers: _sellers, - selectedSeller: widget.selectedSeller, - onSellerSelected: (seller) { - widget.onSellerChanged(seller); - Navigator.pop(context); + builder: (context) => StatefulBuilder( + builder: (context, setModalState) { + _setModalState = setModalState; + return _SellerPickerBottomSheet( + sellers: _sellers, + 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, ), ); } diff --git a/hesabixUI/hesabix_ui/lib/widgets/person/person_import_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/person/person_import_dialog.dart index ee3e3de..ba098c5 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/person/person_import_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/person/person_import_dialog.dart @@ -194,7 +194,7 @@ class _PersonImportDialogState extends State { children: [ Expanded( child: DropdownButtonFormField( - value: _matchBy, + initialValue: _matchBy, isDense: true, items: [ DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')), @@ -208,7 +208,7 @@ class _PersonImportDialogState extends State { const SizedBox(width: 8), Expanded( child: DropdownButtonFormField( - value: _conflictPolicy, + initialValue: _conflictPolicy, isDense: true, items: [ DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')), diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/bulk_price_update_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/product/bulk_price_update_dialog.dart index d34a226..486bffb 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/product/bulk_price_update_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/product/bulk_price_update_dialog.dart @@ -514,7 +514,7 @@ class _BulkPriceUpdateDialogState extends State { return 'مقدار نامعتبر'; } if (parsed < 0) { - return 'مقدار نمی\تواند منفی باشد'; + return 'مقدار نمیتواند منفی باشد'; } return null; } @@ -526,7 +526,7 @@ class _BulkPriceUpdateDialogState extends State { return 'مقدار نامعتبر'; } if (parsed < 0) { - return 'مقدار نمی\تواند منفی باشد'; + return 'مقدار نمیتواند منفی باشد'; } return null; }, @@ -611,7 +611,7 @@ class _BulkPriceUpdateDialogState extends State { const Text('ارز'), const SizedBox(height: 4), DropdownButtonFormField( - value: _selectedCurrencyIds.isNotEmpty ? _selectedCurrencyIds.first : null, + initialValue: _selectedCurrencyIds.isNotEmpty ? _selectedCurrencyIds.first : null, items: [ const DropdownMenuItem( value: null, @@ -643,7 +643,7 @@ class _BulkPriceUpdateDialogState extends State { const Text('لیست قیمت'), const SizedBox(height: 4), DropdownButtonFormField( - value: _selectedPriceListIds.isNotEmpty ? _selectedPriceListIds.first : null, + initialValue: _selectedPriceListIds.isNotEmpty ? _selectedPriceListIds.first : null, items: [ const DropdownMenuItem( value: null, @@ -675,7 +675,7 @@ class _BulkPriceUpdateDialogState extends State { const Text('نوع آیتم'), const SizedBox(height: 4), DropdownButtonFormField( - value: _selectedItemTypes.isNotEmpty ? _selectedItemTypes.first : null, + initialValue: _selectedItemTypes.isNotEmpty ? _selectedItemTypes.first : null, items: const [ DropdownMenuItem( value: null, diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/product_import_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/product/product_import_dialog.dart index 4aebfe2..5a446ce 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/product/product_import_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/product/product_import_dialog.dart @@ -193,7 +193,7 @@ class _ProductImportDialogState extends State { children: [ Expanded( child: DropdownButtonFormField( - value: _matchBy, + initialValue: _matchBy, isDense: true, items: [ DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')), @@ -206,7 +206,7 @@ class _ProductImportDialogState extends State { const SizedBox(width: 8), Expanded( child: DropdownButtonFormField( - value: _conflictPolicy, + initialValue: _conflictPolicy, isDense: true, items: [ DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')), diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart index 53e946a..9d7c5a0 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart @@ -190,7 +190,7 @@ class ProductPricingInventorySection extends StatelessWidget { ), ), ); - }).toList(), + }), ], ); } @@ -290,7 +290,7 @@ class ProductPricingInventorySection extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField( - value: priceListId, + initialValue: priceListId, items: priceLists .map((pl) => DropdownMenuItem( value: (pl['id'] as num).toInt(), @@ -303,7 +303,7 @@ class ProductPricingInventorySection extends StatelessWidget { ), const SizedBox(height: 12), DropdownButtonFormField( - value: currencyId, + initialValue: currencyId, items: currencies .map((c) => DropdownMenuItem( value: (c['id'] as num).toInt(),