implementation of Manage reporting
This commit is contained in:
parent
b1f5fb8d78
commit
7f756f3d4c
@ -1,7 +1,7 @@
|
|||||||
class ApiEndpoints {
|
class ApiEndpoints {
|
||||||
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
// static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
||||||
|
|
||||||
static const String getMasterCurrencies = "/Master/currencies/list";
|
static const String getMasterCurrencies = "/Master/currencies/list";
|
||||||
static const String getMasterExpensesCategories =
|
static const String getMasterExpensesCategories =
|
||||||
@ -127,4 +127,10 @@ class ApiEndpoints {
|
|||||||
|
|
||||||
static const String getAssignedServices = "/Project/get/assigned/services";
|
static const String getAssignedServices = "/Project/get/assigned/services";
|
||||||
static const String getAdvancePayments = '/Expense/get/transactions';
|
static const String getAdvancePayments = '/Expense/get/transactions';
|
||||||
|
|
||||||
|
// Organization Hierarchy endpoints
|
||||||
|
static const String getOrganizationHierarchyList =
|
||||||
|
"/organization/hierarchy/list";
|
||||||
|
static const String manageOrganizationHierarchy =
|
||||||
|
"/organization/hierarchy/manage";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -655,6 +655,60 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch hierarchy list for an employee
|
||||||
|
static Future<List<dynamic>?> getOrganizationHierarchyList(
|
||||||
|
String employeeId) async {
|
||||||
|
if (employeeId.isEmpty) throw ArgumentError('employeeId must not be empty');
|
||||||
|
final endpoint = "${ApiEndpoints.getOrganizationHierarchyList}/$employeeId";
|
||||||
|
|
||||||
|
return _getRequest(endpoint).then(
|
||||||
|
(res) => res != null
|
||||||
|
? _parseResponse(res, label: 'Organization Hierarchy List')
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manage (create/update) organization hierarchy (assign reporters) for an employee
|
||||||
|
/// payload is a List<Map<String,dynamic>> with objects like:
|
||||||
|
/// { "reportToId": "<uuid>", "isPrimary": true, "isActive": true }
|
||||||
|
static Future<bool> manageOrganizationHierarchy({
|
||||||
|
required String employeeId,
|
||||||
|
required List<Map<String, dynamic>> payload,
|
||||||
|
}) async {
|
||||||
|
if (employeeId.isEmpty) throw ArgumentError('employeeId must not be empty');
|
||||||
|
|
||||||
|
final endpoint = "${ApiEndpoints.manageOrganizationHierarchy}/$employeeId";
|
||||||
|
|
||||||
|
logSafe("manageOrganizationHierarchy for $employeeId payload: $payload");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _postRequest(endpoint, payload);
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Manage hierarchy failed: null response",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Manage hierarchy response status: ${response.statusCode}");
|
||||||
|
logSafe("Manage hierarchy response body: ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe("Manage hierarchy succeeded");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Manage hierarchy failed: ${json['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception while manageOrganizationHierarchy: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get Master Currencies
|
/// Get Master Currencies
|
||||||
static Future<CurrencyListResponse?> getMasterCurrenciesApi() async {
|
static Future<CurrencyListResponse?> getMasterCurrenciesApi() async {
|
||||||
const endpoint = ApiEndpoints.getMasterCurrencies;
|
const endpoint = ApiEndpoints.getMasterCurrencies;
|
||||||
|
|||||||
@ -23,6 +23,8 @@ class EmployeeDetailsModel {
|
|||||||
final String? organizationId;
|
final String? organizationId;
|
||||||
final String? aadharNumber;
|
final String? aadharNumber;
|
||||||
final String? panNumber;
|
final String? panNumber;
|
||||||
|
|
||||||
|
|
||||||
EmployeeDetailsModel({
|
EmployeeDetailsModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.firstName,
|
required this.firstName,
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
|||||||
import 'package:marco/model/employees/add_employee_bottom_sheet.dart';
|
import 'package:marco/model/employees/add_employee_bottom_sheet.dart';
|
||||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
import 'package:marco/view/employees/manage_reporting_bottom_sheet.dart';
|
||||||
|
import 'package:marco/model/employees/employee_model.dart';
|
||||||
|
|
||||||
class EmployeeDetailPage extends StatefulWidget {
|
class EmployeeDetailPage extends StatefulWidget {
|
||||||
final String employeeId;
|
final String employeeId;
|
||||||
@ -282,6 +284,64 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
|
|||||||
),
|
),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
_buildSectionCard(
|
||||||
|
title: 'Manage Reporting',
|
||||||
|
titleIcon: Icons.people_outline,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => ManageReportingBottomSheet(
|
||||||
|
initialEmployee: EmployeeModel(
|
||||||
|
id: employee.id,
|
||||||
|
employeeId: employee.id.toString(),
|
||||||
|
firstName: employee.firstName ?? "",
|
||||||
|
lastName: employee.lastName ?? "",
|
||||||
|
name:
|
||||||
|
"${employee.firstName} ${employee.lastName}",
|
||||||
|
email: employee.email ?? "",
|
||||||
|
jobRole: employee.jobRole ?? "",
|
||||||
|
jobRoleID: "0",
|
||||||
|
designation: employee.jobRole ?? "",
|
||||||
|
phoneNumber: employee.phoneNumber ?? "",
|
||||||
|
activity: 0,
|
||||||
|
action: 0,
|
||||||
|
),
|
||||||
|
hideMainSelector: true,
|
||||||
|
hideLoggedUserFromSelection:
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.manage_accounts_outlined,
|
||||||
|
color: Colors.grey),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'View / Update Reporting Managers',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.arrow_forward_ios_rounded,
|
||||||
|
size: 16, color: Colors.grey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
// Contact Information Section
|
// Contact Information Section
|
||||||
_buildSectionCard(
|
_buildSectionCard(
|
||||||
title: 'Contact Information',
|
title: 'Contact Information',
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import 'package:marco/controller/permission_controller.dart';
|
|||||||
import 'package:marco/helpers/utils/permission_constants.dart';
|
import 'package:marco/helpers/utils/permission_constants.dart';
|
||||||
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
||||||
import 'package:marco/view/employees/employee_profile_screen.dart';
|
import 'package:marco/view/employees/employee_profile_screen.dart';
|
||||||
|
import 'package:marco/view/employees/manage_reporting_bottom_sheet.dart';
|
||||||
|
|
||||||
class EmployeesScreen extends StatefulWidget {
|
class EmployeesScreen extends StatefulWidget {
|
||||||
const EmployeesScreen({super.key});
|
const EmployeesScreen({super.key});
|
||||||
@ -64,13 +65,17 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
final searchQuery = query.toLowerCase();
|
final searchQuery = query.toLowerCase();
|
||||||
final filtered = query.isEmpty
|
final filtered = query.isEmpty
|
||||||
? List<EmployeeModel>.from(employees)
|
? List<EmployeeModel>.from(employees)
|
||||||
: employees.where((e) =>
|
: employees
|
||||||
|
.where(
|
||||||
|
(e) =>
|
||||||
e.name.toLowerCase().contains(searchQuery) ||
|
e.name.toLowerCase().contains(searchQuery) ||
|
||||||
e.email.toLowerCase().contains(searchQuery) ||
|
e.email.toLowerCase().contains(searchQuery) ||
|
||||||
e.phoneNumber.toLowerCase().contains(searchQuery) ||
|
e.phoneNumber.toLowerCase().contains(searchQuery) ||
|
||||||
e.jobRole.toLowerCase().contains(searchQuery),
|
e.jobRole.toLowerCase().contains(searchQuery),
|
||||||
).toList();
|
)
|
||||||
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
.toList();
|
||||||
|
filtered
|
||||||
|
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
_filteredEmployees.assignAll(filtered);
|
_filteredEmployees.assignAll(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +111,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
await _refreshEmployees();
|
await _refreshEmployees();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -160,7 +164,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20),
|
icon: const Icon(Icons.arrow_back_ios_new,
|
||||||
|
color: Colors.black, size: 20),
|
||||||
onPressed: () => Get.offNamed('/dashboard'),
|
onPressed: () => Get.offNamed('/dashboard'),
|
||||||
),
|
),
|
||||||
MySpacing.width(8),
|
MySpacing.width(8),
|
||||||
@ -206,7 +211,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
Widget _buildFloatingActionButton() {
|
Widget _buildFloatingActionButton() {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
if (_permissionController.isLoading.value) return const SizedBox.shrink();
|
if (_permissionController.isLoading.value) return const SizedBox.shrink();
|
||||||
final hasPermission = _permissionController.hasPermission(Permissions.manageEmployees);
|
final hasPermission =
|
||||||
|
_permissionController.hasPermission(Permissions.manageEmployees);
|
||||||
if (!hasPermission) return const SizedBox.shrink();
|
if (!hasPermission) return const SizedBox.shrink();
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
@ -218,7 +224,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
color: contentTheme.primary,
|
color: contentTheme.primary,
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 3)),
|
BoxShadow(
|
||||||
|
color: Colors.black26, blurRadius: 6, offset: Offset(0, 3)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: const Row(
|
||||||
@ -235,34 +242,117 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSearchField() {
|
Widget _buildSearchField() {
|
||||||
return SizedBox(
|
return Padding(
|
||||||
height: 36,
|
padding: MySpacing.xy(8, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Search field
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 35,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
style: const TextStyle(fontSize: 13, height: 1.2),
|
style: const TextStyle(fontSize: 13, height: 1.2),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
prefixIcon:
|
||||||
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
|
const Icon(Icons.search, size: 20, color: Colors.grey),
|
||||||
|
suffixIcon: ValueListenableBuilder<TextEditingValue>(
|
||||||
|
valueListenable: _searchController,
|
||||||
|
builder: (context, value, _) {
|
||||||
|
if (value.text.isEmpty) return const SizedBox.shrink();
|
||||||
|
return IconButton(
|
||||||
|
icon: const Icon(Icons.clear,
|
||||||
|
size: 20, color: Colors.grey),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
_filterEmployees('');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
hintText: 'Search employees...',
|
hintText: 'Search employees...',
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: Colors.white,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(5),
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300, width: 1),
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
),
|
),
|
||||||
suffixIcon: _searchController.text.isNotEmpty
|
|
||||||
? GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
_searchController.clear();
|
|
||||||
_filterEmployees('');
|
|
||||||
},
|
|
||||||
child: const Icon(Icons.close, size: 18, color: Colors.grey),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
onChanged: (_) => _filterEmployees(_searchController.text),
|
onChanged: (_) => _filterEmployees(_searchController.text),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.width(10),
|
||||||
|
|
||||||
|
// Three dots menu (Manage Reporting)
|
||||||
|
Container(
|
||||||
|
height: 35,
|
||||||
|
width: 35,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
child: PopupMenuButton<int>(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon:
|
||||||
|
const Icon(Icons.more_vert, size: 20, color: Colors.black87),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(5)),
|
||||||
|
itemBuilder: (context) {
|
||||||
|
List<PopupMenuEntry<int>> menuItems = [];
|
||||||
|
|
||||||
|
// Section: Actions
|
||||||
|
menuItems.add(
|
||||||
|
const PopupMenuItem<int>(
|
||||||
|
enabled: false,
|
||||||
|
height: 30,
|
||||||
|
child: Text(
|
||||||
|
"Actions",
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Manage Reporting option
|
||||||
|
menuItems.add(
|
||||||
|
PopupMenuItem<int>(
|
||||||
|
value: 1,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.manage_accounts_outlined,
|
||||||
|
size: 20, color: Colors.black87),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Expanded(child: Text("Manage Reporting")),
|
||||||
|
Icon(Icons.chevron_right,
|
||||||
|
size: 20, color: contentTheme.primary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => const ManageReportingBottomSheet(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return menuItems;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,7 +373,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 60),
|
padding: const EdgeInsets.only(top: 60),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: MyText.bodySmall("No Employees Found", fontWeight: 600, color: Colors.grey[700]),
|
child: MyText.bodySmall("No Employees Found",
|
||||||
|
fontWeight: 600, color: Colors.grey[700]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
557
lib/view/employees/manage_reporting_bottom_sheet.dart
Normal file
557
lib/view/employees/manage_reporting_bottom_sheet.dart
Normal file
@ -0,0 +1,557 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/theme/app_theme.dart';
|
||||||
|
import 'package:marco/controller/employee/employees_screen_controller.dart';
|
||||||
|
import 'package:marco/model/employees/employee_model.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
|
||||||
|
class ManageReportingBottomSheet extends StatefulWidget {
|
||||||
|
final EmployeeModel? initialEmployee;
|
||||||
|
final bool hideMainSelector;
|
||||||
|
final bool renderAsCard;
|
||||||
|
final bool hideLoggedUserFromSelection; // ✅ new
|
||||||
|
|
||||||
|
const ManageReportingBottomSheet({
|
||||||
|
super.key,
|
||||||
|
this.initialEmployee,
|
||||||
|
this.hideMainSelector = false,
|
||||||
|
this.renderAsCard = false,
|
||||||
|
this.hideLoggedUserFromSelection = false, // default false
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ManageReportingBottomSheet> createState() =>
|
||||||
|
_ManageReportingBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ManageReportingBottomSheetState
|
||||||
|
extends State<ManageReportingBottomSheet> {
|
||||||
|
final EmployeesScreenController _employeeController = Get.find();
|
||||||
|
final TextEditingController _primaryController = TextEditingController();
|
||||||
|
final TextEditingController _secondaryController = TextEditingController();
|
||||||
|
|
||||||
|
final RxList<EmployeeModel> _filteredPrimary = <EmployeeModel>[].obs;
|
||||||
|
final RxList<EmployeeModel> _filteredSecondary = <EmployeeModel>[].obs;
|
||||||
|
final RxList<EmployeeModel> _selectedPrimary = <EmployeeModel>[].obs;
|
||||||
|
final RxList<EmployeeModel> _selectedSecondary = <EmployeeModel>[].obs;
|
||||||
|
|
||||||
|
final TextEditingController _selectEmployeeController =
|
||||||
|
TextEditingController();
|
||||||
|
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
|
||||||
|
EmployeeModel? _selectedEmployee;
|
||||||
|
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_primaryController
|
||||||
|
.addListener(() => _filterEmployees(_primaryController.text, true));
|
||||||
|
_secondaryController
|
||||||
|
.addListener(() => _filterEmployees(_secondaryController.text, false));
|
||||||
|
_selectEmployeeController
|
||||||
|
.addListener(() => _filterMainEmployee(_selectEmployeeController.text));
|
||||||
|
|
||||||
|
// if the parent passed an initialEmployee (profile page), preselect & load hierarchy
|
||||||
|
if (widget.initialEmployee != null) {
|
||||||
|
// delay to let widget finish first build
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_onMainEmployeeSelected(widget.initialEmployee!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_primaryController.dispose();
|
||||||
|
_secondaryController.dispose();
|
||||||
|
_selectEmployeeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _filterMainEmployee(String query) {
|
||||||
|
final employees = _employeeController.employees;
|
||||||
|
final searchQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
final filtered = query.isEmpty
|
||||||
|
? <EmployeeModel>[]
|
||||||
|
: employees
|
||||||
|
.where((e) => e.name.toLowerCase().contains(searchQuery))
|
||||||
|
.take(6)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_filteredEmployees.assignAll(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _filterEmployees(String query, bool isPrimary) {
|
||||||
|
final employees = _employeeController.employees;
|
||||||
|
final searchQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
final filtered = query.isEmpty
|
||||||
|
? <EmployeeModel>[]
|
||||||
|
: employees
|
||||||
|
.where((e) => e.name.toLowerCase().contains(searchQuery))
|
||||||
|
.take(6)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (isPrimary) {
|
||||||
|
_filteredPrimary.assignAll(filtered);
|
||||||
|
} else {
|
||||||
|
_filteredSecondary.assignAll(filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSelection(EmployeeModel emp, bool isPrimary) {
|
||||||
|
final list = isPrimary ? _selectedPrimary : _selectedSecondary;
|
||||||
|
|
||||||
|
if (isPrimary) {
|
||||||
|
//Allow only one primary employee at a time
|
||||||
|
list.clear();
|
||||||
|
list.add(emp);
|
||||||
|
} else {
|
||||||
|
// ✅ Secondary employees can still have multiple selections
|
||||||
|
if (list.any((e) => e.id == emp.id)) {
|
||||||
|
list.removeWhere((e) => e.id == emp.id);
|
||||||
|
} else {
|
||||||
|
list.add(emp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// helper to find employee by id from controller list (returns nullable)
|
||||||
|
EmployeeModel? _findEmployeeById(String id) {
|
||||||
|
for (final e in _employeeController.employees) {
|
||||||
|
if (e.id == id) return e;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when user taps an employee from dropdown to manage reporting for.
|
||||||
|
/// It sets selected employee and fetches existing hierarchy to preselect reporters.
|
||||||
|
Future<void> _onMainEmployeeSelected(EmployeeModel emp) async {
|
||||||
|
setState(() {
|
||||||
|
_selectedEmployee = emp;
|
||||||
|
_selectEmployeeController.text = emp.name;
|
||||||
|
_filteredEmployees.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear previous selections
|
||||||
|
_selectedPrimary.clear();
|
||||||
|
_selectedSecondary.clear();
|
||||||
|
|
||||||
|
// Fetch existing reporting hierarchy for this employee
|
||||||
|
try {
|
||||||
|
final data = await ApiService.getOrganizationHierarchyList(emp.id);
|
||||||
|
if (data == null || data.isEmpty) return;
|
||||||
|
|
||||||
|
for (final item in data) {
|
||||||
|
try {
|
||||||
|
final isPrimary = item['isPrimary'] == true;
|
||||||
|
final reportTo = item['reportTo'];
|
||||||
|
if (reportTo == null) continue;
|
||||||
|
final reportToId = reportTo['id'] as String?;
|
||||||
|
if (reportToId == null) continue;
|
||||||
|
|
||||||
|
final match = _findEmployeeById(reportToId);
|
||||||
|
if (match == null) continue;
|
||||||
|
|
||||||
|
// ✅ Skip the employee whose profile is open
|
||||||
|
if (widget.initialEmployee != null &&
|
||||||
|
match.id == widget.initialEmployee!.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPrimary) {
|
||||||
|
if (!_selectedPrimary.any((e) => e.id == match.id)) {
|
||||||
|
_selectedPrimary.add(match);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!_selectedSecondary.any((e) => e.id == match.id)) {
|
||||||
|
_selectedSecondary.add(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore malformed items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fetch failure - show a subtle snackbar
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to load existing reporting.',
|
||||||
|
type: SnackbarType.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetForm() {
|
||||||
|
setState(() {
|
||||||
|
_selectedEmployee = null;
|
||||||
|
_selectEmployeeController.clear();
|
||||||
|
_selectedPrimary.clear();
|
||||||
|
_selectedSecondary.clear();
|
||||||
|
_filteredEmployees.clear();
|
||||||
|
_filteredPrimary.clear();
|
||||||
|
_filteredSecondary.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetReportersOnly() {
|
||||||
|
_selectedPrimary.clear();
|
||||||
|
_selectedSecondary.clear();
|
||||||
|
_primaryController.clear();
|
||||||
|
_secondaryController.clear();
|
||||||
|
_filteredPrimary.clear();
|
||||||
|
_filteredSecondary.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSubmit() async {
|
||||||
|
if (_selectedEmployee == null) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Please select the employee.',
|
||||||
|
type: SnackbarType.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_selectedPrimary.isEmpty) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Please select at least one primary employee.',
|
||||||
|
type: SnackbarType.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map<String, dynamic>> payload = [];
|
||||||
|
|
||||||
|
for (final emp in _selectedPrimary) {
|
||||||
|
payload.add({
|
||||||
|
"reportToId": emp.id,
|
||||||
|
"isPrimary": true,
|
||||||
|
"isActive": true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (final emp in _selectedSecondary) {
|
||||||
|
payload.add({
|
||||||
|
"reportToId": emp.id,
|
||||||
|
"isPrimary": false,
|
||||||
|
"isActive": true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
// show loader
|
||||||
|
Get.dialog(const Center(child: CircularProgressIndicator()),
|
||||||
|
barrierDismissible: false);
|
||||||
|
|
||||||
|
final employeeId = _selectedEmployee!.id;
|
||||||
|
final success = await ApiService.manageOrganizationHierarchy(
|
||||||
|
employeeId: employeeId,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
// hide loader
|
||||||
|
if (Get.isDialogOpen == true) Get.back();
|
||||||
|
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Reporting assigned successfully',
|
||||||
|
type: SnackbarType.success);
|
||||||
|
|
||||||
|
// Optionally refresh the saved hierarchy (not necessary here) but we can call:
|
||||||
|
await ApiService.getOrganizationHierarchyList(employeeId);
|
||||||
|
|
||||||
|
// Keep sheet open and reset reporter selections for next assignment
|
||||||
|
_resetForm();
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to assign reporting. Please try again.',
|
||||||
|
type: SnackbarType.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleCancel() => Navigator.pop(context);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// build the same child column content you already had, but assign to a variable
|
||||||
|
final Widget content = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// conditional: show search section or simple header
|
||||||
|
if (widget.hideMainSelector)
|
||||||
|
_buildMainEmployeeHeader()
|
||||||
|
else
|
||||||
|
_buildMainEmployeeSection(),
|
||||||
|
|
||||||
|
MySpacing.height(20),
|
||||||
|
|
||||||
|
// Primary Employees section
|
||||||
|
_buildSearchSection(
|
||||||
|
label: "Primary Reporting Manager*",
|
||||||
|
controller: _primaryController,
|
||||||
|
filteredList: _filteredPrimary,
|
||||||
|
selectedList: _selectedPrimary,
|
||||||
|
isPrimary: true,
|
||||||
|
),
|
||||||
|
|
||||||
|
MySpacing.height(20),
|
||||||
|
|
||||||
|
// Secondary Employees section
|
||||||
|
_buildSearchSection(
|
||||||
|
label: "Secondary Reporting Manager",
|
||||||
|
controller: _secondaryController,
|
||||||
|
filteredList: _filteredSecondary,
|
||||||
|
selectedList: _selectedSecondary,
|
||||||
|
isPrimary: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (widget.renderAsCard) {
|
||||||
|
// Inline card for profile screen
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
elevation: 2,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// default: existing bottom sheet usage
|
||||||
|
return BaseBottomSheet(
|
||||||
|
title: "Manage Reporting",
|
||||||
|
submitText: "Submit",
|
||||||
|
isSubmitting: _isSubmitting,
|
||||||
|
onCancel: _handleCancel,
|
||||||
|
onSubmit: _handleSubmit,
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMainEmployeeHeader() {
|
||||||
|
// show selected employee name non-editable (chip-style)
|
||||||
|
final emp = _selectedEmployee;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium("Selected Employee", fontWeight: 600),
|
||||||
|
MySpacing.height(8),
|
||||||
|
if (emp != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Chip(
|
||||||
|
label: Text(emp.name, style: const TextStyle(fontSize: 12)),
|
||||||
|
backgroundColor: Colors.indigo.shade50,
|
||||||
|
labelStyle: TextStyle(color: AppTheme.primaryColor),
|
||||||
|
deleteIcon: const Icon(Icons.close, size: 16),
|
||||||
|
onDeleted: () => setState(() {
|
||||||
|
_selectedEmployee = null;
|
||||||
|
_selectEmployeeController.clear();
|
||||||
|
_resetReportersOnly();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Text('No employee selected',
|
||||||
|
style: TextStyle(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchSection({
|
||||||
|
required String label,
|
||||||
|
required TextEditingController controller,
|
||||||
|
required RxList<EmployeeModel> filteredList,
|
||||||
|
required RxList<EmployeeModel> selectedList,
|
||||||
|
required bool isPrimary,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium(label, fontWeight: 600),
|
||||||
|
MySpacing.height(8),
|
||||||
|
|
||||||
|
// Search field
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "Type to search employees...",
|
||||||
|
isDense: true,
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[50],
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Dropdown suggestions
|
||||||
|
Obx(() {
|
||||||
|
if (filteredList.isEmpty) return const SizedBox.shrink();
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(top: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: filteredList.length,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemBuilder: (_, index) {
|
||||||
|
final emp = filteredList[index];
|
||||||
|
final isSelected = selectedList.any((e) => e.id == emp.id);
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: CircleAvatar(
|
||||||
|
radius: 14,
|
||||||
|
backgroundColor: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
child: MyText.labelSmall(
|
||||||
|
emp.name.isNotEmpty ? emp.name[0].toUpperCase() : "?",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(emp.name, style: const TextStyle(fontSize: 13)),
|
||||||
|
trailing: Icon(
|
||||||
|
isSelected
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.radio_button_unchecked,
|
||||||
|
color: isSelected ? AppTheme.primaryColor : Colors.grey,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
_toggleSelection(emp, isPrimary);
|
||||||
|
filteredList.clear();
|
||||||
|
controller.clear();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
MySpacing.height(10),
|
||||||
|
|
||||||
|
// Selected employees as chips
|
||||||
|
Obx(() {
|
||||||
|
if (selectedList.isEmpty) return const SizedBox.shrink();
|
||||||
|
return Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: selectedList.map((emp) {
|
||||||
|
return Chip(
|
||||||
|
label: Text(emp.name, style: const TextStyle(fontSize: 12)),
|
||||||
|
backgroundColor: Colors.indigo.shade50,
|
||||||
|
labelStyle: TextStyle(color: AppTheme.primaryColor),
|
||||||
|
deleteIcon: const Icon(Icons.close, size: 16),
|
||||||
|
onDeleted: () => selectedList.remove(emp),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMainEmployeeSection() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium("Select Employee *", fontWeight: 600),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextField(
|
||||||
|
controller: _selectEmployeeController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "Type to search employee...",
|
||||||
|
isDense: true,
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[50],
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Obx(() {
|
||||||
|
if (_filteredEmployees.isEmpty) return const SizedBox.shrink();
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(top: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: _filteredEmployees.length,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemBuilder: (_, index) {
|
||||||
|
final emp = _filteredEmployees[index];
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: CircleAvatar(
|
||||||
|
radius: 14,
|
||||||
|
backgroundColor: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
child: MyText.labelSmall(
|
||||||
|
emp.name.isNotEmpty ? emp.name[0].toUpperCase() : "?",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(emp.name, style: const TextStyle(fontSize: 13)),
|
||||||
|
onTap: () => _onMainEmployeeSelected(emp),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
if (_selectedEmployee != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Chip(
|
||||||
|
label: Text(_selectedEmployee!.name,
|
||||||
|
style: const TextStyle(fontSize: 12)),
|
||||||
|
backgroundColor: Colors.indigo.shade50,
|
||||||
|
labelStyle: TextStyle(color: AppTheme.primaryColor),
|
||||||
|
deleteIcon: const Icon(Icons.close, size: 16),
|
||||||
|
onDeleted: () => setState(() {
|
||||||
|
_selectedEmployee = null;
|
||||||
|
_selectEmployeeController.clear();
|
||||||
|
// clear selected reporters too, since employee changed
|
||||||
|
_resetReportersOnly();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -190,11 +190,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.tune, color: Colors.black),
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user