1859 lines
60 KiB
Dart
1859 lines
60 KiB
Dart
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<AddAmcPanel> createState() => _AddAmcPanelState();
|
|
}
|
|
|
|
class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
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<String, dynamic> _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<void> _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<void> _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<int>(
|
|
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<String>(
|
|
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<String>(
|
|
label: 'Payment Frequency',
|
|
value: _paymentFrequency,
|
|
searchHint: 'Search payment frequency...',
|
|
options: assetOptionDropdowns(options.paymentFrequencies),
|
|
enabled: options.paymentFrequencies.isNotEmpty,
|
|
onChanged: (v) => setState(() => _paymentFrequency = v),
|
|
),
|
|
right: AppSearchableDropdown<String>(
|
|
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<AmcContractModel> amcContracts;
|
|
|
|
bool get isEdit => visitId != null;
|
|
|
|
@override
|
|
ConsumerState<LogServiceVisitPanel> createState() => _LogServiceVisitPanelState();
|
|
}
|
|
|
|
class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
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<void> _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<String, dynamic> _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<void> _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<AmcContractModel> 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<AppDropdownOption<int>> _vendorOptions({
|
|
required List<FilterOptionModel> vendors,
|
|
required List<AmcContractModel> 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<AmcContractModel> 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<String>(
|
|
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<String>(
|
|
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<int>(
|
|
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<AppDropdownOption<int>>()
|
|
.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<int>(
|
|
label: 'Vendor',
|
|
value: _vendorId,
|
|
searchHint: 'Search vendor...',
|
|
options: _vendorOptions(
|
|
vendors: lookups.vendors,
|
|
amcContracts: amcContracts,
|
|
),
|
|
onChanged: (v) => setState(() => _vendorId = v),
|
|
),
|
|
),
|
|
right: AppSearchableDropdown<String>(
|
|
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<AddInsurancePanel> createState() => _AddInsurancePanelState();
|
|
}
|
|
|
|
class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
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<void> _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<String, dynamic> _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<void> _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<String>(
|
|
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<TransferAssetPanel> createState() => _TransferAssetPanelState();
|
|
}
|
|
|
|
class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _reasonController = TextEditingController();
|
|
DateTime _transferDate = DateTime.now();
|
|
int? _toLocationId;
|
|
int? _toUserId;
|
|
bool _isSubmitting = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_reasonController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _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<void> _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<int>(
|
|
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<AppDropdownOption<int>>()
|
|
.toList(),
|
|
onChanged: (v) => setState(() => _toLocationId = v),
|
|
),
|
|
const SizedBox(height: 12),
|
|
AppSearchableDropdown<int>(
|
|
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<AppDropdownOption<int>>()
|
|
.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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|