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 .categories import router as categories # noqa: F401
from .products import router as products # noqa: F401 from .products import router as products # noqa: F401
from .price_lists import router as price_lists # noqa: F401 from .price_lists import router as price_lists # noqa: F401
from .invoices import router as invoices # noqa: F401

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.product_attributes import router as product_attributes_router
from adapters.api.v1.products import router as products_router from adapters.api.v1.products import router as products_router
from adapters.api.v1.price_lists import router as price_lists_router from adapters.api.v1.price_lists import router as price_lists_router
from adapters.api.v1.invoices import router as invoices_router
from adapters.api.v1.persons import router as persons_router from adapters.api.v1.persons import router as persons_router
from adapters.api.v1.customers import router as customers_router from adapters.api.v1.customers import router as customers_router
from adapters.api.v1.bank_accounts import router as bank_accounts_router from adapters.api.v1.bank_accounts import router as bank_accounts_router
@ -294,6 +295,7 @@ def create_app() -> FastAPI:
application.include_router(product_attributes_router, prefix=settings.api_v1_prefix) application.include_router(product_attributes_router, prefix=settings.api_v1_prefix)
application.include_router(products_router, prefix=settings.api_v1_prefix) application.include_router(products_router, prefix=settings.api_v1_prefix)
application.include_router(price_lists_router, prefix=settings.api_v1_prefix) application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
application.include_router(invoices_router, prefix=settings.api_v1_prefix)
application.include_router(persons_router, prefix=settings.api_v1_prefix) application.include_router(persons_router, prefix=settings.api_v1_prefix)
application.include_router(customers_router, prefix=settings.api_v1_prefix) application.include_router(customers_router, prefix=settings.api_v1_prefix)
application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix) application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix)

View file

