Compare commits

...

2 Commits

7 changed files with 654 additions and 349 deletions

View File

@ -94,8 +94,9 @@ class AddContactController extends GetxController {
required List<Map<String, String>> phones,
required String address,
required String description,
String? designation,
}) async {
if (isSubmitting.value) return;
if (isSubmitting.value) return;
isSubmitting.value = true;
final categoryId = categoriesMap[selectedCategory.value];
@ -156,6 +157,8 @@ class AddContactController extends GetxController {
if (phones.isNotEmpty) "contactPhones": phones,
if (address.trim().isNotEmpty) "address": address.trim(),
if (description.trim().isNotEmpty) "description": description.trim(),
if (designation != null && designation.trim().isNotEmpty)
"designation": designation.trim(),
};
logSafe("${id != null ? 'Updating' : 'Creating'} contact");

View File

@ -1,13 +1,13 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/my_controller.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:collection/collection.dart';
enum Gender {
male,
@ -18,22 +18,26 @@ enum Gender {
}
class AddEmployeeController extends MyController {
Map<String, dynamic>? editingEmployeeData; // For edit mode
Map<String, dynamic>? editingEmployeeData;
List<PlatformFile> files = [];
// State
final MyFormValidator basicValidator = MyFormValidator();
final List<PlatformFile> files = [];
final List<String> categories = [];
Gender? selectedGender;
List<Map<String, dynamic>> roles = [];
String? selectedRoleId;
String selectedCountryCode = "+91";
String selectedCountryCode = '+91';
bool showOnline = true;
final List<String> categories = [];
DateTime? joiningDate;
String? selectedOrganizationId;
RxString selectedOrganizationName = RxString('');
@override
void onInit() {
super.onInit();
logSafe("Initializing AddEmployeeController...");
logSafe('Initializing AddEmployeeController...');
_initializeFields();
fetchRoles();
@ -45,29 +49,36 @@ class AddEmployeeController extends MyController {
void _initializeFields() {
basicValidator.addField(
'first_name',
label: "First Name",
label: 'First Name',
required: true,
controller: TextEditingController(),
);
basicValidator.addField(
'phone_number',
label: "Phone Number",
label: 'Phone Number',
required: true,
controller: TextEditingController(),
);
basicValidator.addField(
'last_name',
label: "Last Name",
label: 'Last Name',
required: true,
controller: TextEditingController(),
);
logSafe("Fields initialized for first_name, phone_number, last_name.");
// Email is optional in controller; UI enforces when application access is checked
basicValidator.addField(
'email',
label: 'Email',
required: false,
controller: TextEditingController(),
);
logSafe('Fields initialized for first_name, phone_number, last_name, email.');
}
/// Prefill fields in edit mode
// In AddEmployeeController
// Prefill fields in edit mode
void prefillFields() {
logSafe("Prefilling data for editing...");
logSafe('Prefilling data for editing...');
basicValidator.getController('first_name')?.text =
editingEmployeeData?['first_name'] ?? '';
basicValidator.getController('last_name')?.text =
@ -76,10 +87,12 @@ class AddEmployeeController extends MyController {
editingEmployeeData?['phone_number'] ?? '';
selectedGender = editingEmployeeData?['gender'] != null
? Gender.values
.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender'])
? Gender.values.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender'])
: null;
basicValidator.getController('email')?.text =
editingEmployeeData?['email'] ?? '';
selectedRoleId = editingEmployeeData?['job_role_id'];
if (editingEmployeeData?['joining_date'] != null) {
@ -91,92 +104,102 @@ class AddEmployeeController extends MyController {
void setJoiningDate(DateTime date) {
joiningDate = date;
logSafe("Joining date selected: $date");
logSafe('Joining date selected: $date');
update();
}
void onGenderSelected(Gender? gender) {
selectedGender = gender;
logSafe("Gender selected: ${gender?.name}");
logSafe('Gender selected: ${gender?.name}');
update();
}
Future<void> fetchRoles() async {
logSafe("Fetching roles...");
logSafe('Fetching roles...');
try {
final result = await ApiService.getRoles();
if (result != null) {
roles = List<Map<String, dynamic>>.from(result);
logSafe("Roles fetched successfully.");
logSafe('Roles fetched successfully.');
update();
} else {
logSafe("Failed to fetch roles: null result", level: LogLevel.error);
logSafe('Failed to fetch roles: null result', level: LogLevel.error);
}
} catch (e, st) {
logSafe("Error fetching roles",
level: LogLevel.error, error: e, stackTrace: st);
logSafe('Error fetching roles', level: LogLevel.error, error: e, stackTrace: st);
}
}
void onRoleSelected(String? roleId) {
selectedRoleId = roleId;
logSafe("Role selected: $roleId");
logSafe('Role selected: $roleId');
update();
}
/// Create or update employee
Future<Map<String, dynamic>?> createOrUpdateEmployee() async {
// Create or update employee
Future<Map<String, dynamic>?> createOrUpdateEmployee({
String? email,
bool hasApplicationAccess = false,
}) async {
logSafe(editingEmployeeData != null
? "Starting employee update..."
: "Starting employee creation...");
? 'Starting employee update...'
: 'Starting employee creation...');
if (selectedGender == null || selectedRoleId == null) {
showAppSnackbar(
title: "Missing Fields",
message: "Please select both Gender and Role.",
title: 'Missing Fields',
message: 'Please select both Gender and Role.',
type: SnackbarType.warning,
);
return null;
}
final firstName = basicValidator.getController("first_name")?.text.trim();
final lastName = basicValidator.getController("last_name")?.text.trim();
final phoneNumber =
basicValidator.getController("phone_number")?.text.trim();
final firstName = basicValidator.getController('first_name')?.text.trim();
final lastName = basicValidator.getController('last_name')?.text.trim();
final phoneNumber = basicValidator.getController('phone_number')?.text.trim();
try {
// sanitize orgId before sending
final String? orgId = (selectedOrganizationId != null &&
selectedOrganizationId!.trim().isNotEmpty)
? selectedOrganizationId
: null;
final response = await ApiService.createEmployee(
id: editingEmployeeData?['id'], // Pass id if editing
id: editingEmployeeData?['id'],
firstName: firstName!,
lastName: lastName!,
phoneNumber: phoneNumber!,
gender: selectedGender!.name,
jobRoleId: selectedRoleId!,
joiningDate: joiningDate?.toIso8601String() ?? "",
joiningDate: joiningDate?.toIso8601String() ?? '',
organizationId: orgId,
email: email,
hasApplicationAccess: hasApplicationAccess,
);
logSafe("Response: $response");
logSafe('Response: $response');
if (response != null && response['success'] == true) {
showAppSnackbar(
title: "Success",
title: 'Success',
message: editingEmployeeData != null
? "Employee updated successfully!"
: "Employee created successfully!",
? 'Employee updated successfully!'
: 'Employee created successfully!',
type: SnackbarType.success,
);
return response;
} else {
logSafe("Failed operation", level: LogLevel.error);
logSafe('Failed operation', level: LogLevel.error);
}
} catch (e, st) {
logSafe("Error creating/updating employee",
logSafe('Error creating/updating employee',
level: LogLevel.error, error: e, stackTrace: st);
}
showAppSnackbar(
title: "Error",
message: "Failed to save employee.",
title: 'Error',
message: 'Failed to save employee.',
type: SnackbarType.error,
);
return null;
@ -192,9 +215,8 @@ class AddEmployeeController extends MyController {
}
showAppSnackbar(
title: "Permission Required",
message:
"Please allow Contacts permission from settings to pick a contact.",
title: 'Permission Required',
message: 'Please allow Contacts permission from settings to pick a contact.',
type: SnackbarType.warning,
);
return false;
@ -212,8 +234,8 @@ class AddEmployeeController extends MyController {
await FlutterContacts.getContact(picked.id, withProperties: true);
if (contact == null) {
showAppSnackbar(
title: "Error",
message: "Failed to load contact details.",
title: 'Error',
message: 'Failed to load contact details.',
type: SnackbarType.error,
);
return;
@ -221,8 +243,8 @@ class AddEmployeeController extends MyController {
if (contact.phones.isEmpty) {
showAppSnackbar(
title: "No Phone Number",
message: "Selected contact has no phone number.",
title: 'No Phone Number',
message: 'Selected contact has no phone number.',
type: SnackbarType.warning,
);
return;
@ -236,8 +258,8 @@ class AddEmployeeController extends MyController {
if (indiaPhones.isEmpty) {
showAppSnackbar(
title: "No Indian Number",
message: "Selected contact has no Indian (+91) phone number.",
title: 'No Indian Number',
message: 'Selected contact has no Indian (+91) phone number.',
type: SnackbarType.warning,
);
return;
@ -250,19 +272,20 @@ class AddEmployeeController extends MyController {
selectedPhone = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text("Choose an Indian number"),
title: const Text('Choose an Indian number'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: indiaPhones
.map((p) => ListTile(
title: Text(p.number),
onTap: () => Navigator.of(ctx).pop(p.number),
))
.map(
(p) => ListTile(
title: Text(p.number),
onTap: () => Navigator.of(ctx).pop(p.number),
),
)
.toList(),
),
),
);
if (selectedPhone == null) return;
}
@ -275,11 +298,11 @@ class AddEmployeeController extends MyController {
phoneWithoutCountryCode;
update();
} catch (e, st) {
logSafe("Error fetching contacts",
logSafe('Error fetching contacts',
level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar(
title: "Error",
message: "Failed to fetch contacts.",
title: 'Error',
message: 'Failed to fetch contacts.',
type: SnackbarType.error,
);
}

View File

@ -25,7 +25,7 @@ class ApiEndpoints {
static const String getAllEmployees = "/employee/list";
static const String getEmployeesWithoutPermission = "/employee/basic";
static const String getRoles = "/roles/jobrole";
static const String createEmployee = "/employee/manage-mobile";
static const String createEmployee = "/employee/app/manage";
static const String getEmployeeInfo = "/employee/profile/get";
static const String assignEmployee = "/employee/profile/get";
static const String getAssignedProjects = "/project/assigned-projects";

View File

@ -1976,39 +1976,46 @@ class ApiService {
static Future<List<dynamic>?> getRoles() async =>
_getRequest(ApiEndpoints.getRoles).then(
(res) => res != null ? _parseResponse(res, label: 'Roles') : null);
static Future<Map<String, dynamic>?> createEmployee({
String? id,
required String firstName,
required String lastName,
required String phoneNumber,
required String gender,
required String jobRoleId,
required String joiningDate,
}) async {
final body = {
if (id != null) "id": id,
"firstName": firstName,
"lastName": lastName,
"phoneNumber": phoneNumber,
"gender": gender,
"jobRoleId": jobRoleId,
"joiningDate": joiningDate,
};
static Future<Map<String, dynamic>?> createEmployee({
String? id,
required String firstName,
required String lastName,
required String phoneNumber,
required String gender,
required String jobRoleId,
required String joiningDate,
String? email,
String? organizationId,
bool? hasApplicationAccess,
}) async {
final body = {
if (id != null) "id": id,
"firstName": firstName,
"lastName": lastName,
"phoneNumber": phoneNumber,
"gender": gender,
"jobRoleId": jobRoleId,
"joiningDate": joiningDate,
if (email != null && email.isNotEmpty) "email": email,
if (organizationId != null && organizationId.isNotEmpty)
"organizationId": organizationId,
if (hasApplicationAccess != null) "hasApplicationAccess": hasApplicationAccess,
};
final response = await _postRequest(
ApiEndpoints.createEmployee,
body,
customTimeout: extendedTimeout,
);
final response = await _postRequest(
ApiEndpoints.createEmployee,
body,
customTimeout: extendedTimeout,
);
if (response == null) return null;
if (response == null) return null;
final json = jsonDecode(response.body);
return {
"success": response.statusCode == 200 && json['success'] == true,
"data": json,
};
}
final json = jsonDecode(response.body);
return {
"success": response.statusCode == 200 && json['success'] == true,
"data": json,
};
}
static Future<Map<String, dynamic>?> getEmployeeDetails(
String employeeId) async {

View File

@ -24,6 +24,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
final nameCtrl = TextEditingController();
final orgCtrl = TextEditingController();
final designationCtrl = TextEditingController();
final addrCtrl = TextEditingController();
final descCtrl = TextEditingController();
final tagCtrl = TextEditingController();
@ -49,6 +50,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
if (c != null) {
nameCtrl.text = c.name;
orgCtrl.text = c.organization;
designationCtrl.text = c.designation ?? '';
addrCtrl.text = c.address;
descCtrl.text = c.description;
@ -109,6 +111,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
void dispose() {
nameCtrl.dispose();
orgCtrl.dispose();
designationCtrl.dispose();
addrCtrl.dispose();
descCtrl.dispose();
tagCtrl.dispose();
@ -118,6 +121,20 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
super.dispose();
}
Widget _labelWithStar(String label, {bool required = false}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
MyText.labelMedium(label),
if (required)
const Text(
" *",
style: TextStyle(color: Colors.red, fontSize: 14),
),
],
);
}
InputDecoration _inputDecoration(String hint) => InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
@ -145,7 +162,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
_labelWithStar(label, required: required),
MySpacing.height(8),
TextFormField(
controller: ctrl,
@ -386,6 +403,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
phones: phones,
address: addrCtrl.text.trim(),
description: descCtrl.text.trim(),
designation: designationCtrl.text.trim(),
);
}
@ -412,7 +430,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
MySpacing.height(16),
_textField("Organization", orgCtrl, required: true),
MySpacing.height(16),
MyText.labelMedium(" Bucket"),
_labelWithStar("Bucket", required: true),
MySpacing.height(8),
Stack(
children: [
@ -477,19 +495,62 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
icon: const Icon(Icons.add),
label: const Text("Add Phone"),
),
MySpacing.height(16),
MyText.labelMedium("Category"),
MySpacing.height(8),
_popupSelector(controller.selectedCategory,
controller.categories, "Choose Category"),
MySpacing.height(16),
MyText.labelMedium("Tags"),
MySpacing.height(8),
_tagInput(),
MySpacing.height(16),
_textField("Address", addrCtrl),
MySpacing.height(16),
_textField("Description", descCtrl),
Obx(() => showAdvanced.value
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Move Designation field here
_textField("Designation", designationCtrl),
MySpacing.height(16),
_dynamicList(
emailCtrls,
emailLabels,
"Email",
["Office", "Personal", "Other"],
TextInputType.emailAddress,
),
TextButton.icon(
onPressed: () {
emailCtrls.add(TextEditingController());
emailLabels.add("Office".obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Email"),
),
_dynamicList(
phoneCtrls,
phoneLabels,
"Phone",
["Work", "Mobile", "Other"],
TextInputType.phone,
),
TextButton.icon(
onPressed: () {
phoneCtrls.add(TextEditingController());
phoneLabels.add("Work".obs);
},
icon: const Icon(Icons.add),
label: const Text("Add Phone"),
),
MyText.labelMedium("Category"),
MySpacing.height(8),
_popupSelector(
controller.selectedCategory,
controller.categories,
"Choose Category",
),
MySpacing.height(16),
MyText.labelMedium("Tags"),
MySpacing.height(8),
_tagInput(),
MySpacing.height(16),
_textField("Address", addrCtrl),
MySpacing.height(16),
_textField("Description", descCtrl),
],
)
: const SizedBox.shrink()),
],
)
: const SizedBox.shrink()),

View File

@ -2,6 +2,7 @@ class ContactModel {
final String id;
final List<String>? projectIds;
final String name;
final String? designation;
final List<ContactPhone> contactPhones;
final List<ContactEmail> contactEmails;
final ContactCategory? contactCategory;
@ -15,6 +16,7 @@ class ContactModel {
required this.id,
required this.projectIds,
required this.name,
this.designation,
required this.contactPhones,
required this.contactEmails,
required this.contactCategory,
@ -30,6 +32,7 @@ class ContactModel {
id: json['id'],
projectIds: (json['projectIds'] as List?)?.map((e) => e as String).toList(),
name: json['name'],
designation: json['designation'],
contactPhones: (json['contactPhones'] as List)
.map((e) => ContactPhone.fromJson(e))
.toList(),
@ -48,6 +51,7 @@ class ContactModel {
}
}
class ContactPhone {
final String id;
final String label;

View File

@ -1,19 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/employee/add_employee_controller.dart';
import 'package:marco/controller/employee/employees_screen_controller.dart';
import 'package:marco/controller/tenant/organization_selection_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class AddEmployeeBottomSheet extends StatefulWidget {
final Map<String, dynamic>? employeeData;
AddEmployeeBottomSheet({this.employeeData});
const AddEmployeeBottomSheet({super.key, this.employeeData});
@override
State<AddEmployeeBottomSheet> createState() => _AddEmployeeBottomSheetState();
@ -22,28 +24,88 @@ class AddEmployeeBottomSheet extends StatefulWidget {
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
with UIMixin {
late final AddEmployeeController _controller;
final OrganizationController _organizationController =
Get.put(OrganizationController());
// Local UI state
bool _hasApplicationAccess = false;
// Local read-only controllers to avoid recreating TextEditingController in build
late final TextEditingController _orgFieldController;
late final TextEditingController _joiningDateController;
late final TextEditingController _genderController;
late final TextEditingController _roleController;
@override
void initState() {
super.initState();
_controller = Get.put(
AddEmployeeController(),
tag: UniqueKey().toString(),
// Unique tag to avoid clashes, but stable for this widget instance
tag: UniqueKey().toString(),
);
_orgFieldController = TextEditingController(text: '');
_joiningDateController = TextEditingController(text: '');
_genderController = TextEditingController(text: '');
_roleController = TextEditingController(text: '');
// Prefill when editing
if (widget.employeeData != null) {
_controller.editingEmployeeData = widget.employeeData;
_controller.prefillFields();
final orgId = widget.employeeData!['organizationId'];
if (orgId != null) {
_controller.selectedOrganizationId = orgId;
final selectedOrg = _organizationController.organizations
.firstWhereOrNull((o) => o.id == orgId);
if (selectedOrg != null) {
_organizationController.selectOrganization(selectedOrg);
_orgFieldController.text = selectedOrg.name;
}
}
if (_controller.joiningDate != null) {
_joiningDateController.text =
DateFormat('dd MMM yyyy').format(_controller.joiningDate!);
}
if (_controller.selectedGender != null) {
_genderController.text =
_controller.selectedGender!.name.capitalizeFirst ?? '';
}
final roleName = _controller.roles.firstWhereOrNull(
(r) => r['id'] == _controller.selectedRoleId)?['name'] ??
'';
_roleController.text = roleName;
} else {
_orgFieldController.text = _organizationController.currentSelection;
}
}
@override
void dispose() {
_orgFieldController.dispose();
_joiningDateController.dispose();
_genderController.dispose();
_roleController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GetBuilder<AddEmployeeController>(
init: _controller,
builder: (_) {
// Keep org field in sync with controller selection
_orgFieldController.text = _organizationController.currentSelection;
return BaseBottomSheet(
title: widget.employeeData != null ? "Edit Employee" : "Add Employee",
title: widget.employeeData != null ? 'Edit Employee' : 'Add Employee',
onCancel: () => Navigator.pop(context),
onSubmit: _handleSubmit,
child: Form(
@ -51,11 +113,11 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel("Personal Info"),
_sectionLabel('Personal Info'),
MySpacing.height(16),
_inputWithIcon(
label: "First Name",
hint: "e.g., John",
label: 'First Name',
hint: 'e.g., John',
icon: Icons.person,
controller:
_controller.basicValidator.getController('first_name')!,
@ -64,8 +126,8 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
),
MySpacing.height(16),
_inputWithIcon(
label: "Last Name",
hint: "e.g., Doe",
label: 'Last Name',
hint: 'e.g., Doe',
icon: Icons.person_outline,
controller:
_controller.basicValidator.getController('last_name')!,
@ -73,37 +135,91 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
_controller.basicValidator.getValidation('last_name'),
),
MySpacing.height(16),
_sectionLabel("Joining Details"),
_sectionLabel('Organization'),
MySpacing.height(8),
GestureDetector(
onTap: () => _showOrganizationPopup(context),
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: _orgFieldController,
validator: (val) {
if (val == null ||
val.trim().isEmpty ||
val == 'All Organizations') {
return 'Organization is required';
}
return null;
},
decoration:
_inputDecoration('Select Organization').copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
MySpacing.height(24),
_sectionLabel('Application Access'),
Row(
children: [
Checkbox(
value: _hasApplicationAccess,
onChanged: (val) {
setState(() => _hasApplicationAccess = val ?? false);
},
fillColor:
WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return Colors.indigo;
}
return Colors.white;
}),
side: WidgetStateBorderSide.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return BorderSide.none;
}
return const BorderSide(
color: Colors.black,
width: 2,
);
}),
checkColor: Colors.white,
),
MyText.bodyMedium(
'Has Application Access',
fontWeight: 600,
),
],
),
MySpacing.height(8),
_buildEmailField(),
MySpacing.height(12),
_sectionLabel('Joining Details'),
MySpacing.height(16),
_buildDatePickerField(
label: "Joining Date",
value: _controller.joiningDate != null
? DateFormat("dd MMM yyyy")
.format(_controller.joiningDate!)
: "",
hint: "Select Joining Date",
label: 'Joining Date',
controller: _joiningDateController,
hint: 'Select Joining Date',
onTap: () => _pickJoiningDate(context),
),
MySpacing.height(16),
_sectionLabel("Contact Details"),
_sectionLabel('Contact Details'),
MySpacing.height(16),
_buildPhoneInput(context),
MySpacing.height(24),
_sectionLabel("Other Details"),
_sectionLabel('Other Details'),
MySpacing.height(16),
_buildDropdownField(
label: "Gender",
value: _controller.selectedGender?.name.capitalizeFirst ?? '',
hint: "Select Gender",
label: 'Gender',
controller: _genderController,
hint: 'Select Gender',
onTap: () => _showGenderPopup(context),
),
MySpacing.height(16),
_buildDropdownField(
label: "Role",
value: _controller.roles.firstWhereOrNull((role) =>
role['id'] == _controller.selectedRoleId)?['name'] ??
"",
hint: "Select Role",
label: 'Role',
controller: _roleController,
hint: 'Select Role',
onTap: () => _showRolePopup(context),
),
],
@ -114,96 +230,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
);
}
Widget _requiredLabel(String text) {
return Row(
children: [
MyText.labelMedium(text),
const SizedBox(width: 4),
const Text("*", style: TextStyle(color: Colors.red)),
],
);
}
Widget _buildDatePickerField({
required String label,
required String value,
required String hint,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel(label),
MySpacing.height(8),
GestureDetector(
onTap: onTap,
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: TextEditingController(text: value),
validator: (val) {
if (val == null || val.trim().isEmpty) {
return "$label is required";
}
return null;
},
decoration: _inputDecoration(hint).copyWith(
suffixIcon: const Icon(Icons.calendar_today),
),
),
),
),
],
);
}
Future<void> _pickJoiningDate(BuildContext context) async {
final picked = await showDatePicker(
context: context,
initialDate: _controller.joiningDate ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
_controller.setJoiningDate(picked);
_controller.update();
}
}
Future<void> _handleSubmit() async {
final isValid =
_controller.basicValidator.formKey.currentState?.validate() ?? false;
if (!isValid ||
_controller.joiningDate == null ||
_controller.selectedGender == null ||
_controller.selectedRoleId == null) {
showAppSnackbar(
title: "Missing Fields",
message: "Please complete all required fields.",
type: SnackbarType.warning,
);
return;
}
final result = await _controller.createOrUpdateEmployee();
if (result != null && result['success'] == true) {
final employeeController = Get.find<EmployeesScreenController>();
final projectId = employeeController.selectedProjectId;
if (projectId == null) {
await employeeController.fetchAllEmployees();
} else {
await employeeController.fetchEmployeesByProject(projectId);
}
employeeController.update(['employee_screen_controller']);
Navigator.pop(context, result['data']);
}
}
// UI Pieces
Widget _sectionLabel(String title) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -214,116 +241,12 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
],
);
Widget _inputWithIcon({
required String label,
required String hint,
required IconData icon,
required TextEditingController controller,
required String? Function(String?)? validator,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
Widget _requiredLabel(String text) {
return Row(
children: [
_requiredLabel(label),
MySpacing.height(8),
TextFormField(
controller: controller,
validator: (val) {
if (val == null || val.trim().isEmpty) {
return "$label is required";
}
return validator?.call(val);
},
decoration: _inputDecoration(hint).copyWith(
prefixIcon: Icon(icon, size: 20),
),
),
],
);
}
Widget _buildPhoneInput(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel("Phone Number"),
MySpacing.height(8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
color: Colors.grey.shade100,
),
child: const Text("+91"),
),
MySpacing.width(12),
Expanded(
child: TextFormField(
controller:
_controller.basicValidator.getController('phone_number'),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Phone Number is required";
}
if (value.trim().length != 10) {
return "Phone Number must be exactly 10 digits";
}
if (!RegExp(r'^\d{10}$').hasMatch(value.trim())) {
return "Enter a valid 10-digit number";
}
return null;
},
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
decoration: _inputDecoration("e.g., 9876543210").copyWith(
suffixIcon: IconButton(
icon: const Icon(Icons.contacts),
onPressed: () => _controller.pickContact(context),
),
),
),
),
],
),
],
);
}
Widget _buildDropdownField({
required String label,
required String value,
required String hint,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel(label),
MySpacing.height(8),
GestureDetector(
onTap: onTap,
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: TextEditingController(text: value),
validator: (val) {
if (val == null || val.trim().isEmpty) {
return "$label is required";
}
return null;
},
decoration: _inputDecoration(hint).copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
MyText.labelMedium(text),
const SizedBox(width: 4),
const Text('*', style: TextStyle(color: Colors.red)),
],
);
}
@ -350,20 +273,298 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
);
}
Widget _inputWithIcon({
required String label,
required String hint,
required IconData icon,
required TextEditingController controller,
required String? Function(String?)? validator,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel(label),
MySpacing.height(8),
TextFormField(
controller: controller,
validator: (val) {
if (val == null || val.trim().isEmpty) {
return '$label is required';
}
return validator?.call(val);
},
decoration: _inputDecoration(hint).copyWith(
prefixIcon: Icon(icon, size: 20),
),
),
],
);
}
Widget _buildEmailField() {
final emailController = _controller.basicValidator.getController('email') ??
TextEditingController();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
MyText.labelMedium('Email'),
const SizedBox(width: 4),
if (_hasApplicationAccess)
const Text('*', style: TextStyle(color: Colors.red)),
],
),
MySpacing.height(8),
TextFormField(
controller: emailController,
validator: (val) {
if (_hasApplicationAccess) {
if (val == null || val.trim().isEmpty) {
return 'Email is required for application users';
}
final email = val.trim();
if (!RegExp(r'^[\w\-\.]+@([\w\-]+\.)+[\w\-]{2,4}$')
.hasMatch(email)) {
return 'Enter a valid email address';
}
}
return null;
},
keyboardType: TextInputType.emailAddress,
decoration: _inputDecoration('e.g., john.doe@example.com').copyWith(
),
),
],
);
}
Widget _buildDatePickerField({
required String label,
required TextEditingController controller,
required String hint,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel(label),
MySpacing.height(8),
GestureDetector(
onTap: onTap,
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: controller,
validator: (val) {
if (val == null || val.trim().isEmpty) {
return '$label is required';
}
return null;
},
decoration: _inputDecoration(hint).copyWith(
suffixIcon: const Icon(Icons.calendar_today),
),
),
),
),
],
);
}
Widget _buildDropdownField({
required String label,
required TextEditingController controller,
required String hint,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel(label),
MySpacing.height(8),
GestureDetector(
onTap: onTap,
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
controller: controller,
validator: (val) {
if (val == null || val.trim().isEmpty) {
return '$label is required';
}
return null;
},
decoration: _inputDecoration(hint).copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
),
),
),
],
);
}
Widget _buildPhoneInput(BuildContext context) {
final phoneController =
_controller.basicValidator.getController('phone_number');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_requiredLabel('Phone Number'),
MySpacing.height(8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
color: Colors.grey.shade100,
),
child: const Text('+91'),
),
MySpacing.width(12),
Expanded(
child: TextFormField(
controller: phoneController,
validator: (value) {
final v = value?.trim() ?? '';
if (v.isEmpty) return 'Phone Number is required';
if (v.length != 10)
return 'Phone Number must be exactly 10 digits';
if (!RegExp(r'^\d{10}$').hasMatch(v)) {
return 'Enter a valid 10-digit number';
}
return null;
},
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
decoration: _inputDecoration('e.g., 9876543210').copyWith(
suffixIcon: IconButton(
icon: const Icon(Icons.contacts),
onPressed: () => _controller.pickContact(context),
),
),
),
),
],
),
],
);
}
// Actions
Future<void> _pickJoiningDate(BuildContext context) async {
final picked = await showDatePicker(
context: context,
initialDate: _controller.joiningDate ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
_controller.setJoiningDate(picked);
_joiningDateController.text = DateFormat('dd MMM yyyy').format(picked);
_controller.update();
}
}
Future<void> _handleSubmit() async {
final isValid =
_controller.basicValidator.formKey.currentState?.validate() ?? false;
if (!isValid ||
_controller.joiningDate == null ||
_controller.selectedGender == null ||
_controller.selectedRoleId == null ||
_organizationController.currentSelection.isEmpty ||
_organizationController.currentSelection == 'All Organizations') {
showAppSnackbar(
title: 'Missing Fields',
message: 'Please complete all required fields.',
type: SnackbarType.warning,
);
return;
}
final result = await _controller.createOrUpdateEmployee(
email: _controller.basicValidator.getController('email')?.text.trim(),
hasApplicationAccess: _hasApplicationAccess,
);
if (result != null && result['success'] == true) {
final employeeController = Get.find<EmployeesScreenController>();
final projectId = employeeController.selectedProjectId;
if (projectId == null) {
await employeeController.fetchAllEmployees();
} else {
await employeeController.fetchEmployeesByProject(projectId);
}
employeeController.update(['employee_screen_controller']);
if (mounted) Navigator.pop(context, result['data']);
}
}
void _showOrganizationPopup(BuildContext context) async {
final orgs = _organizationController.organizations;
if (orgs.isEmpty) {
showAppSnackbar(
title: 'No Organizations',
message: 'No organizations available to select.',
type: SnackbarType.warning,
);
return;
}
final selected = await showMenu<String>(
context: context,
position: _popupMenuPosition(context),
items: orgs
.map(
(org) => PopupMenuItem<String>(
value: org.id,
child: Text(org.name),
),
)
.toList(),
);
if (selected != null && selected.trim().isNotEmpty) {
final chosen = orgs.firstWhere((e) => e.id == selected);
_organizationController.selectOrganization(chosen);
_controller.selectedOrganizationId = chosen.id;
_orgFieldController.text = chosen.name;
_controller.update();
}
}
void _showGenderPopup(BuildContext context) async {
final selected = await showMenu<Gender>(
context: context,
position: _popupMenuPosition(context),
items: Gender.values.map((gender) {
return PopupMenuItem<Gender>(
value: gender,
child: Text(gender.name.capitalizeFirst!),
);
}).toList(),
items: Gender.values
.map(
(gender) => PopupMenuItem<Gender>(
value: gender,
child: Text(gender.name.capitalizeFirst!),
),
)
.toList(),
);
if (selected != null) {
_controller.onGenderSelected(selected);
_genderController.text = selected.name.capitalizeFirst ?? '';
_controller.update();
}
}
@ -372,16 +573,22 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
final selected = await showMenu<String>(
context: context,
position: _popupMenuPosition(context),
items: _controller.roles.map((role) {
return PopupMenuItem<String>(
value: role['id'],
child: Text(role['name']),
);
}).toList(),
items: _controller.roles
.map(
(role) => PopupMenuItem<String>(
value: role['id'],
child: Text(role['name']),
),
)
.toList(),
);
if (selected != null) {
_controller.onRoleSelected(selected);
final roleName = _controller.roles
.firstWhereOrNull((r) => r['id'] == selected)?['name'] ??
'';
_roleController.text = roleName;
_controller.update();
}
}