progress in invoices
This commit is contained in:
parent
09c17b580d
commit
ff968aed7a
|
|
@ -2,4 +2,5 @@ from .health import router as health # noqa: F401
|
|||
from .categories import router as categories # noqa: F401
|
||||
from .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
|
||||
|
||||
|
|
|
|||
67
hesabixAPI/adapters/api/v1/invoices.py
Normal file
67
hesabixAPI/adapters/api/v1/invoices.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from adapters.db.session import get_db
|
||||
from app.core.auth_dependency import get_current_user, AuthContext
|
||||
from app.core.permissions import require_business_access
|
||||
from app.core.responses import success_response
|
||||
from adapters.api.v1.schemas import QueryInfo
|
||||
|
||||
|
||||
router = APIRouter(prefix="/invoices", tags=["invoices"]) # Stubs only
|
||||
|
||||
|
||||
@router.post("/business/{business_id}")
|
||||
@require_business_access("business_id")
|
||||
def create_invoice_endpoint(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
payload: Dict[str, Any],
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
# Stub only: no implementation yet
|
||||
return success_response(data={}, request=request, message="INVOICE_CREATE_STUB")
|
||||
|
||||
|
||||
@router.put("/business/{business_id}/{invoice_id}")
|
||||
@require_business_access("business_id")
|
||||
def update_invoice_endpoint(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
invoice_id: int,
|
||||
payload: Dict[str, Any],
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
# Stub only: no implementation yet
|
||||
return success_response(data={}, request=request, message="INVOICE_UPDATE_STUB")
|
||||
|
||||
|
||||
@router.get("/business/{business_id}/{invoice_id}")
|
||||
@require_business_access("business_id")
|
||||
def get_invoice_endpoint(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
invoice_id: int,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
# Stub only: no implementation yet
|
||||
return success_response(data={"item": None}, request=request, message="INVOICE_GET_STUB")
|
||||
|
||||
|
||||
@router.post("/business/{business_id}/search")
|
||||
@require_business_access("business_id")
|
||||
def search_invoices_endpoint(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
query_info: QueryInfo,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
# Stub only: no implementation yet
|
||||
return success_response(data={"items": [], "total": 0, "take": query_info.take, "skip": query_info.skip}, request=request, message="INVOICE_SEARCH_STUB")
|
||||
|
||||
|
||||
|
|
@ -15,6 +15,7 @@ from adapters.api.v1.categories import router as categories_router
|
|||
from adapters.api.v1.product_attributes import router as product_attributes_router
|
||||
from adapters.api.v1.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)
|
||||
|
|
|
|||
|
|
@ -65,23 +65,13 @@ class ProductFormController extends ChangeNotifier {
|
|||
|
||||
void addOrUpdateDraftPriceItem(Map<String, dynamic> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MyApp> {
|
|||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'reports',
|
||||
name: 'business_reports',
|
||||
builder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
child: ReportsPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
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!,
|
||||
|
|
|
|||
|
|
@ -1226,7 +1226,15 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
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<BusinessShell> {
|
|||
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';
|
||||
|
|
|
|||
|
|
@ -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<NewInvoicePage> 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<NewInvoicePage> with SingleTickerProvid
|
|||
|
||||
// تراکنشهای فاکتور
|
||||
List<InvoiceTransaction> _transactions = [];
|
||||
// ردیفهای فاکتور برای ساخت payload
|
||||
List<InvoiceLineItem> _lineItems = <InvoiceLineItem>[];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -280,8 +284,11 @@ class _NewInvoicePageState extends State<NewInvoicePage> 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<NewInvoicePage> 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<NewInvoicePage> with SingleTickerProvid
|
|||
isRequired: false,
|
||||
label: 'مشتری',
|
||||
hintText: 'انتخاب مشتری',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -709,17 +720,136 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
);
|
||||
}
|
||||
|
||||
void _saveInvoice() {
|
||||
// TODO: پیادهسازی عملیات ذخیره فاکتور
|
||||
final printInfo = _printAfterSave ? '\n• چاپ فاکتور: فعال' : '';
|
||||
final taxInfo = _sendToTaxFolder ? '\n• ارسال به کارپوشه مودیان: فعال' : '';
|
||||
final transactionInfo = _transactions.isNotEmpty ? '\n• تعداد تراکنشها: ${_transactions.length}' : '';
|
||||
|
||||
Future<void> _saveInvoice() async {
|
||||
final validation = _validateAndBuildPayload();
|
||||
if (validation is String) {
|
||||
_showError(validation);
|
||||
return;
|
||||
}
|
||||
final payload = validation as Map<String, dynamic>;
|
||||
|
||||
try {
|
||||
final service = InvoiceService(apiClient: ApiClient());
|
||||
await service.createInvoice(businessId: widget.businessId, payload: payload);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('فاکتور با موفقیت ثبت شد'),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
_showError('خطا در ذخیره فاکتور: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _validateAndBuildPayload() {
|
||||
// اعتبارسنجیهای پایه
|
||||
if (_selectedInvoiceType == null) {
|
||||
return 'نوع فاکتور الزامی است';
|
||||
}
|
||||
if (_invoiceDate == null) {
|
||||
return 'تاریخ فاکتور الزامی است';
|
||||
}
|
||||
if (_selectedCurrencyId == null) {
|
||||
return 'ارز فاکتور الزامی است';
|
||||
}
|
||||
if (_lineItems.isEmpty) {
|
||||
return 'حداقل یک ردیف کالا/خدمت وارد کنید';
|
||||
}
|
||||
// اعتبارسنجی ردیفها
|
||||
for (int i = 0; i < _lineItems.length; i++) {
|
||||
final r = _lineItems[i];
|
||||
if (r.productId == null) {
|
||||
return 'محصول ردیف ${i + 1} انتخاب نشده است';
|
||||
}
|
||||
if ((r.quantity) <= 0) {
|
||||
return 'تعداد ردیف ${i + 1} باید بزرگتر از صفر باشد';
|
||||
}
|
||||
if (r.unitPrice < 0) {
|
||||
return 'قیمت واحد ردیف ${i + 1} نمیتواند منفی باشد';
|
||||
}
|
||||
if (r.discountType == 'percent' && (r.discountValue < 0 || r.discountValue > 100)) {
|
||||
return 'درصد تخفیف ردیف ${i + 1} باید بین 0 تا 100 باشد';
|
||||
}
|
||||
if (r.taxRate < 0 || r.taxRate > 100) {
|
||||
return 'درصد مالیات ردیف ${i + 1} باید بین 0 تا 100 باشد';
|
||||
}
|
||||
}
|
||||
|
||||
final isSalesOrReturn = _selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn;
|
||||
// مشتری برای انواع خاص الزامی نیست
|
||||
final shouldHaveCustomer = !(_selectedInvoiceType == InvoiceType.waste || _selectedInvoiceType == InvoiceType.directConsumption || _selectedInvoiceType == InvoiceType.production);
|
||||
if (shouldHaveCustomer && _selectedCustomer == null) {
|
||||
return 'انتخاب مشتری الزامی است';
|
||||
}
|
||||
|
||||
// اعتبارسنجی کارمزد در حالت فروش
|
||||
if (isSalesOrReturn && _selectedSeller != null && _commissionType != null) {
|
||||
if (_commissionType == CommissionType.percentage) {
|
||||
final p = _commissionPercentage ?? 0;
|
||||
if (p < 0 || p > 100) return 'درصد کارمزد باید بین 0 تا 100 باشد';
|
||||
} else if (_commissionType == CommissionType.amount) {
|
||||
final a = _commissionAmount ?? 0;
|
||||
if (a < 0) return 'مبلغ کارمزد نمیتواند منفی باشد';
|
||||
}
|
||||
}
|
||||
|
||||
// ساخت payload
|
||||
final payload = <String, dynamic>{
|
||||
'type': _selectedInvoiceType!.value,
|
||||
'is_draft': _isDraft,
|
||||
if (_invoiceNumber != null && _invoiceNumber!.trim().isNotEmpty) 'number': _invoiceNumber!.trim(),
|
||||
'invoice_date': _invoiceDate!.toIso8601String(),
|
||||
if (_dueDate != null) 'due_date': _dueDate!.toIso8601String(),
|
||||
'currency_id': _selectedCurrencyId,
|
||||
if (_invoiceTitle != null && _invoiceTitle!.isNotEmpty) 'title': _invoiceTitle,
|
||||
if (_invoiceReference != null && _invoiceReference!.isNotEmpty) 'reference': _invoiceReference,
|
||||
if (_selectedCustomer != null) 'customer_id': _selectedCustomer!.id,
|
||||
if (_selectedSeller?.id != null) 'seller_id': _selectedSeller!.id,
|
||||
if (_commissionType != null) 'commission_type': _commissionType == CommissionType.percentage ? 'percentage' : 'amount',
|
||||
if (_commissionType == CommissionType.percentage && _commissionPercentage != null) 'commission_percentage': _commissionPercentage,
|
||||
if (_commissionType == CommissionType.amount && _commissionAmount != null) 'commission_amount': _commissionAmount,
|
||||
'settings': {
|
||||
'print_after_save': _printAfterSave,
|
||||
'printer': _selectedPrinter,
|
||||
'paper_size': _selectedPaperSize,
|
||||
'is_official_invoice': _isOfficialInvoice,
|
||||
'print_template': _selectedPrintTemplate,
|
||||
'send_to_tax_folder': _sendToTaxFolder,
|
||||
},
|
||||
'transactions': _transactions.map((t) => t.toJson()).toList(),
|
||||
'line_items': _lineItems.map((e) => _serializeLineItem(e)).toList(),
|
||||
'summary': {
|
||||
'subtotal': _sumSubtotal,
|
||||
'discount': _sumDiscount,
|
||||
'tax': _sumTax,
|
||||
'total': _sumTotal,
|
||||
},
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _serializeLineItem(InvoiceLineItem e) {
|
||||
return <String, dynamic>{
|
||||
'product_id': e.productId,
|
||||
'unit': e.selectedUnit ?? e.mainUnit,
|
||||
'quantity': e.quantity,
|
||||
'unit_price': e.unitPrice,
|
||||
'unit_price_source': e.unitPriceSource,
|
||||
'discount_type': e.discountType,
|
||||
'discount_value': e.discountValue,
|
||||
'tax_rate': e.taxRate,
|
||||
if ((e.description ?? '').isNotEmpty) 'description': e.description,
|
||||
};
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
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<NewInvoicePage> with SingleTickerProvid
|
|||
invoiceType: (_selectedInvoiceType?.value ?? 'sales'),
|
||||
onChanged: (rows) {
|
||||
setState(() {
|
||||
_lineItems = rows;
|
||||
_sumSubtotal = rows.fold<num>(0, (acc, e) => acc + e.subtotal);
|
||||
_sumDiscount = rows.fold<num>(0, (acc, e) => acc + e.discountAmount);
|
||||
_sumTax = rows.fold<num>(0, (acc, e) => acc + e.taxAmount);
|
||||
|
|
@ -778,6 +909,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
transactions: _transactions,
|
||||
businessId: widget.businessId,
|
||||
calendarController: widget.calendarController,
|
||||
invoiceType: _selectedInvoiceType ?? InvoiceType.sales,
|
||||
onChanged: (transactions) {
|
||||
setState(() {
|
||||
_transactions = transactions;
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
InvoiceType? _selectedInvoiceType;
|
||||
bool _isDraft = false;
|
||||
String? _invoiceNumber;
|
||||
bool _autoGenerateInvoiceNumber = true;
|
||||
final bool _autoGenerateInvoiceNumber = true;
|
||||
Customer? _selectedCustomer;
|
||||
Person? _selectedSeller;
|
||||
double? _commissionPercentage;
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ class _PriceListItemsPageState extends State<PriceListItemsPage> {
|
|||
onChanged: (v) => productId = int.tryParse(v),
|
||||
),
|
||||
DropdownButtonFormField<int>(
|
||||
value: currencyId,
|
||||
initialValue: currencyId,
|
||||
items: _fallbackCurrencies
|
||||
.map((c) => DropdownMenuItem<int>(
|
||||
value: c['id'] as int,
|
||||
|
|
@ -143,7 +143,7 @@ class _PriceListItemsPageState extends State<PriceListItemsPage> {
|
|||
onChanged: (v) => tierName = v,
|
||||
),
|
||||
DropdownButtonFormField<int>(
|
||||
value: unitId,
|
||||
initialValue: unitId,
|
||||
items: _fallbackUnits
|
||||
.map((u) => DropdownMenuItem<int>(
|
||||
value: u['id'] as int,
|
||||
|
|
|
|||
333
hesabixUI/hesabix_ui/lib/pages/business/reports_page.dart
Normal file
333
hesabixUI/hesabix_ui/lib/pages/business/reports_page.dart
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import '../../core/auth_store.dart';
|
||||
import '../../widgets/permission/access_denied_page.dart';
|
||||
|
||||
class ReportsPage extends StatelessWidget {
|
||||
final int businessId;
|
||||
final AuthStore authStore;
|
||||
|
||||
const ReportsPage({
|
||||
super.key,
|
||||
required this.businessId,
|
||||
required this.authStore,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
|
||||
if (!authStore.canReadSection('reports')) {
|
||||
return AccessDeniedPage(message: t.accessDenied);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSection(
|
||||
context,
|
||||
title: 'گزارشات اشخاص',
|
||||
icon: Icons.people_outline,
|
||||
children: [
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'لیست بدهکاران',
|
||||
subtitle: 'نمایش اشخاص با مانده بدهکار',
|
||||
icon: Icons.trending_down,
|
||||
onTap: () => _showComingSoonDialog(context, 'لیست بدهکاران'),
|
||||
),
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'لیست بستانکاران',
|
||||
subtitle: 'نمایش اشخاص با مانده بستانکار',
|
||||
icon: Icons.trending_up,
|
||||
onTap: () => _showComingSoonDialog(context, 'لیست بستانکاران'),
|
||||
),
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'گزارش تراکنشهای اشخاص',
|
||||
subtitle: 'ریز دریافتها و پرداختها به تفکیک شخص',
|
||||
icon: Icons.receipt_long,
|
||||
onTap: () => _showComingSoonDialog(context, 'گزارش تراکنشهای اشخاص'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSection(
|
||||
context,
|
||||
title: 'گزارشات کالا و خدمات',
|
||||
icon: Icons.inventory_2_outlined,
|
||||
children: [
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'گردش کالا',
|
||||
subtitle: 'ورود، خروج و مانده کالا به تفکیک بازه',
|
||||
icon: Icons.sync_alt,
|
||||
onTap: () => _showComingSoonDialog(context, 'گردش کالا'),
|
||||
),
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'کارتکس انبار',
|
||||
subtitle: 'ریز گردش هر کالا (FIFO/LIFO/میانگین)',
|
||||
icon: Icons.storage,
|
||||
onTap: () => _showComingSoonDialog(context, 'کارتکس انبار'),
|
||||
),
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'فروش به تفکیک کالا',
|
||||
subtitle: 'عملکرد فروش هر کالا در بازه زمانی',
|
||||
icon: Icons.shopping_cart_checkout,
|
||||
onTap: () => _showComingSoonDialog(context, 'فروش به تفکیک کالا'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSection(
|
||||
context,
|
||||
title: 'گزارشات بانک و صندوق',
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
children: [
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'گردش حسابهای بانکی',
|
||||
subtitle: 'برداشت و واریز به تفکیک حساب',
|
||||
icon: Icons.account_balance,
|
||||
onTap: () => _showComingSoonDialog(context, 'گردش حسابهای بانکی'),
|
||||
),
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'گردش صندوق و تنخواه',
|
||||
subtitle: 'ریز ورود و خروج وجه نقد',
|
||||
icon: Icons.savings,
|
||||
onTap: () => _showComingSoonDialog(context, 'گردش صندوق و تنخواه'),
|
||||
),
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'چکها',
|
||||
subtitle: 'دریافتی، پرداختی، سررسیدها و وضعیتها',
|
||||
icon: Icons.payments_outlined,
|
||||
onTap: () => _showComingSoonDialog(context, 'چکها'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSection(
|
||||
context,
|
||||
title: 'گزارشات فروش',
|
||||
icon: Icons.point_of_sale,
|
||||
children: [
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'فروش روزانه',
|
||||
subtitle: 'عملکرد فروش روزانه و روندها',
|
||||
icon: Icons.today,
|
||||
onTap: () => _showComingSoonDialog(context, 'فروش روزانه'),
|
||||
),
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'فروش ماهانه',
|
||||
subtitle: 'مقایسه ماهانه و رشد فروش',
|
||||
icon: Icons.calendar_month,
|
||||
onTap: () => _showComingSoonDialog(context, 'فروش ماهانه'),
|
||||
),
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'مشتریان برتر',
|
||||
subtitle: 'رتبهبندی بر اساس مبلغ یا تعداد',
|
||||
icon: Icons.emoji_events_outlined,
|
||||
onTap: () => _showComingSoonDialog(context, 'مشتریان برتر'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSection(
|
||||
context,
|
||||
title: 'گزارشات خرید',
|
||||
icon: Icons.shopping_bag_outlined,
|
||||
children: [
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'خرید روزانه',
|
||||
subtitle: 'عملکرد خرید روزانه و روندها',
|
||||
icon: Icons.today,
|
||||
onTap: () => _showComingSoonDialog(context, 'خرید روزانه'),
|
||||
),
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'تامینکنندگان برتر',
|
||||
subtitle: 'رتبهبندی تامینکنندگان بر اساس خرید',
|
||||
icon: Icons.handshake,
|
||||
onTap: () => _showComingSoonDialog(context, 'تامینکنندگان برتر'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSection(
|
||||
context,
|
||||
title: 'گزارشات تولید',
|
||||
icon: Icons.factory_outlined,
|
||||
children: [
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'گزارش مصرف مواد',
|
||||
subtitle: 'مصرف مواد اولیه به ازای محصول',
|
||||
icon: Icons.dataset_outlined,
|
||||
onTap: () => _showComingSoonDialog(context, 'گزارش مصرف مواد'),
|
||||
),
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'گزارش تولیدات',
|
||||
subtitle: 'میزان تولید و ضایعات',
|
||||
icon: Icons.precision_manufacturing_outlined,
|
||||
onTap: () => _showComingSoonDialog(context, 'گزارش تولیدات'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSection(
|
||||
context,
|
||||
title: 'حسابداری پایه',
|
||||
icon: Icons.calculate_outlined,
|
||||
children: [
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'تراز آزمایشی',
|
||||
subtitle: 'تراز دو/چهار/شش/هشت ستونی',
|
||||
icon: Icons.grid_on,
|
||||
onTap: () => _showComingSoonDialog(context, 'تراز آزمایشی'),
|
||||
),
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'دفتر کل',
|
||||
subtitle: 'گردش حسابها در بازه زمانی',
|
||||
icon: Icons.menu_book_outlined,
|
||||
onTap: () => _showComingSoonDialog(context, 'دفتر کل'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildSection(
|
||||
context,
|
||||
title: 'سود و زیان',
|
||||
icon: Icons.assessment_outlined,
|
||||
children: [
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'گزارش سود و زیان دوره',
|
||||
subtitle: 'درآمدها، هزینهها و سود/زیان خالص',
|
||||
icon: Icons.show_chart,
|
||||
onTap: () => _showComingSoonDialog(context, 'گزارش سود و زیان دوره'),
|
||||
),
|
||||
_buildReportItem(
|
||||
context,
|
||||
title: 'سود و زیان تجمیعی',
|
||||
subtitle: 'مقایسه دورهای و تجمیعی',
|
||||
icon: Icons.query_stats,
|
||||
onTap: () => _showComingSoonDialog(context, 'سود و زیان تجمیعی'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required List<Widget> children,
|
||||
}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: cs.primary, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportItem(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required IconData icon,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: cs.primary),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showComingSoonDialog(BuildContext context, String title) {
|
||||
final t = AppLocalizations.of(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text('این گزارش بهزودی در دسترس خواهد بود.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(t.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,122 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class Error404Page extends StatefulWidget {
|
||||
class Error404Page extends StatelessWidget {
|
||||
const Error404Page({super.key});
|
||||
|
||||
@override
|
||||
State<Error404Page> createState() => _Error404PageState();
|
||||
}
|
||||
|
||||
class _Error404PageState extends State<Error404Page>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _fadeController;
|
||||
late AnimationController _slideController;
|
||||
late AnimationController _bounceController;
|
||||
late AnimationController _pulseController;
|
||||
late AnimationController _rotateController;
|
||||
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
late Animation<double> _bounceAnimation;
|
||||
late Animation<double> _pulseAnimation;
|
||||
late Animation<double> _rotateAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// کنترلرهای انیمیشن
|
||||
_fadeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_slideController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1800),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_bounceController = AnimationController(
|
||||
duration: const Duration(milliseconds: 2500),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_pulseController = AnimationController(
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_rotateController = AnimationController(
|
||||
duration: const Duration(milliseconds: 3000),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// انیمیشنها
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _fadeController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.5),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _slideController,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
_bounceAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _bounceController,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
_pulseAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 1.05,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _pulseController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
_rotateAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _rotateController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
// شروع انیمیشنها
|
||||
_startAnimations();
|
||||
}
|
||||
|
||||
void _startAnimations() async {
|
||||
await _fadeController.forward();
|
||||
await _slideController.forward();
|
||||
await _bounceController.forward();
|
||||
|
||||
// انیمیشنهای مداوم
|
||||
_pulseController.repeat(reverse: true);
|
||||
_rotateController.repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fadeController.dispose();
|
||||
_slideController.dispose();
|
||||
_bounceController.dispose();
|
||||
_pulseController.dispose();
|
||||
_rotateController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -149,177 +36,118 @@ class _Error404PageState extends State<Error404Page>
|
|||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
final BusinessData _businessData = BusinessData();
|
||||
int _currentStep = 0;
|
||||
bool _isLoading = false;
|
||||
int _fiscalTabIndex = 0;
|
||||
final int _fiscalTabIndex = 0;
|
||||
late TextEditingController _fiscalTitleController;
|
||||
List<Map<String, dynamic>> _currencies = [];
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
}
|
||||
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<NewBusinessPage> {
|
|||
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<NewBusinessPage> {
|
|||
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<NewBusinessPage> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<int>(
|
||||
value: _businessData.defaultCurrencyId,
|
||||
initialValue: _businessData.defaultCurrencyId,
|
||||
items: _currencies.map((c) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: c['id'] as int,
|
||||
|
|
|
|||
62
hesabixUI/hesabix_ui/lib/services/invoice_service.dart
Normal file
62
hesabixUI/hesabix_ui/lib/services/invoice_service.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import '../core/api_client.dart';
|
||||
|
||||
class InvoiceService {
|
||||
final ApiClient _api;
|
||||
|
||||
InvoiceService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient();
|
||||
|
||||
Future<Map<String, dynamic>> createInvoice({
|
||||
required int businessId,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
final res = await _api.post<Map<String, dynamic>>(
|
||||
'/api/v1/invoices/business/$businessId',
|
||||
data: payload,
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> updateInvoice({
|
||||
required int businessId,
|
||||
required int invoiceId,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
final res = await _api.put<Map<String, dynamic>>(
|
||||
'/api/v1/invoices/business/$businessId/$invoiceId',
|
||||
data: payload,
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getInvoice({
|
||||
required int businessId,
|
||||
required int invoiceId,
|
||||
}) async {
|
||||
final res = await _api.get<Map<String, dynamic>>(
|
||||
'/api/v1/invoices/business/$businessId/$invoiceId',
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> searchInvoices({
|
||||
required int businessId,
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
String? search,
|
||||
Map<String, dynamic>? filters,
|
||||
}) async {
|
||||
final body = {
|
||||
'take': limit,
|
||||
'skip': (page - 1) * limit,
|
||||
if (search != null && search.isNotEmpty) 'search': search,
|
||||
if (filters != null) 'filters': filters,
|
||||
};
|
||||
final res = await _api.post<Map<String, dynamic>>(
|
||||
'/api/v1/invoices/business/$businessId/search',
|
||||
data: body,
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -26,6 +26,10 @@ class PersonService {
|
|||
|
||||
if (search != null && search.isNotEmpty) {
|
||||
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<Map<String, dynamic>> filtersList = <Map<String, dynamic>>[];
|
||||
filters.forEach((key, value) {
|
||||
// یکسانسازی: اگر person_type ارسال شود، به person_types (لیستی) تبدیل میکنیم
|
||||
if (key == 'person_type') {
|
||||
final List<dynamic> values = value is List ? List<dynamic>.from(value) : <dynamic>[value];
|
||||
filtersList.add({
|
||||
'property': 'person_types',
|
||||
'operator': 'in',
|
||||
'value': values,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key == 'person_types') {
|
||||
final List<dynamic> values = value is List ? List<dynamic>.from(value) : <dynamic>[value];
|
||||
filtersList.add({
|
||||
'property': 'person_types',
|
||||
'operator': 'in',
|
||||
'value': values,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// سایر فیلترها: اگر مقدار لیست باشد از in، در غیر این صورت از = استفاده میکنیم
|
||||
final bool isList = value is List;
|
||||
filtersList.add({
|
||||
'property': key,
|
||||
'operator': isList ? 'in' : '=',
|
||||
'value': value,
|
||||
});
|
||||
});
|
||||
queryParams['filters'] = filtersList;
|
||||
}
|
||||
|
||||
// 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('خطا در دریافت لیست اشخاص');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class PriceListService {
|
|||
final qp = <String, String>{};
|
||||
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<Map<String, dynamic>>('/api/v1/price-lists/business/$businessId/$priceListId/items$query');
|
||||
final data = res.data?['data'];
|
||||
final items = (data is Map<String, dynamic>) ? data['items'] : null;
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ class ProductFormValidator {
|
|||
errors['basePurchasePrice'] = 'قیمت خرید نمیتواند منفی باشد';
|
||||
}
|
||||
|
||||
if (formData.unitConversionFactor != null && formData.unitConversionFactor! <= 0) {
|
||||
if (formData.unitConversionFactor <= 0) {
|
||||
errors['unitConversionFactor'] = 'ضریب تبدیل باید بزرگتر از صفر باشد';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,10 @@ class CategoryPickerField extends FormField<int?> {
|
|||
required this.businessId,
|
||||
required List<Map<String, dynamic>> categoriesTree,
|
||||
required ValueChanged<int?> 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': <Map<String, dynamic>>[],
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1397,7 +1397,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
thumbVisibility: true,
|
||||
child: DataTableTheme(
|
||||
data: DataTableThemeData(
|
||||
headingRowColor: MaterialStatePropertyAll(
|
||||
headingRowColor: WidgetStatePropertyAll(
|
||||
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
|
||||
),
|
||||
headingTextStyle: theme.textTheme.titleSmall?.copyWith(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,290 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/bank_account_service.dart';
|
||||
|
||||
class BankAccountOption {
|
||||
final String id;
|
||||
final String name;
|
||||
const BankAccountOption(this.id, this.name);
|
||||
}
|
||||
|
||||
class BankAccountComboboxWidget extends StatefulWidget {
|
||||
final int businessId;
|
||||
final String? selectedAccountId;
|
||||
final ValueChanged<BankAccountOption?> onChanged;
|
||||
final String label;
|
||||
final String hintText;
|
||||
final bool isRequired;
|
||||
|
||||
const BankAccountComboboxWidget({
|
||||
super.key,
|
||||
required this.businessId,
|
||||
required this.onChanged,
|
||||
this.selectedAccountId,
|
||||
this.label = 'بانک',
|
||||
this.hintText = 'جستوجو و انتخاب بانک',
|
||||
this.isRequired = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BankAccountComboboxWidget> createState() => _BankAccountComboboxWidgetState();
|
||||
}
|
||||
|
||||
class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
|
||||
final BankAccountService _service = BankAccountService();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _debounceTimer;
|
||||
int _seq = 0;
|
||||
String _latestQuery = '';
|
||||
void Function(void Function())? _setModalState;
|
||||
|
||||
List<BankAccountOption> _items = <BankAccountOption>[];
|
||||
bool _isLoading = false;
|
||||
bool _isSearching = false;
|
||||
bool _hasSearched = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
await _performSearch('');
|
||||
}
|
||||
|
||||
void _onSearchChanged(String q) {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim()));
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
final int seq = ++_seq;
|
||||
_latestQuery = query;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (query.isEmpty) {
|
||||
_isLoading = true;
|
||||
_hasSearched = false;
|
||||
} else {
|
||||
_isSearching = true;
|
||||
_hasSearched = true;
|
||||
}
|
||||
});
|
||||
_setModalState?.call(() {});
|
||||
|
||||
try {
|
||||
final res = await _service.list(
|
||||
businessId: widget.businessId,
|
||||
queryInfo: {
|
||||
'take': query.isEmpty ? 50 : 20,
|
||||
'skip': 0,
|
||||
if (query.isNotEmpty) 'search': query,
|
||||
if (query.isNotEmpty)
|
||||
'search_fields': ['code', 'name', 'branch', 'account_number', 'sheba_number', 'card_number', 'owner_name', 'pos_number', 'payment_id'],
|
||||
},
|
||||
);
|
||||
if (seq != _seq || query != _latestQuery) return;
|
||||
// پشتیبانی از هر دو ساختار: data.items و items سطح بالا
|
||||
final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null)
|
||||
? (res['data'] as Map)['items']
|
||||
: res['items'];
|
||||
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
|
||||
final m = Map<String, dynamic>.from(e as Map);
|
||||
return BankAccountOption('${m['id']}', (m['name']?.toString() ?? 'نامشخص'));
|
||||
}).toList();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
if (query.isEmpty) {
|
||||
_isLoading = false;
|
||||
_hasSearched = false;
|
||||
} else {
|
||||
_isSearching = false;
|
||||
}
|
||||
});
|
||||
_setModalState?.call(() {});
|
||||
} catch (e) {
|
||||
if (seq != _seq || query != _latestQuery) return;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = <BankAccountOption>[];
|
||||
if (query.isEmpty) {
|
||||
_isLoading = false;
|
||||
_hasSearched = false;
|
||||
} else {
|
||||
_isSearching = false;
|
||||
}
|
||||
});
|
||||
_setModalState?.call(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در دریافت لیست بانکها: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
void _openPicker() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
_setModalState = setModalState;
|
||||
return _BankPickerBottomSheet(
|
||||
label: widget.label,
|
||||
hintText: widget.hintText,
|
||||
items: _items,
|
||||
searchController: _searchController,
|
||||
isLoading: _isLoading,
|
||||
isSearching: _isSearching,
|
||||
hasSearched: _hasSearched,
|
||||
onSearchChanged: _onSearchChanged,
|
||||
onSelected: (opt) {
|
||||
widget.onChanged(opt);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final selected = _items.firstWhere(
|
||||
(e) => e.id == widget.selectedAccountId,
|
||||
orElse: () => const BankAccountOption('', ''),
|
||||
);
|
||||
final text = (widget.selectedAccountId != null && widget.selectedAccountId!.isNotEmpty)
|
||||
? (selected.name.isNotEmpty ? selected.name : widget.hintText)
|
||||
: widget.hintText;
|
||||
|
||||
return InkWell(
|
||||
onTap: _openPicker,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: theme.colorScheme.surface,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.account_balance, color: theme.colorScheme.primary, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
Icon(Icons.arrow_drop_down, color: theme.colorScheme.onSurface.withValues(alpha: 0.6)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BankPickerBottomSheet extends StatelessWidget {
|
||||
final String label;
|
||||
final String hintText;
|
||||
final List<BankAccountOption> items;
|
||||
final TextEditingController searchController;
|
||||
final bool isLoading;
|
||||
final bool isSearching;
|
||||
final bool hasSearched;
|
||||
final ValueChanged<String> onSearchChanged;
|
||||
final ValueChanged<BankAccountOption?> onSelected;
|
||||
|
||||
const _BankPickerBottomSheet({
|
||||
required this.label,
|
||||
required this.hintText,
|
||||
required this.items,
|
||||
required this.searchController,
|
||||
required this.isLoading,
|
||||
required this.isSearching,
|
||||
required this.hasSearched,
|
||||
required this.onSearchChanged,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
suffixIcon: isSearching
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: onSearchChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: items.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.account_balance, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.5)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
hasSearched ? 'بانکی با این مشخصات یافت نشد' : 'بانکی ثبت نشده است',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final it = items[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Icon(Icons.account_balance, color: colorScheme.onPrimaryContainer),
|
||||
),
|
||||
title: Text(it.name),
|
||||
onTap: () => onSelected(it),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/cash_register_service.dart';
|
||||
|
||||
class CashRegisterOption {
|
||||
final String id;
|
||||
final String name;
|
||||
const CashRegisterOption(this.id, this.name);
|
||||
}
|
||||
|
||||
class CashRegisterComboboxWidget extends StatefulWidget {
|
||||
final int businessId;
|
||||
final String? selectedRegisterId;
|
||||
final ValueChanged<CashRegisterOption?> onChanged;
|
||||
final String label;
|
||||
final String hintText;
|
||||
final bool isRequired;
|
||||
|
||||
const CashRegisterComboboxWidget({
|
||||
super.key,
|
||||
required this.businessId,
|
||||
required this.onChanged,
|
||||
this.selectedRegisterId,
|
||||
this.label = 'صندوق',
|
||||
this.hintText = 'جستوجو و انتخاب صندوق',
|
||||
this.isRequired = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CashRegisterComboboxWidget> createState() => _CashRegisterComboboxWidgetState();
|
||||
}
|
||||
|
||||
class _CashRegisterComboboxWidgetState extends State<CashRegisterComboboxWidget> {
|
||||
final CashRegisterService _service = CashRegisterService();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _debounceTimer;
|
||||
int _seq = 0;
|
||||
String _latestQuery = '';
|
||||
void Function(void Function())? _setModalState;
|
||||
|
||||
List<CashRegisterOption> _items = <CashRegisterOption>[];
|
||||
bool _isLoading = false;
|
||||
bool _isSearching = false;
|
||||
bool _hasSearched = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
await _performSearch('');
|
||||
}
|
||||
|
||||
void _onSearchChanged(String q) {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim()));
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
final int seq = ++_seq;
|
||||
_latestQuery = query;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (query.isEmpty) {
|
||||
_isLoading = true;
|
||||
_hasSearched = false;
|
||||
} else {
|
||||
_isSearching = true;
|
||||
_hasSearched = true;
|
||||
}
|
||||
});
|
||||
_setModalState?.call(() {});
|
||||
|
||||
try {
|
||||
final res = await _service.list(
|
||||
businessId: widget.businessId,
|
||||
queryInfo: {
|
||||
'take': query.isEmpty ? 50 : 20,
|
||||
'skip': 0,
|
||||
if (query.isNotEmpty) 'search': query,
|
||||
if (query.isNotEmpty) 'search_fields': ['name', 'code', 'description', 'payment_switch_number', 'payment_terminal_number', 'merchant_id'],
|
||||
},
|
||||
);
|
||||
if (seq != _seq || query != _latestQuery) return;
|
||||
final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null)
|
||||
? (res['data'] as Map)['items']
|
||||
: res['items'];
|
||||
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
|
||||
final m = Map<String, dynamic>.from(e as Map);
|
||||
return CashRegisterOption('${m['id']}', (m['name']?.toString() ?? 'نامشخص'));
|
||||
}).toList();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
if (query.isEmpty) {
|
||||
_isLoading = false;
|
||||
_hasSearched = false;
|
||||
} else {
|
||||
_isSearching = false;
|
||||
}
|
||||
});
|
||||
_setModalState?.call(() {});
|
||||
} catch (e) {
|
||||
if (seq != _seq || query != _latestQuery) return;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = <CashRegisterOption>[];
|
||||
if (query.isEmpty) {
|
||||
_isLoading = false;
|
||||
_hasSearched = false;
|
||||
} else {
|
||||
_isSearching = false;
|
||||
}
|
||||
});
|
||||
_setModalState?.call(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در دریافت لیست صندوقها: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
void _openPicker() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
_setModalState = setModalState;
|
||||
return _CashRegisterPickerBottomSheet(
|
||||
label: widget.label,
|
||||
hintText: widget.hintText,
|
||||
items: _items,
|
||||
searchController: _searchController,
|
||||
isLoading: _isLoading,
|
||||
isSearching: _isSearching,
|
||||
hasSearched: _hasSearched,
|
||||
onSearchChanged: _onSearchChanged,
|
||||
onSelected: (opt) {
|
||||
widget.onChanged(opt);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final selected = _items.firstWhere(
|
||||
(e) => e.id == widget.selectedRegisterId,
|
||||
orElse: () => const CashRegisterOption('', ''),
|
||||
);
|
||||
final text = (widget.selectedRegisterId != null && widget.selectedRegisterId!.isNotEmpty)
|
||||
? (selected.name.isNotEmpty ? selected.name : widget.hintText)
|
||||
: widget.hintText;
|
||||
|
||||
return InkWell(
|
||||
onTap: _openPicker,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: theme.colorScheme.surface,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.point_of_sale, color: theme.colorScheme.primary, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
Icon(Icons.arrow_drop_down, color: theme.colorScheme.onSurface.withValues(alpha: 0.6)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CashRegisterPickerBottomSheet extends StatelessWidget {
|
||||
final String label;
|
||||
final String hintText;
|
||||
final List<CashRegisterOption> items;
|
||||
final TextEditingController searchController;
|
||||
final bool isLoading;
|
||||
final bool isSearching;
|
||||
final bool hasSearched;
|
||||
final ValueChanged<String> onSearchChanged;
|
||||
final ValueChanged<CashRegisterOption?> onSelected;
|
||||
|
||||
const _CashRegisterPickerBottomSheet({
|
||||
required this.label,
|
||||
required this.hintText,
|
||||
required this.items,
|
||||
required this.searchController,
|
||||
required this.isLoading,
|
||||
required this.isSearching,
|
||||
required this.hasSearched,
|
||||
required this.onSearchChanged,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
suffixIcon: isSearching
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: onSearchChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: items.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.point_of_sale, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.5)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
hasSearched ? 'صندوقی با این مشخصات یافت نشد' : 'صندوقی ثبت نشده است',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final it = items[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Icon(Icons.point_of_sale, color: colorScheme.onPrimaryContainer),
|
||||
),
|
||||
title: Text(it.name),
|
||||
onTap: () => onSelected(it),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
|
||||
class CodeFieldWidget extends StatefulWidget {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class _CommissionTypeSelectorState extends State<CommissionTypeSelector> {
|
|||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return DropdownButtonFormField<CommissionType>(
|
||||
value: _selectedType,
|
||||
initialValue: _selectedType,
|
||||
onChanged: (CommissionType? newValue) {
|
||||
if (newValue != null) {
|
||||
_selectType(newValue);
|
||||
|
|
|
|||
|
|
@ -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<InvoiceTransaction> transactions;
|
||||
final ValueChanged<List<InvoiceTransaction>> 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<InvoiceTransactionsWidget> {
|
|||
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<InvoiceTransaction> 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<TransactionDialog> {
|
|||
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<TransactionDialog> {
|
|||
String? _selectedCheckId;
|
||||
String? _selectedPersonId;
|
||||
String? _selectedAccountId;
|
||||
|
||||
// لیستهای داده
|
||||
List<Map<String, dynamic>> _banks = [];
|
||||
List<Map<String, dynamic>> _cashRegisters = [];
|
||||
List<Map<String, dynamic>> _pettyCashList = [];
|
||||
List<Map<String, dynamic>> _persons = [];
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -375,6 +404,53 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
_selectedCheckId = widget.transaction?.checkId;
|
||||
_selectedPersonId = widget.transaction?.personId;
|
||||
_selectedAccountId = widget.transaction?.accountId;
|
||||
|
||||
// لود کردن دادهها از دیتابیس
|
||||
_loadData();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// لود کردن بانکها
|
||||
final bankResponse = await _bankService.list(
|
||||
businessId: widget.businessId,
|
||||
queryInfo: {'take': 100, 'skip': 0},
|
||||
);
|
||||
_banks = (bankResponse['items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
|
||||
// لود کردن صندوقها
|
||||
final cashRegisterResponse = await _cashRegisterService.list(
|
||||
businessId: widget.businessId,
|
||||
queryInfo: {'take': 100, 'skip': 0},
|
||||
);
|
||||
_cashRegisters = (cashRegisterResponse['items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
|
||||
// لود کردن تنخواهگردانها
|
||||
final pettyCashResponse = await _pettyCashService.list(
|
||||
businessId: widget.businessId,
|
||||
queryInfo: {'take': 100, 'skip': 0},
|
||||
);
|
||||
_pettyCashList = (pettyCashResponse['items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
|
||||
// لود کردن اشخاص
|
||||
final personResponse = await _personService.getPersons(
|
||||
businessId: widget.businessId,
|
||||
limit: 100,
|
||||
);
|
||||
_persons = (personResponse['items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
|
||||
} catch (e) {
|
||||
// در صورت خطا، لیستها خالی باقی میمانند
|
||||
print('خطا در لود کردن دادهها: $e');
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -447,7 +523,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
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<TransactionDialog> {
|
|||
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<TransactionDialog> {
|
|||
}
|
||||
}
|
||||
|
||||
List<TransactionType> _availableTransactionTypes() {
|
||||
// خرج چک فقط برای خرید یا برگشت از فروش نمایش داده شود
|
||||
final showCheckExpense = widget.invoiceType == InvoiceType.purchase || widget.invoiceType == InvoiceType.salesReturn;
|
||||
final all = TransactionType.allTypes;
|
||||
if (showCheckExpense) return all;
|
||||
return all.where((t) => t != TransactionType.checkExpense).toList();
|
||||
}
|
||||
|
||||
Widget _buildBankFields() {
|
||||
return DropdownButtonFormField<String>(
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<TransactionDialog> {
|
|||
}
|
||||
|
||||
Widget _buildPersonFields() {
|
||||
return DropdownButtonFormField<String>(
|
||||
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<TransactionDialog> {
|
|||
}
|
||||
|
||||
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: () => <String, dynamic>{},
|
||||
);
|
||||
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: () => <String, dynamic>{},
|
||||
);
|
||||
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: () => <String, dynamic>{},
|
||||
);
|
||||
return pettyCash['name']?.toString();
|
||||
}
|
||||
|
||||
String? _getCheckNumber(String? id) {
|
||||
|
|
@ -813,11 +898,12 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
}
|
||||
|
||||
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: () => <String, dynamic>{},
|
||||
);
|
||||
return person['name']?.toString();
|
||||
}
|
||||
|
||||
String? _getAccountName(String? id) {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ class _InvoiceTypeComboboxState extends State<InvoiceTypeCombobox> {
|
|||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return DropdownButtonFormField<InvoiceType>(
|
||||
value: _selectedType,
|
||||
initialValue: _selectedType,
|
||||
onChanged: (InvoiceType? newValue) {
|
||||
if (newValue != null) {
|
||||
_selectType(newValue);
|
||||
|
|
|
|||
|
|
@ -241,9 +241,7 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
|||
),
|
||||
)
|
||||
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<InvoiceLineItemsTable> {
|
|||
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<InvoiceLineItemsTable> {
|
|||
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<InvoiceLineItemsTable> {
|
|||
}
|
||||
|
||||
|
||||
Widget _buildFooter(BuildContext context) {
|
||||
final sumSubtotal = _rows.fold<num>(0, (acc, e) => acc + e.subtotal);
|
||||
final sumDiscount = _rows.fold<num>(0, (acc, e) => acc + e.discountAmount);
|
||||
final sumTax = _rows.fold<num>(0, (acc, e) => acc + e.taxAmount);
|
||||
final sumTotal = _rows.fold<num>(0, (acc, e) => acc + e.total);
|
||||
final style = Theme.of(context).textTheme.bodyLarge;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
SizedBox(width: 140, child: Text('جمع مبلغ: ${formatWithThousands(sumSubtotal, decimalPlaces: 0)}', style: style)),
|
||||
SizedBox(width: 120, child: Text('جمع تخفیف: ${formatWithThousands(sumDiscount, decimalPlaces: 0)}', style: style)),
|
||||
SizedBox(width: 120, child: Text('جمع مالیات: ${formatWithThousands(sumTax, decimalPlaces: 0)}', style: style)),
|
||||
SizedBox(width: 140, child: Align(alignment: Alignment.centerRight, child: Text('جمع کل: ${formatWithThousands(sumTotal, decimalPlaces: 0)}', style: style))),
|
||||
const SizedBox(width: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
// فوتر جمعها حذف شد؛ جمعها در صفحهٔ والد نمایش داده میشوند
|
||||
|
||||
void _showUnitSelectorDialog(InvoiceLineItem item, ValueChanged<String?> onChanged) {
|
||||
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<String>(
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,446 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/person_service.dart';
|
||||
import '../../models/person_model.dart';
|
||||
|
||||
class PersonComboboxWidget extends StatefulWidget {
|
||||
final int businessId;
|
||||
final Person? selectedPerson;
|
||||
final ValueChanged<Person?> onChanged;
|
||||
final String label;
|
||||
final String hintText;
|
||||
final bool isRequired;
|
||||
final List<String>? personTypes; // فیلتر بر اساس نوع شخص (مثل ['فروشنده', 'بازاریاب'])
|
||||
final String? searchHint;
|
||||
|
||||
const PersonComboboxWidget({
|
||||
super.key,
|
||||
required this.businessId,
|
||||
required this.onChanged,
|
||||
this.selectedPerson,
|
||||
this.label = 'شخص',
|
||||
this.hintText = 'جستوجو و انتخاب شخص',
|
||||
this.isRequired = false,
|
||||
this.personTypes,
|
||||
this.searchHint,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PersonComboboxWidget> createState() => _PersonComboboxWidgetState();
|
||||
}
|
||||
|
||||
class _PersonComboboxWidgetState extends State<PersonComboboxWidget> {
|
||||
final PersonService _personService = PersonService();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _debounceTimer;
|
||||
int _searchSeq = 0; // برای جلوگیری از نمایش نتایج قدیمی
|
||||
String _latestQuery = '';
|
||||
void Function(void Function())? _setModalState;
|
||||
|
||||
List<Person> _persons = [];
|
||||
bool _isLoading = false;
|
||||
bool _isSearching = false;
|
||||
bool _hasSearched = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.text = widget.selectedPerson?.displayName ?? '';
|
||||
_loadRecentPersons();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadRecentPersons() async {
|
||||
// استفاده از مسیر واحد جستوجو با کوئری خالی
|
||||
await _performSearch('');
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
|
||||
_performSearch(query.trim());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
final int seq = ++_searchSeq;
|
||||
_latestQuery = query;
|
||||
|
||||
// حالت لودینگ بسته به خالی بودن کوئری
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (query.isEmpty) {
|
||||
_isLoading = true;
|
||||
_hasSearched = false;
|
||||
} else {
|
||||
_isSearching = true;
|
||||
_hasSearched = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Debug: نمایش پارامترهای جستوجو
|
||||
print('PersonComboboxWidget - _performSearch:');
|
||||
print('seq: $seq');
|
||||
print('query: $query');
|
||||
print('businessId: ${widget.businessId}');
|
||||
print('personTypes: ${widget.personTypes}');
|
||||
|
||||
final response = await _personService.getPersons(
|
||||
businessId: widget.businessId,
|
||||
search: query.isEmpty ? null : query,
|
||||
limit: query.isEmpty ? 10 : 20,
|
||||
filters: widget.personTypes != null && widget.personTypes!.isNotEmpty
|
||||
? {'person_types': widget.personTypes}
|
||||
: null,
|
||||
);
|
||||
|
||||
// پاسخ کهنه را نادیده بگیر
|
||||
if (seq != _searchSeq || query != _latestQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
final persons = _personService.parsePersonsList(response);
|
||||
print('Search returned ${persons.length} persons');
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_persons = persons;
|
||||
if (query.isEmpty) {
|
||||
_isLoading = false;
|
||||
_hasSearched = false;
|
||||
} else {
|
||||
_isSearching = false;
|
||||
}
|
||||
});
|
||||
_setModalState?.call(() {});
|
||||
}
|
||||
} catch (e) {
|
||||
// پاسخ کهنه را نادیده بگیر
|
||||
if (seq != _searchSeq || query != _latestQuery) {
|
||||
return;
|
||||
}
|
||||
print('Error in _performSearch: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_persons = [];
|
||||
if (query.isEmpty) {
|
||||
_isLoading = false;
|
||||
_hasSearched = false;
|
||||
} else {
|
||||
_isSearching = false;
|
||||
}
|
||||
});
|
||||
_showErrorSnackBar('خطا در جستوجو: $e');
|
||||
_setModalState?.call(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorSnackBar(String message) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _selectPerson(Person? person) {
|
||||
if (person == null) {
|
||||
_searchController.clear();
|
||||
widget.onChanged(null);
|
||||
return;
|
||||
}
|
||||
|
||||
_searchController.text = person.displayName;
|
||||
widget.onChanged(person);
|
||||
}
|
||||
|
||||
void _showPersonPicker() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
_setModalState = setModalState;
|
||||
return _PersonPickerBottomSheet(
|
||||
persons: _persons,
|
||||
selectedPerson: widget.selectedPerson,
|
||||
onPersonSelected: _selectPerson,
|
||||
searchController: _searchController,
|
||||
onSearchChanged: (query) {
|
||||
_onSearchChanged(query);
|
||||
setModalState(() {});
|
||||
},
|
||||
isLoading: _isLoading,
|
||||
isSearching: _isSearching,
|
||||
hasSearched: _hasSearched,
|
||||
label: widget.label,
|
||||
searchHint: widget.searchHint ?? 'جستوجو در اشخاص...',
|
||||
personTypes: widget.personTypes,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final displayText = widget.selectedPerson?.displayName ?? widget.hintText;
|
||||
final isSelected = widget.selectedPerson != null;
|
||||
|
||||
return InkWell(
|
||||
onTap: _showPersonPicker,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: colorScheme.surface,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_search,
|
||||
color: colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayText,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onSurface
|
||||
: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
if (widget.selectedPerson != null)
|
||||
GestureDetector(
|
||||
onTap: () => _selectPerson(null),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
Icons.clear,
|
||||
color: colorScheme.error,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PersonPickerBottomSheet extends StatefulWidget {
|
||||
final List<Person> persons;
|
||||
final Person? selectedPerson;
|
||||
final Function(Person?) onPersonSelected;
|
||||
final TextEditingController searchController;
|
||||
final Function(String) onSearchChanged;
|
||||
final bool isLoading;
|
||||
final bool isSearching;
|
||||
final bool hasSearched;
|
||||
final String label;
|
||||
final String searchHint;
|
||||
final List<String>? personTypes;
|
||||
|
||||
const _PersonPickerBottomSheet({
|
||||
required this.persons,
|
||||
required this.selectedPerson,
|
||||
required this.onPersonSelected,
|
||||
required this.searchController,
|
||||
required this.onSearchChanged,
|
||||
required this.isLoading,
|
||||
required this.isSearching,
|
||||
required this.hasSearched,
|
||||
required this.label,
|
||||
required this.searchHint,
|
||||
this.personTypes,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PersonPickerBottomSheet> createState() => _PersonPickerBottomSheetState();
|
||||
}
|
||||
|
||||
class _PersonPickerBottomSheetState extends State<_PersonPickerBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// هدر
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.label,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// فیلد جستوجو
|
||||
TextField(
|
||||
controller: widget.searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.searchHint,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
suffixIcon: widget.isSearching
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: widget.onSearchChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// لیست اشخاص
|
||||
Expanded(
|
||||
child: _buildPersonsList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPersonsList() {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
if (widget.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (widget.persons.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_off,
|
||||
size: 48,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.hasSearched
|
||||
? 'شخصی با این مشخصات یافت نشد'
|
||||
: 'هیچ شخصی ثبت نشده است',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
if (widget.personTypes != null && widget.personTypes!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'فیلتر: ${widget.personTypes!.join(', ')}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: widget.persons.length,
|
||||
itemBuilder: (context, index) {
|
||||
final person = widget.persons[index];
|
||||
final isSelected = widget.selectedPerson?.id == person.id;
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
title: Text(person.displayName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (person.personTypes.isNotEmpty)
|
||||
Text(
|
||||
person.personTypes.first.persianName,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
if (person.phone != null)
|
||||
Text(
|
||||
'تلفن: ${person.phone}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
if (person.email != null)
|
||||
Text(
|
||||
'ایمیل: ${person.email}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: isSelected
|
||||
? Icon(
|
||||
Icons.check_circle,
|
||||
color: colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
widget.onPersonSelected(person);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/petty_cash_service.dart';
|
||||
|
||||
class PettyCashOption {
|
||||
final String id;
|
||||
final String name;
|
||||
const PettyCashOption(this.id, this.name);
|
||||
}
|
||||
|
||||
class PettyCashComboboxWidget extends StatefulWidget {
|
||||
final int businessId;
|
||||
final String? selectedPettyCashId;
|
||||
final ValueChanged<PettyCashOption?> onChanged;
|
||||
final String label;
|
||||
final String hintText;
|
||||
final bool isRequired;
|
||||
|
||||
const PettyCashComboboxWidget({
|
||||
super.key,
|
||||
required this.businessId,
|
||||
required this.onChanged,
|
||||
this.selectedPettyCashId,
|
||||
this.label = 'تنخواهگردان',
|
||||
this.hintText = 'جستوجو و انتخاب تنخواهگردان',
|
||||
this.isRequired = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PettyCashComboboxWidget> createState() => _PettyCashComboboxWidgetState();
|
||||
}
|
||||
|
||||
class _PettyCashComboboxWidgetState extends State<PettyCashComboboxWidget> {
|
||||
final PettyCashService _service = PettyCashService();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _debounceTimer;
|
||||
int _seq = 0;
|
||||
String _latestQuery = '';
|
||||
void Function(void Function())? _setModalState;
|
||||
|
||||
List<PettyCashOption> _items = <PettyCashOption>[];
|
||||
bool _isLoading = false;
|
||||
bool _isSearching = false;
|
||||
bool _hasSearched = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
await _performSearch('');
|
||||
}
|
||||
|
||||
void _onSearchChanged(String q) {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim()));
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
final int seq = ++_seq;
|
||||
_latestQuery = query;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (query.isEmpty) {
|
||||
_isLoading = true;
|
||||
_hasSearched = false;
|
||||
} else {
|
||||
_isSearching = true;
|
||||
_hasSearched = true;
|
||||
}
|
||||
});
|
||||
_setModalState?.call(() {});
|
||||
|
||||
try {
|
||||
final res = await _service.list(
|
||||
businessId: widget.businessId,
|
||||
queryInfo: {
|
||||
'take': query.isEmpty ? 50 : 20,
|
||||
'skip': 0,
|
||||
if (query.isNotEmpty) 'search': query,
|
||||
if (query.isNotEmpty) 'search_fields': ['name', 'code', 'description'],
|
||||
},
|
||||
);
|
||||
if (seq != _seq || query != _latestQuery) return;
|
||||
final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null)
|
||||
? (res['data'] as Map)['items']
|
||||
: res['items'];
|
||||
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
|
||||
final m = Map<String, dynamic>.from(e as Map);
|
||||
return PettyCashOption('${m['id']}', (m['name']?.toString() ?? 'نامشخص'));
|
||||
}).toList();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
if (query.isEmpty) {
|
||||
_isLoading = false;
|
||||
_hasSearched = false;
|
||||
} else {
|
||||
_isSearching = false;
|
||||
}
|
||||
});
|
||||
_setModalState?.call(() {});
|
||||
} catch (e) {
|
||||
if (seq != _seq || query != _latestQuery) return;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = <PettyCashOption>[];
|
||||
if (query.isEmpty) {
|
||||
_isLoading = false;
|
||||
_hasSearched = false;
|
||||
} else {
|
||||
_isSearching = false;
|
||||
}
|
||||
});
|
||||
_setModalState?.call(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در دریافت لیست تنخواهگردانها: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
void _openPicker() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
_setModalState = setModalState;
|
||||
return _PettyCashPickerBottomSheet(
|
||||
label: widget.label,
|
||||
hintText: widget.hintText,
|
||||
items: _items,
|
||||
searchController: _searchController,
|
||||
isLoading: _isLoading,
|
||||
isSearching: _isSearching,
|
||||
hasSearched: _hasSearched,
|
||||
onSearchChanged: _onSearchChanged,
|
||||
onSelected: (opt) {
|
||||
widget.onChanged(opt);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final selected = _items.firstWhere(
|
||||
(e) => e.id == widget.selectedPettyCashId,
|
||||
orElse: () => const PettyCashOption('', ''),
|
||||
);
|
||||
final text = (widget.selectedPettyCashId != null && widget.selectedPettyCashId!.isNotEmpty)
|
||||
? (selected.name.isNotEmpty ? selected.name : widget.hintText)
|
||||
: widget.hintText;
|
||||
|
||||
return InkWell(
|
||||
onTap: _openPicker,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: theme.colorScheme.surface,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.wallet, color: theme.colorScheme.primary, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
Icon(Icons.arrow_drop_down, color: theme.colorScheme.onSurface.withValues(alpha: 0.6)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PettyCashPickerBottomSheet extends StatelessWidget {
|
||||
final String label;
|
||||
final String hintText;
|
||||
final List<PettyCashOption> items;
|
||||
final TextEditingController searchController;
|
||||
final bool isLoading;
|
||||
final bool isSearching;
|
||||
final bool hasSearched;
|
||||
final ValueChanged<String> onSearchChanged;
|
||||
final ValueChanged<PettyCashOption?> onSelected;
|
||||
|
||||
const _PettyCashPickerBottomSheet({
|
||||
required this.label,
|
||||
required this.hintText,
|
||||
required this.items,
|
||||
required this.searchController,
|
||||
required this.isLoading,
|
||||
required this.isSearching,
|
||||
required this.hasSearched,
|
||||
required this.onSearchChanged,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
suffixIcon: isSearching
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: onSearchChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: items.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.wallet, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.5)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
hasSearched ? 'تنخواهگردانی با این مشخصات یافت نشد' : 'تنخواهگردانی ثبت نشده است',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final it = items[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Icon(Icons.wallet, color: colorScheme.onPrimaryContainer),
|
||||
),
|
||||
title: Text(it.name),
|
||||
onTap: () => onSelected(it),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ class _PriceListComboboxWidgetState extends State<PriceListComboboxWidget> {
|
|||
child: Tooltip(
|
||||
message: _selected != null ? (_selected!['name']?.toString() ?? '') : widget.hintText,
|
||||
child: DropdownButtonFormField<int>(
|
||||
value: _selected != null ? (_selected!['id'] as int) : null,
|
||||
initialValue: _selected != null ? (_selected!['id'] as int) : null,
|
||||
isExpanded: true,
|
||||
items: _items
|
||||
.map((e) => DropdownMenuItem<int>(
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
|||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ class _SellerPickerWidgetState extends State<SellerPickerWidget> {
|
|||
List<Person> _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<SellerPickerWidget> {
|
|||
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<SellerPickerWidget> {
|
|||
}
|
||||
|
||||
Future<void> _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<SellerPickerWidget> {
|
|||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
|
|||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _matchBy,
|
||||
initialValue: _matchBy,
|
||||
isDense: true,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')),
|
||||
|
|
@ -208,7 +208,7 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
|
|||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _conflictPolicy,
|
||||
initialValue: _conflictPolicy,
|
||||
isDense: true,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')),
|
||||
|
|
|
|||
|
|
@ -514,7 +514,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
|||
return 'مقدار نامعتبر';
|
||||
}
|
||||
if (parsed < 0) {
|
||||
return 'مقدار نمی\تواند منفی باشد';
|
||||
return 'مقدار نمیتواند منفی باشد';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -526,7 +526,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
|||
return 'مقدار نامعتبر';
|
||||
}
|
||||
if (parsed < 0) {
|
||||
return 'مقدار نمی\تواند منفی باشد';
|
||||
return 'مقدار نمیتواند منفی باشد';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
|
@ -611,7 +611,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
|||
const Text('ارز'),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonFormField<int?>(
|
||||
value: _selectedCurrencyIds.isNotEmpty ? _selectedCurrencyIds.first : null,
|
||||
initialValue: _selectedCurrencyIds.isNotEmpty ? _selectedCurrencyIds.first : null,
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
|
|
@ -643,7 +643,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
|||
const Text('لیست قیمت'),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonFormField<int?>(
|
||||
value: _selectedPriceListIds.isNotEmpty ? _selectedPriceListIds.first : null,
|
||||
initialValue: _selectedPriceListIds.isNotEmpty ? _selectedPriceListIds.first : null,
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
|
|
@ -675,7 +675,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
|
|||
const Text('نوع آیتم'),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonFormField<String?>(
|
||||
value: _selectedItemTypes.isNotEmpty ? _selectedItemTypes.first : null,
|
||||
initialValue: _selectedItemTypes.isNotEmpty ? _selectedItemTypes.first : null,
|
||||
items: const [
|
||||
DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ class _ProductImportDialogState extends State<ProductImportDialog> {
|
|||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _matchBy,
|
||||
initialValue: _matchBy,
|
||||
isDense: true,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')),
|
||||
|
|
@ -206,7 +206,7 @@ class _ProductImportDialogState extends State<ProductImportDialog> {
|
|||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _conflictPolicy,
|
||||
initialValue: _conflictPolicy,
|
||||
isDense: true,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')),
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ class ProductPricingInventorySection extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -290,7 +290,7 @@ class ProductPricingInventorySection extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DropdownButtonFormField<int>(
|
||||
value: priceListId,
|
||||
initialValue: priceListId,
|
||||
items: priceLists
|
||||
.map((pl) => DropdownMenuItem<int>(
|
||||
value: (pl['id'] as num).toInt(),
|
||||
|
|
@ -303,7 +303,7 @@ class ProductPricingInventorySection extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
value: currencyId,
|
||||
initialValue: currencyId,
|
||||
items: currencies
|
||||
.map((c) => DropdownMenuItem<int>(
|
||||
value: (c['id'] as num).toInt(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue