2025-11-03 15:54:44 +03:30
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:hesabix_ui/widgets/data_table/data_table_widget.dart';
|
|
|
|
|
import 'package:hesabix_ui/widgets/data_table/data_table_config.dart';
|
|
|
|
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
|
|
|
|
import 'package:hesabix_ui/core/calendar_controller.dart';
|
|
|
|
|
import 'package:hesabix_ui/widgets/date_input_field.dart';
|
|
|
|
|
import 'package:hesabix_ui/models/person_model.dart';
|
|
|
|
|
import 'package:hesabix_ui/models/account_model.dart';
|
|
|
|
|
import 'package:hesabix_ui/widgets/invoice/person_combobox_widget.dart';
|
|
|
|
|
import 'package:hesabix_ui/widgets/invoice/product_combobox_widget.dart';
|
|
|
|
|
import 'package:hesabix_ui/widgets/invoice/bank_account_combobox_widget.dart';
|
|
|
|
|
import 'package:hesabix_ui/widgets/invoice/cash_register_combobox_widget.dart';
|
|
|
|
|
import 'package:hesabix_ui/widgets/invoice/petty_cash_combobox_widget.dart';
|
|
|
|
|
import 'package:hesabix_ui/widgets/invoice/account_tree_combobox_widget.dart';
|
|
|
|
|
import 'package:hesabix_ui/widgets/invoice/check_combobox_widget.dart';
|
|
|
|
|
import 'package:hesabix_ui/core/api_client.dart';
|
|
|
|
|
import 'package:hesabix_ui/services/business_dashboard_service.dart';
|
2025-11-04 05:21:23 +03:30
|
|
|
import 'package:hesabix_ui/services/person_service.dart';
|
|
|
|
|
import 'package:hesabix_ui/services/product_service.dart';
|
|
|
|
|
import 'package:hesabix_ui/services/bank_account_service.dart';
|
|
|
|
|
import 'package:hesabix_ui/services/cash_register_service.dart';
|
|
|
|
|
import 'package:hesabix_ui/services/petty_cash_service.dart';
|
|
|
|
|
import 'package:hesabix_ui/services/account_service.dart';
|
|
|
|
|
import 'package:hesabix_ui/services/check_service.dart';
|
|
|
|
|
import 'package:hesabix_ui/services/warehouse_service.dart';
|
|
|
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
2025-11-03 15:54:44 +03:30
|
|
|
|
|
|
|
|
class KardexPage extends StatefulWidget {
|
|
|
|
|
final int businessId;
|
|
|
|
|
final CalendarController calendarController;
|
2025-11-04 05:21:23 +03:30
|
|
|
final List<int>? initialPersonIds;
|
|
|
|
|
const KardexPage({super.key, required this.businessId, required this.calendarController, this.initialPersonIds});
|
2025-11-03 15:54:44 +03:30
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<KardexPage> createState() => _KardexPageState();
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 05:21:23 +03:30
|
|
|
enum FilterType { person, product, bank, cash, petty, account, check }
|
|
|
|
|
|
2025-11-03 15:54:44 +03:30
|
|
|
class _KardexPageState extends State<KardexPage> {
|
|
|
|
|
final GlobalKey _tableKey = GlobalKey();
|
2025-11-04 05:21:23 +03:30
|
|
|
final GlobalKey _addFilterBtnKey = GlobalKey();
|
|
|
|
|
void _log(String msg) {
|
|
|
|
|
// ignore: avoid_print
|
|
|
|
|
print('[KardexPage] ' + msg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unified filter picker control
|
|
|
|
|
FilterType? _activePicker;
|
|
|
|
|
bool _manualApply = false;
|
|
|
|
|
Timer? _applyDebounce;
|
|
|
|
|
// Presets
|
|
|
|
|
Map<String, Map<String, dynamic>> _presets = <String, Map<String, dynamic>>{};
|
|
|
|
|
String? _selectedPresetName;
|
2025-11-03 15:54:44 +03:30
|
|
|
|
|
|
|
|
// Simple filter inputs (initial version)
|
|
|
|
|
DateTime? _fromDate;
|
|
|
|
|
DateTime? _toDate;
|
|
|
|
|
String _matchMode = 'any';
|
|
|
|
|
String _resultScope = 'lines_matching';
|
|
|
|
|
bool _includeRunningBalance = false;
|
|
|
|
|
int? _selectedFiscalYearId;
|
|
|
|
|
List<Map<String, dynamic>> _fiscalYears = const [];
|
|
|
|
|
|
|
|
|
|
// Multi-select state
|
|
|
|
|
final List<Person> _selectedPersons = [];
|
|
|
|
|
final List<Map<String, dynamic>> _selectedProducts = [];
|
|
|
|
|
final List<BankAccountOption> _selectedBankAccounts = [];
|
|
|
|
|
final List<CashRegisterOption> _selectedCashRegisters = [];
|
|
|
|
|
final List<PettyCashOption> _selectedPettyCash = [];
|
|
|
|
|
final List<Account> _selectedAccounts = [];
|
|
|
|
|
final List<CheckOption> _selectedChecks = [];
|
2025-11-04 05:21:23 +03:30
|
|
|
final List<Map<String, dynamic>> _selectedWarehouses = [];
|
2025-11-03 15:54:44 +03:30
|
|
|
// Initial filters from URL
|
|
|
|
|
List<int> _initialPersonIds = const [];
|
2025-11-04 05:21:23 +03:30
|
|
|
final PersonService _personService = PersonService();
|
|
|
|
|
List<int> _initialProductIds = const [];
|
|
|
|
|
List<int> _initialBankAccountIds = const [];
|
|
|
|
|
List<int> _initialCashRegisterIds = const [];
|
|
|
|
|
List<int> _initialPettyCashIds = const [];
|
|
|
|
|
List<int> _initialAccountIds = const [];
|
|
|
|
|
List<int> _initialCheckIds = const [];
|
|
|
|
|
List<int> _initialWarehouseIds = const [];
|
|
|
|
|
|
|
|
|
|
final ProductService _productService = ProductService();
|
|
|
|
|
final BankAccountService _bankAccountService = BankAccountService();
|
|
|
|
|
final CashRegisterService _cashRegisterService = CashRegisterService();
|
|
|
|
|
final PettyCashService _pettyCashService = PettyCashService();
|
|
|
|
|
final AccountService _accountService = AccountService();
|
|
|
|
|
final CheckService _checkService = CheckService();
|
|
|
|
|
final WarehouseService _warehouseService = WarehouseService();
|
2025-11-03 15:54:44 +03:30
|
|
|
|
|
|
|
|
// Temp selections for pickers (to clear after add)
|
|
|
|
|
Person? _personToAdd;
|
|
|
|
|
Map<String, dynamic>? _productToAdd;
|
|
|
|
|
BankAccountOption? _bankToAdd;
|
|
|
|
|
CashRegisterOption? _cashToAdd;
|
|
|
|
|
PettyCashOption? _pettyToAdd;
|
|
|
|
|
Account? _accountToAdd;
|
|
|
|
|
CheckOption? _checkToAdd;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _refreshData() {
|
2025-11-04 05:21:23 +03:30
|
|
|
_log('Manual refresh triggered. additionalParams=' + _additionalParams().toString());
|
2025-11-03 15:54:44 +03:30
|
|
|
final state = _tableKey.currentState;
|
|
|
|
|
if (state != null) {
|
|
|
|
|
try {
|
|
|
|
|
(state as dynamic).refresh();
|
|
|
|
|
return;
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
if (mounted) setState(() {});
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-04 05:21:23 +03:30
|
|
|
void _scheduleApply() {
|
|
|
|
|
if (_manualApply) return;
|
|
|
|
|
_applyDebounce?.cancel();
|
|
|
|
|
_applyDebounce = Timer(const Duration(milliseconds: 500), () {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
_refreshData();
|
|
|
|
|
_updateRouteQuery();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _updateRouteQuery() {
|
|
|
|
|
try {
|
|
|
|
|
final qp = <String, String>{};
|
|
|
|
|
Map<String, dynamic> params = _additionalParams();
|
|
|
|
|
List<int> idsOf(String key) => (params[key] as List<dynamic>? ?? const <dynamic>[])
|
|
|
|
|
.map((e) => int.tryParse('$e'))
|
|
|
|
|
.whereType<int>()
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
void addCsv(String key, List<int> ids) {
|
|
|
|
|
if (ids.isEmpty) return;
|
|
|
|
|
qp[key] = ids.join(',');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addCsv('person_ids', idsOf('person_ids'));
|
|
|
|
|
addCsv('product_ids', idsOf('product_ids'));
|
|
|
|
|
addCsv('bank_account_ids', idsOf('bank_account_ids'));
|
|
|
|
|
addCsv('cash_register_ids', idsOf('cash_register_ids'));
|
|
|
|
|
addCsv('petty_cash_ids', idsOf('petty_cash_ids'));
|
|
|
|
|
addCsv('account_ids', idsOf('account_ids'));
|
|
|
|
|
addCsv('check_ids', idsOf('check_ids'));
|
|
|
|
|
addCsv('warehouse_ids', idsOf('warehouse_ids'));
|
|
|
|
|
if (params['from_date'] != null) qp['dateFrom'] = '${params['from_date']}';
|
|
|
|
|
if (params['to_date'] != null) qp['dateTo'] = '${params['to_date']}';
|
|
|
|
|
if (params['fiscal_year_id'] != null) qp['fiscal_year_id'] = '${params['fiscal_year_id']}';
|
|
|
|
|
if ((params['match_mode'] ?? '').toString().isNotEmpty) qp['match_mode'] = '${params['match_mode']}';
|
|
|
|
|
if ((params['result_scope'] ?? '').toString().isNotEmpty) qp['result_scope'] = '${params['result_scope']}';
|
|
|
|
|
|
|
|
|
|
final path = '/business/${widget.businessId}/reports/kardex';
|
|
|
|
|
final uri = Uri(path: path, queryParameters: qp.isEmpty ? null : qp);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
context.go(uri.toString());
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _clearAllFilters() {
|
|
|
|
|
setState(() {
|
|
|
|
|
_fromDate = null;
|
|
|
|
|
_toDate = null;
|
|
|
|
|
_selectedFiscalYearId = _selectedFiscalYearId; // نگهداشتن سال مالی انتخابشده
|
|
|
|
|
_matchMode = 'any';
|
|
|
|
|
_resultScope = 'lines_matching';
|
|
|
|
|
_includeRunningBalance = false;
|
|
|
|
|
|
|
|
|
|
_selectedPersons.clear();
|
|
|
|
|
_selectedProducts.clear();
|
|
|
|
|
_selectedBankAccounts.clear();
|
|
|
|
|
_selectedCashRegisters.clear();
|
|
|
|
|
_selectedPettyCash.clear();
|
|
|
|
|
_selectedAccounts.clear();
|
|
|
|
|
_selectedChecks.clear();
|
|
|
|
|
|
|
|
|
|
// همچنین fallback اولیه را خنثی میکنیم تا بعد از ریست از URL خوانده نشود
|
|
|
|
|
_initialPersonIds = const [];
|
|
|
|
|
_initialProductIds = const [];
|
|
|
|
|
_initialBankAccountIds = const [];
|
|
|
|
|
_initialCashRegisterIds = const [];
|
|
|
|
|
_initialPettyCashIds = const [];
|
|
|
|
|
_initialAccountIds = const [];
|
|
|
|
|
_initialCheckIds = const [];
|
|
|
|
|
_initialWarehouseIds = const [];
|
|
|
|
|
_activePicker = null;
|
|
|
|
|
});
|
|
|
|
|
// اعمال فوری
|
|
|
|
|
_refreshData();
|
|
|
|
|
_updateRouteQuery();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _presetsKey() => 'kardex_presets_${widget.businessId}';
|
|
|
|
|
|
|
|
|
|
Future<void> _loadPresets() async {
|
|
|
|
|
try {
|
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
|
final raw = prefs.getString(_presetsKey());
|
|
|
|
|
if (raw != null && raw.isNotEmpty) {
|
|
|
|
|
final map = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
|
|
|
|
final converted = <String, Map<String, dynamic>>{};
|
|
|
|
|
for (final entry in map.entries) {
|
|
|
|
|
converted[entry.key] = Map<String, dynamic>.from(entry.value as Map);
|
|
|
|
|
}
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_presets = converted;
|
|
|
|
|
if (_presets.isNotEmpty && _selectedPresetName == null) {
|
|
|
|
|
_selectedPresetName = _presets.keys.first;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _savePreset(String name) async {
|
|
|
|
|
try {
|
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
|
final params = _additionalParams();
|
|
|
|
|
final updated = Map<String, Map<String, dynamic>>.from(_presets);
|
|
|
|
|
updated[name] = params;
|
|
|
|
|
await prefs.setString(_presetsKey(), jsonEncode(updated));
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_presets = updated;
|
|
|
|
|
_selectedPresetName = name;
|
|
|
|
|
});
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('پریست ذخیره شد')));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در ذخیره پریست: $e')));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _deletePreset(String name) async {
|
|
|
|
|
try {
|
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
|
final updated = Map<String, Map<String, dynamic>>.from(_presets);
|
|
|
|
|
updated.remove(name);
|
|
|
|
|
await prefs.setString(_presetsKey(), jsonEncode(updated));
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_presets = updated;
|
|
|
|
|
if (_selectedPresetName == name) {
|
|
|
|
|
_selectedPresetName = _presets.isNotEmpty ? _presets.keys.first : null;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در حذف پریست: $e')));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _applyPreset(Map<String, dynamic> p) async {
|
|
|
|
|
try {
|
|
|
|
|
DateTime? parseDate(String? s) => (s == null || s.isEmpty) ? null : DateTime.tryParse(s);
|
|
|
|
|
final from = parseDate(p['from_date']?.toString());
|
|
|
|
|
final to = parseDate(p['to_date']?.toString());
|
|
|
|
|
List<int> ids(String key) {
|
|
|
|
|
final raw = (p[key] as List<dynamic>? ?? const <dynamic>[]);
|
|
|
|
|
return raw.map((e) => int.tryParse('$e')).whereType<int>().toList();
|
|
|
|
|
}
|
|
|
|
|
setState(() {
|
|
|
|
|
_fromDate = from;
|
|
|
|
|
_toDate = to;
|
|
|
|
|
_selectedFiscalYearId = (p['fiscal_year_id'] is int) ? p['fiscal_year_id'] as int : int.tryParse('${p['fiscal_year_id'] ?? ''}');
|
|
|
|
|
_matchMode = (p['match_mode'] ?? 'any').toString();
|
|
|
|
|
_resultScope = (p['result_scope'] ?? 'lines_matching').toString();
|
|
|
|
|
_includeRunningBalance = (p['include_running_balance'] == true);
|
|
|
|
|
|
|
|
|
|
_selectedPersons.clear();
|
|
|
|
|
_selectedProducts.clear();
|
|
|
|
|
_selectedBankAccounts.clear();
|
|
|
|
|
_selectedCashRegisters.clear();
|
|
|
|
|
_selectedPettyCash.clear();
|
|
|
|
|
_selectedAccounts.clear();
|
|
|
|
|
_selectedChecks.clear();
|
|
|
|
|
|
|
|
|
|
_initialPersonIds = ids('person_ids');
|
|
|
|
|
_initialProductIds = ids('product_ids');
|
|
|
|
|
_initialBankAccountIds = ids('bank_account_ids');
|
|
|
|
|
_initialCashRegisterIds = ids('cash_register_ids');
|
|
|
|
|
_initialPettyCashIds = ids('petty_cash_ids');
|
|
|
|
|
_initialAccountIds = ids('account_ids');
|
|
|
|
|
_initialCheckIds = ids('check_ids');
|
|
|
|
|
_initialWarehouseIds = ids('warehouse_ids');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (_initialPersonIds.isNotEmpty) await _hydrateInitialPersons(_initialPersonIds);
|
|
|
|
|
if (_initialProductIds.isNotEmpty) await _hydrateInitialProducts(_initialProductIds);
|
|
|
|
|
if (_initialBankAccountIds.isNotEmpty) await _hydrateInitialBankAccounts(_initialBankAccountIds);
|
|
|
|
|
if (_initialCashRegisterIds.isNotEmpty) await _hydrateInitialCashRegisters(_initialCashRegisterIds);
|
|
|
|
|
if (_initialPettyCashIds.isNotEmpty) await _hydrateInitialPettyCash(_initialPettyCashIds);
|
|
|
|
|
if (_initialAccountIds.isNotEmpty) await _hydrateInitialAccounts(_initialAccountIds);
|
|
|
|
|
if (_initialCheckIds.isNotEmpty) await _hydrateInitialChecks(_initialCheckIds);
|
|
|
|
|
if (_initialWarehouseIds.isNotEmpty) await _hydrateInitialWarehouses(_initialWarehouseIds);
|
|
|
|
|
|
|
|
|
|
_updateRouteQuery();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در اعمال پریست: $e')));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-03 15:54:44 +03:30
|
|
|
Map<String, dynamic> _additionalParams() {
|
|
|
|
|
String? fmt(DateTime? d) => d == null ? null : d.toIso8601String().substring(0, 10);
|
|
|
|
|
var personIds = _selectedPersons.map((p) => p.id).whereType<int>().toList();
|
|
|
|
|
if (personIds.isEmpty && _initialPersonIds.isNotEmpty) {
|
|
|
|
|
personIds = List<int>.from(_initialPersonIds);
|
|
|
|
|
}
|
2025-11-04 05:21:23 +03:30
|
|
|
var productIds = _selectedProducts.map((m) => m['id']).map((e) => int.tryParse('$e')).whereType<int>().toList();
|
|
|
|
|
if (productIds.isEmpty && _initialProductIds.isNotEmpty) {
|
|
|
|
|
productIds = List<int>.from(_initialProductIds);
|
|
|
|
|
}
|
|
|
|
|
var bankIds = _selectedBankAccounts.map((b) => int.tryParse(b.id)).whereType<int>().toList();
|
|
|
|
|
if (bankIds.isEmpty && _initialBankAccountIds.isNotEmpty) {
|
|
|
|
|
bankIds = List<int>.from(_initialBankAccountIds);
|
|
|
|
|
}
|
|
|
|
|
var cashIds = _selectedCashRegisters.map((c) => int.tryParse(c.id)).whereType<int>().toList();
|
|
|
|
|
if (cashIds.isEmpty && _initialCashRegisterIds.isNotEmpty) {
|
|
|
|
|
cashIds = List<int>.from(_initialCashRegisterIds);
|
|
|
|
|
}
|
|
|
|
|
var pettyIds = _selectedPettyCash.map((p) => int.tryParse(p.id)).whereType<int>().toList();
|
|
|
|
|
if (pettyIds.isEmpty && _initialPettyCashIds.isNotEmpty) {
|
|
|
|
|
pettyIds = List<int>.from(_initialPettyCashIds);
|
|
|
|
|
}
|
|
|
|
|
var accountIds = _selectedAccounts.map((a) => a.id).whereType<int>().toList();
|
|
|
|
|
if (accountIds.isEmpty && _initialAccountIds.isNotEmpty) {
|
|
|
|
|
accountIds = List<int>.from(_initialAccountIds);
|
|
|
|
|
}
|
|
|
|
|
var checkIds = _selectedChecks.map((c) => int.tryParse(c.id)).whereType<int>().toList();
|
|
|
|
|
if (checkIds.isEmpty && _initialCheckIds.isNotEmpty) {
|
|
|
|
|
checkIds = List<int>.from(_initialCheckIds);
|
|
|
|
|
}
|
|
|
|
|
var warehouseIds = _selectedWarehouses.map((w) => int.tryParse('${w['id']}')).whereType<int>().toList();
|
|
|
|
|
if (warehouseIds.isEmpty && _initialWarehouseIds.isNotEmpty) {
|
|
|
|
|
warehouseIds = List<int>.from(_initialWarehouseIds);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final params = {
|
2025-11-03 15:54:44 +03:30
|
|
|
if (_fromDate != null) 'from_date': fmt(_fromDate),
|
|
|
|
|
if (_toDate != null) 'to_date': fmt(_toDate),
|
|
|
|
|
'person_ids': personIds,
|
|
|
|
|
'product_ids': productIds,
|
|
|
|
|
'bank_account_ids': bankIds,
|
|
|
|
|
'cash_register_ids': cashIds,
|
|
|
|
|
'petty_cash_ids': pettyIds,
|
|
|
|
|
'account_ids': accountIds,
|
|
|
|
|
'check_ids': checkIds,
|
2025-11-04 05:21:23 +03:30
|
|
|
'warehouse_ids': warehouseIds,
|
2025-11-03 15:54:44 +03:30
|
|
|
'match_mode': _matchMode,
|
|
|
|
|
'result_scope': _resultScope,
|
|
|
|
|
'include_running_balance': _includeRunningBalance,
|
|
|
|
|
if (_selectedFiscalYearId != null) 'fiscal_year_id': _selectedFiscalYearId,
|
|
|
|
|
};
|
2025-11-04 05:21:23 +03:30
|
|
|
_log('Built additionalParams=' + params.toString());
|
|
|
|
|
return params;
|
2025-11-03 15:54:44 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DataTableConfig<Map<String, dynamic>> _buildTableConfig(AppLocalizations t) {
|
|
|
|
|
return DataTableConfig<Map<String, dynamic>>(
|
|
|
|
|
endpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines',
|
|
|
|
|
excelEndpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines/export/excel',
|
|
|
|
|
pdfEndpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines/export/pdf',
|
2025-11-09 08:46:37 +03:30
|
|
|
businessId: widget.businessId,
|
|
|
|
|
reportModuleKey: 'kardex',
|
|
|
|
|
reportSubtype: 'list',
|
2025-11-03 15:54:44 +03:30
|
|
|
columns: [
|
|
|
|
|
DateColumn('document_date', 'تاریخ سند',
|
|
|
|
|
formatter: (item) => (item as Map<String, dynamic>)['document_date']?.toString()),
|
|
|
|
|
TextColumn('document_code', 'کد سند',
|
|
|
|
|
formatter: (item) => (item as Map<String, dynamic>)['document_code']?.toString()),
|
|
|
|
|
TextColumn('document_type', 'نوع سند',
|
|
|
|
|
formatter: (item) => (item as Map<String, dynamic>)['document_type']?.toString()),
|
2025-11-04 05:21:23 +03:30
|
|
|
TextColumn('warehouse_name', 'انبار',
|
|
|
|
|
formatter: (item) {
|
|
|
|
|
final m = (item as Map<String, dynamic>);
|
|
|
|
|
return (m['warehouse_name'] ?? m['warehouse_id'])?.toString();
|
|
|
|
|
}),
|
|
|
|
|
TextColumn('movement', 'جهت حرکت',
|
|
|
|
|
formatter: (item) => (item as Map<String, dynamic>)['movement']?.toString()),
|
2025-11-03 15:54:44 +03:30
|
|
|
TextColumn('description', 'شرح',
|
|
|
|
|
formatter: (item) => (item as Map<String, dynamic>)['description']?.toString()),
|
|
|
|
|
NumberColumn('debit', 'بدهکار',
|
|
|
|
|
formatter: (item) => ((item as Map<String, dynamic>)['debit'])?.toString()),
|
|
|
|
|
NumberColumn('credit', 'بستانکار',
|
|
|
|
|
formatter: (item) => ((item as Map<String, dynamic>)['credit'])?.toString()),
|
|
|
|
|
NumberColumn('quantity', 'تعداد',
|
|
|
|
|
formatter: (item) => ((item as Map<String, dynamic>)['quantity'])?.toString()),
|
|
|
|
|
NumberColumn('running_amount', 'مانده مبلغ',
|
|
|
|
|
formatter: (item) => ((item as Map<String, dynamic>)['running_amount'])?.toString()),
|
|
|
|
|
NumberColumn('running_quantity', 'مانده تعداد',
|
|
|
|
|
formatter: (item) => ((item as Map<String, dynamic>)['running_quantity'])?.toString()),
|
|
|
|
|
],
|
|
|
|
|
searchFields: const [],
|
|
|
|
|
defaultPageSize: 20,
|
|
|
|
|
additionalParams: _additionalParams(),
|
|
|
|
|
showExportButtons: true,
|
|
|
|
|
getExportParams: () => _additionalParams(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_loadFiscalYears();
|
|
|
|
|
_parseInitialQueryParams();
|
2025-11-04 05:21:23 +03:30
|
|
|
_loadPresets();
|
|
|
|
|
if (widget.initialPersonIds != null && widget.initialPersonIds!.isNotEmpty) {
|
|
|
|
|
_initialPersonIds = List<int>.from(widget.initialPersonIds!);
|
|
|
|
|
}
|
|
|
|
|
_log('initState: initialPersonIds=' + _initialPersonIds.toString());
|
|
|
|
|
if (_initialPersonIds.isNotEmpty) {
|
|
|
|
|
_hydrateInitialPersons(_initialPersonIds);
|
|
|
|
|
}
|
|
|
|
|
if (_initialProductIds.isNotEmpty) {
|
|
|
|
|
_hydrateInitialProducts(_initialProductIds);
|
|
|
|
|
}
|
|
|
|
|
if (_initialBankAccountIds.isNotEmpty) {
|
|
|
|
|
_hydrateInitialBankAccounts(_initialBankAccountIds);
|
|
|
|
|
}
|
|
|
|
|
if (_initialCashRegisterIds.isNotEmpty) {
|
|
|
|
|
_hydrateInitialCashRegisters(_initialCashRegisterIds);
|
|
|
|
|
}
|
|
|
|
|
if (_initialPettyCashIds.isNotEmpty) {
|
|
|
|
|
_hydrateInitialPettyCash(_initialPettyCashIds);
|
|
|
|
|
}
|
|
|
|
|
if (_initialAccountIds.isNotEmpty) {
|
|
|
|
|
_hydrateInitialAccounts(_initialAccountIds);
|
|
|
|
|
}
|
|
|
|
|
if (_initialCheckIds.isNotEmpty) {
|
|
|
|
|
_hydrateInitialChecks(_initialCheckIds);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _hydrateInitialPersons(List<int> ids) async {
|
|
|
|
|
try {
|
|
|
|
|
final added = <int>{ for (final p in _selectedPersons) if (p.id != null) p.id! };
|
|
|
|
|
for (final id in ids) {
|
|
|
|
|
if (added.contains(id)) continue;
|
|
|
|
|
final person = await _personService.getPerson(id);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_selectedPersons.add(person);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
_log('Hydrated selected persons from ids=' + ids.toString());
|
|
|
|
|
// بعد از نمایش چیپها، رفرش کن تا پارامترهای انتخابی همواره ارسال شوند
|
|
|
|
|
_refreshData();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_log('Failed to hydrate persons: ' + e.toString());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _hydrateInitialProducts(List<int> ids) async {
|
|
|
|
|
try {
|
|
|
|
|
final added = <int>{ for (final m in _selectedProducts) int.tryParse('${m['id']}') ?? -1 };
|
|
|
|
|
for (final id in ids) {
|
|
|
|
|
if (added.contains(id)) continue;
|
|
|
|
|
try {
|
|
|
|
|
final m = await _productService.getProduct(businessId: widget.businessId, productId: id);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_selectedProducts.add(<String, dynamic>{
|
|
|
|
|
'id': m['id'],
|
|
|
|
|
'code': m['code'],
|
|
|
|
|
'name': m['name'],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
_refreshData();
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _hydrateInitialBankAccounts(List<int> ids) async {
|
|
|
|
|
try {
|
|
|
|
|
final added = <int>{ for (final it in _selectedBankAccounts) int.tryParse(it.id) ?? -1 };
|
|
|
|
|
for (final id in ids) {
|
|
|
|
|
if (added.contains(id)) continue;
|
|
|
|
|
try {
|
|
|
|
|
final acc = await _bankAccountService.getById(id);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_selectedBankAccounts.add(BankAccountOption('${acc.id}', acc.name, currencyId: acc.currencyId));
|
|
|
|
|
});
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
_refreshData();
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _hydrateInitialCashRegisters(List<int> ids) async {
|
|
|
|
|
try {
|
|
|
|
|
final added = <int>{ for (final it in _selectedCashRegisters) int.tryParse(it.id) ?? -1 };
|
|
|
|
|
for (final id in ids) {
|
|
|
|
|
if (added.contains(id)) continue;
|
|
|
|
|
try {
|
|
|
|
|
final cr = await _cashRegisterService.getById(id);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_selectedCashRegisters.add(CashRegisterOption('${cr.id}', cr.name, currencyId: cr.currencyId));
|
|
|
|
|
});
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
_refreshData();
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _hydrateInitialPettyCash(List<int> ids) async {
|
|
|
|
|
try {
|
|
|
|
|
final added = <int>{ for (final it in _selectedPettyCash) int.tryParse(it.id) ?? -1 };
|
|
|
|
|
for (final id in ids) {
|
|
|
|
|
if (added.contains(id)) continue;
|
|
|
|
|
try {
|
|
|
|
|
final pc = await _pettyCashService.getById(id);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_selectedPettyCash.add(PettyCashOption('${pc.id}', pc.name, currencyId: pc.currencyId));
|
|
|
|
|
});
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
_refreshData();
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _hydrateInitialAccounts(List<int> ids) async {
|
|
|
|
|
try {
|
|
|
|
|
final added = <int>{ for (final it in _selectedAccounts) it.id ?? -1 };
|
|
|
|
|
for (final id in ids) {
|
|
|
|
|
if (added.contains(id)) continue;
|
|
|
|
|
try {
|
|
|
|
|
final m = await _accountService.getAccount(businessId: widget.businessId, accountId: id);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_selectedAccounts.add(Account.fromJson(m));
|
|
|
|
|
});
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
_refreshData();
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _hydrateInitialChecks(List<int> ids) async {
|
|
|
|
|
try {
|
|
|
|
|
final added = <int>{ for (final it in _selectedChecks) int.tryParse(it.id) ?? -1 };
|
|
|
|
|
for (final id in ids) {
|
|
|
|
|
if (added.contains(id)) continue;
|
|
|
|
|
try {
|
|
|
|
|
final m = await _checkService.getById(id);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
final checkNumber = (m['check_number'] ?? '').toString();
|
|
|
|
|
final personName = (m['person_name'] ?? m['holder_name'])?.toString();
|
|
|
|
|
final bankName = (m['bank_name'] ?? '').toString();
|
|
|
|
|
final sayad = (m['sayad_code'] ?? '').toString();
|
|
|
|
|
setState(() {
|
|
|
|
|
_selectedChecks.add(CheckOption(
|
|
|
|
|
id: '$id',
|
|
|
|
|
number: checkNumber,
|
|
|
|
|
personName: personName,
|
|
|
|
|
bankName: bankName,
|
|
|
|
|
sayadCode: sayad,
|
|
|
|
|
));
|
|
|
|
|
});
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
_refreshData();
|
|
|
|
|
} catch (_) {}
|
2025-11-03 15:54:44 +03:30
|
|
|
}
|
|
|
|
|
|
2025-11-04 05:21:23 +03:30
|
|
|
Future<void> _hydrateInitialWarehouses(List<int> ids) async {
|
|
|
|
|
try {
|
|
|
|
|
final added = <int>{ for (final it in _selectedWarehouses) int.tryParse('${it['id']}') ?? -1 };
|
|
|
|
|
for (final id in ids) {
|
|
|
|
|
if (added.contains(id)) continue;
|
|
|
|
|
try {
|
|
|
|
|
final w = await _warehouseService.getWarehouse(businessId: widget.businessId, warehouseId: id);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_selectedWarehouses.add(<String, dynamic>{
|
|
|
|
|
'id': w.id,
|
|
|
|
|
'name': w.name,
|
|
|
|
|
'code': w.code,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
_refreshData();
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
2025-11-03 15:54:44 +03:30
|
|
|
void _parseInitialQueryParams() {
|
|
|
|
|
try {
|
|
|
|
|
final uri = Uri.base;
|
2025-11-04 05:21:23 +03:30
|
|
|
_log('Parsing query params: ' + uri.toString());
|
|
|
|
|
List<int> _parseIds(String singularKey, String pluralKey) {
|
|
|
|
|
final out = <int>{};
|
|
|
|
|
final repeated = uri.queryParametersAll[singularKey] ?? const <String>[];
|
|
|
|
|
for (final v in repeated) {
|
|
|
|
|
final p = int.tryParse(v);
|
|
|
|
|
if (p != null) out.add(p);
|
|
|
|
|
}
|
|
|
|
|
final csv = uri.queryParameters[pluralKey];
|
|
|
|
|
if (csv != null && csv.trim().isNotEmpty) {
|
|
|
|
|
for (final part in csv.split(',')) {
|
|
|
|
|
final p = int.tryParse(part.trim());
|
|
|
|
|
if (p != null) out.add(p);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out.toList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_initialPersonIds = _parseIds('person_id', 'person_ids');
|
|
|
|
|
_initialProductIds = _parseIds('product_id', 'product_ids');
|
|
|
|
|
_initialBankAccountIds = _parseIds('bank_account_id', 'bank_account_ids');
|
|
|
|
|
_initialCashRegisterIds = _parseIds('cash_register_id', 'cash_register_ids');
|
|
|
|
|
_initialPettyCashIds = _parseIds('petty_cash_id', 'petty_cash_ids');
|
|
|
|
|
_initialAccountIds = _parseIds('account_id', 'account_ids');
|
|
|
|
|
_initialCheckIds = _parseIds('check_id', 'check_ids');
|
|
|
|
|
_initialWarehouseIds = _parseIds('warehouse_id', 'warehouse_ids');
|
|
|
|
|
|
|
|
|
|
_log('Parsed initial ids | person=' + _initialPersonIds.toString() + ' product=' + _initialProductIds.toString() + ' bank=' + _initialBankAccountIds.toString() + ' cash=' + _initialCashRegisterIds.toString() + ' petty=' + _initialPettyCashIds.toString() + ' account=' + _initialAccountIds.toString() + ' check=' + _initialCheckIds.toString() + ' warehouse=' + _initialWarehouseIds.toString());
|
2025-11-03 15:54:44 +03:30
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _loadFiscalYears() async {
|
|
|
|
|
try {
|
|
|
|
|
final svc = BusinessDashboardService(ApiClient());
|
|
|
|
|
final items = await svc.listFiscalYears(widget.businessId);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_fiscalYears = items;
|
|
|
|
|
final current = items.firstWhere(
|
|
|
|
|
(e) => (e['is_current'] == true),
|
|
|
|
|
orElse: () => const <String, dynamic>{},
|
|
|
|
|
);
|
|
|
|
|
final id = current['id'];
|
|
|
|
|
if (id is int) {
|
|
|
|
|
_selectedFiscalYearId = id;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} catch (_) {
|
|
|
|
|
// ignore errors; dropdown remains empty
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final t = AppLocalizations.of(context);
|
|
|
|
|
return Scaffold(
|
|
|
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
|
|
|
body: SafeArea(
|
|
|
|
|
child: SingleChildScrollView(
|
|
|
|
|
padding: const EdgeInsets.all(8.0),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
children: [
|
|
|
|
|
_buildFilters(t),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
_buildTableArea(t),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildFilters(AppLocalizations t) {
|
|
|
|
|
return Card(
|
|
|
|
|
margin: const EdgeInsets.all(0),
|
|
|
|
|
elevation: 0,
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(8.0),
|
|
|
|
|
child: Wrap(
|
|
|
|
|
spacing: 8,
|
|
|
|
|
runSpacing: 8,
|
|
|
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
|
|
|
children: [
|
2025-11-04 05:21:23 +03:30
|
|
|
// Add filter button
|
|
|
|
|
ElevatedButton.icon(
|
|
|
|
|
key: _addFilterBtnKey,
|
|
|
|
|
onPressed: () async {
|
|
|
|
|
RelativeRect position = const RelativeRect.fromLTRB(100, 100, 0, 0);
|
|
|
|
|
try {
|
|
|
|
|
final RenderBox button = _addFilterBtnKey.currentContext!.findRenderObject() as RenderBox;
|
|
|
|
|
final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
|
|
|
|
|
position = RelativeRect.fromRect(
|
|
|
|
|
Rect.fromPoints(
|
|
|
|
|
button.localToGlobal(Offset.zero, ancestor: overlay),
|
|
|
|
|
button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay),
|
|
|
|
|
),
|
|
|
|
|
Offset.zero & overlay.size,
|
|
|
|
|
);
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
final picked = await showMenu<FilterType>(
|
|
|
|
|
context: context,
|
|
|
|
|
position: position,
|
|
|
|
|
items: [
|
|
|
|
|
PopupMenuItem(value: FilterType.person, child: Text('افزودن فیلتر: اشخاص')),
|
|
|
|
|
PopupMenuItem(value: FilterType.product, child: Text('افزودن فیلتر: کالا/خدمت')),
|
|
|
|
|
PopupMenuItem(value: FilterType.bank, child: Text('افزودن فیلتر: بانک')),
|
|
|
|
|
PopupMenuItem(value: FilterType.cash, child: Text('افزودن فیلتر: صندوق')),
|
|
|
|
|
PopupMenuItem(value: FilterType.petty, child: Text('افزودن فیلتر: تنخواه')),
|
|
|
|
|
PopupMenuItem(value: FilterType.account, child: Text('افزودن فیلتر: حساب دفتری')),
|
|
|
|
|
PopupMenuItem(value: FilterType.check, child: Text('افزودن فیلتر: چک')),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
if (picked != null && mounted) setState(() => _activePicker = picked);
|
|
|
|
|
},
|
|
|
|
|
icon: const Icon(Icons.add),
|
|
|
|
|
label: const Text('افزودن فیلتر'),
|
|
|
|
|
),
|
|
|
|
|
TextButton.icon(
|
|
|
|
|
onPressed: _clearAllFilters,
|
|
|
|
|
icon: const Icon(Icons.refresh),
|
|
|
|
|
label: const Text('بازنشانی'),
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
// Presets controls
|
|
|
|
|
if (_presets.isNotEmpty)
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: 220,
|
|
|
|
|
child: DropdownButtonFormField<String>(
|
|
|
|
|
value: _selectedPresetName,
|
|
|
|
|
items: _presets.keys
|
|
|
|
|
.map((name) => DropdownMenuItem<String>(value: name, child: Text(name)))
|
|
|
|
|
.toList(),
|
|
|
|
|
onChanged: (v) => setState(() => _selectedPresetName = v),
|
|
|
|
|
decoration: const InputDecoration(
|
|
|
|
|
labelText: 'پریستها',
|
|
|
|
|
border: OutlineInputBorder(),
|
|
|
|
|
isDense: true,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
if (_presets.isNotEmpty)
|
|
|
|
|
ElevatedButton.icon(
|
|
|
|
|
onPressed: (_selectedPresetName != null)
|
|
|
|
|
? () => _applyPreset(_presets[_selectedPresetName] ?? const <String, dynamic>{})
|
|
|
|
|
: null,
|
|
|
|
|
icon: const Icon(Icons.playlist_add_check),
|
|
|
|
|
label: const Text('اعمال پریست'),
|
|
|
|
|
),
|
|
|
|
|
if (_presets.isNotEmpty)
|
|
|
|
|
IconButton(
|
|
|
|
|
onPressed: (_selectedPresetName != null)
|
|
|
|
|
? () => _deletePreset(_selectedPresetName!)
|
|
|
|
|
: null,
|
|
|
|
|
tooltip: 'حذف پریست انتخابشده',
|
|
|
|
|
icon: const Icon(Icons.delete_outline),
|
|
|
|
|
),
|
|
|
|
|
TextButton.icon(
|
|
|
|
|
onPressed: () async {
|
|
|
|
|
final controller = TextEditingController();
|
|
|
|
|
final name = await showDialog<String>(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (ctx) => AlertDialog(
|
|
|
|
|
title: const Text('ذخیره پریست'),
|
|
|
|
|
content: TextField(
|
|
|
|
|
controller: controller,
|
|
|
|
|
decoration: const InputDecoration(hintText: 'نام پریست را وارد کنید'),
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('انصراف')),
|
|
|
|
|
ElevatedButton(onPressed: () => Navigator.pop(ctx, controller.text.trim()), child: const Text('ذخیره')),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
if (name != null && name.isNotEmpty) {
|
|
|
|
|
await _savePreset(name);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
icon: const Icon(Icons.save_alt),
|
|
|
|
|
label: const Text('ذخیره پریست'),
|
|
|
|
|
),
|
|
|
|
|
|
2025-11-03 15:54:44 +03:30
|
|
|
SizedBox(
|
|
|
|
|
width: 200,
|
|
|
|
|
child: DateInputField(
|
|
|
|
|
labelText: 'از تاریخ',
|
|
|
|
|
value: _fromDate,
|
2025-11-04 05:21:23 +03:30
|
|
|
onChanged: (d) {
|
|
|
|
|
setState(() => _fromDate = d);
|
|
|
|
|
_scheduleApply();
|
|
|
|
|
},
|
2025-11-03 15:54:44 +03:30
|
|
|
calendarController: widget.calendarController,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: 200,
|
|
|
|
|
child: DateInputField(
|
|
|
|
|
labelText: 'تا تاریخ',
|
|
|
|
|
value: _toDate,
|
2025-11-04 05:21:23 +03:30
|
|
|
onChanged: (d) {
|
|
|
|
|
setState(() => _toDate = d);
|
|
|
|
|
_scheduleApply();
|
|
|
|
|
},
|
2025-11-03 15:54:44 +03:30
|
|
|
calendarController: widget.calendarController,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: 220,
|
|
|
|
|
child: DropdownButtonFormField<int>(
|
|
|
|
|
value: _selectedFiscalYearId,
|
|
|
|
|
decoration: const InputDecoration(
|
|
|
|
|
labelText: 'سال مالی',
|
|
|
|
|
border: OutlineInputBorder(),
|
|
|
|
|
isDense: true,
|
|
|
|
|
),
|
|
|
|
|
items: _fiscalYears.map<DropdownMenuItem<int>>((fy) {
|
|
|
|
|
final id = fy['id'] as int?;
|
|
|
|
|
final title = (fy['title'] ?? '').toString();
|
|
|
|
|
return DropdownMenuItem<int>(
|
|
|
|
|
value: id,
|
|
|
|
|
child: Text(title.isNotEmpty ? title : 'FY ${id ?? ''}'),
|
|
|
|
|
);
|
|
|
|
|
}).toList(),
|
|
|
|
|
onChanged: (val) {
|
|
|
|
|
setState(() => _selectedFiscalYearId = val);
|
2025-11-04 05:21:23 +03:30
|
|
|
_scheduleApply();
|
2025-11-03 15:54:44 +03:30
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
_chipsSection(
|
|
|
|
|
label: 'اشخاص',
|
|
|
|
|
chips: _selectedPersons.map((p) => _ChipData(id: p.id!, label: p.displayName)).toList(),
|
|
|
|
|
onRemove: (id) {
|
|
|
|
|
setState(() => _selectedPersons.removeWhere((p) => p.id == id));
|
2025-11-04 05:21:23 +03:30
|
|
|
_scheduleApply();
|
2025-11-03 15:54:44 +03:30
|
|
|
},
|
2025-11-04 05:21:23 +03:30
|
|
|
picker: _activePicker == FilterType.person ? SizedBox(
|
2025-11-03 15:54:44 +03:30
|
|
|
width: 260,
|
|
|
|
|
child: PersonComboboxWidget(
|
|
|
|
|
businessId: widget.businessId,
|
|
|
|
|
selectedPerson: _personToAdd,
|
|
|
|
|
onChanged: (person) {
|
|
|
|
|
if (person == null) return;
|
|
|
|
|
final exists = _selectedPersons.any((p) => p.id == person.id);
|
|
|
|
|
setState(() {
|
|
|
|
|
if (!exists) _selectedPersons.add(person);
|
|
|
|
|
_personToAdd = null;
|
2025-11-04 05:21:23 +03:30
|
|
|
_activePicker = null;
|
2025-11-03 15:54:44 +03:30
|
|
|
});
|
2025-11-04 05:21:23 +03:30
|
|
|
_scheduleApply();
|
2025-11-03 15:54:44 +03:30
|
|
|
},
|
|
|
|
|
hintText: 'افزودن شخص',
|
|
|
|
|
),
|
2025-11-04 05:21:23 +03:30
|
|
|
) : const SizedBox.shrink(),
|
|
|
|
|
type: FilterType.person,
|
2025-11-03 15:54:44 +03:30
|
|
|
),
|
|
|
|
|
_chipsSection(
|
|
|
|
|
label: 'کالا/خدمت',
|
|
|
|
|
chips: _selectedProducts.map((m) {
|
|
|
|
|
final id = int.tryParse('${m['id']}') ?? 0;
|
|
|
|
|
final code = (m['code'] ?? '').toString();
|
|
|
|
|
final name = (m['name'] ?? '').toString();
|
|
|
|
|
return _ChipData(id: id, label: code.isNotEmpty ? '$code - $name' : name);
|
|
|
|
|
}).toList(),
|
2025-11-04 05:21:23 +03:30
|
|
|
onRemove: (id) {
|
|
|
|
|
setState(() => _selectedProducts.removeWhere((m) => int.tryParse('${m['id']}') == id));
|
|
|
|
|
_scheduleApply();
|
|
|
|
|
},
|
|
|
|
|
picker: _activePicker == FilterType.product ? SizedBox(
|
2025-11-03 15:54:44 +03:30
|
|
|
width: 260,
|
|
|
|
|
child: ProductComboboxWidget(
|
|
|
|
|
businessId: widget.businessId,
|
|
|
|
|
selectedProduct: _productToAdd,
|
|
|
|
|
onChanged: (prod) {
|
|
|
|
|
if (prod == null) return;
|
|
|
|
|
final pid = int.tryParse('${prod['id']}');
|
|
|
|
|
final exists = _selectedProducts.any((m) => int.tryParse('${m['id']}') == pid);
|
|
|
|
|
setState(() {
|
|
|
|
|
if (!exists) _selectedProducts.add(prod);
|
|
|
|
|
_productToAdd = null;
|
2025-11-04 05:21:23 +03:30
|
|
|
_activePicker = null;
|
2025-11-03 15:54:44 +03:30
|
|
|
});
|
2025-11-04 05:21:23 +03:30
|
|
|
_scheduleApply();
|
2025-11-03 15:54:44 +03:30
|
|
|
},
|
|
|
|
|
),
|
2025-11-04 05:21:23 +03:30
|
|
|
) : const SizedBox.shrink(),
|
|
|
|
|
type: FilterType.product,
|
2025-11-03 15:54:44 +03:30
|
|
|
),
|
|
|
|
|
_chipsSection(
|
|
|
|
|
label: 'بانک',
|
|
|
|
|
chips: _selectedBankAccounts.map((b) => _ChipData(id: int.tryParse(b.id) ?? 0, label: b.name)).toList(),
|
2025-11-04 05:21:23 +03:30
|
|
|
onRemove: (id) {
|
|
|
|
|
setState(() => _selectedBankAccounts.removeWhere((b) => int.tryParse(b.id) == id));
|
|
|
|
|
_scheduleApply();
|
|
|
|
|
},
|
|
|
|
|
picker: _activePicker == FilterType.bank ? SizedBox(
|
2025-11-03 15:54:44 +03:30
|
|
|
width: 260,
|
|
|
|
|
child: BankAccountComboboxWidget(
|
|
|
|
|
businessId: widget.businessId,
|
|
|
|
|
selectedAccountId: _bankToAdd?.id,
|
|
|
|
|
onChanged: (opt) {
|
|
|
|
|
if (opt == null) return;
|
|
|
|
|
final exists = _selectedBankAccounts.any((b) => b.id == opt.id);
|
|
|
|
|
setState(() {
|
|
|
|
|
if (!exists) _selectedBankAccounts.add(opt);
|
|
|
|
|
_bankToAdd = null;
|
2025-11-04 05:21:23 +03:30
|
|
|
_activePicker = null;
|
2025-11-03 15:54:44 +03:30
|
|
|
});
|
2025-11-04 05:21:23 +03:30
|
|
|
_scheduleApply();
|
2025-11-03 15:54:44 +03:30
|
|
|
},
|
|
|
|
|
hintText: 'افزودن حساب بانکی',
|
|
|
|
|
),
|
2025-11-04 05:21:23 +03:30
|
|
|
) : const SizedBox.shrink(),
|
|
|
|
|
type: FilterType.bank,
|
2025-11-03 15:54:44 +03:30
|
|
|
),
|
|
|
|
|
_chipsSection(
|
|
|
|
|
label: 'صندوق',
|
|
|
|
|
chips: _selectedCashRegisters.map((c) => _ChipData(id: int.tryParse(c.id) ?? 0, label: c.name)).toList(),
|
2025-11-04 05:21:23 +03:30
|
|
|
onRemove: (id) {
|
|
|
|
|
setState(() => _selectedCashRegisters.removeWhere((c) => int.tryParse(c.id) == id));
|
|
|
|
|
_scheduleApply();
|
|
|
|
|
},
|
|
|
|
|
picker: _activePicker == FilterType.cash ? SizedBox(
|
2025-11-03 15:54:44 +03:30
|
|
|
width: 260,
|
|
|
|
|
child: CashRegisterComboboxWidget(
|
|
|
|
|
businessId: widget.businessId,
|
|
|
|
|
selectedRegisterId: _cashToAdd?.id,
|
|
|
|
|
onChanged: (opt) {
|
|
|
|
|
if (opt == null) return;
|
|
|
|
|
final exists = _selectedCashRegisters.any((c) => c.id == opt.id);
|
|
|
|
|
setState(() {
|
|
|
|
|
if (!exists) _selectedCashRegisters.add(opt);
|
|
|
|
|
_cashToAdd = null;
|
2025-11-04 05:21:23 +03:30
|
|
|
_activePicker = null;
|
2025-11-03 15:54:44 +03:30
|
|
|
});
|
2025-11-04 05:21:23 +03:30
|
|
|
_scheduleApply();
|
2025-11-03 15:54:44 +03:30
|
|
|
},
|
|
|
|
|
hintText: 'افزودن صندوق',
|
|
|
|
|
),
|
2025-11-04 05:21:23 +03:30
|
|
|
) : const SizedBox.shrink(),
|
|
|
|
|
type: FilterType.cash,
|
2025-11-03 15:54:44 +03:30
|
|
|
),
|
|
|
|
|
_chipsSection(
|
|
|
|
|
label: 'تنخواه',
|
|
|
|
|
chips: _selectedPettyCash.map((p) => _ChipData(id: int.tryParse(p.id) ?? 0, label: p.name)).toList(),
|
2025-11-04 05:21:23 +03:30
|
|
|
onRemove: (id) {
|
|
|
|
|
setState(() => _selectedPettyCash.removeWhere((p) => int.tryParse(p.id) == id));
|
|
|
|
|
_scheduleApply();
|
|
|
|
|
},
|
|
|
|
|
picker: _activePicker == FilterType.petty ? SizedBox(
|
2025-11-03 15:54:44 +03:30
|
|
|
width: 260,
|
|
|
|
|
child: PettyCashComboboxWidget(
|
|
|
|
|
businessId: widget.businessId,
|
|
|
|
|
selectedPettyCashId: _pettyToAdd?.id,
|
|
|
|
|
onChanged: (opt) {
|
|
|
|
|
if (opt == null) return;
|
|
|
|
|
final exists = _selectedPettyCash.any((p) => p.id == opt.id);
|
|
|
|
|
setState(() {
|
|
|
|
|
if (!exists) _selectedPettyCash.add(opt);
|
|
|
|
|
_pettyToAdd = null;
|
2025-11-04 05:21:23 +03:30
|
|
|
_activePicker = null;
|
2025-11-03 15:54:44 +03:30
|
|
|
});
|
2025-11-04 05:21:23 +03:30
|
|
|
_scheduleApply();
|
2025-11-03 15:54:44 +03:30
|
|
|
},
|
|
|
|
|
hintText: 'افزودن تنخواه',
|
|
|
|
|
),
|
2025-11-04 05:21:23 +03:30
|
|
|
) : const SizedBox.shrink(),
|
|
|
|
|
type: FilterType.petty,
|
2025-11-03 15:54:44 +03:30
|
|
|
),
|
|
|
|
|
_chipsSection(
|
|
|
|
|
label: 'حساب دفتری',
|
|
|
|
|
chips: _selectedAccounts.map((a) => _ChipData(id: a.id!, label: '${a.code} - ${a.name}')).toList(),
|
2025-11-04 05:21:23 +03:30
|
|
|
onRemove: (id) {
|
|
|
|
|
setState(() => _selectedAccounts.removeWhere((a) => a.id == id));
|
|
|
|
|
_scheduleApply();
|
|
|
|
|
},
|
|
|
|
|
picker: _activePicker == FilterType.account ? SizedBox(
|
2025-11-03 15:54:44 +03:30
|
|
|
width: 260,
|
|
|
|
|
child: AccountTreeComboboxWidget(
|
|
|
|
|
businessId: widget.businessId,
|
|
|
|
|
selectedAccount: _accountToAdd,
|
|
|
|
|
onChanged: (acc) {
|
|
|
|
|
if (acc == null) return;
|
|
|
|
|
final exists = _selectedAccounts.any((a) => a.id == acc.id);
|
|
|
|
|
setState(() {
|
|
|
|
|
if (!exists) _selectedAccounts.add(acc);
|
|
|
|
|
_accountToAdd = null;
|
2025-11-04 05:21:23 +03:30
|
|
|
_activePicker = null;
|
2025-11-03 15:54:44 +03:30
|
|
|
});
|
2025-11-04 05:21:23 +03:30
|
|
|
_scheduleApply();
|
2025-11-03 15:54:44 +03:30
|
|
|
},
|
|
|
|
|
hintText: 'افزودن حساب',
|
|
|
|
|
),
|
2025-11-04 05:21:23 +03:30
|
|
|
) : const SizedBox.shrink(),
|
|
|
|
|
type: FilterType.account,
|
2025-11-03 15:54:44 +03:30
|
|
|
),
|
|
|
|
|
_chipsSection(
|
|
|
|
|
label: 'چک',
|
|
|
|
|
chips: _selectedChecks.map((c) => _ChipData(id: int.tryParse(c.id) ?? 0, label: c.number.isNotEmpty ? c.number : 'چک #${c.id}')).toList(),
|
2025-11-04 05:21:23 +03:30
|
|
|
onRemove: (id) {
|
|
|
|
|
setState(() => _selectedChecks.removeWhere((c) => int.tryParse(c.id) == id));
|
|
|
|
|
_scheduleApply();
|
|
|
|
|
},
|
|
|
|
|
picker: _activePicker == FilterType.check ? SizedBox(
|
2025-11-03 15:54:44 +03:30
|
|
|
width: 260,
|
|
|
|
|
child: CheckComboboxWidget(
|
|
|
|
|
businessId: widget.businessId,
|
|
|
|
|
selectedCheckId: _checkToAdd?.id,
|
|
|
|
|
onChanged: (opt) {
|
|
|
|
|
if (opt == null) return;
|
|
|
|
|
final exists = _selectedChecks.any((c) => c.id == opt.id);
|
|
|
|
|
setState(() {
|
|
|
|
|
if (!exists) _selectedChecks.add(opt);
|
|
|
|
|
_checkToAdd = null;
|
2025-11-04 05:21:23 +03:30
|
|
|
_activePicker = null;
|
2025-11-03 15:54:44 +03:30
|
|
|
});
|
2025-11-04 05:21:23 +03:30
|
|
|
_scheduleApply();
|
2025-11-03 15:54:44 +03:30
|
|
|
},
|
|
|
|
|
),
|
2025-11-04 05:21:23 +03:30
|
|
|
) : const SizedBox.shrink(),
|
|
|
|
|
type: FilterType.check,
|
|
|
|
|
),
|
|
|
|
|
_chipsSection(
|
|
|
|
|
label: 'انبار',
|
|
|
|
|
chips: _selectedWarehouses.map((w) {
|
|
|
|
|
final id = int.tryParse('${w['id']}') ?? 0;
|
|
|
|
|
final code = (w['code'] ?? '').toString();
|
|
|
|
|
final name = (w['name'] ?? '').toString();
|
|
|
|
|
return _ChipData(id: id, label: code.isNotEmpty ? '$code - $name' : name);
|
|
|
|
|
}).toList(),
|
|
|
|
|
onRemove: (id) {
|
|
|
|
|
setState(() => _selectedWarehouses.removeWhere((w) => int.tryParse('${w['id']}') == id));
|
|
|
|
|
_scheduleApply();
|
|
|
|
|
},
|
|
|
|
|
picker: _activePicker == null ? const SizedBox.shrink() : const SizedBox.shrink(),
|
2025-11-03 15:54:44 +03:30
|
|
|
),
|
|
|
|
|
DropdownButton<String>(
|
|
|
|
|
value: _matchMode,
|
2025-11-04 05:21:23 +03:30
|
|
|
onChanged: (v) {
|
|
|
|
|
setState(() => _matchMode = v ?? 'any');
|
|
|
|
|
_scheduleApply();
|
|
|
|
|
},
|
2025-11-03 15:54:44 +03:30
|
|
|
items: const [
|
|
|
|
|
DropdownMenuItem(value: 'any', child: Text('هرکدام')),
|
|
|
|
|
DropdownMenuItem(value: 'same_line', child: Text('همزمان در یک خط')),
|
|
|
|
|
DropdownMenuItem(value: 'document_and', child: Text('همزمان در یک سند')),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
DropdownButton<String>(
|
|
|
|
|
value: _resultScope,
|
2025-11-04 05:21:23 +03:30
|
|
|
onChanged: (v) {
|
|
|
|
|
setState(() => _resultScope = v ?? 'lines_matching');
|
|
|
|
|
_scheduleApply();
|
|
|
|
|
},
|
2025-11-03 15:54:44 +03:30
|
|
|
items: const [
|
|
|
|
|
DropdownMenuItem(value: 'lines_matching', child: Text('فقط خطوط منطبق')),
|
|
|
|
|
DropdownMenuItem(value: 'lines_of_document', child: Text('کل خطوط سند')),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
Row(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
2025-11-04 05:21:23 +03:30
|
|
|
Switch(value: _includeRunningBalance, onChanged: (v) {
|
|
|
|
|
setState(() => _includeRunningBalance = v);
|
|
|
|
|
_scheduleApply();
|
|
|
|
|
}),
|
2025-11-03 15:54:44 +03:30
|
|
|
const SizedBox(width: 6),
|
|
|
|
|
const Text('مانده تجمعی'),
|
|
|
|
|
],
|
|
|
|
|
),
|
2025-11-04 05:21:23 +03:30
|
|
|
Row(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Switch(value: _manualApply, onChanged: (v) => setState(() => _manualApply = v)),
|
|
|
|
|
const SizedBox(width: 6),
|
|
|
|
|
const Text('اعمال دستی'),
|
|
|
|
|
],
|
|
|
|
|
),
|
2025-11-03 15:54:44 +03:30
|
|
|
ElevatedButton.icon(
|
2025-11-04 05:21:23 +03:30
|
|
|
onPressed: () {
|
|
|
|
|
_refreshData();
|
|
|
|
|
_updateRouteQuery();
|
|
|
|
|
},
|
2025-11-03 15:54:44 +03:30
|
|
|
icon: const Icon(Icons.search),
|
|
|
|
|
label: const Text('اعمال فیلتر'),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildTableArea(AppLocalizations t) {
|
|
|
|
|
final screenH = MediaQuery.of(context).size.height;
|
|
|
|
|
// حداقل ارتفاع مناسب برای جدول؛ اگر فضا کمتر بود، صفحه اسکرول میخورد
|
|
|
|
|
final tableHeight = screenH - 280.0; // تقریبی با احتساب فیلترها و پدینگ
|
|
|
|
|
final effectiveHeight = tableHeight < 420 ? 420.0 : tableHeight;
|
2025-11-04 05:21:23 +03:30
|
|
|
_log('Building table area with height=' + effectiveHeight.toString());
|
2025-11-03 15:54:44 +03:30
|
|
|
return SizedBox(
|
|
|
|
|
height: effectiveHeight,
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 4),
|
|
|
|
|
child: DataTableWidget<Map<String, dynamic>>(
|
|
|
|
|
key: _tableKey,
|
|
|
|
|
config: _buildTableConfig(t),
|
|
|
|
|
fromJson: (json) => Map<String, dynamic>.from(json as Map),
|
|
|
|
|
calendarController: widget.calendarController,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Chips helpers
|
|
|
|
|
Widget _chipsSection({
|
|
|
|
|
required String label,
|
|
|
|
|
required List<_ChipData> chips,
|
|
|
|
|
required void Function(int id) onRemove,
|
|
|
|
|
required Widget picker,
|
2025-11-04 05:21:23 +03:30
|
|
|
FilterType? type,
|
2025-11-03 15:54:44 +03:30
|
|
|
}) {
|
|
|
|
|
return ConstrainedBox(
|
|
|
|
|
constraints: const BoxConstraints(maxWidth: 900),
|
|
|
|
|
child: Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: 90,
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.only(top: 10),
|
|
|
|
|
child: Text(label, textAlign: TextAlign.right),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Wrap(
|
|
|
|
|
spacing: 6,
|
|
|
|
|
runSpacing: 6,
|
|
|
|
|
children: [
|
2025-11-04 05:21:23 +03:30
|
|
|
_chips(items: chips, onRemove: onRemove, type: type),
|
2025-11-03 15:54:44 +03:30
|
|
|
picker,
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _chips({
|
|
|
|
|
required List<_ChipData> items,
|
|
|
|
|
required void Function(int id) onRemove,
|
2025-11-04 05:21:23 +03:30
|
|
|
FilterType? type,
|
2025-11-03 15:54:44 +03:30
|
|
|
}) {
|
|
|
|
|
if (items.isEmpty) return const SizedBox.shrink();
|
2025-11-04 05:21:23 +03:30
|
|
|
const int maxToShow = 5;
|
|
|
|
|
final List<_ChipData> visible = (items.length > maxToShow)
|
|
|
|
|
? items.sublist(0, maxToShow - 1)
|
|
|
|
|
: items;
|
|
|
|
|
final int remaining = items.length - visible.length;
|
2025-11-03 15:54:44 +03:30
|
|
|
return Wrap(
|
|
|
|
|
spacing: 6,
|
|
|
|
|
runSpacing: 6,
|
2025-11-04 05:21:23 +03:30
|
|
|
children: [
|
|
|
|
|
...visible.map((it) => InputChip(
|
|
|
|
|
label: Text(it.label),
|
|
|
|
|
onDeleted: () => onRemove(it.id),
|
|
|
|
|
onPressed: () {
|
|
|
|
|
if (type != null) setState(() => _activePicker = type);
|
|
|
|
|
},
|
|
|
|
|
)),
|
|
|
|
|
if (remaining > 0)
|
|
|
|
|
InputChip(
|
|
|
|
|
label: Text('+$remaining مورد دیگر'),
|
|
|
|
|
onPressed: () {
|
|
|
|
|
if (type != null) setState(() => _activePicker = type);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
],
|
2025-11-03 15:54:44 +03:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _ChipData {
|
|
|
|
|
final int id;
|
|
|
|
|
final String label;
|
|
|
|
|
_ChipData({required this.id, required this.label});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// _DateBox حذف شد و با DateInputField جایگزین شد
|
|
|
|
|
|
|
|
|
|
|