@ -65,23 +65,13 @@ class ProductFormController extends ChangeNotifier {
void addOrUpdateDraftPriceItem(Map<String, dynamic> item) { void addOrUpdateDraftPriceItem(Map<String, dynamic> item) {
final String key = ( final String key = (
(item['price_list_id']?.toString() ?? '') + '|' + '${item['price_list_id']?.toString() ?? ''}|${item['product_id']?.toString() ?? ''}|${item['unit_id']?.toString() ?? 'null'}|${item['currency_id']?.toString() ?? ''}|${item['tier_name']?.toString() ?? ''}|${item['min_qty']?.toString() ?? '0'}'
(item['product_id']?.toString() ?? '') + '|' +
(item['unit_id']?.toString() ?? 'null') + '|' +
(item['currency_id']?.toString() ?? '') + '|' +
(item['tier_name']?.toString() ?? '') + '|' +
(item['min_qty']?.toString() ?? '0')
); );
int existingIndex = -1; int existingIndex = -1;
for (int i = 0; i < _draftPriceItems.length; i++) { for (int i = 0; i < _draftPriceItems.length; i++) {
final it = _draftPriceItems[i]; final it = _draftPriceItems[i];
final itKey = ( final itKey = (
(it['price_list_id']?.toString() ?? '') + '|' + '${it['price_list_id']?.toString() ?? ''}|${it['product_id']?.toString() ?? ''}|${it['unit_id']?.toString() ?? 'null'}|${it['currency_id']?.toString() ?? ''}|${it['tier_name']?.toString() ?? ''}|${it['min_qty']?.toString() ?? '0'}'
(it['product_id']?.toString() ?? '') + '|' +
(it['unit_id']?.toString() ?? 'null') + '|' +
(it['currency_id']?.toString() ?? '') + '|' +
(it['tier_name']?.toString() ?? '') + '|' +
(it['min_qty']?.toString() ?? '0')
); );
if (itKey == key) { if (itKey == key) {
existingIndex = i; existingIndex = i;
@ -371,8 +361,4 @@ class ProductFormController extends ChangeNotifier {
_errorMessage = null; _errorMessage = null;
} }
@override
void dispose() {
super.dispose();
}
} }

View file

@ -28,6 +28,7 @@ import 'pages/business/wallet_page.dart';
import 'pages/business/invoice_page.dart'; import 'pages/business/invoice_page.dart';
import 'pages/business/new_invoice_page.dart'; import 'pages/business/new_invoice_page.dart';
import 'pages/business/settings_page.dart'; import 'pages/business/settings_page.dart';
import 'pages/business/reports_page.dart';
import 'pages/business/persons_page.dart'; import 'pages/business/persons_page.dart';
import 'pages/business/product_attributes_page.dart'; import 'pages/business/product_attributes_page.dart';
import 'pages/business/products_page.dart'; import 'pages/business/products_page.dart';
@ -657,11 +658,33 @@ class _MyAppState extends State<MyApp> {
); );
}, },
), ),
GoRoute(
path: 'reports',
name: 'business_reports',
builder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: ReportsPage(
businessId: businessId,
authStore: _authStore!,
),
);
},
),
GoRoute( GoRoute(
path: 'settings', path: 'settings',
name: 'business_settings', name: 'business_settings',
builder: (context, state) { builder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
// گارد دسترسی: فقط کاربرانی که دسترسی join دارند
if (!_authStore!.hasBusinessPermission('settings', 'join')) {
return PermissionGuard.buildAccessDeniedPage();
}
return BusinessShell( return BusinessShell(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,

View file

@ -1226,7 +1226,15 @@ class _BusinessShellState extends State<BusinessShell> {
return true; return true;
} }
// برای کاربران عضو، بررسی دسترسی view // برای کاربران عضو، بررسی دسترسی
// تنظیمات: نیازمند دسترسی join
if (section == 'settings' && item.label == AppLocalizations.of(context).settings) {
final hasJoin = widget.authStore.hasBusinessPermission('settings', 'join');
print(' Settings item requires join permission: $hasJoin');
return hasJoin;
}
// سایر سکشنها: بررسی دسترسی view
final hasAccess = widget.authStore.canReadSection(section); final hasAccess = widget.authStore.canReadSection(section);
print(' Checking view permission for section "$section": $hasAccess'); print(' Checking view permission for section "$section": $hasAccess');
@ -1276,6 +1284,7 @@ class _BusinessShellState extends State<BusinessShell> {
if (label == t.documents) return 'accounting_documents'; if (label == t.documents) return 'accounting_documents';
if (label == t.chartOfAccounts) return 'chart_of_accounts'; if (label == t.chartOfAccounts) return 'chart_of_accounts';
if (label == t.openingBalance) return 'opening_balance'; if (label == t.openingBalance) return 'opening_balance';
if (label == t.reports) return 'reports';
if (label == t.warehouses) return 'warehouses'; if (label == t.warehouses) return 'warehouses';
if (label == t.shipments) return 'warehouse_transfers'; if (label == t.shipments) return 'warehouse_transfers';
if (label == t.inquiries) return 'reports'; if (label == t.inquiries) return 'reports';

View file

@ -21,6 +21,8 @@ import '../../utils/number_formatters.dart';
import '../../services/currency_service.dart'; import '../../services/currency_service.dart';
import '../../core/api_client.dart'; import '../../core/api_client.dart';
import '../../models/invoice_transaction.dart'; import '../../models/invoice_transaction.dart';
import '../../models/invoice_line_item.dart';
import '../../services/invoice_service.dart';
class NewInvoicePage extends StatefulWidget { class NewInvoicePage extends StatefulWidget {
final int businessId; final int businessId;
@ -44,7 +46,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
InvoiceType? _selectedInvoiceType; InvoiceType? _selectedInvoiceType;
bool _isDraft = false; bool _isDraft = false;
String? _invoiceNumber; String? _invoiceNumber;
bool _autoGenerateInvoiceNumber = true; final bool _autoGenerateInvoiceNumber = true;
Customer? _selectedCustomer; Customer? _selectedCustomer;
Person? _selectedSeller; Person? _selectedSeller;
double? _commissionPercentage; double? _commissionPercentage;
@ -71,6 +73,8 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
// تراکنشهای فاکتور // تراکنشهای فاکتور
List<InvoiceTransaction> _transactions = []; List<InvoiceTransaction> _transactions = [];
// ردیفهای فاکتور برای ساخت payload
List<InvoiceLineItem> _lineItems = <InvoiceLineItem>[];
@override @override
void initState() { void initState() {
@ -280,8 +284,11 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// مشتری // مشتری (برای ضایعات/مصرف مستقیم/تولید مخفی میشود)
CustomerComboboxWidget( if (!(_selectedInvoiceType == InvoiceType.waste ||
_selectedInvoiceType == InvoiceType.directConsumption ||
_selectedInvoiceType == InvoiceType.production))
CustomerComboboxWidget(
selectedCustomer: _selectedCustomer, selectedCustomer: _selectedCustomer,
onCustomerChanged: (customer) { onCustomerChanged: (customer) {
setState(() { setState(() {
@ -516,7 +523,11 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: CustomerComboboxWidget( child: (_selectedInvoiceType == InvoiceType.waste ||
_selectedInvoiceType == InvoiceType.directConsumption ||
_selectedInvoiceType == InvoiceType.production)
? const SizedBox()
: CustomerComboboxWidget(
selectedCustomer: _selectedCustomer, selectedCustomer: _selectedCustomer,
onCustomerChanged: (customer) { onCustomerChanged: (customer) {
setState(() { setState(() {
@ -528,7 +539,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
isRequired: false, isRequired: false,
label: 'مشتری', label: 'مشتری',
hintText: 'انتخاب مشتری', hintText: 'انتخاب مشتری',
), ),
), ),
], ],
), ),
@ -709,17 +720,136 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
); );
} }
void _saveInvoice() { Future<void> _saveInvoice() async {
// TODO: پیادهسازی عملیات ذخیره فاکتور final validation = _validateAndBuildPayload();
final printInfo = _printAfterSave ? '\n• چاپ فاکتور: فعال' : ''; if (validation is String) {
final taxInfo = _sendToTaxFolder ? '\n• ارسال به کارپوشه مودیان: فعال' : ''; _showError(validation);
final transactionInfo = _transactions.isNotEmpty ? '\n• تعداد تراکنش‌ها: ${_transactions.length}' : ''; return;
}
final payload = validation as Map<String, dynamic>;
try {
final service = InvoiceService(apiClient: ApiClient());
await service.createInvoice(businessId: widget.businessId, payload: payload);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('فاکتور با موفقیت ثبت شد'),
backgroundColor: Theme.of(context).colorScheme.primary,
duration: const Duration(seconds: 2),
),
);
} catch (e) {
_showError('خطا در ذخیره فاکتور: ${e.toString()}');
}
}
dynamic _validateAndBuildPayload() {
// اعتبارسنجیهای پایه
if (_selectedInvoiceType == null) {
return 'نوع فاکتور الزامی است';
}
if (_invoiceDate == null) {
return 'تاریخ فاکتور الزامی است';
}
if (_selectedCurrencyId == null) {
return 'ارز فاکتور الزامی است';
}
if (_lineItems.isEmpty) {
return 'حداقل یک ردیف کالا/خدمت وارد کنید';
}
// اعتبارسنجی ردیفها
for (int i = 0; i < _lineItems.length; i++) {
final r = _lineItems[i];
if (r.productId == null) {
return 'محصول ردیف ${i + 1} انتخاب نشده است';
}
if ((r.quantity) <= 0) {
return 'تعداد ردیف ${i + 1} باید بزرگ‌تر از صفر باشد';
}
if (r.unitPrice < 0) {
return 'قیمت واحد ردیف ${i + 1} نمی‌تواند منفی باشد';
}
if (r.discountType == 'percent' && (r.discountValue < 0 || r.discountValue > 100)) {
return 'درصد تخفیف ردیف ${i + 1} باید بین 0 تا 100 باشد';
}
if (r.taxRate < 0 || r.taxRate > 100) {
return 'درصد مالیات ردیف ${i + 1} باید بین 0 تا 100 باشد';
}
}
final isSalesOrReturn = _selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn;
// مشتری برای انواع خاص الزامی نیست
final shouldHaveCustomer = !(_selectedInvoiceType == InvoiceType.waste || _selectedInvoiceType == InvoiceType.directConsumption || _selectedInvoiceType == InvoiceType.production);
if (shouldHaveCustomer && _selectedCustomer == null) {
return 'انتخاب مشتری الزامی است';
}
// اعتبارسنجی کارمزد در حالت فروش
if (isSalesOrReturn && _selectedSeller != null && _commissionType != null) {
if (_commissionType == CommissionType.percentage) {
final p = _commissionPercentage ?? 0;
if (p < 0 || p > 100) return 'درصد کارمزد باید بین 0 تا 100 باشد';
} else if (_commissionType == CommissionType.amount) {
final a = _commissionAmount ?? 0;
if (a < 0) return 'مبلغ کارمزد نمی‌تواند منفی باشد';
}
}
// ساخت payload
final payload = <String, dynamic>{
'type': _selectedInvoiceType!.value,
'is_draft': _isDraft,
if (_invoiceNumber != null && _invoiceNumber!.trim().isNotEmpty) 'number': _invoiceNumber!.trim(),
'invoice_date': _invoiceDate!.toIso8601String(),
if (_dueDate != null) 'due_date': _dueDate!.toIso8601String(),
'currency_id': _selectedCurrencyId,
if (_invoiceTitle != null && _invoiceTitle!.isNotEmpty) 'title': _invoiceTitle,
if (_invoiceReference != null && _invoiceReference!.isNotEmpty) 'reference': _invoiceReference,
if (_selectedCustomer != null) 'customer_id': _selectedCustomer!.id,
if (_selectedSeller?.id != null) 'seller_id': _selectedSeller!.id,
if (_commissionType != null) 'commission_type': _commissionType == CommissionType.percentage ? 'percentage' : 'amount',
if (_commissionType == CommissionType.percentage && _commissionPercentage != null) 'commission_percentage': _commissionPercentage,
if (_commissionType == CommissionType.amount && _commissionAmount != null) 'commission_amount': _commissionAmount,
'settings': {
'print_after_save': _printAfterSave,
'printer': _selectedPrinter,
'paper_size': _selectedPaperSize,
'is_official_invoice': _isOfficialInvoice,
'print_template': _selectedPrintTemplate,
'send_to_tax_folder': _sendToTaxFolder,
},
'transactions': _transactions.map((t) => t.toJson()).toList(),
'line_items': _lineItems.map((e) => _serializeLineItem(e)).toList(),
'summary': {
'subtotal': _sumSubtotal,
'discount': _sumDiscount,
'tax': _sumTax,
'total': _sumTotal,
},
};
return payload;
}
Map<String, dynamic> _serializeLineItem(InvoiceLineItem e) {
return <String, dynamic>{
'product_id': e.productId,
'unit': e.selectedUnit ?? e.mainUnit,
'quantity': e.quantity,
'unit_price': e.unitPrice,
'unit_price_source': e.unitPriceSource,
'discount_type': e.discountType,
'discount_value': e.discountValue,
'tax_rate': e.taxRate,
if ((e.description ?? '').isNotEmpty) 'description': e.description,
};
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('عملیات ذخیره فاکتور به زودی پیاده‌سازی خواهد شد$printInfo$taxInfo$transactionInfo'), content: Text(message),
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 3),
), ),
); );
} }
@ -739,6 +869,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
invoiceType: (_selectedInvoiceType?.value ?? 'sales'), invoiceType: (_selectedInvoiceType?.value ?? 'sales'),
onChanged: (rows) { onChanged: (rows) {
setState(() { setState(() {
_lineItems = rows;
_sumSubtotal = rows.fold<num>(0, (acc, e) => acc + e.subtotal); _sumSubtotal = rows.fold<num>(0, (acc, e) => acc + e.subtotal);
_sumDiscount = rows.fold<num>(0, (acc, e) => acc + e.discountAmount); _sumDiscount = rows.fold<num>(0, (acc, e) => acc + e.discountAmount);
_sumTax = rows.fold<num>(0, (acc, e) => acc + e.taxAmount); _sumTax = rows.fold<num>(0, (acc, e) => acc + e.taxAmount);
@ -778,6 +909,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
transactions: _transactions, transactions: _transactions,
businessId: widget.businessId, businessId: widget.businessId,
calendarController: widget.calendarController, calendarController: widget.calendarController,
invoiceType: _selectedInvoiceType ?? InvoiceType.sales,
onChanged: (transactions) { onChanged: (transactions) {
setState(() { setState(() {
_transactions = transactions; _transactions = transactions;

View file

@ -39,7 +39,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
InvoiceType? _selectedInvoiceType; InvoiceType? _selectedInvoiceType;
bool _isDraft = false; bool _isDraft = false;
String? _invoiceNumber; String? _invoiceNumber;
bool _autoGenerateInvoiceNumber = true; final bool _autoGenerateInvoiceNumber = true;
Customer? _selectedCustomer; Customer? _selectedCustomer;
Person? _selectedSeller; Person? _selectedSeller;
double? _commissionPercentage; double? _commissionPercentage;

View file

@ -125,7 +125,7 @@ class _PriceListItemsPageState extends State<PriceListItemsPage> {
onChanged: (v) => productId = int.tryParse(v), onChanged: (v) => productId = int.tryParse(v),
), ),
DropdownButtonFormField<int>( DropdownButtonFormField<int>(
value: currencyId, initialValue: currencyId,
items: _fallbackCurrencies items: _fallbackCurrencies
.map((c) => DropdownMenuItem<int>( .map((c) => DropdownMenuItem<int>(
value: c['id'] as int, value: c['id'] as int,
@ -143,7 +143,7 @@ class _PriceListItemsPageState extends State<PriceListItemsPage> {
onChanged: (v) => tierName = v, onChanged: (v) => tierName = v,
), ),
DropdownButtonFormField<int>( DropdownButtonFormField<int>(
value: unitId, initialValue: unitId,
items: _fallbackUnits items: _fallbackUnits
.map((u) => DropdownMenuItem<int>( .map((u) => DropdownMenuItem<int>(
value: u['id'] as int, value: u['id'] as int,

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:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class Error404Page extends StatefulWidget { class Error404Page extends StatelessWidget {
const Error404Page({super.key}); const Error404Page({super.key});
@override
State<Error404Page> createState() => _Error404PageState();
}
class _Error404PageState extends State<Error404Page>
with TickerProviderStateMixin {
late AnimationController _fadeController;
late AnimationController _slideController;
late AnimationController _bounceController;
late AnimationController _pulseController;
late AnimationController _rotateController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
late Animation<double> _bounceAnimation;
late Animation<double> _pulseAnimation;
late Animation<double> _rotateAnimation;
@override
void initState() {
super.initState();
// کنترلرهای انیمیشن
_fadeController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
);
_slideController = AnimationController(
duration: const Duration(milliseconds: 1800),
vsync: this,
);
_bounceController = AnimationController(
duration: const Duration(milliseconds: 2500),
vsync: this,
);
_pulseController = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
);
_rotateController = AnimationController(
duration: const Duration(milliseconds: 3000),
vsync: this,
);
// انیمیشنها
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fadeController,
curve: Curves.easeInOut,
));
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController,
curve: Curves.elasticOut,
));
_bounceAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _bounceController,
curve: Curves.elasticOut,
));
_pulseAnimation = Tween<double>(
begin: 1.0,
end: 1.05,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
_rotateAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _rotateController,
curve: Curves.easeInOut,
));
// شروع انیمیشنها
_startAnimations();
}
void _startAnimations() async {
await _fadeController.forward();
await _slideController.forward();
await _bounceController.forward();
// انیمیشنهای مداوم
_pulseController.repeat(reverse: true);
_rotateController.repeat();
}
@override
void dispose() {
_fadeController.dispose();
_slideController.dispose();
_bounceController.dispose();
_pulseController.dispose();
_rotateController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -149,177 +36,118 @@ class _Error404PageState extends State<Error404Page>
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// انیمیشن 404 با افکتهای پیشرفته // آیکون 404 ساده
AnimatedBuilder( Container(
animation: Listenable.merge([_bounceAnimation, _pulseAnimation, _rotateAnimation]), width: 220,
builder: (context, child) { height: 220,
return Transform.scale( decoration: BoxDecoration(
scale: _bounceAnimation.value * _pulseAnimation.value, shape: BoxShape.circle,
child: Transform.rotate( gradient: RadialGradient(
angle: _rotateAnimation.value * 0.1, colors: isDark
child: Container( ? [
width: 220, const Color(0xFF6366F1).withValues(alpha: 0.4),
height: 220, const Color(0xFF8B5CF6).withValues(alpha: 0.2),
decoration: BoxDecoration( const Color(0xFFEC4899).withValues(alpha: 0.1),
shape: BoxShape.circle, ]
gradient: RadialGradient( : [
colors: isDark const Color(0xFF6366F1).withValues(alpha: 0.3),
? [ const Color(0xFF8B5CF6).withValues(alpha: 0.15),
const Color(0xFF6366F1).withValues(alpha: 0.4), const Color(0xFFEC4899).withValues(alpha: 0.05),
const Color(0xFF8B5CF6).withValues(alpha: 0.2),
const Color(0xFFEC4899).withValues(alpha: 0.1),
]
: [
const Color(0xFF6366F1).withValues(alpha: 0.3),
const Color(0xFF8B5CF6).withValues(alpha: 0.15),
const Color(0xFFEC4899).withValues(alpha: 0.05),
],
),
boxShadow: [
BoxShadow(
color: isDark
? const Color(0xFF6366F1).withValues(alpha: 0.3)
: const Color(0xFF4F46E5).withValues(alpha: 0.2),
blurRadius: 30,
spreadRadius: 5,
),
], ],
), ),
child: Stack( boxShadow: [
alignment: Alignment.center, BoxShadow(
children: [ color: isDark
// حلقههای متحرک ? const Color(0xFF6366F1).withValues(alpha: 0.3)
...List.generate(3, (index) { : const Color(0xFF4F46E5).withValues(alpha: 0.2),
return AnimatedBuilder( blurRadius: 30,
animation: _rotateAnimation, spreadRadius: 5,
builder: (context, child) {
return Transform.rotate(
angle: _rotateAnimation.value * (2 * 3.14159) * (index + 1) * 0.3,
child: Container(
width: 180 - (index * 20),
height: 180 - (index * 20),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isDark
? const Color(0xFF6366F1).withValues(alpha: 0.3 - (index * 0.1))
: const Color(0xFF4F46E5).withValues(alpha: 0.2 - (index * 0.05)),
width: 2,
),
),
),
);
},
);
}),
// متن 404
Text(
'404',
style: TextStyle(
fontSize: 80,
fontWeight: FontWeight.bold,
color: isDark
? const Color(0xFF6366F1)
: const Color(0xFF4F46E5),
shadows: [
Shadow(
color: isDark
? const Color(0xFF6366F1).withValues(alpha: 0.6)
: const Color(0xFF4F46E5).withValues(alpha: 0.4),
blurRadius: 25,
),
],
),
),
],
),
),
), ),
); ],
}, ),
child: Center(
child: Text(
'404',
style: TextStyle(
fontSize: 80,
fontWeight: FontWeight.bold,
color: isDark
? const Color(0xFF6366F1)
: const Color(0xFF4F46E5),
shadows: [
Shadow(
color: isDark
? const Color(0xFF6366F1).withValues(alpha: 0.6)
: const Color(0xFF4F46E5).withValues(alpha: 0.4),
blurRadius: 25,
),
],
),
),
),
), ),
const SizedBox(height: 50), const SizedBox(height: 50),
// متن اصلی با انیمیشن // متن اصلی
FadeTransition( Column(
opacity: _fadeAnimation, children: [
child: SlideTransition( // عنوان اصلی
position: _slideAnimation, Text(
child: Column( 'صفحه مورد نظر یافت نشد',
children: [ style: TextStyle(
// عنوان اصلی fontSize: 36,
Text( fontWeight: FontWeight.bold,
'صفحه مورد نظر یافت نشد', color: isDark ? Colors.white : const Color(0xFF1E293B),
style: TextStyle( height: 1.2,
fontSize: 36, letterSpacing: 0.5,
fontWeight: FontWeight.bold, ),
color: isDark ? Colors.white : const Color(0xFF1E293B), textAlign: TextAlign.center,
height: 1.2,
letterSpacing: 0.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
// توضیحات
Container(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
'متأسفانه صفحه‌ای که به دنبال آن هستید وجود ندارد یا حذف شده است. لطفاً آدرس را بررسی کنید یا از دکمه‌های زیر استفاده کنید.',
style: TextStyle(
fontSize: 18,
color: isDark
? Colors.grey[300]
: const Color(0xFF64748B),
height: 1.6,
letterSpacing: 0.3,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 60),
// دکمه بازگشت
AnimatedBuilder(
animation: _fadeAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, 30 * (1 - _fadeAnimation.value)),
child: ElevatedButton.icon(
onPressed: () {
// همیشه سعی کن به صفحه قبلی برگردی
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else {
// اگر نمیتونی pop کنی، به root برگرد
context.go('/');
}
},
icon: const Icon(Icons.arrow_back_ios, size: 20),
label: const Text('بازگشت به صفحه قبلی'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6366F1),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 20,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 6,
shadowColor: const Color(0xFF6366F1).withValues(alpha: 0.4),
),
),
);
},
),
],
), ),
),
const SizedBox(height: 20),
// توضیحات
Container(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
'متأسفانه صفحه‌ای که به دنبال آن هستید وجود ندارد یا حذف شده است. لطفاً آدرس را بررسی کنید یا از دکمه زیر استفاده کنید.',
style: TextStyle(
fontSize: 18,
color: isDark
? Colors.grey[300]
: const Color(0xFF64748B),
height: 1.6,
letterSpacing: 0.3,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 60),
// دکمه صفحه نخست
ElevatedButton.icon(
onPressed: () {
context.go('/');
},
icon: const Icon(Icons.home, size: 20),
label: const Text('صفحه نخست'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6366F1),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 20,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 6,
shadowColor: const Color(0xFF6366F1).withValues(alpha: 0.4),
),
),
],
), ),
], ],
), ),

View file

@ -21,7 +21,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
final BusinessData _businessData = BusinessData(); final BusinessData _businessData = BusinessData();
int _currentStep = 0; int _currentStep = 0;
bool _isLoading = false; bool _isLoading = false;
int _fiscalTabIndex = 0; final int _fiscalTabIndex = 0;
late TextEditingController _fiscalTitleController; late TextEditingController _fiscalTitleController;
List<Map<String, dynamic>> _currencies = []; List<Map<String, dynamic>> _currencies = [];
@ -87,7 +87,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
} }
final fiscal = _businessData.fiscalYears[_fiscalTabIndex]; final fiscal = _businessData.fiscalYears[_fiscalTabIndex];
String _autoTitle() { String autoTitle() {
final isJalali = widget.calendarController.isJalali; final isJalali = widget.calendarController.isJalali;
final end = fiscal.endDate; final end = fiscal.endDate;
if (end == null) return fiscal.title; if (end == null) return fiscal.title;
@ -139,7 +139,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
final s = fiscal.startDate!; final s = fiscal.startDate!;
fiscal.endDate = DateTime(s.year + 1, s.month, s.day); fiscal.endDate = DateTime(s.year + 1, s.month, s.day);
} }
fiscal.title = _autoTitle(); fiscal.title = autoTitle();
_fiscalTitleController.text = fiscal.title; _fiscalTitleController.text = fiscal.title;
} }
}); });
@ -157,7 +157,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
setState(() { setState(() {
fiscal.endDate = d; fiscal.endDate = d;
if (fiscal.title.trim().isEmpty || fiscal.title.startsWith('سال مالی منتهی به')) { if (fiscal.title.trim().isEmpty || fiscal.title.startsWith('سال مالی منتهی به')) {
fiscal.title = _autoTitle(); fiscal.title = autoTitle();
_fiscalTitleController.text = fiscal.title; _fiscalTitleController.text = fiscal.title;
} }
}); });
@ -1524,7 +1524,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
DropdownButtonFormField<int>( DropdownButtonFormField<int>(
value: _businessData.defaultCurrencyId, initialValue: _businessData.defaultCurrencyId,
items: _currencies.map((c) { items: _currencies.map((c) {
return DropdownMenuItem<int>( return DropdownMenuItem<int>(
value: c['id'] as int, value: c['id'] as int,

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) { if (search != null && search.isNotEmpty) {
queryParams['search'] = search; queryParams['search'] = search;
// اگر search_fields مشخص نشده، فیلدهای پیشفرض را استفاده کن
if (searchFields == null || searchFields.isEmpty) {
queryParams['search_fields'] = ['alias_name', 'first_name', 'last_name', 'company_name', 'mobile', 'email'];
}
} }
if (searchFields != null && searchFields.isNotEmpty) { if (searchFields != null && searchFields.isNotEmpty) {
@ -37,22 +41,55 @@ class PersonService {
} }
if (filters != null && filters.isNotEmpty) { if (filters != null && filters.isNotEmpty) {
// تبدیل Map به لیست برای API // تبدیل Map به لیست برای API با پشتیبانی از person_type و person_types
final filtersList = filters.entries.map((e) => { final List<Map<String, dynamic>> filtersList = <Map<String, dynamic>>[];
'property': e.key, filters.forEach((key, value) {
'operator': 'in', // برای فیلترهای چندتایی از عملگر 'in' استفاده میکنیم // یکسانسازی: اگر person_type ارسال شود، به person_types (لیستی) تبدیل میکنیم
'value': e.value, if (key == 'person_type') {
}).toList(); final List<dynamic> values = value is List ? List<dynamic>.from(value) : <dynamic>[value];
filtersList.add({
'property': 'person_types',
'operator': 'in',
'value': values,
});
return;
}
if (key == 'person_types') {
final List<dynamic> values = value is List ? List<dynamic>.from(value) : <dynamic>[value];
filtersList.add({
'property': 'person_types',
'operator': 'in',
'value': values,
});
return;
}
// سایر فیلترها: اگر مقدار لیست باشد از in، در غیر این صورت از = استفاده میکنیم
final bool isList = value is List;
filtersList.add({
'property': key,
'operator': isList ? 'in' : '=',
'value': value,
});
});
queryParams['filters'] = filtersList; queryParams['filters'] = filtersList;
} }
// Debug: نمایش پارامترهای ارسالی
print('PersonService API Call:');
print('URL: /api/v1/persons/businesses/$businessId/persons');
print('Data: $queryParams');
final response = await _apiClient.post( final response = await _apiClient.post(
'/api/v1/persons/businesses/$businessId/persons', '/api/v1/persons/businesses/$businessId/persons',
data: queryParams, data: queryParams,
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
return response.data['data']; final data = response.data['data'];
print('PersonService Response Data: $data');
return data;
} else { } else {
throw Exception('خطا در دریافت لیست اشخاص'); throw Exception('خطا در دریافت لیست اشخاص');
} }

View file

@ -32,7 +32,7 @@ class PriceListService {
final qp = <String, String>{}; final qp = <String, String>{};
if (productId != null) qp['product_id'] = '$productId'; if (productId != null) qp['product_id'] = '$productId';
if (currencyId != null) qp['currency_id'] = '$currencyId'; if (currencyId != null) qp['currency_id'] = '$currencyId';
final query = qp.isEmpty ? '' : ('?' + qp.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')); final query = qp.isEmpty ? '' : ('?${qp.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}');
final res = await _api.get<Map<String, dynamic>>('/api/v1/price-lists/business/$businessId/$priceListId/items$query'); final res = await _api.get<Map<String, dynamic>>('/api/v1/price-lists/business/$businessId/$priceListId/items$query');
final data = res.data?['data']; final data = res.data?['data'];
final items = (data is Map<String, dynamic>) ? data['items'] : null; final items = (data is Map<String, dynamic>) ? data['items'] : null;

View file

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

View file

@ -10,12 +10,10 @@ class CategoryPickerField extends FormField<int?> {
required this.businessId, required this.businessId,
required List<Map<String, dynamic>> categoriesTree, required List<Map<String, dynamic>> categoriesTree,
required ValueChanged<int?> onChanged, required ValueChanged<int?> onChanged,
int? initialValue, super.initialValue,
String? label, String? label,
String? Function(int?)? validator, super.validator,
}) : super( }) : super(
initialValue: initialValue,
validator: validator,
builder: (state) { builder: (state) {
final context = state.context; final context = state.context;
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
@ -263,8 +261,11 @@ class _CategoryPickerDialogState extends State<_CategoryPickerDialog> {
'children': <Map<String, dynamic>>[], 'children': <Map<String, dynamic>>[],
}; };
byId[nid] = existing; byId[nid] = existing;
if (parent == null) roots.add(existing); if (parent == null) {
else (parent['children'] as List).add(existing); roots.add(existing);
} else {
(parent['children'] as List).add(existing);
}
} }
parent = existing; parent = existing;
} }

View file

@ -1397,7 +1397,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
thumbVisibility: true, thumbVisibility: true,
child: DataTableTheme( child: DataTableTheme(
data: DataTableThemeData( data: DataTableThemeData(
headingRowColor: MaterialStatePropertyAll( headingRowColor: WidgetStatePropertyAll(
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
), ),
headingTextStyle: theme.textTheme.titleSmall?.copyWith( headingTextStyle: theme.textTheme.titleSmall?.copyWith(

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/material.dart';
import 'package:flutter/services.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
class CodeFieldWidget extends StatefulWidget { class CodeFieldWidget extends StatefulWidget {

View file

@ -67,7 +67,7 @@ class _CommissionTypeSelectorState extends State<CommissionTypeSelector> {
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return DropdownButtonFormField<CommissionType>( return DropdownButtonFormField<CommissionType>(
value: _selectedType, initialValue: _selectedType,
onChanged: (CommissionType? newValue) { onChanged: (CommissionType? newValue) {
if (newValue != null) { if (newValue != null) {
_selectType(newValue); _selectType(newValue);

View file

@ -1,15 +1,26 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../../models/invoice_transaction.dart'; import '../../models/invoice_transaction.dart';
import '../../models/person_model.dart';
import '../../core/date_utils.dart'; import '../../core/date_utils.dart';
import '../../core/calendar_controller.dart'; import '../../core/calendar_controller.dart';
import '../../utils/number_formatters.dart'; import '../../utils/number_formatters.dart';
import '../../services/bank_account_service.dart';
import '../../services/cash_register_service.dart';
import '../../services/petty_cash_service.dart';
import '../../services/person_service.dart';
import 'person_combobox_widget.dart';
import 'bank_account_combobox_widget.dart';
import 'cash_register_combobox_widget.dart';
import 'petty_cash_combobox_widget.dart';
import '../../models/invoice_type_model.dart';
class InvoiceTransactionsWidget extends StatefulWidget { class InvoiceTransactionsWidget extends StatefulWidget {
final List<InvoiceTransaction> transactions; final List<InvoiceTransaction> transactions;
final ValueChanged<List<InvoiceTransaction>> onChanged; final ValueChanged<List<InvoiceTransaction>> onChanged;
final int businessId; final int businessId;
final CalendarController calendarController; final CalendarController calendarController;
final InvoiceType invoiceType;
const InvoiceTransactionsWidget({ const InvoiceTransactionsWidget({
super.key, super.key,
@ -17,6 +28,7 @@ class InvoiceTransactionsWidget extends StatefulWidget {
required this.onChanged, required this.onChanged,
required this.businessId, required this.businessId,
required this.calendarController, required this.calendarController,
required this.invoiceType,
}); });
@override @override
@ -305,6 +317,7 @@ class _InvoiceTransactionsWidgetState extends State<InvoiceTransactionsWidget> {
transaction: transaction, transaction: transaction,
businessId: widget.businessId, businessId: widget.businessId,
calendarController: widget.calendarController, calendarController: widget.calendarController,
invoiceType: widget.invoiceType,
onSave: (newTransaction) { onSave: (newTransaction) {
if (index != null) { if (index != null) {
// ویرایش تراکنش موجود // ویرایش تراکنش موجود
@ -328,12 +341,14 @@ class TransactionDialog extends StatefulWidget {
final int businessId; final int businessId;
final CalendarController calendarController; final CalendarController calendarController;
final ValueChanged<InvoiceTransaction> onSave; final ValueChanged<InvoiceTransaction> onSave;
final InvoiceType invoiceType;
const TransactionDialog({ const TransactionDialog({
super.key, super.key,
this.transaction, this.transaction,
required this.businessId, required this.businessId,
required this.calendarController, required this.calendarController,
required this.invoiceType,
required this.onSave, required this.onSave,
}); });
@ -351,6 +366,12 @@ class _TransactionDialogState extends State<TransactionDialog> {
final _commissionController = TextEditingController(); final _commissionController = TextEditingController();
final _descriptionController = TextEditingController(); final _descriptionController = TextEditingController();
// سرویسها
final BankAccountService _bankService = BankAccountService();
final CashRegisterService _cashRegisterService = CashRegisterService();
final PettyCashService _pettyCashService = PettyCashService();
final PersonService _personService = PersonService();
// فیلدهای خاص هر نوع تراکنش // فیلدهای خاص هر نوع تراکنش
String? _selectedBankId; String? _selectedBankId;
String? _selectedCashRegisterId; String? _selectedCashRegisterId;
@ -358,6 +379,14 @@ class _TransactionDialogState extends State<TransactionDialog> {
String? _selectedCheckId; String? _selectedCheckId;
String? _selectedPersonId; String? _selectedPersonId;
String? _selectedAccountId; String? _selectedAccountId;
// لیستهای داده
List<Map<String, dynamic>> _banks = [];
List<Map<String, dynamic>> _cashRegisters = [];
List<Map<String, dynamic>> _pettyCashList = [];
List<Map<String, dynamic>> _persons = [];
bool _isLoading = false;
@override @override
void initState() { void initState() {
@ -375,6 +404,53 @@ class _TransactionDialogState extends State<TransactionDialog> {
_selectedCheckId = widget.transaction?.checkId; _selectedCheckId = widget.transaction?.checkId;
_selectedPersonId = widget.transaction?.personId; _selectedPersonId = widget.transaction?.personId;
_selectedAccountId = widget.transaction?.accountId; _selectedAccountId = widget.transaction?.accountId;
// لود کردن دادهها از دیتابیس
_loadData();
}
Future<void> _loadData() async {
setState(() {
_isLoading = true;
});
try {
// لود کردن بانکها
final bankResponse = await _bankService.list(
businessId: widget.businessId,
queryInfo: {'take': 100, 'skip': 0},
);
_banks = (bankResponse['items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
// لود کردن صندوقها
final cashRegisterResponse = await _cashRegisterService.list(
businessId: widget.businessId,
queryInfo: {'take': 100, 'skip': 0},
);
_cashRegisters = (cashRegisterResponse['items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
// لود کردن تنخواهگردانها
final pettyCashResponse = await _pettyCashService.list(
businessId: widget.businessId,
queryInfo: {'take': 100, 'skip': 0},
);
_pettyCashList = (pettyCashResponse['items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
// لود کردن اشخاص
final personResponse = await _personService.getPersons(
businessId: widget.businessId,
limit: 100,
);
_persons = (personResponse['items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
} catch (e) {
// در صورت خطا، لیستها خالی باقی میمانند
print('خطا در لود کردن داده‌ها: $e');
} finally {
setState(() {
_isLoading = false;
});
}
} }
@override @override
@ -447,7 +523,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
labelText: 'نوع تراکنش *', labelText: 'نوع تراکنش *',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
items: TransactionType.allTypes.map((type) { items: _availableTransactionTypes().map((type) {
return DropdownMenuItem( return DropdownMenuItem(
value: type, value: type,
child: Text(type.label), child: Text(type.label),
@ -464,7 +540,10 @@ class _TransactionDialogState extends State<TransactionDialog> {
const SizedBox(height: 16), const SizedBox(height: 16),
// فیلدهای خاص بر اساس نوع تراکنش // فیلدهای خاص بر اساس نوع تراکنش
_buildTypeSpecificFields(), if (_isLoading)
const Center(child: CircularProgressIndicator())
else
_buildTypeSpecificFields(),
const SizedBox(height: 16), const SizedBox(height: 16),
// تاریخ تراکنش // تاریخ تراکنش
@ -595,61 +674,56 @@ class _TransactionDialogState extends State<TransactionDialog> {
} }
} }
List<TransactionType> _availableTransactionTypes() {
// خرج چک فقط برای خرید یا برگشت از فروش نمایش داده شود
final showCheckExpense = widget.invoiceType == InvoiceType.purchase || widget.invoiceType == InvoiceType.salesReturn;
final all = TransactionType.allTypes;
if (showCheckExpense) return all;
return all.where((t) => t != TransactionType.checkExpense).toList();
}
Widget _buildBankFields() { Widget _buildBankFields() {
return DropdownButtonFormField<String>( return BankAccountComboboxWidget(
initialValue: _selectedBankId, businessId: widget.businessId,
decoration: const InputDecoration( selectedAccountId: _selectedBankId,
labelText: 'بانک *', onChanged: (opt) {
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'bank1', child: Text('بانک ملی')),
DropdownMenuItem(value: 'bank2', child: Text('بانک صادرات')),
DropdownMenuItem(value: 'bank3', child: Text('بانک ملت')),
],
onChanged: (value) {
setState(() { setState(() {
_selectedBankId = value; _selectedBankId = opt?.id;
}); });
}, },
label: 'بانک *',
hintText: 'جست‌وجو و انتخاب بانک',
isRequired: true,
); );
} }
Widget _buildCashRegisterFields() { Widget _buildCashRegisterFields() {
return DropdownButtonFormField<String>( return CashRegisterComboboxWidget(
initialValue: _selectedCashRegisterId, businessId: widget.businessId,
decoration: const InputDecoration( selectedRegisterId: _selectedCashRegisterId,
labelText: 'صندوق *', onChanged: (opt) {
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'cash1', child: Text('صندوق اصلی')),
DropdownMenuItem(value: 'cash2', child: Text('صندوق فرعی')),
],
onChanged: (value) {
setState(() { setState(() {
_selectedCashRegisterId = value; _selectedCashRegisterId = opt?.id;
}); });
}, },
label: 'صندوق *',
hintText: 'جست‌وجو و انتخاب صندوق',
isRequired: true,
); );
} }
Widget _buildPettyCashFields() { Widget _buildPettyCashFields() {
return DropdownButtonFormField<String>( return PettyCashComboboxWidget(
initialValue: _selectedPettyCashId, businessId: widget.businessId,
decoration: const InputDecoration( selectedPettyCashId: _selectedPettyCashId,
labelText: 'تنخواهگردان *', onChanged: (opt) {
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'petty1', child: Text('تنخواهگردان اصلی')),
DropdownMenuItem(value: 'petty2', child: Text('تنخواهگردان فرعی')),
],
onChanged: (value) {
setState(() { setState(() {
_selectedPettyCashId = value; _selectedPettyCashId = opt?.id;
}); });
}, },
label: 'تنخواهگردان *',
hintText: 'جست‌وجو و انتخاب تنخواه‌گردان',
isRequired: true,
); );
} }
@ -692,21 +766,30 @@ class _TransactionDialogState extends State<TransactionDialog> {
} }
Widget _buildPersonFields() { Widget _buildPersonFields() {
return DropdownButtonFormField<String>( // پیدا کردن شخص انتخاب شده از لیست
initialValue: _selectedPersonId, Person? selectedPerson;
decoration: const InputDecoration( if (_selectedPersonId != null) {
labelText: 'شخص *', try {
border: OutlineInputBorder(), final personData = _persons.firstWhere(
), (p) => p['id']?.toString() == _selectedPersonId,
items: const [ );
DropdownMenuItem(value: 'person1', child: Text('احمد محمدی')), selectedPerson = Person.fromJson(personData);
DropdownMenuItem(value: 'person2', child: Text('فاطمه احمدی')), } catch (e) {
], selectedPerson = null;
onChanged: (value) { }
}
return PersonComboboxWidget(
businessId: widget.businessId,
selectedPerson: selectedPerson,
onChanged: (person) {
setState(() { setState(() {
_selectedPersonId = value; _selectedPersonId = person?.id?.toString();
}); });
}, },
label: 'شخص *',
hintText: 'انتخاب شخص',
isRequired: true,
); );
} }
@ -780,28 +863,30 @@ class _TransactionDialogState extends State<TransactionDialog> {
} }
String? _getBankName(String? id) { String? _getBankName(String? id) {
switch (id) { if (id == null) return null;
case 'bank1': return 'بانک ملی'; final bank = _banks.firstWhere(
case 'bank2': return 'بانک صادرات'; (b) => b['id']?.toString() == id,
case 'bank3': return 'بانک ملت'; orElse: () => <String, dynamic>{},
default: return null; );
} return bank['name']?.toString();
} }
String? _getCashRegisterName(String? id) { String? _getCashRegisterName(String? id) {
switch (id) { if (id == null) return null;
case 'cash1': return 'صندوق اصلی'; final cashRegister = _cashRegisters.firstWhere(
case 'cash2': return 'صندوق فرعی'; (c) => c['id']?.toString() == id,
default: return null; orElse: () => <String, dynamic>{},
} );
return cashRegister['name']?.toString();
} }
String? _getPettyCashName(String? id) { String? _getPettyCashName(String? id) {
switch (id) { if (id == null) return null;
case 'petty1': return 'تنخواهگردان اصلی'; final pettyCash = _pettyCashList.firstWhere(
case 'petty2': return 'تنخواهگردان فرعی'; (p) => p['id']?.toString() == id,
default: return null; orElse: () => <String, dynamic>{},
} );
return pettyCash['name']?.toString();
} }
String? _getCheckNumber(String? id) { String? _getCheckNumber(String? id) {
@ -813,11 +898,12 @@ class _TransactionDialogState extends State<TransactionDialog> {
} }
String? _getPersonName(String? id) { String? _getPersonName(String? id) {
switch (id) { if (id == null) return null;
case 'person1': return 'احمد محمدی'; final person = _persons.firstWhere(
case 'person2': return 'فاطمه احمدی'; (p) => p['id']?.toString() == id,
default: return null; orElse: () => <String, dynamic>{},
} );
return person['name']?.toString();
} }
String? _getAccountName(String? id) { String? _getAccountName(String? id) {

View file

@ -71,7 +71,7 @@ class _InvoiceTypeComboboxState extends State<InvoiceTypeCombobox> {
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return DropdownButtonFormField<InvoiceType>( return DropdownButtonFormField<InvoiceType>(
value: _selectedType, initialValue: _selectedType,
onChanged: (InvoiceType? newValue) { onChanged: (InvoiceType? newValue) {
if (newValue != null) { if (newValue != null) {
_selectType(newValue); _selectType(newValue);

View file

@ -241,9 +241,7 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
), ),
) )
else else
..._rows.asMap().entries.map((e) => _buildCompactRow(context, e.key, e.value)).toList(), ..._rows.asMap().entries.map((e) => _buildCompactRow(context, e.key, e.value)),
const Divider(height: 1),
_buildFooter(context),
], ],
), ),
), ),
@ -418,13 +416,23 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
const SizedBox(width: 8), const SizedBox(width: 8),
Flexible( Flexible(
flex: 2, flex: 2,
child: Align( child: SizedBox(
alignment: Alignment.centerRight, height: 36,
child: Tooltip( child: Tooltip(
message: 'مبلغ کل این ردیف', message: 'مبلغ کل این ردیف',
child: Text( child: InputDecorator(
formatWithThousands(item.total, decimalPlaces: 0), decoration: const InputDecoration(
style: theme.textTheme.bodyMedium, isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
child: Align(
alignment: Alignment.centerRight,
child: Text(
formatWithThousands(item.total, decimalPlaces: 0),
style: theme.textTheme.bodyMedium,
),
),
), ),
), ),
), ),
@ -449,11 +457,12 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
onChanged: (v) { onChanged: (v) {
_updateRow(index, item.copyWith(description: v)); _updateRow(index, item.copyWith(description: v));
}, },
decoration: const InputDecoration( decoration: const InputDecoration(
isDense: true, isDense: true,
border: OutlineInputBorder(), border: OutlineInputBorder(),
hintText: 'شرح (اختیاری)' contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
), hintText: 'شرح (اختیاری)'
),
), ),
), ),
), ),
@ -509,26 +518,7 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
} }
Widget _buildFooter(BuildContext context) { // فوتر جمعها حذف شد؛ جمعها در صفحهٔ والد نمایش داده میشوند
final sumSubtotal = _rows.fold<num>(0, (acc, e) => acc + e.subtotal);
final sumDiscount = _rows.fold<num>(0, (acc, e) => acc + e.discountAmount);
final sumTax = _rows.fold<num>(0, (acc, e) => acc + e.taxAmount);
final sumTotal = _rows.fold<num>(0, (acc, e) => acc + e.total);
final style = Theme.of(context).textTheme.bodyLarge;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
children: [
const Spacer(),
SizedBox(width: 140, child: Text('جمع مبلغ: ${formatWithThousands(sumSubtotal, decimalPlaces: 0)}', style: style)),
SizedBox(width: 120, child: Text('جمع تخفیف: ${formatWithThousands(sumDiscount, decimalPlaces: 0)}', style: style)),
SizedBox(width: 120, child: Text('جمع مالیات: ${formatWithThousands(sumTax, decimalPlaces: 0)}', style: style)),
SizedBox(width: 140, child: Align(alignment: Alignment.centerRight, child: Text('جمع کل: ${formatWithThousands(sumTotal, decimalPlaces: 0)}', style: style))),
const SizedBox(width: 40),
],
),
);
}
void _showUnitSelectorDialog(InvoiceLineItem item, ValueChanged<String?> onChanged) { void _showUnitSelectorDialog(InvoiceLineItem item, ValueChanged<String?> onChanged) {
showDialog( showDialog(
@ -625,7 +615,7 @@ class _DiscountCellState extends State<_DiscountCell> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
String _typeLabel(String t) => t == 'percent' ? 'درصد' : 'مبلغ'; String typeLabel(String t) => t == 'percent' ? 'درصد' : 'مبلغ';
return SizedBox( return SizedBox(
height: 36, height: 36,
child: TextFormField( child: TextFormField(
@ -647,7 +637,7 @@ class _DiscountCellState extends State<_DiscountCell> {
else else
Padding( Padding(
padding: const EdgeInsetsDirectional.only(end: 4), padding: const EdgeInsetsDirectional.only(end: 4),
child: Text(_typeLabel(_type), style: theme.textTheme.bodySmall), child: Text(typeLabel(_type), style: theme.textTheme.bodySmall),
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
tooltip: 'نوع تخفیف', tooltip: 'نوع تخفیف',
@ -694,11 +684,13 @@ class _TaxCell extends StatefulWidget {
class _TaxCellState extends State<_TaxCell> { class _TaxCellState extends State<_TaxCell> {
late TextEditingController _controller; late TextEditingController _controller;
bool _isUserTyping = false; bool _isUserTyping = false;
late TextEditingController _amountCtrl;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = TextEditingController(text: widget.rate.toString()); _controller = TextEditingController(text: widget.rate.toString());
_amountCtrl = TextEditingController(text: formatWithThousands(widget.taxAmount, decimalPlaces: 0));
} }
@override @override
@ -708,11 +700,15 @@ class _TaxCellState extends State<_TaxCell> {
if (oldWidget.rate != widget.rate && !_isUserTyping) { if (oldWidget.rate != widget.rate && !_isUserTyping) {
_controller.text = widget.rate.toString(); _controller.text = widget.rate.toString();
} }
if (oldWidget.taxAmount != widget.taxAmount) {
_amountCtrl.text = formatWithThousands(widget.taxAmount, decimalPlaces: 0);
}
} }
@override @override
void dispose() { void dispose() {
_controller.dispose(); _controller.dispose();
_amountCtrl.dispose();
super.dispose(); super.dispose();
} }
@ -722,6 +718,7 @@ class _TaxCellState extends State<_TaxCell> {
children: [ children: [
SizedBox( SizedBox(
width: 70, width: 70,
height: 36,
child: TextFormField( child: TextFormField(
controller: _controller, controller: _controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
@ -735,18 +732,29 @@ class _TaxCellState extends State<_TaxCell> {
} }
}); });
}, },
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder(), suffixText: '%'), decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
suffixText: '%',
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Container( child: SizedBox(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), height: 36,
decoration: BoxDecoration( child: TextFormField(
border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), controller: _amountCtrl,
borderRadius: BorderRadius.circular(8), readOnly: true,
enableInteractiveSelection: false,
textAlign: TextAlign.right,
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
), ),
child: Text(formatWithThousands(widget.taxAmount, decimalPlaces: 0)),
), ),
), ),
], ],
@ -779,7 +787,7 @@ class _UnitPriceCell extends StatefulWidget {
class _UnitPriceCellState extends State<_UnitPriceCell> { class _UnitPriceCellState extends State<_UnitPriceCell> {
late TextEditingController _ctrl; late TextEditingController _ctrl;
bool _loading = false; final bool _loading = false;
final PriceListService _pls = PriceListService(apiClient: ApiClient()); final PriceListService _pls = PriceListService(apiClient: ApiClient());
late FocusNode _focusNode; late FocusNode _focusNode;

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( child: Tooltip(
message: _selected != null ? (_selected!['name']?.toString() ?? '') : widget.hintText, message: _selected != null ? (_selected!['name']?.toString() ?? '') : widget.hintText,
child: DropdownButtonFormField<int>( child: DropdownButtonFormField<int>(
value: _selected != null ? (_selected!['id'] as int) : null, initialValue: _selected != null ? (_selected!['id'] as int) : null,
isExpanded: true, isExpanded: true,
items: _items items: _items
.map((e) => DropdownMenuItem<int>( .map((e) => DropdownMenuItem<int>(

View file

@ -34,7 +34,7 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
void initState() { void initState() {
super.initState(); super.initState();
_searchCtrl.text = widget.selectedProduct != null _searchCtrl.text = widget.selectedProduct != null
? ((widget.selectedProduct!['code']?.toString() ?? '') + ' - ' + (widget.selectedProduct!['name']?.toString() ?? '')) ? ('${widget.selectedProduct!['code']?.toString() ?? ''} - ${widget.selectedProduct!['name']?.toString() ?? ''}')
: ''; : '';
_loadRecent(); _loadRecent();
} }

View file

@ -33,6 +33,9 @@ class _SellerPickerWidgetState extends State<SellerPickerWidget> {
List<Person> _sellers = []; List<Person> _sellers = [];
bool _isLoading = false; bool _isLoading = false;
bool _isSearching = false; bool _isSearching = false;
int _searchSeq = 0; // برای جلوگیری از نمایش نتایج قدیمی
String _latestQuery = '';
void Function(void Function())? _setModalState;
@override @override
void initState() { void initState() {
@ -57,7 +60,8 @@ class _SellerPickerWidgetState extends State<SellerPickerWidget> {
final response = await _personService.getPersons( final response = await _personService.getPersons(
businessId: widget.businessId, businessId: widget.businessId,
filters: { filters: {
'person_types': ['فروشنده', 'بازاریاب'], // فقط فروشنده و بازاریاب // یکسانسازی با API: استفاده از person_types (لیستی از مقادیر)
'person_types': ['فروشنده', 'بازاریاب'],
}, },
limit: 100, // دریافت همه فروشندگان/بازاریابها limit: 100, // دریافت همه فروشندگان/بازاریابها
); );
@ -86,40 +90,62 @@ class _SellerPickerWidgetState extends State<SellerPickerWidget> {
} }
Future<void> _searchSellers(String query) async { Future<void> _searchSellers(String query) async {
if (query.isEmpty) { final int seq = ++_searchSeq;
_loadSellers(); _latestQuery = query;
return;
}
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isSearching = true; if (query.isEmpty) {
_isLoading = true; // برای نمایش لودینگ مرکزی هنگام پاککردن کوئری
} else {
_isSearching = true; // برای نمایش اسپینر کوچک کنار فیلد جستوجو
}
}); });
_setModalState?.call(() {});
try { try {
final response = await _personService.getPersons( final response = await _personService.getPersons(
businessId: widget.businessId, businessId: widget.businessId,
search: query, search: query.isEmpty ? null : query,
filters: { filters: {
'person_types': ['فروشنده', 'بازاریاب'], 'person_types': ['فروشنده', 'بازاریاب'],
}, },
limit: 50, limit: query.isEmpty ? 100 : 50,
); );
// پاسخ کهنه را نادیده بگیر
if (seq != _searchSeq || query != _latestQuery) {
return;
}
final sellers = _personService.parsePersonsList(response); final sellers = _personService.parsePersonsList(response);
if (mounted) { if (mounted) {
setState(() { setState(() {
_sellers = sellers; _sellers = sellers;
_isSearching = false; if (query.isEmpty) {
_isLoading = false;
} else {
_isSearching = false;
}
}); });
_setModalState?.call(() {});
} }
} catch (e) { } catch (e) {
// پاسخ کهنه را نادیده بگیر
if (seq != _searchSeq || query != _latestQuery) {
return;
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_isSearching = false; if (query.isEmpty) {
_isLoading = false;
} else {
_isSearching = false;
}
}); });
_setModalState?.call(() {});
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('خطا در جست‌وجو: $e'), content: Text('خطا در جست‌وجو: $e'),
@ -134,16 +160,21 @@ class _SellerPickerWidgetState extends State<SellerPickerWidget> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (context) => _SellerPickerBottomSheet( builder: (context) => StatefulBuilder(
sellers: _sellers, builder: (context, setModalState) {
selectedSeller: widget.selectedSeller, _setModalState = setModalState;
onSellerSelected: (seller) { return _SellerPickerBottomSheet(
widget.onSellerChanged(seller); sellers: _sellers,
Navigator.pop(context); selectedSeller: widget.selectedSeller,
onSellerSelected: (seller) {
widget.onSellerChanged(seller);
Navigator.pop(context);
},
searchController: _searchController,
onSearchChanged: _searchSellers,
isLoading: _isLoading || _isSearching,
);
}, },
searchController: _searchController,
onSearchChanged: _searchSellers,
isLoading: _isLoading || _isSearching,
), ),
); );
} }

View file

@ -194,7 +194,7 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
children: [ children: [
Expanded( Expanded(
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
value: _matchBy, initialValue: _matchBy,
isDense: true, isDense: true,
items: [ items: [
DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')), DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')),
@ -208,7 +208,7 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
value: _conflictPolicy, initialValue: _conflictPolicy,
isDense: true, isDense: true,
items: [ items: [
DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')), DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')),

View file

@ -514,7 +514,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
return 'مقدار نامعتبر'; return 'مقدار نامعتبر';
} }
if (parsed < 0) { if (parsed < 0) {
return 'مقدار نمی\تواند منفی باشد'; return 'مقدار نمیتواند منفی باشد';
} }
return null; return null;
} }
@ -526,7 +526,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
return 'مقدار نامعتبر'; return 'مقدار نامعتبر';
} }
if (parsed < 0) { if (parsed < 0) {
return 'مقدار نمی\تواند منفی باشد'; return 'مقدار نمیتواند منفی باشد';
} }
return null; return null;
}, },
@ -611,7 +611,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
const Text('ارز'), const Text('ارز'),
const SizedBox(height: 4), const SizedBox(height: 4),
DropdownButtonFormField<int?>( DropdownButtonFormField<int?>(
value: _selectedCurrencyIds.isNotEmpty ? _selectedCurrencyIds.first : null, initialValue: _selectedCurrencyIds.isNotEmpty ? _selectedCurrencyIds.first : null,
items: [ items: [
const DropdownMenuItem<int?>( const DropdownMenuItem<int?>(
value: null, value: null,
@ -643,7 +643,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
const Text('لیست قیمت'), const Text('لیست قیمت'),
const SizedBox(height: 4), const SizedBox(height: 4),
DropdownButtonFormField<int?>( DropdownButtonFormField<int?>(
value: _selectedPriceListIds.isNotEmpty ? _selectedPriceListIds.first : null, initialValue: _selectedPriceListIds.isNotEmpty ? _selectedPriceListIds.first : null,
items: [ items: [
const DropdownMenuItem<int?>( const DropdownMenuItem<int?>(
value: null, value: null,
@ -675,7 +675,7 @@ class _BulkPriceUpdateDialogState extends State<BulkPriceUpdateDialog> {
const Text('نوع آیتم'), const Text('نوع آیتم'),
const SizedBox(height: 4), const SizedBox(height: 4),
DropdownButtonFormField<String?>( DropdownButtonFormField<String?>(
value: _selectedItemTypes.isNotEmpty ? _selectedItemTypes.first : null, initialValue: _selectedItemTypes.isNotEmpty ? _selectedItemTypes.first : null,
items: const [ items: const [
DropdownMenuItem<String?>( DropdownMenuItem<String?>(
value: null, value: null,

View file

@ -193,7 +193,7 @@ class _ProductImportDialogState extends State<ProductImportDialog> {
children: [ children: [
Expanded( Expanded(
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
value: _matchBy, initialValue: _matchBy,
isDense: true, isDense: true,
items: [ items: [
DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')), DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')),
@ -206,7 +206,7 @@ class _ProductImportDialogState extends State<ProductImportDialog> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
value: _conflictPolicy, initialValue: _conflictPolicy,
isDense: true, isDense: true,
items: [ items: [
DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')), DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')),

View file

@ -190,7 +190,7 @@ class ProductPricingInventorySection extends StatelessWidget {
), ),
), ),
); );
}).toList(), }),
], ],
); );
} }
@ -290,7 +290,7 @@ class ProductPricingInventorySection extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
DropdownButtonFormField<int>( DropdownButtonFormField<int>(
value: priceListId, initialValue: priceListId,
items: priceLists items: priceLists
.map((pl) => DropdownMenuItem<int>( .map((pl) => DropdownMenuItem<int>(
value: (pl['id'] as num).toInt(), value: (pl['id'] as num).toInt(),
@ -303,7 +303,7 @@ class ProductPricingInventorySection extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
DropdownButtonFormField<int>( DropdownButtonFormField<int>(
value: currencyId, initialValue: currencyId,
items: currencies items: currencies
.map((c) => DropdownMenuItem<int>( .map((c) => DropdownMenuItem<int>(
value: (c['id'] as num).toInt(), value: (c['id'] as num).toInt(),