implementation of Manage reporting
This commit is contained in:
parent
6b56351a49
commit
340d0b8a1e
@ -1,7 +1,7 @@
|
||||
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://devapi.marcoaiot.com/api";
|
||||
static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
||||
|
||||
static const String getMasterCurrencies = "/Master/currencies/list";
|
||||
static const String getMasterExpensesCategories =
|
||||
@ -127,4 +127,10 @@ class ApiEndpoints {
|
||||
|
||||
static const String getAssignedServices = "/Project/get/assigned/services";
|
||||
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
|
||||
static Future<CurrencyListResponse?> getMasterCurrenciesApi() async {
|
||||
const endpoint = ApiEndpoints.getMasterCurrencies;
|
||||
|
||||
@ -23,6 +23,8 @@ class EmployeeDetailsModel {
|
||||
final String? organizationId;
|
||||
final String? aadharNumber;
|
||||
final String? panNumber;
|
||||
|
||||
|
||||
EmployeeDetailsModel({
|
||||
required this.id,
|
||||
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/helpers/utils/mixins/ui_mixin.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';
|
||||
import 'package:marco/view/employees/assign_employee_bottom_sheet.dart';
|
||||
|
||||
class EmployeeDetailPage extends StatefulWidget {
|
||||
@ -283,6 +285,64 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
|
||||
),
|
||||
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
|
||||
_buildSectionCard(
|
||||
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/widgets/my_refresh_indicator.dart';
|
||||
import 'package:marco/view/employees/employee_profile_screen.dart';
|
||||
import 'package:marco/view/employees/manage_reporting_bottom_sheet.dart';
|
||||
|
||||
class EmployeesScreen extends StatefulWidget {
|
||||
const EmployeesScreen({super.key});
|
||||
@ -64,13 +65,17 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
final searchQuery = query.toLowerCase();
|
||||
final filtered = query.isEmpty
|
||||
? List<EmployeeModel>.from(employees)
|
||||
: employees.where((e) =>
|
||||
: employees
|
||||
.where(
|
||||
(e) =>
|
||||
e.name.toLowerCase().contains(searchQuery) ||
|
||||
e.email.toLowerCase().contains(searchQuery) ||
|
||||
e.phoneNumber.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);
|
||||
}
|
||||
|
||||
@ -106,7 +111,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
await _refreshEmployees();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@ -160,7 +164,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
child: Row(
|
||||
children: [
|
||||
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'),
|
||||
),
|
||||
MySpacing.width(8),
|
||||
@ -206,7 +211,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
Widget _buildFloatingActionButton() {
|
||||
return Obx(() {
|
||||
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();
|
||||
|
||||
return InkWell(
|
||||
@ -218,7 +224,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
color: contentTheme.primary,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
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(
|
||||
@ -235,34 +242,117 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
}
|
||||
|
||||
Widget _buildSearchField() {
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
return Padding(
|
||||
padding: MySpacing.xy(8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// Search field
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 35,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
style: const TextStyle(fontSize: 13, height: 1.2),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
prefixIcon:
|
||||
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...',
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300, width: 1),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
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(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
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