Merge pull request 'OH_Dev_Manish' (#81) from OH_Dev_Manish into Service_Project_Feature

Reviewed-on: #81
This commit is contained in:
manish.zure 2025-11-17 09:48:29 +00:00
commit 9dd66bd297
5 changed files with 227 additions and 70 deletions

View File

@ -52,12 +52,14 @@ class ServiceProjectDetailsController extends GetxController {
errorMessage.value = ''; errorMessage.value = '';
try { try {
final result = await ApiService.getServiceProjectDetailApi(projectId.value); final result =
await ApiService.getServiceProjectDetailApi(projectId.value);
if (result != null && result.data != null) { if (result != null && result.data != null) {
projectDetail.value = result.data!; projectDetail.value = result.data!;
} else { } else {
errorMessage.value = result?.message ?? "Failed to fetch project details"; errorMessage.value =
result?.message ?? "Failed to fetch project details";
} }
} catch (e) { } catch (e) {
errorMessage.value = "Error: $e"; errorMessage.value = "Error: $e";

View File

@ -135,6 +135,7 @@ class ApiEndpoints {
static const String manageOrganizationHierarchy = static const String manageOrganizationHierarchy =
"/organization/hierarchy/manage"; "/organization/hierarchy/manage";
// Service Project Module API Endpoints // Service Project Module API Endpoints
static const String getServiceProjectsList = "/serviceproject/list"; static const String getServiceProjectsList = "/serviceproject/list";
static const String getServiceProjectDetail = "/serviceproject/details"; static const String getServiceProjectDetail = "/serviceproject/details";

View File

@ -504,6 +504,11 @@ class ApiService {
} }
/// Get details of a single service project /// Get details of a single service project
/// Get details of a single service project
static Future<ServiceProjectDetailModel?> getServiceProjectDetailApi(
String projectId) async {
final endpoint = "${ApiEndpoints.getServiceProjectDetail}/$projectId";
logSafe("Fetching details for Service Project ID: $projectId");
static Future<ServiceProjectDetailModel?> getServiceProjectDetailApi( static Future<ServiceProjectDetailModel?> getServiceProjectDetailApi(
String projectId) async { String projectId) async {
final endpoint = "${ApiEndpoints.getServiceProjectDetail}/$projectId"; final endpoint = "${ApiEndpoints.getServiceProjectDetail}/$projectId";
@ -512,6 +517,13 @@ class ApiService {
try { try {
final response = await _getRequest(endpoint); final response = await _getRequest(endpoint);
if (response == null) {
logSafe(
"Service Project Detail request failed: null response",
level: LogLevel.error,
);
return null;
}
if (response == null) { if (response == null) {
logSafe("Service Project Detail request failed: null response", logSafe("Service Project Detail request failed: null response",
level: LogLevel.error); level: LogLevel.error);
@ -523,6 +535,19 @@ class ApiService {
label: "Service Project Detail", 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) { if (jsonResponse != null) {
return ServiceProjectDetailModel.fromJson(jsonResponse); return ServiceProjectDetailModel.fromJson(jsonResponse);
} }

View File

@ -51,18 +51,18 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
} }
String _getDisplayValue(dynamic value) { String _getDisplayValue(dynamic value) {
if (value == null || value.toString().trim().isEmpty || value == 'null') { if (value == null || value.toString().trim().isEmpty || value == "null") {
return 'NA'; return "NA";
} }
return value.toString(); return value.toString();
} }
String _formatDate(DateTime? date) { String _formatDate(DateTime? date) {
if (date == null || date == DateTime(1)) return 'NA'; if (date == null || date == DateTime(1)) return "NA";
try { try {
return DateFormat('d/M/yyyy').format(date); return DateFormat('d/M/yyyy').format(date);
} catch (_) { } catch (_) {
return 'NA'; return "NA";
} }
} }
@ -77,18 +77,15 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
child: InkWell( child: InkWell(
onTap: isActionable && value != 'NA' ? onTap : null, onTap: isActionable && value != "NA" ? onTap : null,
onLongPress: isActionable && value != 'NA' ? onLongPress : null, onLongPress: isActionable && value != "NA" ? onLongPress : null,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Icon( child: Icon(icon, size: 20),
icon,
size: 20,
),
), ),
MySpacing.width(16), MySpacing.width(16),
Expanded( Expanded(
@ -103,23 +100,19 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
MySpacing.height(4), MySpacing.height(4),
MyText( MyText(
value, value,
color: isActionable && value != 'NA' color: isActionable && value != "NA"
? Colors.blueAccent ? Colors.blueAccent
: Colors.black87, : Colors.black87,
fontWeight: 500, fontWeight: 500,
decoration: isActionable && value != 'NA' decoration: isActionable && value != "NA"
? TextDecoration.underline ? TextDecoration.underline
: TextDecoration.none, : TextDecoration.none,
), ),
], ],
), ),
), ),
if (isActionable && value != 'NA') if (isActionable && value != "NA")
Icon( Icon(Icons.chevron_right, color: Colors.grey[400], size: 20),
Icons.chevron_right,
color: Colors.grey[400],
size: 20,
),
], ],
), ),
), ),
@ -142,10 +135,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
children: [ children: [
Row( Row(
children: [ children: [
Icon( Icon(titleIcon, size: 20),
titleIcon,
size: 20,
),
MySpacing.width(8), MySpacing.width(8),
MyText( MyText(
title, title,
@ -172,7 +162,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
backgroundColor: const Color(0xFFF1F1F1), backgroundColor: const Color(0xFFF1F1F1),
appBar: showAppBar appBar: showAppBar
? CustomAppBar( ? CustomAppBar(
title: 'Employee Details', title: "Employee Details",
onBackPressed: () { onBackPressed: () {
if (widget.fromProfile) { if (widget.fromProfile) {
Get.back(); Get.back();
@ -189,7 +179,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
final employee = controller.selectedEmployeeDetails.value; final employee = controller.selectedEmployeeDetails.value;
if (employee == null) { if (employee == null) {
return Center(child: MyText('No employee details found.')); return const Center(child: MyText("No employee details found."));
} }
return SafeArea( return SafeArea(
@ -204,7 +194,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Header Section /// ------------------ HEADER CARD ------------------
Card( Card(
elevation: 2, elevation: 2,
shadowColor: Colors.black12, shadowColor: Colors.black12,
@ -257,8 +247,8 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
employee.hasApplicationAccess, employee.hasApplicationAccess,
'gender': employee.gender.toLowerCase(), 'gender': employee.gender.toLowerCase(),
'job_role_id': employee.jobRoleId, 'job_role_id': employee.jobRoleId,
'joining_date': 'joining_date': employee.joiningDate
employee.joiningDate?.toIso8601String(), ?.toIso8601String(),
}, },
), ),
); );
@ -273,8 +263,10 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
), ),
), ),
), ),
MySpacing.height(16), MySpacing.height(16),
/// ------------------ MANAGE REPORTING ------------------
_buildSectionCard( _buildSectionCard(
title: 'Manage Reporting', title: 'Manage Reporting',
titleIcon: Icons.people_outline, titleIcon: Icons.people_outline,
@ -308,7 +300,6 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
), ),
); );
// 🔄 Refresh reporting managers after editing
await controller.fetchReportingManagers(employee.id); await controller.fetchReportingManagers(employee.id);
}, },
child: Padding( child: Padding(
@ -355,41 +346,32 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
} }
return Padding( return Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
top: 8.0, left: 8, right: 8, bottom: 8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Text(
padding: 'Primary → ${_getManagerNames(primary)}',
const EdgeInsets.symmetric(vertical: 4.0), style: const TextStyle(
child: Text(
'Primary → ${_getManagerNames(primary)}',
style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600),
),
),
), ),
Padding( Text(
padding: 'Secondary → ${_getManagerNames(secondary)}',
const EdgeInsets.symmetric(vertical: 4.0), style: const TextStyle(
child: Text(
'Secondary → ${_getManagerNames(secondary)}',
style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600),
),
),
), ),
], ],
), ),
); );
}) }),
], ],
), ),
// Contact Information Section MySpacing.height(16),
/// ------------------ CONTACT INFO ------------------
_buildSectionCard( _buildSectionCard(
title: 'Contact Information', title: 'Contact Information',
titleIcon: Icons.contact_phone, titleIcon: Icons.contact_phone,
@ -408,7 +390,8 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
onLongPress: () { onLongPress: () {
if (employee.email != null && if (employee.email != null &&
employee.email.toString().trim().isNotEmpty) { employee.email.toString().trim().isNotEmpty) {
LauncherUtils.copyToClipboard(employee.email!, LauncherUtils.copyToClipboard(
employee.email!,
typeLabel: 'Email'); typeLabel: 'Email');
} }
}, },
@ -434,9 +417,10 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
), ),
], ],
), ),
MySpacing.height(16), MySpacing.height(16),
// Emergency Contact Section /// ------------------ EMERGENCY CONTACT ------------------
_buildSectionCard( _buildSectionCard(
title: 'Emergency Contact', title: 'Emergency Contact',
titleIcon: Icons.emergency, titleIcon: Icons.emergency,
@ -446,17 +430,16 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
label: 'Contact Person', label: 'Contact Person',
value: value:
_getDisplayValue(employee.emergencyContactPerson), _getDisplayValue(employee.emergencyContactPerson),
isActionable: false,
), ),
_buildDetailRow( _buildDetailRow(
icon: Icons.phone_in_talk_outlined, icon: Icons.phone_in_talk_outlined,
label: 'Emergency Phone', label: 'Emergency Phone',
value: _getDisplayValue(employee.emergencyPhoneNumber), value:
_getDisplayValue(employee.emergencyPhoneNumber),
isActionable: true, isActionable: true,
onTap: () { onTap: () {
if (employee.emergencyPhoneNumber != null && if (employee.emergencyPhoneNumber != null &&
employee.emergencyPhoneNumber employee.emergencyPhoneNumber!
.toString()
.trim() .trim()
.isNotEmpty) { .isNotEmpty) {
LauncherUtils.launchPhone( LauncherUtils.launchPhone(
@ -465,8 +448,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
}, },
onLongPress: () { onLongPress: () {
if (employee.emergencyPhoneNumber != null && if (employee.emergencyPhoneNumber != null &&
employee.emergencyPhoneNumber employee.emergencyPhoneNumber!
.toString()
.trim() .trim()
.isNotEmpty) { .isNotEmpty) {
LauncherUtils.copyToClipboard( LauncherUtils.copyToClipboard(
@ -477,9 +459,10 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
), ),
], ],
), ),
MySpacing.height(16), MySpacing.height(16),
// Personal Information Section /// ------------------ PERSONAL INFO ------------------
_buildSectionCard( _buildSectionCard(
title: 'Personal Information', title: 'Personal Information',
titleIcon: Icons.person, titleIcon: Icons.person,
@ -488,25 +471,23 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
icon: Icons.wc_outlined, icon: Icons.wc_outlined,
label: 'Gender', label: 'Gender',
value: _getDisplayValue(employee.gender), value: _getDisplayValue(employee.gender),
isActionable: false,
), ),
_buildDetailRow( _buildDetailRow(
icon: Icons.cake_outlined, icon: Icons.cake_outlined,
label: 'Birth Date', label: 'Birth Date',
value: _formatDate(employee.birthDate), value: _formatDate(employee.birthDate),
isActionable: false,
), ),
_buildDetailRow( _buildDetailRow(
icon: Icons.work_outline, icon: Icons.work_outline,
label: 'Joining Date', label: 'Joining Date',
value: _formatDate(employee.joiningDate), value: _formatDate(employee.joiningDate),
isActionable: false,
), ),
], ],
), ),
MySpacing.height(16), MySpacing.height(16),
// Address Information Section /// ------------------ ADDRESS INFO ------------------
_buildSectionCard( _buildSectionCard(
title: 'Address Information', title: 'Address Information',
titleIcon: Icons.location_on, titleIcon: Icons.location_on,
@ -515,13 +496,11 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
icon: Icons.home_outlined, icon: Icons.home_outlined,
label: 'Current Address', label: 'Current Address',
value: _getDisplayValue(employee.currentAddress), value: _getDisplayValue(employee.currentAddress),
isActionable: false,
), ),
_buildDetailRow( _buildDetailRow(
icon: Icons.home_work_outlined, icon: Icons.home_work_outlined,
label: 'Permanent Address', label: 'Permanent Address',
value: _getDisplayValue(employee.permanentAddress), value: _getDisplayValue(employee.permanentAddress),
isActionable: false,
), ),
], ],
), ),
@ -532,7 +511,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
); );
}), }),
// Floating Assign to Project FAB /// ------------------ FLOATING BUTTON ------------------
floatingActionButton: Obx(() { floatingActionButton: Obx(() {
final employee = controller.selectedEmployeeDetails.value; final employee = controller.selectedEmployeeDetails.value;
if (employee == null) return const SizedBox.shrink(); if (employee == null) return const SizedBox.shrink();
@ -550,17 +529,18 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
); );
}, },
backgroundColor: contentTheme.primary, backgroundColor: contentTheme.primary,
label: MyText( icon: const Icon(Icons.add),
label: MyText(
'Assign to Project', 'Assign to Project',
fontSize: 14, fontSize: 14,
fontWeight: 500, fontWeight: 500,
), ),
icon: const Icon(Icons.add),
); );
}), }),
); );
} }
/// ------------------ UTIL ------------------
String _getManagerNames(List<EmployeeModel> managers) { String _getManagerNames(List<EmployeeModel> managers) {
if (managers.isEmpty) return ''; if (managers.isEmpty) return '';
return managers return managers

View File

@ -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() { Widget _buildJobsTab() {
return Obx(() { return Obx(() {
if (controller.isJobLoading.value && controller.jobList.isEmpty) { if (controller.isJobLoading.value && controller.jobList.isEmpty) {