import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/errors/failure.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/validators.dart'; import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/user_management_models.dart' show FilterOptionModel; import '../../../../shared/widgets/api_feedback.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_form_toggle_field.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/error_view.dart'; import '../providers/asset_form_lookups_provider.dart'; import '../providers/assets_provider.dart'; class AddAmcPanel extends ConsumerStatefulWidget { const AddAmcPanel({ super.key, required this.assetId, this.contractId, }); final String assetId; final String? contractId; bool get isEdit => contractId != null; @override ConsumerState createState() => _AddAmcPanelState(); } class _AddAmcPanelState extends ConsumerState { final _formKey = GlobalKey(); final _contractNoController = TextEditingController(); final _annualCostController = TextEditingController(); final _visitsPerYearController = TextEditingController(); final _contactPersonController = TextEditingController(); final _contactPhoneController = TextEditingController(); final _contactEmailController = TextEditingController(); final _scopeOfWorkController = TextEditingController(); final _exclusionsController = TextEditingController(); final _remarksController = TextEditingController(); DateTime? _startDate; DateTime? _endDate; DateTime? _renewalDate; int? _vendorId; String? _contractType; String? _paymentFrequency; String? _serviceFrequency; bool _isActive = true; bool _isSubmitting = false; String? _populatedContractId; @override void dispose() { _contractNoController.dispose(); _annualCostController.dispose(); _visitsPerYearController.dispose(); _contactPersonController.dispose(); _contactPhoneController.dispose(); _contactEmailController.dispose(); _scopeOfWorkController.dispose(); _exclusionsController.dispose(); _remarksController.dispose(); super.dispose(); } void _populateFromContract(AmcContractModel contract) { if (_populatedContractId == contract.id) return; _populatedContractId = contract.id; _vendorId = contract.vendorId; _contractNoController.text = contract.contractNo ?? ''; _contractType = contract.contractType; _startDate = contract.startDate; _endDate = contract.endDate; _renewalDate = contract.renewalDate; if (contract.annualCost != null) { _annualCostController.text = CurrencyFormatter.formatEditable(contract.annualCost); } _paymentFrequency = contract.paymentFrequency; _serviceFrequency = contract.serviceFrequency; if (contract.visitsPerYear != null) { _visitsPerYearController.text = contract.visitsPerYear.toString(); } _contactPersonController.text = contract.contactPerson ?? ''; _contactPhoneController.text = contract.contactPhone ?? ''; _contactEmailController.text = contract.contactEmail ?? ''; _scopeOfWorkController.text = contract.scopeOfWork ?? ''; _exclusionsController.text = contract.exclusions ?? ''; _remarksController.text = contract.remarks ?? ''; _isActive = contract.isActive; } Map _buildPayload() { final annualCost = CurrencyFormatter.tryParse(_annualCostController.text); final visitsPerYear = int.tryParse(_visitsPerYearController.text.trim()); return { 'vendor_id': _vendorId, if (_contractNoController.text.isNotEmpty) 'contract_no': _contractNoController.text.trim(), if (_contractType != null && _contractType!.trim().isNotEmpty) 'contract_type': _contractType, 'start_date': DateFormatter.toApiDate(_startDate!), 'end_date': DateFormatter.toApiDate(_endDate!), if (_renewalDate != null) 'renewal_date': DateFormatter.toApiDate(_renewalDate!), if (annualCost != null) 'annual_cost': annualCost, if (_paymentFrequency != null && _paymentFrequency!.trim().isNotEmpty) 'payment_frequency': _paymentFrequency, if (_serviceFrequency != null && _serviceFrequency!.trim().isNotEmpty) 'service_frequency': _serviceFrequency, if (visitsPerYear != null) 'visits_per_year': visitsPerYear, if (_contactPersonController.text.trim().isNotEmpty) 'contact_person': _contactPersonController.text.trim(), if (_contactPhoneController.text.trim().isNotEmpty) 'contact_phone': _contactPhoneController.text.trim(), if (_contactEmailController.text.trim().isNotEmpty) 'contact_email': _contactEmailController.text.trim(), if (_scopeOfWorkController.text.trim().isNotEmpty) 'scope_of_work': _scopeOfWorkController.text.trim(), if (_exclusionsController.text.trim().isNotEmpty) 'exclusions': _exclusionsController.text.trim(), if (_remarksController.text.trim().isNotEmpty) 'remarks': _remarksController.text.trim(), 'is_active': _isActive, }; } Future _pickDate({ required DateTime? current, required void Function(DateTime date) onPicked, DateTime? firstDate, DateTime? lastDate, }) async { final first = firstDate ?? DateTime(2000); final last = lastDate ?? DateTime(2100); var initial = current ?? DateTime.now(); if (initial.isBefore(first)) initial = first; if (initial.isAfter(last)) initial = last; final picked = await showAppDatePopup( context: context, initialDate: initial, firstDate: first, lastDate: last, helpText: 'Select date', ); if (picked != null) { setState(() => onPicked(picked)); } } void _onStartDatePicked(DateTime date) { final clearedEnd = _endDate != null && DateTime(_endDate!.year, _endDate!.month, _endDate!.day) .isBefore(DateTime(date.year, date.month, date.day)); _startDate = date; if (clearedEnd) { _endDate = null; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; showSidePanelSnackBar( context, 'End date cleared. Please select an end date on or after the start date.', ); }); } } Future _save() async { if (!_formKey.currentState!.validate()) return; if (_startDate == null || _endDate == null) { showSidePanelSnackBar(context, 'Please select start and end dates'); return; } if (DateTime(_endDate!.year, _endDate!.month, _endDate!.day).isBefore( DateTime(_startDate!.year, _startDate!.month, _startDate!.day), )) { showSidePanelSnackBar( context, 'End date cannot be earlier than start date', ); return; } if (_vendorId == null) { showSidePanelSnackBar(context, 'Please select a vendor'); return; } setState(() => _isSubmitting = true); try { final payload = _buildPayload(); final notifier = ref.read(assetDetailProvider(widget.assetId).notifier); if (widget.isEdit) { await notifier.updateAmc(widget.contractId!, payload); } else { await notifier.createAmc(payload); } if (mounted) Navigator.of(context, rootNavigator: true).pop(true); } catch (e) { if (mounted) { showSidePanelApiError(context, e); } } finally { if (mounted) setState(() => _isSubmitting = false); } } Widget _buildForm() { final lookupsAsync = ref.watch(assetFormLookupsProvider); final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel(); if (lookupsAsync.hasValue) { final nextContractType = resolveAssetOptionValue( _contractType, options.contractTypes, preferFirstWhenEmpty: !widget.isEdit, ); final nextPaymentFrequency = resolveAssetOptionValue( _paymentFrequency, options.paymentFrequencies, preferFirstWhenEmpty: !widget.isEdit, ); final nextServiceFrequency = resolveAssetOptionValue( _serviceFrequency, options.serviceFrequencies, preferFirstWhenEmpty: !widget.isEdit, ); if (nextContractType != _contractType || nextPaymentFrequency != _paymentFrequency || nextServiceFrequency != _serviceFrequency) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setState(() { _contractType = nextContractType; _paymentFrequency = nextPaymentFrequency; _serviceFrequency = nextServiceFrequency; }); }); } } return Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SidePanelFormRow( left: lookupsAsync.when( loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('Failed to load vendors'), data: (lookups) => AppSearchableDropdown( label: 'Vendor *', value: _vendorId, searchHint: 'Search vendor...', options: lookups.vendors .map((vendor) => AppDropdownOption( value: int.tryParse(vendor.id) ?? 0, label: vendor.name, )) .where((option) => option.value != 0) .toList(), onChanged: (v) => setState(() => _vendorId = v), validator: (v) => v == null ? 'Vendor is required' : null, ), ), right: AppTextField( controller: _contractNoController, label: 'Contract No', ), ), AppSearchableDropdown( label: 'Contract Type', value: _contractType, searchHint: 'Search contract type...', options: assetOptionDropdowns(options.contractTypes), enabled: options.contractTypes.isNotEmpty, onChanged: (v) { if (v != null) setState(() => _contractType = v); }, validator: (v) => options.contractTypes.isEmpty ? null : (v == null ? 'Contract type is required' : null), ), const SizedBox(height: 12), SidePanelFormRow( left: _SidePanelDateField( label: 'Start Date', isRequired: true, value: _startDate, onPick: () => _pickDate( current: _startDate, onPicked: _onStartDatePicked, ), ), right: _SidePanelDateField( label: 'End Date', isRequired: true, value: _endDate, onPick: () => _pickDate( current: _endDate, firstDate: _startDate ?? DateTime(2000), onPicked: (date) => _endDate = date, ), ), ), const SizedBox(height: 12), SidePanelFormRow( left: _SidePanelDateField( label: 'Renewal Date', value: _renewalDate, onPick: () => _pickDate( current: _renewalDate, onPicked: (date) => _renewalDate = date, ), ), right: AppTextField( controller: _annualCostController, label: 'Annual Cost', keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: CurrencyFormatter.amountInput, validator: (v) => Validators.optionalNonNegativeDouble( v, fieldName: 'Annual Cost', ), ), ), const SizedBox(height: 12), SidePanelFormRow( left: AppSearchableDropdown( label: 'Payment Frequency', value: _paymentFrequency, searchHint: 'Search payment frequency...', options: assetOptionDropdowns(options.paymentFrequencies), enabled: options.paymentFrequencies.isNotEmpty, onChanged: (v) => setState(() => _paymentFrequency = v), ), right: AppSearchableDropdown( label: 'Service Frequency', value: _serviceFrequency, searchHint: 'Search service frequency...', options: assetOptionDropdowns(options.serviceFrequencies), enabled: options.serviceFrequencies.isNotEmpty, onChanged: (v) => setState(() => _serviceFrequency = v), ), ), const SizedBox(height: 12), AppTextField( controller: _visitsPerYearController, label: 'Visits Per Year', keyboardType: TextInputType.number, validator: (v) => Validators.optionalPositiveInt( v, fieldName: 'Visits Per Year', ), ), const SizedBox(height: 12), SidePanelFormRow( left: AppTextField( controller: _contactPersonController, label: 'Contact Person', ), right: AppTextField( controller: _contactPhoneController, label: 'Contact Phone', keyboardType: TextInputType.phone, validator: Validators.optionalMobile, inputFormatters: Validators.mobileInput, ), ), const SizedBox(height: 12), AppTextField( controller: _contactEmailController, label: 'Contact Email', keyboardType: TextInputType.emailAddress, validator: Validators.optionalEmail, ), const SizedBox(height: 12), AppTextField( controller: _scopeOfWorkController, label: 'Scope Of Work', maxLines: 3, ), const SizedBox(height: 12), AppTextField( controller: _exclusionsController, label: 'Exclusions', maxLines: 3, ), const SizedBox(height: 12), AppTextField( controller: _remarksController, label: 'Remarks', maxLines: 3, ), const SizedBox(height: 8), AppFormToggleField( label: 'Active', value: _isActive, onChanged: (value) => setState(() => _isActive = value), ), ], ), ); } @override Widget build(BuildContext context) { if (widget.isEdit) { final contractAsync = ref.watch( amcContractFormProvider(( assetId: widget.assetId, contractId: widget.contractId!, )), ); return contractAsync.when( loading: () => SidePanelScaffold( title: 'Edit AMC Contract', footer: _panelFooter( context, isSubmitting: true, saveLabel: 'Update AMC Contract', onSave: () {}, ), child: const Center(child: CircularProgressIndicator()), ), error: (error, _) => SidePanelScaffold( title: 'Edit AMC Contract', child: Center(child: Text(error.toString())), ), data: (contract) { _populateFromContract(contract); return SidePanelScaffold( title: 'Edit AMC Contract', footer: _panelFooter( context, isSubmitting: _isSubmitting, saveLabel: 'Update AMC Contract', onSave: _save, ), child: _buildForm(), ); }, ); } return SidePanelScaffold( title: 'Add AMC Contract', footer: _panelFooter( context, isSubmitting: _isSubmitting, saveLabel: 'Save AMC Contract', onSave: _save, ), child: _buildForm(), ); } } class LogServiceVisitPanel extends ConsumerStatefulWidget { const LogServiceVisitPanel({ super.key, required this.assetId, this.visitId, this.amcContracts = const [], }); final String assetId; final String? visitId; final List amcContracts; bool get isEdit => visitId != null; @override ConsumerState createState() => _LogServiceVisitPanelState(); } class _LogServiceVisitPanelState extends ConsumerState { final _formKey = GlobalKey(); final _complaintNoController = TextEditingController(); final _complaintDescController = TextEditingController(); final _engineerNameController = TextEditingController(); final _engineerPhoneController = TextEditingController(); final _workDoneController = TextEditingController(); final _partsReplacedController = TextEditingController(); final _downtimeHoursController = TextEditingController(); final _serviceCostController = TextEditingController(); final _remarksController = TextEditingController(); DateTime? _visitDate = DateTime.now(); DateTime? _complaintDate; DateTime? _nextServiceDate; int? _amcContractId; int? _vendorId; String? _visitType; String? _status; String? _assetConditionAfter; bool _isUnderAmc = false; bool _isSubmitting = false; String? _populatedVisitId; @override void dispose() { _complaintNoController.dispose(); _complaintDescController.dispose(); _engineerNameController.dispose(); _engineerPhoneController.dispose(); _workDoneController.dispose(); _partsReplacedController.dispose(); _downtimeHoursController.dispose(); _serviceCostController.dispose(); _remarksController.dispose(); super.dispose(); } void _populateFromVisit(ServiceVisitModel visit) { if (_populatedVisitId == visit.id) return; _populatedVisitId = visit.id; _visitType = visit.visitType; _visitDate = visit.visitDate; _amcContractId = visit.amcContractId; _complaintNoController.text = visit.complaintNo ?? ''; _complaintDate = visit.complaintDate; _complaintDescController.text = visit.complaintDesc ?? ''; _engineerNameController.text = visit.engineerName ?? ''; _engineerPhoneController.text = visit.engineerPhone ?? ''; _vendorId = visit.vendorId; _workDoneController.text = visit.workDone ?? ''; _partsReplacedController.text = visit.partsReplaced ?? ''; _nextServiceDate = visit.nextServiceDate; _status = visit.status; if (visit.downtimeHours != null) { _downtimeHoursController.text = visit.downtimeHours.toString(); } if (visit.serviceCost != null) { _serviceCostController.text = CurrencyFormatter.formatEditable(visit.serviceCost); } _isUnderAmc = visit.isUnderAmc || visit.amcContractId != null; _assetConditionAfter = visit.assetConditionAfter; _remarksController.text = visit.remarks ?? ''; } Future _pickDate({ required DateTime? current, required void Function(DateTime date) onPicked, }) async { final picked = await showAppDatePopup( context: context, initialDate: current ?? DateTime.now(), firstDate: DateTime(2000), lastDate: DateTime(2100), helpText: 'Select date', ); if (picked != null) { setState(() => onPicked(picked)); } } Map _buildPayload() { final downtimeHours = CurrencyFormatter.tryParse(_downtimeHoursController.text); final serviceCost = CurrencyFormatter.tryParse(_serviceCostController.text); return { if (_visitType != null && _visitType!.trim().isNotEmpty) 'visit_type': _visitType, 'visit_date': DateFormatter.toApiDate(_visitDate!), if (_isUnderAmc && _amcContractId != null) 'amc_contract_id': _amcContractId, if (_complaintNoController.text.trim().isNotEmpty) 'complaint_no': _complaintNoController.text.trim(), if (_complaintDate != null) 'complaint_date': DateFormatter.toApiDate(_complaintDate!), if (_complaintDescController.text.trim().isNotEmpty) 'complaint_desc': _complaintDescController.text.trim(), if (_engineerNameController.text.trim().isNotEmpty) 'engineer_name': _engineerNameController.text.trim(), if (_engineerPhoneController.text.trim().isNotEmpty) 'engineer_phone': _engineerPhoneController.text.trim(), if (_vendorId != null) 'vendor_id': _vendorId, if (_workDoneController.text.trim().isNotEmpty) 'work_done': _workDoneController.text.trim(), if (_partsReplacedController.text.trim().isNotEmpty) 'parts_replaced': _partsReplacedController.text.trim(), if (_nextServiceDate != null) 'next_service_date': DateFormatter.toApiDate(_nextServiceDate!), if (_status != null && _status!.trim().isNotEmpty) 'status': _status, if (downtimeHours != null) 'downtime_hours': downtimeHours, if (serviceCost != null) 'service_cost': serviceCost, 'is_under_amc': _isUnderAmc, if (_assetConditionAfter != null && _assetConditionAfter!.trim().isNotEmpty) 'asset_condition_after': _assetConditionAfter, if (_remarksController.text.trim().isNotEmpty) 'remarks': _remarksController.text.trim(), }; } Future _save() async { if (!_formKey.currentState!.validate()) return; if (_visitDate == null) { showSidePanelSnackBar(context, 'Please select visit date'); return; } setState(() => _isSubmitting = true); try { final payload = _buildPayload(); final notifier = ref.read(assetDetailProvider(widget.assetId).notifier); if (widget.isEdit) { await notifier.updateVisit(widget.visitId!, payload); } else { await notifier.logVisit(payload); } if (mounted) Navigator.of(context, rootNavigator: true).pop(true); } catch (e) { if (mounted) { showSidePanelApiError(context, e); } } finally { if (mounted) setState(() => _isSubmitting = false); } } void _setUnderAmc(bool value) { setState(() { _isUnderAmc = value; if (!value) { _amcContractId = null; } }); } void _onAmcContractChanged( int? contractId, List amcContracts, ) { setState(() { _amcContractId = contractId; if (contractId == null) return; final contract = amcContracts .where((c) => int.tryParse(c.id) == contractId) .firstOrNull; if (contract?.vendorId != null) { _vendorId = contract!.vendorId; } }); } List> _vendorOptions({ required List vendors, required List amcContracts, }) { final all = vendors .map( (vendor) => AppDropdownOption( value: int.tryParse(vendor.id) ?? 0, label: vendor.name, ), ) .where((option) => option.value != 0) .toList(); if (!_isUnderAmc || _amcContractId == null) return all; final contract = amcContracts .where((c) => int.tryParse(c.id) == _amcContractId) .firstOrNull; final contractVendorId = contract?.vendorId; if (contractVendorId == null) return all; return all.where((option) => option.value == contractVendorId).toList(); } Widget _buildForm(List amcContracts) { final lookupsAsync = ref.watch(assetFormLookupsProvider); final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel(); if (lookupsAsync.hasValue) { final nextVisitType = resolveAssetOptionValue( _visitType, options.visitTypes, preferFirstWhenEmpty: !widget.isEdit, ); final nextStatus = resolveAssetOptionValue( _status, options.visitStatuses, preferFirstWhenEmpty: !widget.isEdit, ); final nextConditionAfter = resolveAssetOptionValue( _assetConditionAfter, options.visitConditionsAfter, preferFirstWhenEmpty: !widget.isEdit, ); if (nextVisitType != _visitType || nextStatus != _status || nextConditionAfter != _assetConditionAfter) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setState(() { _visitType = nextVisitType; _status = nextStatus; _assetConditionAfter = nextConditionAfter; }); }); } } return Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 1) Visit classification SidePanelFormRow( left: AppSearchableDropdown( label: 'Visit Type *', value: _visitType, searchHint: 'Search visit type...', options: assetOptionDropdowns(options.visitTypes), enabled: options.visitTypes.isNotEmpty, onChanged: (v) { if (v != null) setState(() => _visitType = v); }, validator: (v) => v == null ? 'Visit type is required' : null, ), right: AppSearchableDropdown( label: 'Status', value: _status, searchHint: 'Search status...', options: assetOptionDropdowns(options.visitStatuses), enabled: options.visitStatuses.isNotEmpty, onChanged: (v) { if (v != null) setState(() => _status = v); }, ), ), _SidePanelDateField( label: 'Visit Date', isRequired: true, value: _visitDate, onPick: () => _pickDate( current: _visitDate, onPicked: (date) => setState(() => _visitDate = date), ), ), const SizedBox(height: 8), // 2) Contract & Vendor AppFormToggleField( label: 'Under AMC', value: _isUnderAmc, onChanged: _setUnderAmc, ), if (_isUnderAmc) ...[ const SizedBox(height: 8), AppSearchableDropdown( label: 'AMC Contract', value: _amcContractId, searchHint: 'Search AMC contract...', options: amcContracts .map((contract) { final id = int.tryParse(contract.id); if (id == null) return null; final label = contract.contractNo?.trim().isNotEmpty == true ? contract.contractNo! : 'AMC #${contract.id}'; return AppDropdownOption(value: id, label: label); }) .whereType>() .toList(), onChanged: (v) => _onAmcContractChanged(v, amcContracts), ), ], const SizedBox(height: 12), SidePanelFormRow( left: lookupsAsync.when( loading: () => const Padding( padding: EdgeInsets.only(top: 8), child: LinearProgressIndicator(), ), error: (_, __) => const Text('Failed to load vendors'), data: (lookups) => AppSearchableDropdown( label: 'Vendor', value: _vendorId, searchHint: 'Search vendor...', options: _vendorOptions( vendors: lookups.vendors, amcContracts: amcContracts, ), onChanged: (v) => setState(() => _vendorId = v), ), ), right: AppSearchableDropdown( label: 'Asset Condition After', value: _assetConditionAfter, searchHint: 'Search condition...', options: assetOptionDropdowns(options.visitConditionsAfter), enabled: options.visitConditionsAfter.isNotEmpty, onChanged: (v) => setState(() => _assetConditionAfter = v), ), ), // 3) Complaint intake SidePanelFormRow( left: AppTextField( controller: _complaintNoController, label: 'Complaint No', ), right: _SidePanelDateField( label: 'Complaint Date', value: _complaintDate, onPick: () => _pickDate( current: _complaintDate, onPicked: (date) => setState(() => _complaintDate = date), ), ), ), AppTextField( controller: _complaintDescController, label: 'Complaint Description', maxLines: 3, ), const SizedBox(height: 12), // 4) Engineer & work performed SidePanelFormRow( left: AppTextField( controller: _engineerNameController, label: 'Engineer Name', ), right: AppTextField( controller: _engineerPhoneController, label: 'Engineer Phone', keyboardType: TextInputType.phone, validator: Validators.optionalMobile, inputFormatters: Validators.mobileInput, ), ), AppTextField( controller: _workDoneController, label: 'Work Done', maxLines: 3, ), const SizedBox(height: 12), AppTextField( controller: _partsReplacedController, label: 'Parts Replaced', maxLines: 3, ), const SizedBox(height: 12), // 5) Outcome & cost SidePanelFormRow( left: _SidePanelDateField( label: 'Next Service Date', value: _nextServiceDate, onPick: () => _pickDate( current: _nextServiceDate, onPicked: (date) => setState(() => _nextServiceDate = date), ), ), right: AppTextField( controller: _downtimeHoursController, label: 'Downtime Hours', keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (v) => Validators.optionalPositiveDouble( v, fieldName: 'Downtime Hours', ), ), ), AppTextField( controller: _serviceCostController, label: 'Service Cost', keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: CurrencyFormatter.amountInput, validator: (v) => Validators.optionalPositiveDouble( v, fieldName: 'Service Cost', ), ), const SizedBox(height: 12), AppTextField( controller: _remarksController, label: 'Remarks', maxLines: 3, ), ], ), ); } @override Widget build(BuildContext context) { if (widget.isEdit) { final visitAsync = ref.watch( serviceVisitFormProvider(( assetId: widget.assetId, visitId: widget.visitId!, )), ); return visitAsync.when( loading: () => SidePanelScaffold( title: 'Edit Service Visit', footer: _panelFooter( context, isSubmitting: true, saveLabel: 'Update Service Visit', onSave: () {}, ), child: const Center(child: CircularProgressIndicator()), ), error: (error, _) => SidePanelScaffold( title: 'Edit Service Visit', child: Center(child: Text(error.toString())), ), data: (visit) { _populateFromVisit(visit); return SidePanelScaffold( title: 'Edit Service Visit', footer: _panelFooter( context, isSubmitting: _isSubmitting, saveLabel: 'Update Service Visit', onSave: _save, ), child: _buildForm(widget.amcContracts), ); }, ); } return SidePanelScaffold( title: 'Log Service Visit', footer: _panelFooter( context, isSubmitting: _isSubmitting, saveLabel: 'Log Service Visit', onSave: _save, ), child: _buildForm(widget.amcContracts), ); } } class AddInsurancePanel extends ConsumerStatefulWidget { const AddInsurancePanel({ super.key, required this.assetId, this.policyId, }); final String assetId; final String? policyId; bool get isEdit => policyId != null; @override ConsumerState createState() => _AddInsurancePanelState(); } class _AddInsurancePanelState extends ConsumerState { final _formKey = GlobalKey(); final _policyNoController = TextEditingController(); final _insurerNameController = TextEditingController(); final _insurerBranchController = TextEditingController(); final _insurerContactController = TextEditingController(); final _insurerPhoneController = TextEditingController(); final _insurerEmailController = TextEditingController(); final _sumInsuredController = TextEditingController(); final _annualPremiumController = TextEditingController(); final _remarksController = TextEditingController(); DateTime? _startDate; DateTime? _endDate; DateTime? _renewalDate; DateTime? _premiumPaidDate; String? _policyType; bool _isAutoRenewal = false; bool _premiumPaid = false; bool _isActive = true; bool _isSubmitting = false; String? _populatedPolicyId; @override void dispose() { _policyNoController.dispose(); _insurerNameController.dispose(); _insurerBranchController.dispose(); _insurerContactController.dispose(); _insurerPhoneController.dispose(); _insurerEmailController.dispose(); _sumInsuredController.dispose(); _annualPremiumController.dispose(); _remarksController.dispose(); super.dispose(); } void _populateFromPolicy(InsurancePolicyModel policy) { if (_populatedPolicyId == policy.id) return; _populatedPolicyId = policy.id; _policyNoController.text = policy.policyNo; _insurerNameController.text = policy.insurerName; _insurerBranchController.text = policy.insurerBranch ?? ''; _insurerContactController.text = policy.insurerContact ?? ''; _insurerPhoneController.text = policy.insurerPhone ?? ''; _insurerEmailController.text = policy.insurerEmail ?? ''; _policyType = policy.policyType; if (policy.sumInsured != null) { _sumInsuredController.text = CurrencyFormatter.formatEditable(policy.sumInsured); } if (policy.annualPremium != null) { _annualPremiumController.text = CurrencyFormatter.formatEditable(policy.annualPremium); } _startDate = policy.policyStartDate; _endDate = policy.policyEndDate; _renewalDate = policy.renewalDate; _isAutoRenewal = policy.isAutoRenewal; _premiumPaid = policy.premiumPaid; _premiumPaidDate = policy.premiumPaidDate; _remarksController.text = policy.remarks ?? ''; _isActive = policy.isActive; } Future _pickDate({ required DateTime? current, required void Function(DateTime date) onPicked, DateTime? firstDate, DateTime? lastDate, }) async { final first = firstDate ?? DateTime(2000); final last = lastDate ?? DateTime(2100); var initial = current ?? DateTime.now(); if (initial.isBefore(first)) initial = first; if (initial.isAfter(last)) initial = last; final picked = await showAppDatePopup( context: context, initialDate: initial, firstDate: first, lastDate: last, helpText: 'Select date', ); if (picked != null) { setState(() => onPicked(picked)); } } void _onStartDatePicked(DateTime date) { final clearedEnd = _endDate != null && DateTime(_endDate!.year, _endDate!.month, _endDate!.day) .isBefore(DateTime(date.year, date.month, date.day)); _startDate = date; if (clearedEnd) { _endDate = null; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; showSidePanelSnackBar( context, 'End date cleared. Please select an end date on or after the start date.', ); }); } } Map _buildPayload() { final sumInsured = CurrencyFormatter.tryParse(_sumInsuredController.text); final annualPremium = CurrencyFormatter.tryParse(_annualPremiumController.text); return { 'policy_no': _policyNoController.text.trim(), 'insurer_name': _insurerNameController.text.trim(), if (_insurerBranchController.text.trim().isNotEmpty) 'insurer_branch': _insurerBranchController.text.trim(), if (_insurerContactController.text.trim().isNotEmpty) 'insurer_contact': _insurerContactController.text.trim(), if (_insurerPhoneController.text.trim().isNotEmpty) 'insurer_phone': _insurerPhoneController.text.trim(), if (_insurerEmailController.text.trim().isNotEmpty) 'insurer_email': _insurerEmailController.text.trim(), if (_policyType != null && _policyType!.trim().isNotEmpty) 'policy_type': _policyType, if (sumInsured != null) 'sum_insured': sumInsured, if (annualPremium != null) 'annual_premium': annualPremium, 'policy_start_date': DateFormatter.toApiDate(_startDate!), 'policy_end_date': DateFormatter.toApiDate(_endDate!), if (_renewalDate != null) 'renewal_date': DateFormatter.toApiDate(_renewalDate!), 'is_auto_renewal': _isAutoRenewal, 'premium_paid': _premiumPaid, if (_premiumPaidDate != null) 'premium_paid_date': DateFormatter.toApiDate(_premiumPaidDate!), if (_remarksController.text.trim().isNotEmpty) 'remarks': _remarksController.text.trim(), 'is_active': _isActive, }; } Future _save() async { if (!_formKey.currentState!.validate()) return; if (_startDate == null || _endDate == null) { showSidePanelSnackBar(context, 'Please select start and end dates'); return; } if (DateTime(_endDate!.year, _endDate!.month, _endDate!.day).isBefore( DateTime(_startDate!.year, _startDate!.month, _startDate!.day), )) { showSidePanelSnackBar( context, 'End date cannot be earlier than start date', ); return; } setState(() => _isSubmitting = true); try { final payload = _buildPayload(); final notifier = ref.read(assetDetailProvider(widget.assetId).notifier); if (widget.isEdit) { await notifier.updateInsurance(widget.policyId!, payload); } else { await notifier.createInsurance(payload); } if (mounted) Navigator.of(context, rootNavigator: true).pop(true); } catch (e) { if (mounted) { showSidePanelApiError(context, e); } } finally { if (mounted) setState(() => _isSubmitting = false); } } Widget _buildForm() { final lookupsAsync = ref.watch(assetFormLookupsProvider); final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel(); if (lookupsAsync.hasValue) { final nextPolicyType = resolveAssetOptionValue( _policyType, options.policyTypes, preferFirstWhenEmpty: !widget.isEdit, ); if (nextPolicyType != _policyType) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setState(() => _policyType = nextPolicyType); }); } } return Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SidePanelFormRow( left: AppTextField( controller: _policyNoController, label: 'Policy No *', validator: (v) => Validators.required(v, fieldName: 'Policy No'), ), right: AppTextField( controller: _insurerNameController, label: 'Insurer Name *', validator: (v) => Validators.required(v, fieldName: 'Insurer Name'), ), ), const SizedBox(height: 12), SidePanelFormRow( left: AppTextField( controller: _insurerBranchController, label: 'Insurer Branch', ), right: AppTextField( controller: _insurerContactController, label: 'Insurer Contact', ), ), const SizedBox(height: 12), SidePanelFormRow( left: AppTextField( controller: _insurerPhoneController, label: 'Insurer Phone', ), right: AppTextField( controller: _insurerEmailController, label: 'Insurer Email', keyboardType: TextInputType.emailAddress, validator: Validators.optionalEmail, ), ), const SizedBox(height: 12), SidePanelFormRow( left: AppSearchableDropdown( label: 'Policy Type', value: _policyType, searchHint: 'Search policy type...', options: assetOptionDropdowns(options.policyTypes), enabled: options.policyTypes.isNotEmpty, onChanged: (v) { if (v != null) setState(() => _policyType = v); }, ), right: AppTextField( controller: _sumInsuredController, label: 'Sum Insured', keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: CurrencyFormatter.amountInput, validator: (v) => Validators.optionalNonNegativeDouble( v, fieldName: 'Sum Insured', ), ), ), const SizedBox(height: 12), AppTextField( controller: _annualPremiumController, label: 'Annual Premium', keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: CurrencyFormatter.amountInput, validator: (v) => Validators.optionalNonNegativeDouble( v, fieldName: 'Annual Premium', ), ), const SizedBox(height: 12), SidePanelFormRow( left: _SidePanelDateField( label: 'Start Date', isRequired: true, value: _startDate, onPick: () => _pickDate( current: _startDate, onPicked: _onStartDatePicked, ), ), right: _SidePanelDateField( label: 'End Date', isRequired: true, value: _endDate, onPick: () => _pickDate( current: _endDate, firstDate: _startDate ?? DateTime(2000), onPicked: (date) => _endDate = date, ), ), ), const SizedBox(height: 12), SidePanelFormRow( left: _SidePanelDateField( label: 'Renewal Date', value: _renewalDate, onPick: () => _pickDate( current: _renewalDate, onPicked: (date) => _renewalDate = date, ), ), right: _SidePanelDateField( label: 'Premium Paid Date', value: _premiumPaidDate, onPick: () => _pickDate( current: _premiumPaidDate, onPicked: (date) => _premiumPaidDate = date, ), ), ), const SizedBox(height: 8), AppFormToggleField( label: 'Auto Renewal', value: _isAutoRenewal, onChanged: (value) => setState(() => _isAutoRenewal = value), ), AppFormToggleField( label: 'Premium Paid', value: _premiumPaid, onChanged: (value) => setState(() => _premiumPaid = value), ), AppFormToggleField( label: 'Active', value: _isActive, onChanged: (value) => setState(() => _isActive = value), ), const SizedBox(height: 12), AppTextField( controller: _remarksController, label: 'Remarks', maxLines: 3, ), ], ), ); } @override Widget build(BuildContext context) { if (widget.isEdit) { final policyAsync = ref.watch( insurancePolicyFormProvider(( assetId: widget.assetId, policyId: widget.policyId!, )), ); return policyAsync.when( loading: () => SidePanelScaffold( title: 'Edit Insurance Policy', footer: _panelFooter( context, isSubmitting: true, saveLabel: 'Update Insurance Policy', onSave: () {}, ), child: const Center(child: CircularProgressIndicator()), ), error: (error, _) => SidePanelScaffold( title: 'Edit Insurance Policy', child: Center(child: Text(error.toString())), ), data: (policy) { _populateFromPolicy(policy); return SidePanelScaffold( title: 'Edit Insurance Policy', footer: _panelFooter( context, isSubmitting: _isSubmitting, saveLabel: 'Update Insurance Policy', onSave: _save, ), child: _buildForm(), ); }, ); } return SidePanelScaffold( title: 'Add Insurance Policy', footer: _panelFooter( context, isSubmitting: _isSubmitting, saveLabel: 'Save Insurance Policy', onSave: _save, ), child: _buildForm(), ); } } class TransferAssetPanel extends ConsumerStatefulWidget { const TransferAssetPanel({super.key, required this.assetId}); final String assetId; @override ConsumerState createState() => _TransferAssetPanelState(); } class _TransferAssetPanelState extends ConsumerState { final _formKey = GlobalKey(); final _reasonController = TextEditingController(); DateTime _transferDate = DateTime.now(); int? _toLocationId; int? _toUserId; bool _isSubmitting = false; @override void dispose() { _reasonController.dispose(); super.dispose(); } Future _pickDate() async { final picked = await showAppDatePopup( context: context, initialDate: _transferDate, firstDate: DateTime(2000), lastDate: DateTime(2100), helpText: 'Transfer date', ); if (picked != null) { setState(() => _transferDate = picked); } } Future _save() async { if (!_formKey.currentState!.validate()) return; final hasDestination = _toLocationId != null || _toUserId != null; if (!hasDestination) { showSidePanelSnackBar( context, 'Choose at least one destination field', ); return; } setState(() => _isSubmitting = true); try { await ref.read(assetDetailProvider(widget.assetId).notifier).transferAsset({ 'transfer_date': DateFormatter.toApiDate(_transferDate), if (_toLocationId != null) 'to_location_id': _toLocationId, if (_toUserId != null) 'to_user_id': _toUserId, 'reason': _reasonController.text.trim(), }); if (mounted) { Navigator.of(context, rootNavigator: true).pop(true); } } catch (e) { if (mounted) { showSidePanelApiError(context, e); } } finally { if (mounted) setState(() => _isSubmitting = false); } } @override Widget build(BuildContext context) { final lookupsAsync = ref.watch(assetFormLookupsProvider); return SidePanelScaffold( title: 'Transfer Asset', footer: _panelFooter( context, isSubmitting: _isSubmitting, saveLabel: 'Transfer', onSave: _save, ), child: lookupsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center(child: Text(error.toString())), data: (lookups) => Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _SidePanelDateField( label: 'Transfer Date', isRequired: true, value: _transferDate, onPick: _pickDate, ), const SizedBox(height: 12), AppSearchableDropdown( label: 'To Location', value: _toLocationId, searchHint: 'Search plant or warehouse...', options: lookups.locations .map((option) { final id = int.tryParse(option.id); if (id == null) return null; return AppDropdownOption(value: id, label: option.name); }) .whereType>() .toList(), onChanged: (v) => setState(() => _toLocationId = v), ), const SizedBox(height: 12), AppSearchableDropdown( label: 'To User', value: _toUserId, searchHint: 'Search user...', options: lookups.users .map((option) { final id = int.tryParse(option.id); if (id == null) return null; return AppDropdownOption(value: id, label: option.name); }) .whereType>() .toList(), onChanged: (v) => setState(() => _toUserId = v), ), const SizedBox(height: 12), AppTextField( controller: _reasonController, label: 'Reason *', maxLines: 3, validator: (v) => Validators.required(v, fieldName: 'Reason'), ), ], ), ), ), ); } } class _TransferHistoryEntry extends StatelessWidget { const _TransferHistoryEntry({required this.item}); final AssetTransferHistoryModel item; DateTime? _resolvedTransferDateTime() { final transferDate = item.transferDate?.toLocal(); final createdAt = item.createdAt?.toLocal(); if (transferDate == null) return createdAt; if (createdAt == null) return transferDate; return DateTime( transferDate.year, transferDate.month, transferDate.day, createdAt.hour, createdAt.minute, ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final resolvedDateTime = _resolvedTransferDateTime(); final transferDate = resolvedDateTime != null ? DateFormatter.displayDateTime(resolvedDateTime) : '—'; final reason = item.reason?.trim(); final showReason = reason != null && reason.isNotEmpty && reason.toLowerCase() != 'nil'; return AppCard( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.swap_horiz_rounded, size: 18, color: theme.colorScheme.primary, ), const SizedBox(width: 8), Text( transferDate, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), ), ], ), const SizedBox(height: 16), _TransferFromToGrid( fromLocation: item.fromLocationName, fromUser: item.fromUserName, toLocation: item.toLocationName, toUser: item.toUserName, ), if (showReason) ...[ const SizedBox(height: 16), Divider( height: 1, color: theme.colorScheme.outline.withValues(alpha: 0.2), ), const SizedBox(height: 12), Text( 'Reason', style: theme.textTheme.labelSmall?.copyWith( fontWeight: FontWeight.w700, letterSpacing: 0.6, color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 4), Text( reason, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ], ), ), ); } } class _TransferFromToGrid extends StatelessWidget { const _TransferFromToGrid({ this.fromLocation, this.fromUser, this.toLocation, this.toUser, }); final String? fromLocation; final String? fromUser; final String? toLocation; final String? toUser; static const _arrowWidth = 34.0; @override Widget build(BuildContext context) { final theme = Theme.of(context); final labelStyle = theme.textTheme.labelSmall?.copyWith( fontWeight: FontWeight.w700, letterSpacing: 0.6, color: theme.colorScheme.onSurfaceVariant, ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Expanded(child: Text('From', style: labelStyle)), const SizedBox(width: _arrowWidth), Expanded(child: Text('To', style: labelStyle)), ], ), const SizedBox(height: 8), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _TransferLocationRow( icon: Icons.place_outlined, value: fromLocation, ), ), SizedBox( width: _arrowWidth, child: Padding( padding: const EdgeInsets.only(top: 1), child: Icon( Icons.arrow_forward_rounded, size: 18, color: theme.colorScheme.outline, ), ), ), Expanded( child: _TransferLocationRow( icon: Icons.place_outlined, value: toLocation, ), ), ], ), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _TransferLocationRow( icon: Icons.person_outline, value: fromUser, ), ), const SizedBox(width: _arrowWidth), Expanded( child: _TransferLocationRow( icon: Icons.person_outline, value: toUser, ), ), ], ), ], ); } } class _TransferLocationRow extends StatelessWidget { const _TransferLocationRow({ required this.icon, this.value, }); final IconData icon; final String? value; @override Widget build(BuildContext context) { final theme = Theme.of(context); final display = value?.trim().isNotEmpty == true ? value!.trim() : '—'; return Padding( padding: const EdgeInsets.only(bottom: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( icon, size: 14, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 6), Expanded( child: Text( display, style: theme.textTheme.bodySmall?.copyWith( color: display == '—' ? theme.colorScheme.onSurfaceVariant : theme.colorScheme.onSurface, ), ), ), ], ), ); } } class TransferHistoryPanel extends ConsumerWidget { const TransferHistoryPanel({super.key, required this.assetId}); final String assetId; @override Widget build(BuildContext context, WidgetRef ref) { final historyAsync = ref.watch(transferHistoryProvider(assetId)); return SidePanelScaffold( title: 'Transfer History', child: historyAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => ErrorView( message: error is Failure ? error.message : 'Unable to load transfer history', onRetry: () => ref.invalidate(transferHistoryProvider(assetId)), ), data: (history) { if (history.isEmpty) { return const AppEmptyState( title: 'No transfer history', description: 'Transfers for this asset will appear here.', icon: Icons.swap_horiz_outlined, ); } final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${history.length} transfer${history.length == 1 ? '' : 's'}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 16), for (var index = 0; index < history.length; index++) ...[ if (index > 0) const SizedBox(height: 12), _TransferHistoryEntry(item: history[index]), ], ], ); }, ), ); } } Widget _panelFooter( BuildContext context, { required bool isSubmitting, required String saveLabel, required VoidCallback onSave, }) { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ OutlinedButton( onPressed: isSubmitting ? null : () => Navigator.of(context, rootNavigator: true).pop(), child: const Text('Cancel'), ), const SizedBox(width: 12), AppButton( label: saveLabel, expand: false, icon: Icons.check, isLoading: isSubmitting, onPressed: isSubmitting ? null : onSave, ), ], ); } class _SidePanelDateField extends StatefulWidget { const _SidePanelDateField({ required this.label, required this.value, required this.onPick, this.isRequired = false, }); final String label; final DateTime? value; final VoidCallback onPick; final bool isRequired; @override State<_SidePanelDateField> createState() => _SidePanelDateFieldState(); } class _SidePanelDateFieldState extends State<_SidePanelDateField> { late final TextEditingController _controller; String get _displayText => widget.value != null ? DateFormatter.displayDate(widget.value) : ''; String get _labelText => widget.isRequired ? '${widget.label} *' : widget.label; @override void initState() { super.initState(); _controller = TextEditingController(text: _displayText); } @override void didUpdateWidget(covariant _SidePanelDateField oldWidget) { super.didUpdateWidget(oldWidget); final text = _displayText; if (_controller.text != text) { _controller.text = text; } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(top: 8), child: TextFormField( readOnly: true, onTap: widget.onPick, controller: _controller, decoration: InputDecoration( labelText: _labelText, hintText: 'Select date', floatingLabelBehavior: FloatingLabelBehavior.always, suffixIcon: const Icon(Icons.calendar_today_outlined, size: 20), ), ), ); } }