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 = '';
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";

View File

@ -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";

View File

@ -504,6 +504,11 @@ class ApiService {
}
/// 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(
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);
}

View File

@ -51,18 +51,18 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeDetailPage> with UIMixin {
),
),
),
MySpacing.height(16),
/// ------------------ MANAGE REPORTING ------------------
_buildSectionCard(
title: 'Manage Reporting',
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);
},
child: Padding(
@ -355,41 +346,32 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeDetailPage> with UIMixin {
},
onLongPress: () {
if (employee.emergencyPhoneNumber != null &&
employee.emergencyPhoneNumber
.toString()
employee.emergencyPhoneNumber!
.trim()
.isNotEmpty) {
LauncherUtils.copyToClipboard(
@ -477,9 +459,10 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeDetailPage> 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<EmployeeModel> managers) {
if (managers.isEmpty) return '';
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() {
return Obx(() {
if (controller.isJobLoading.value && controller.jobList.isEmpty) {