progress in invoices

This commit is contained in:
Hesabix 2025-10-11 02:13:18 +03:30
parent 09c17b580d
commit ff968aed7a
34 changed files with 2402 additions and 485 deletions

View file

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

View 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")

View file

@ -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)

View file

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

View file

@ -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!,

View file

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

View file

@ -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,7 +284,10 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
),
const SizedBox(height: 16),
// مشتری
// مشتری (برای ضایعات/مصرف مستقیم/تولید مخفی میشود)
if (!(_selectedInvoiceType == InvoiceType.waste ||
_selectedInvoiceType == InvoiceType.directConsumption ||
_selectedInvoiceType == InvoiceType.production))
CustomerComboboxWidget(
selectedCustomer: _selectedCustomer,
onCustomerChanged: (customer) {
@ -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(() {
@ -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: Text('عملیات ذخیره فاکتور به زودی پیاده‌سازی خواهد شد$printInfo$taxInfo$transactionInfo'),
content: const Text('فاکتور با موفقیت ثبت شد'),
backgroundColor: Theme.of(context).colorScheme.primary,
duration: const Duration(seconds: 3),
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(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;

View file

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

View file

@ -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,

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

View file

@ -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,15 +36,8 @@ 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(
// آیکون 404 ساده
Container(
width: 220,
height: 220,
decoration: BoxDecoration(
@ -185,35 +65,8 @@ class _Error404PageState extends State<Error404Page>
),
],
),
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(
child: Center(
child: Text(
'404',
style: TextStyle(
fontSize: 80,
@ -231,22 +84,13 @@ class _Error404PageState extends State<Error404Page>
],
),
),
],
),
),
),
);
},
),
const SizedBox(height: 50),
// متن اصلی با انیمیشن
FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Column(
// متن اصلی
Column(
children: [
// عنوان اصلی
Text(
@ -267,7 +111,7 @@ class _Error404PageState extends State<Error404Page>
Container(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
'متأسفانه صفحه‌ای که به دنبال آن هستید وجود ندارد یا حذف شده است. لطفاً آدرس را بررسی کنید یا از دکمه‌های زیر استفاده کنید.',
'متأسفانه صفحه‌ای که به دنبال آن هستید وجود ندارد یا حذف شده است. لطفاً آدرس را بررسی کنید یا از دکمه زیر استفاده کنید.',
style: TextStyle(
fontSize: 18,
color: isDark
@ -282,24 +126,13 @@ class _Error404PageState extends State<Error404Page>
const SizedBox(height: 60),
// دکمه بازگشت
AnimatedBuilder(
animation: _fadeAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, 30 * (1 - _fadeAnimation.value)),
child: ElevatedButton.icon(
// دکمه صفحه نخست
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('بازگشت به صفحه قبلی'),
icon: const Icon(Icons.home, size: 20),
label: const Text('صفحه نخست'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6366F1),
foregroundColor: Colors.white,
@ -314,13 +147,8 @@ class _Error404PageState extends State<Error404Page>
shadowColor: const Color(0xFF6366F1).withValues(alpha: 0.4),
),
),
);
},
),
],
),
),
),
],
),
),

View file

@ -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,

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

View file

@ -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('خطا در دریافت لیست اشخاص');
}

View file

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

View file

@ -109,7 +109,7 @@ class ProductFormValidator {
errors['basePurchasePrice'] = 'قیمت خرید نمی‌تواند منفی باشد';
}
if (formData.unitConversionFactor != null && formData.unitConversionFactor! <= 0) {
if (formData.unitConversionFactor <= 0) {
errors['unitConversionFactor'] = 'ضریب تبدیل باید بزرگتر از صفر باشد';
}

View file

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

View file

@ -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(

View file

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

View file

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

View file

@ -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 {

View file

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

View file

@ -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;
@ -359,6 +380,14 @@ class _TransactionDialogState extends State<TransactionDialog> {
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() {
super.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,6 +540,9 @@ class _TransactionDialogState extends State<TransactionDialog> {
const SizedBox(height: 16),
// فیلدهای خاص بر اساس نوع تراکنش
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) {

View file

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

View file

@ -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,10 +416,18 @@ 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: 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,
@ -429,6 +435,8 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
),
),
),
),
),
const SizedBox(width: 8),
IconButton(onPressed: () => _removeRow(index), icon: const Icon(Icons.delete, color: Colors.red)),
],
@ -452,6 +460,7 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
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;

View file

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

View file

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

View file

@ -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>(

View file

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

View file

@ -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;
if (query.isEmpty) {
_isLoading = false;
} else {
_isSearching = false;
}
});
_setModalState?.call(() {});
}
} catch (e) {
// پاسخ کهنه را نادیده بگیر
if (seq != _searchSeq || query != _latestQuery) {
return;
}
if (mounted) {
setState(() {
if (query.isEmpty) {
_isLoading = false;
} else {
_isSearching = false;
}
});
_setModalState?.call(() {});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در جست‌وجو: $e'),
@ -134,7 +160,10 @@ class _SellerPickerWidgetState extends State<SellerPickerWidget> {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => _SellerPickerBottomSheet(
builder: (context) => StatefulBuilder(
builder: (context, setModalState) {
_setModalState = setModalState;
return _SellerPickerBottomSheet(
sellers: _sellers,
selectedSeller: widget.selectedSeller,
onSellerSelected: (seller) {
@ -144,6 +173,8 @@ class _SellerPickerWidgetState extends State<SellerPickerWidget> {
searchController: _searchController,
onSearchChanged: _searchSellers,
isLoading: _isLoading || _isSearching,
);
},
),
);
}

View file

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

View file

@ -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,

View file

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

View file

@ -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(),