428 lines
16 KiB
Dart
428 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../../core/errors/failure.dart';
|
|
import '../../../../core/utils/validators.dart';
|
|
import '../../../../shared/models/user_management_models.dart';
|
|
import '../../../../shared/widgets/app_button.dart';
|
|
import '../../../../shared/widgets/app_dropdown.dart';
|
|
import '../../../../shared/widgets/app_loading_view.dart';
|
|
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
|
import '../../../../shared/widgets/app_searchable_multi_select_dropdown.dart';
|
|
import '../../../../shared/widgets/app_text_field.dart';
|
|
import '../../../../shared/widgets/error_view.dart';
|
|
import '../../../../shared/widgets/app_side_panel.dart';
|
|
import '../../../master_data/presentation/widgets/master_quick_add.dart';
|
|
import '../providers/add_user_form_provider.dart';
|
|
import '../../../../shared/widgets/app_toast.dart';
|
|
|
|
class AddUserPanel extends ConsumerStatefulWidget {
|
|
const AddUserPanel({super.key, this.userId});
|
|
|
|
final String? userId;
|
|
|
|
bool get isEditing => userId != null;
|
|
|
|
@override
|
|
ConsumerState<AddUserPanel> createState() => _AddUserPanelState();
|
|
}
|
|
|
|
class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _nameController = TextEditingController();
|
|
final _employeeCodeController = TextEditingController();
|
|
final _emailController = TextEditingController();
|
|
final _mobileController = TextEditingController();
|
|
final _passwordController = TextEditingController();
|
|
List<String> _selectedRoleIds = [];
|
|
String _selectedStatus = 'Active';
|
|
String? _selectedDepartmentId;
|
|
String? _selectedPlantId;
|
|
String? _selectedDesignationId;
|
|
String? _selectedReportingToId;
|
|
bool _prefilled = false;
|
|
bool _obscurePassword = true;
|
|
|
|
static const _statusOptions = [
|
|
AppDropdownOption(value: 'Active', label: 'Active'),
|
|
AppDropdownOption(value: 'Inactive', label: 'Inactive'),
|
|
AppDropdownOption(value: 'Locked', label: 'Locked'),
|
|
];
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_employeeCodeController.dispose();
|
|
_emailController.dispose();
|
|
_mobileController.dispose();
|
|
_passwordController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
String _statusToApi(String label) => switch (label) {
|
|
'Active' => 'active',
|
|
'Inactive' => 'inactive',
|
|
'Locked' => 'locked',
|
|
_ => 'active',
|
|
};
|
|
|
|
String _statusFromApi(String status) => switch (status.toLowerCase()) {
|
|
'active' => 'Active',
|
|
'inactive' => 'Inactive',
|
|
'locked' => 'Locked',
|
|
_ => 'Active',
|
|
};
|
|
|
|
void _prefillFromUser(ManagedUserModel user) {
|
|
if (_prefilled) return;
|
|
_prefilled = true;
|
|
_nameController.text = user.fullName;
|
|
_employeeCodeController.text = user.employeeCode;
|
|
_emailController.text = user.email;
|
|
_mobileController.text = user.mobile;
|
|
_selectedRoleIds = user.effectiveRoleIds;
|
|
_selectedDepartmentId = user.departmentId;
|
|
_selectedDesignationId = user.designationId;
|
|
_selectedPlantId = user.plantId;
|
|
_selectedReportingToId = user.reportingTo;
|
|
_selectedStatus = _statusLabelFromUser(user);
|
|
}
|
|
|
|
String _statusLabelFromUser(ManagedUserModel user) {
|
|
final normalized = user.status.trim().toLowerCase();
|
|
if (normalized.isNotEmpty && normalized != 'active') {
|
|
return _statusFromApi(user.status);
|
|
}
|
|
if (!user.isActive) return 'Inactive';
|
|
return _statusFromApi(user.status);
|
|
}
|
|
|
|
List<AppDropdownOption<String>> _toOptions(List<FilterOptionModel> items) {
|
|
return items
|
|
.map((item) => AppDropdownOption(value: item.id, label: item.name))
|
|
.toList();
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
final roleIds = _selectedRoleIds
|
|
.map((id) => int.tryParse(id))
|
|
.whereType<int>()
|
|
.toList();
|
|
if (roleIds.isEmpty) {
|
|
showAppToastFromSnackBar(context,
|
|
const SnackBar(content: Text('Please select at least one role')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final notifier = ref.read(addUserFormProvider(widget.userId).notifier);
|
|
final bool success;
|
|
|
|
if (widget.isEditing) {
|
|
success = await notifier.submitUpdate(
|
|
widget.userId!,
|
|
UpdateUserRequest(
|
|
employeeCode: _employeeCodeController.text.trim(),
|
|
fullName: _nameController.text.trim(),
|
|
email: _emailController.text.trim(),
|
|
password: _passwordController.text.isEmpty ? null : _passwordController.text,
|
|
mobile: _mobileController.text.trim().isEmpty
|
|
? null
|
|
: _mobileController.text.trim(),
|
|
roleId: roleIds.first,
|
|
roleIds: roleIds,
|
|
departmentId: int.tryParse(_selectedDepartmentId ?? ''),
|
|
designationId: int.tryParse(_selectedDesignationId ?? ''),
|
|
plantId: int.tryParse(_selectedPlantId ?? ''),
|
|
reportingTo: int.tryParse(_selectedReportingToId ?? ''),
|
|
status: _statusToApi(_selectedStatus),
|
|
isActive: _selectedStatus == 'Active',
|
|
),
|
|
);
|
|
} else {
|
|
success = await notifier.submitCreate(
|
|
CreateUserRequest(
|
|
employeeCode: _employeeCodeController.text.trim(),
|
|
fullName: _nameController.text.trim(),
|
|
email: _emailController.text.trim(),
|
|
password: _passwordController.text,
|
|
mobile: _mobileController.text.trim().isEmpty
|
|
? null
|
|
: _mobileController.text.trim(),
|
|
roleId: roleIds.first,
|
|
roleIds: roleIds,
|
|
departmentId: int.tryParse(_selectedDepartmentId ?? ''),
|
|
designationId: int.tryParse(_selectedDesignationId ?? ''),
|
|
plantId: int.tryParse(_selectedPlantId ?? ''),
|
|
reportingTo: int.tryParse(_selectedReportingToId ?? ''),
|
|
status: _statusToApi(_selectedStatus),
|
|
isActive: _selectedStatus == 'Active',
|
|
),
|
|
);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
if (success) {
|
|
Navigator.of(context, rootNavigator: true).pop(true);
|
|
return;
|
|
}
|
|
|
|
final error = ref.read(addUserFormProvider(widget.userId)).valueOrNull?.errorMessage;
|
|
showAppToastFromSnackBar(context,
|
|
SnackBar(
|
|
content: Text(
|
|
error ?? (widget.isEditing ? 'Failed to update user' : 'Failed to create user'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDropdown({
|
|
required String label,
|
|
required String? value,
|
|
required List<FilterOptionModel> options,
|
|
required ValueChanged<String?> onChanged,
|
|
String? hint,
|
|
bool required = false,
|
|
String? masterId,
|
|
Map<String, dynamic>? initialValues,
|
|
}) {
|
|
final fieldLabel = required ? '$label *' : label;
|
|
final fieldHint = hint ?? 'Select ${label.toLowerCase()}';
|
|
final fieldEnabled = options.isNotEmpty || masterId != null;
|
|
final validator =
|
|
required ? (String? v) => v == null ? 'Please select $label' : null : null;
|
|
|
|
if (masterId == null) {
|
|
return AppSearchableDropdown<String>(
|
|
label: fieldLabel,
|
|
value: value,
|
|
hint: fieldHint,
|
|
searchHint: 'Search $label...',
|
|
enabled: fieldEnabled,
|
|
options: _toOptions(options),
|
|
onChanged: onChanged,
|
|
validator: validator,
|
|
);
|
|
}
|
|
|
|
return MasterQuickAddDropdown<String>(
|
|
masterId: masterId,
|
|
label: fieldLabel,
|
|
value: value,
|
|
hint: fieldHint,
|
|
searchHint: 'Search $label...',
|
|
enabled: fieldEnabled,
|
|
options: _toOptions(options),
|
|
initialValues: initialValues,
|
|
refreshLookups: () =>
|
|
ref.invalidate(addUserFormProvider(widget.userId)),
|
|
parseCreatedId: (id) => id,
|
|
onChanged: onChanged,
|
|
validator: validator,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final formAsync = ref.watch(addUserFormProvider(widget.userId));
|
|
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
|
|
|
|
return SidePanelScaffold(
|
|
title: widget.isEditing ? 'Edit User' : 'Add User',
|
|
footer: 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: widget.isEditing ? 'Update User' : 'Save User',
|
|
expand: false,
|
|
icon: Icons.check,
|
|
isLoading: isSubmitting,
|
|
onPressed: isSubmitting ? null : _save,
|
|
),
|
|
],
|
|
),
|
|
child: formAsync.when(
|
|
loading: () => AppLoadingView(
|
|
message: widget.isEditing ? 'Loading user...' : 'Loading form options...',
|
|
),
|
|
error: (error, _) => ErrorView.fromFailure(
|
|
error is Failure ? error : Failure.unknown(message: error.toString()),
|
|
onRetry: () => ref.invalidate(addUserFormProvider(widget.userId)),
|
|
),
|
|
data: (formState) {
|
|
if (formState.editingUser != null) {
|
|
_prefillFromUser(formState.editingUser!);
|
|
}
|
|
|
|
return Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SidePanelFormRow(
|
|
left: AppTextField(
|
|
controller: _nameController,
|
|
label: 'Full Name *',
|
|
hint: 'e.g. Ravi Kumar',
|
|
validator: (v) => Validators.required(v, fieldName: 'Name'),
|
|
),
|
|
right: AppTextField(
|
|
controller: _employeeCodeController,
|
|
label: 'Employee Code *',
|
|
hint: 'e.g. EMP002',
|
|
validator: (v) =>
|
|
Validators.required(v, fieldName: 'Employee Code'),
|
|
),
|
|
),
|
|
SidePanelFormRow(
|
|
left: AppTextField(
|
|
controller: _emailController,
|
|
label: 'Email *',
|
|
hint: 'ravi@company.com',
|
|
keyboardType: TextInputType.emailAddress,
|
|
validator: Validators.email,
|
|
),
|
|
right: AppTextField(
|
|
controller: _mobileController,
|
|
label: 'Mobile',
|
|
hint: '9XXXXXXXXX',
|
|
keyboardType: TextInputType.phone,
|
|
validator: Validators.optionalMobile,
|
|
inputFormatters: Validators.mobileInput,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
SidePanelSection(
|
|
title: 'ROLE & ACCESS',
|
|
children: [
|
|
SidePanelFormRow(
|
|
left: AppSearchableMultiSelectDropdown<String>(
|
|
label: 'Role *',
|
|
values: _selectedRoleIds,
|
|
hint: formState.roles.isEmpty
|
|
? 'No roles available'
|
|
: 'Select roles',
|
|
searchHint: 'Search role...',
|
|
enabled: formState.roles.isNotEmpty,
|
|
options: _toOptions(formState.roles),
|
|
onChanged: (ids) =>
|
|
setState(() => _selectedRoleIds = ids),
|
|
validator: (ids) =>
|
|
ids == null || ids.isEmpty
|
|
? 'Please select at least one role'
|
|
: null,
|
|
),
|
|
right: AppSearchableDropdown<String>(
|
|
label: 'Status',
|
|
value: _selectedStatus,
|
|
options: _statusOptions,
|
|
searchHint: 'Search status...',
|
|
onChanged: (v) =>
|
|
setState(() => _selectedStatus = v ?? 'Active'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SidePanelSection(
|
|
title: 'ORGANISATION',
|
|
children: [
|
|
SidePanelFormRow(
|
|
left: _buildDropdown(
|
|
label: 'Department',
|
|
value: _selectedDepartmentId,
|
|
options: formState.departments,
|
|
masterId: 'departments',
|
|
onChanged: (v) =>
|
|
setState(() => _selectedDepartmentId = v),
|
|
),
|
|
right: _buildDropdown(
|
|
label: 'Designation',
|
|
value: _selectedDesignationId,
|
|
options: formState.designations,
|
|
masterId: 'designations',
|
|
onChanged: (v) =>
|
|
setState(() => _selectedDesignationId = v),
|
|
),
|
|
),
|
|
SidePanelFormRow(
|
|
left: _buildDropdown(
|
|
label: 'Plant / Unit',
|
|
value: _selectedPlantId,
|
|
options: formState.plants,
|
|
masterId: 'locations',
|
|
initialValues: const {'type': 'plant'},
|
|
onChanged: (v) => setState(() => _selectedPlantId = v),
|
|
),
|
|
right: _buildDropdown(
|
|
label: 'Reporting To',
|
|
value: _selectedReportingToId,
|
|
options: formState.managers,
|
|
hint: formState.managers.isEmpty
|
|
? 'No managers available'
|
|
: 'Select manager',
|
|
onChanged: (v) =>
|
|
setState(() => _selectedReportingToId = v),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SidePanelSection(
|
|
title: 'PASSWORD',
|
|
children: [
|
|
AppTextField(
|
|
controller: _passwordController,
|
|
label: widget.isEditing
|
|
? 'New Password'
|
|
: 'Temporary Password *',
|
|
hint: '8+ chars, upper, lower, digit, special',
|
|
obscureText: _obscurePassword,
|
|
suffixIcon: IconButton(
|
|
tooltip: _obscurePassword
|
|
? 'Show password'
|
|
: 'Hide password',
|
|
icon: Icon(
|
|
_obscurePassword
|
|
? Icons.visibility_outlined
|
|
: Icons.visibility_off_outlined,
|
|
size: 20,
|
|
),
|
|
onPressed: () => setState(
|
|
() => _obscurePassword = !_obscurePassword,
|
|
),
|
|
),
|
|
validator: widget.isEditing
|
|
? Validators.optionalPassword
|
|
: Validators.password,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
widget.isEditing
|
|
? 'Leave blank to keep the current password.'
|
|
: 'User will be asked to change this on first login.',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color:
|
|
Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|