diff --git a/lib/controller/service_project/service_project_details_screen_controller.dart b/lib/controller/service_project/service_project_details_screen_controller.dart index 6a3f1d6..b09a150 100644 --- a/lib/controller/service_project/service_project_details_screen_controller.dart +++ b/lib/controller/service_project/service_project_details_screen_controller.dart @@ -52,12 +52,14 @@ class ServiceProjectDetailsController extends GetxController { errorMessage.value = ''; try { - final result = await ApiService.getServiceProjectDetailApi(projectId.value); + final result = + await ApiService.getServiceProjectDetailApi(projectId.value); if (result != null && result.data != null) { projectDetail.value = result.data!; } else { - errorMessage.value = result?.message ?? "Failed to fetch project details"; + errorMessage.value = + result?.message ?? "Failed to fetch project details"; } } catch (e) { errorMessage.value = "Error: $e"; diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index fae25cf..0f67c10 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -135,6 +135,7 @@ class ApiEndpoints { static const String manageOrganizationHierarchy = "/organization/hierarchy/manage"; + // Service Project Module API Endpoints static const String getServiceProjectsList = "/serviceproject/list"; static const String getServiceProjectDetail = "/serviceproject/details"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 59a2d6c..9b8e2b0 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -504,6 +504,11 @@ class ApiService { } /// Get details of a single service project + /// Get details of a single service project + static Future getServiceProjectDetailApi( + String projectId) async { + final endpoint = "${ApiEndpoints.getServiceProjectDetail}/$projectId"; + logSafe("Fetching details for Service Project ID: $projectId"); static Future getServiceProjectDetailApi( String projectId) async { final endpoint = "${ApiEndpoints.getServiceProjectDetail}/$projectId"; @@ -512,6 +517,13 @@ class ApiService { try { final response = await _getRequest(endpoint); + if (response == null) { + logSafe( + "Service Project Detail request failed: null response", + level: LogLevel.error, + ); + return null; + } if (response == null) { logSafe("Service Project Detail request failed: null response", level: LogLevel.error); @@ -523,6 +535,19 @@ class ApiService { label: "Service Project Detail", ); + if (jsonResponse != null) { + return ServiceProjectDetailModel.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe( + "Exception during getServiceProjectDetailApi: $e", + level: LogLevel.error, + ); + logSafe( + "StackTrace: $stack", + level: LogLevel.debug, + ); + } if (jsonResponse != null) { return ServiceProjectDetailModel.fromJson(jsonResponse); } diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index 6492674..16af71e 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -51,18 +51,18 @@ class _EmployeeDetailPageState extends State with UIMixin { } String _getDisplayValue(dynamic value) { - if (value == null || value.toString().trim().isEmpty || value == 'null') { - return 'NA'; + if (value == null || value.toString().trim().isEmpty || value == "null") { + return "NA"; } return value.toString(); } String _formatDate(DateTime? date) { - if (date == null || date == DateTime(1)) return 'NA'; + if (date == null || date == DateTime(1)) return "NA"; try { return DateFormat('d/M/yyyy').format(date); } catch (_) { - return 'NA'; + return "NA"; } } @@ -77,18 +77,15 @@ class _EmployeeDetailPageState extends State with UIMixin { return Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: InkWell( - onTap: isActionable && value != 'NA' ? onTap : null, - onLongPress: isActionable && value != 'NA' ? onLongPress : null, + onTap: isActionable && value != "NA" ? onTap : null, + onLongPress: isActionable && value != "NA" ? onLongPress : null, borderRadius: BorderRadius.circular(5), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.all(8), - child: Icon( - icon, - size: 20, - ), + child: Icon(icon, size: 20), ), MySpacing.width(16), Expanded( @@ -103,23 +100,19 @@ class _EmployeeDetailPageState extends State with UIMixin { MySpacing.height(4), MyText( value, - color: isActionable && value != 'NA' + color: isActionable && value != "NA" ? Colors.blueAccent : Colors.black87, fontWeight: 500, - decoration: isActionable && value != 'NA' + decoration: isActionable && value != "NA" ? TextDecoration.underline : TextDecoration.none, ), ], ), ), - if (isActionable && value != 'NA') - Icon( - Icons.chevron_right, - color: Colors.grey[400], - size: 20, - ), + if (isActionable && value != "NA") + Icon(Icons.chevron_right, color: Colors.grey[400], size: 20), ], ), ), @@ -142,10 +135,7 @@ class _EmployeeDetailPageState extends State with UIMixin { children: [ Row( children: [ - Icon( - titleIcon, - size: 20, - ), + Icon(titleIcon, size: 20), MySpacing.width(8), MyText( title, @@ -172,7 +162,7 @@ class _EmployeeDetailPageState extends State with UIMixin { backgroundColor: const Color(0xFFF1F1F1), appBar: showAppBar ? CustomAppBar( - title: 'Employee Details', + title: "Employee Details", onBackPressed: () { if (widget.fromProfile) { Get.back(); @@ -189,7 +179,7 @@ class _EmployeeDetailPageState extends State with UIMixin { final employee = controller.selectedEmployeeDetails.value; if (employee == null) { - return Center(child: MyText('No employee details found.')); + return const Center(child: MyText("No employee details found.")); } return SafeArea( @@ -204,7 +194,7 @@ class _EmployeeDetailPageState extends State with UIMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Header Section + /// ------------------ HEADER CARD ------------------ Card( elevation: 2, shadowColor: Colors.black12, @@ -257,8 +247,8 @@ class _EmployeeDetailPageState extends State with UIMixin { employee.hasApplicationAccess, 'gender': employee.gender.toLowerCase(), 'job_role_id': employee.jobRoleId, - 'joining_date': - employee.joiningDate?.toIso8601String(), + 'joining_date': employee.joiningDate + ?.toIso8601String(), }, ), ); @@ -273,8 +263,10 @@ class _EmployeeDetailPageState extends State with UIMixin { ), ), ), + MySpacing.height(16), + /// ------------------ MANAGE REPORTING ------------------ _buildSectionCard( title: 'Manage Reporting', titleIcon: Icons.people_outline, @@ -308,7 +300,6 @@ class _EmployeeDetailPageState extends State with UIMixin { ), ); - // 🔄 Refresh reporting managers after editing await controller.fetchReportingManagers(employee.id); }, child: Padding( @@ -355,41 +346,32 @@ class _EmployeeDetailPageState extends State with UIMixin { } return Padding( - padding: const EdgeInsets.only( - top: 8.0, left: 8, right: 8, bottom: 8), + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: - const EdgeInsets.symmetric(vertical: 4.0), - child: Text( - 'Primary → ${_getManagerNames(primary)}', - style: const TextStyle( + Text( + 'Primary → ${_getManagerNames(primary)}', + style: const TextStyle( fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), + fontWeight: FontWeight.w600), ), - Padding( - padding: - const EdgeInsets.symmetric(vertical: 4.0), - child: Text( - 'Secondary → ${_getManagerNames(secondary)}', - style: const TextStyle( + Text( + 'Secondary → ${_getManagerNames(secondary)}', + style: const TextStyle( fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), + fontWeight: FontWeight.w600), ), ], ), ); - }) + }), ], ), - // Contact Information Section + MySpacing.height(16), + + /// ------------------ CONTACT INFO ------------------ _buildSectionCard( title: 'Contact Information', titleIcon: Icons.contact_phone, @@ -408,7 +390,8 @@ class _EmployeeDetailPageState extends State with UIMixin { onLongPress: () { if (employee.email != null && employee.email.toString().trim().isNotEmpty) { - LauncherUtils.copyToClipboard(employee.email!, + LauncherUtils.copyToClipboard( + employee.email!, typeLabel: 'Email'); } }, @@ -434,9 +417,10 @@ class _EmployeeDetailPageState extends State with UIMixin { ), ], ), + MySpacing.height(16), - // Emergency Contact Section + /// ------------------ EMERGENCY CONTACT ------------------ _buildSectionCard( title: 'Emergency Contact', titleIcon: Icons.emergency, @@ -446,17 +430,16 @@ class _EmployeeDetailPageState extends State with UIMixin { label: 'Contact Person', value: _getDisplayValue(employee.emergencyContactPerson), - isActionable: false, ), _buildDetailRow( icon: Icons.phone_in_talk_outlined, label: 'Emergency Phone', - value: _getDisplayValue(employee.emergencyPhoneNumber), + value: + _getDisplayValue(employee.emergencyPhoneNumber), isActionable: true, onTap: () { if (employee.emergencyPhoneNumber != null && - employee.emergencyPhoneNumber - .toString() + employee.emergencyPhoneNumber! .trim() .isNotEmpty) { LauncherUtils.launchPhone( @@ -465,8 +448,7 @@ class _EmployeeDetailPageState extends State with UIMixin { }, onLongPress: () { if (employee.emergencyPhoneNumber != null && - employee.emergencyPhoneNumber - .toString() + employee.emergencyPhoneNumber! .trim() .isNotEmpty) { LauncherUtils.copyToClipboard( @@ -477,9 +459,10 @@ class _EmployeeDetailPageState extends State with UIMixin { ), ], ), + MySpacing.height(16), - // Personal Information Section + /// ------------------ PERSONAL INFO ------------------ _buildSectionCard( title: 'Personal Information', titleIcon: Icons.person, @@ -488,25 +471,23 @@ class _EmployeeDetailPageState extends State with UIMixin { icon: Icons.wc_outlined, label: 'Gender', value: _getDisplayValue(employee.gender), - isActionable: false, ), _buildDetailRow( icon: Icons.cake_outlined, label: 'Birth Date', value: _formatDate(employee.birthDate), - isActionable: false, ), _buildDetailRow( icon: Icons.work_outline, label: 'Joining Date', value: _formatDate(employee.joiningDate), - isActionable: false, ), ], ), + MySpacing.height(16), - // Address Information Section + /// ------------------ ADDRESS INFO ------------------ _buildSectionCard( title: 'Address Information', titleIcon: Icons.location_on, @@ -515,13 +496,11 @@ class _EmployeeDetailPageState extends State with UIMixin { icon: Icons.home_outlined, label: 'Current Address', value: _getDisplayValue(employee.currentAddress), - isActionable: false, ), _buildDetailRow( icon: Icons.home_work_outlined, label: 'Permanent Address', value: _getDisplayValue(employee.permanentAddress), - isActionable: false, ), ], ), @@ -532,7 +511,7 @@ class _EmployeeDetailPageState extends State with UIMixin { ); }), - // Floating “Assign to Project” FAB + /// ------------------ FLOATING BUTTON ------------------ floatingActionButton: Obx(() { final employee = controller.selectedEmployeeDetails.value; if (employee == null) return const SizedBox.shrink(); @@ -550,17 +529,18 @@ class _EmployeeDetailPageState extends State with UIMixin { ); }, backgroundColor: contentTheme.primary, - label: MyText( + icon: const Icon(Icons.add), + label: MyText( 'Assign to Project', fontSize: 14, fontWeight: 500, ), - icon: const Icon(Icons.add), ); }), ); } + /// ------------------ UTIL ------------------ String _getManagerNames(List managers) { if (managers.isEmpty) return '—'; return managers diff --git a/lib/view/service_project/service_project_details_screen.dart b/lib/view/service_project/service_project_details_screen.dart index 6e1dc36..33f6311 100644 --- a/lib/view/service_project/service_project_details_screen.dart +++ b/lib/view/service_project/service_project_details_screen.dart @@ -315,6 +315,155 @@ class _ServiceProjectDetailsScreenState ); } + Widget _buildJobsTab() { + return Obx(() { + if (controller.isJobLoading.value && controller.jobList.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.jobErrorMessage.value.isNotEmpty && + controller.jobList.isEmpty) { + return Center( + child: MyText.bodyMedium(controller.jobErrorMessage.value)); + } + + if (controller.jobList.isEmpty) { + return Center(child: MyText.bodyMedium("No jobs found")); + } + + return ListView.separated( + controller: _jobScrollController, + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + itemCount: controller.jobList.length + 1, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + if (index == controller.jobList.length) { + return controller.hasMoreJobs.value + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox.shrink(); + } + + final job = controller.jobList[index]; + return Card( + elevation: 3, + shadowColor: Colors.black26, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Job Title + MyText.titleMedium(job.title, fontWeight: 700), + MySpacing.height(6), + + // Job Description + MyText.bodySmall( + job.description.isNotEmpty + ? job.description + : "No description provided", + color: Colors.grey[700], + ), + + // Tags + if (job.tags != null && job.tags!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Wrap( + spacing: 2, + runSpacing: 4, + children: job.tags!.map((tag) { + return Chip( + label: Text( + tag.name, + style: const TextStyle(fontSize: 12), + ), + backgroundColor: Colors.grey[200], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + ); + }).toList(), + ), + ), + + MySpacing.height(8), + + // Assignees & Status + Row( + children: [ + if (job.assignees != null && job.assignees!.isNotEmpty) + ...job.assignees!.map((assignee) { + return Padding( + padding: const EdgeInsets.only(right: 6), + child: CircleAvatar( + radius: 12, + backgroundImage: assignee.photo.isNotEmpty + ? NetworkImage(assignee.photo) + : null, + child: assignee.photo.isEmpty + ? Text(assignee.firstName[0]) + : null, + ), + ); + }).toList(), + ], + ), + + MySpacing.height(8), + + // Date Row with Status Chip + Row( + children: [ + // Dates (same as existing) + const Icon(Icons.calendar_today_outlined, + size: 14, color: Colors.grey), + MySpacing.width(4), + Text( + "${DateTimeUtils.convertUtcToLocal(job.startDate, format: 'dd MMM yyyy')} to " + "${DateTimeUtils.convertUtcToLocal(job.dueDate, format: 'dd MMM yyyy')}", + style: + const TextStyle(fontSize: 12, color: Colors.grey), + ), + + const Spacer(), + + // Status Chip + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: job.status.name.toLowerCase() == 'completed' + ? Colors.green[100] + : Colors.orange[100], + borderRadius: BorderRadius.circular(5), + ), + child: Text( + job.status.displayName, + style: TextStyle( + fontSize: 12, + color: job.status.name.toLowerCase() == 'completed' + ? Colors.green[800] + : Colors.orange[800], + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }); + } + Widget _buildJobsTab() { return Obx(() { if (controller.isJobLoading.value && controller.jobList.isEmpty) {