diff --git a/app/src/main/java/com/example/smarthr_app/data/model/MeetingModels.kt b/app/src/main/java/com/example/smarthr_app/data/model/MeetingModels.kt new file mode 100644 index 0000000..f4baadf --- /dev/null +++ b/app/src/main/java/com/example/smarthr_app/data/model/MeetingModels.kt @@ -0,0 +1,51 @@ +package com.example.smarthr_app.data.model + +import com.google.gson.annotations.SerializedName + +// Meeting Request Models +data class MeetingCreateRequestDto( + val title: String, + val description: String, + val startTime: String, // ISO format: "2025-07-22T10:00:00" + val endTime: String, // ISO format: "2025-07-22T11:00:00" + val meetingLink: String? = null, + val participants: List // List of participant IDs +) + +data class MeetingUpdateRequestDto( + val title: String, + val description: String, + val startTime: String, + val endTime: String, + val meetingLink: String? = null, + val participants: List +) + +// Meeting Response Models +data class MeetingResponseDto( + val id: String, + val title: String, + val description: String, + val organizer: String, // HR ID + val companyCode: String, + val meetingLink: String?, + val startTime: String, + val endTime: String, + val participants: List, + val responses: List, + val status: String // "SCHEDULED", "CANCELLED", etc. +) + +data class MeetingResponseInfo( + val participant: UserInfo, + val status: String // "ACCEPTED", "DECLINED", "PENDING" +) + +// Enums +enum class MeetingStatus { + SCHEDULED, CANCELLED, COMPLETED +} + +enum class ParticipantResponseStatus { + PENDING, ACCEPTED, DECLINED +} \ No newline at end of file diff --git a/app/src/main/java/com/example/smarthr_app/data/remote/ApiService.kt b/app/src/main/java/com/example/smarthr_app/data/remote/ApiService.kt index d9fba51..b3851c7 100644 --- a/app/src/main/java/com/example/smarthr_app/data/remote/ApiService.kt +++ b/app/src/main/java/com/example/smarthr_app/data/remote/ApiService.kt @@ -210,4 +210,42 @@ interface ApiService { @Query("date") date: String? = null // Optional date parameter, defaults to today ): Response> + // Meeting endpoints + @POST("meetings/create") + suspend fun createMeeting( + @Header("Authorization") token: String, + @Body request: MeetingCreateRequestDto + ): Response + + @GET("meetings/myMeetings") + suspend fun getMyMeetings( + @Header("Authorization") token: String + ): Response> + + @GET("meetings/{id}") + suspend fun getMeetingById( + @Header("Authorization") token: String, + @Path("id") meetingId: String + ): Response + + @POST("meetings/{id}") + suspend fun updateMeeting( + @Header("Authorization") token: String, + @Path("id") meetingId: String, + @Body request: MeetingUpdateRequestDto + ): Response + + @POST("meetings/cancel/{id}") + suspend fun cancelMeeting( + @Header("Authorization") token: String, + @Path("id") meetingId: String + ): Response + + @POST("meetings/respond/{id}") + suspend fun respondToMeeting( + @Header("Authorization") token: String, + @Path("id") meetingId: String, + @Query("status") status: String // "ACCEPTED" or "DECLINED" + ): Response + } \ No newline at end of file diff --git a/app/src/main/java/com/example/smarthr_app/data/remote/RetrofitInstance.kt b/app/src/main/java/com/example/smarthr_app/data/remote/RetrofitInstance.kt index 04ab153..53498c3 100644 --- a/app/src/main/java/com/example/smarthr_app/data/remote/RetrofitInstance.kt +++ b/app/src/main/java/com/example/smarthr_app/data/remote/RetrofitInstance.kt @@ -8,8 +8,8 @@ import java.util.concurrent.TimeUnit object RetrofitInstance { -// const val BASE_URL = "https://smarthr-backend-jx0v.onrender.com/" - const val BASE_URL = "https://3e1dce4d1855.ngrok-free.app/" + const val BASE_URL = "https://smarthr-backend-jx0v.onrender.com/" +// const val BASE_URL = "https://3e1dce4d1855.ngrok-free.app/" private val loggingInterceptor = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY diff --git a/app/src/main/java/com/example/smarthr_app/data/repository/MeetingRepository.kt b/app/src/main/java/com/example/smarthr_app/data/repository/MeetingRepository.kt new file mode 100644 index 0000000..b755a8e --- /dev/null +++ b/app/src/main/java/com/example/smarthr_app/data/repository/MeetingRepository.kt @@ -0,0 +1,161 @@ +package com.example.smarthr_app.data.repository + +import com.example.smarthr_app.data.local.DataStoreManager +import com.example.smarthr_app.data.model.* +import com.example.smarthr_app.data.remote.RetrofitInstance +import com.example.smarthr_app.utils.Resource +import kotlinx.coroutines.flow.first + +class MeetingRepository(private val dataStoreManager: DataStoreManager) { + + suspend fun createMeeting( + title: String, + description: String, + startTime: String, + endTime: String, + meetingLink: String?, + participants: List + ): Resource { + return try { + val token = dataStoreManager.token.first() + if (token != null) { + val request = MeetingCreateRequestDto( + title = title, + description = description, + startTime = startTime, + endTime = endTime, + meetingLink = meetingLink, + participants = participants + ) + val response = RetrofitInstance.api.createMeeting("Bearer $token", request) + if (response.isSuccessful) { + response.body()?.let { + Resource.Success(it) + } ?: Resource.Error("Meeting created but no data received") + } else { + Resource.Error("Failed to create meeting: ${response.message()}") + } + } else { + Resource.Error("No authentication token found") + } + } catch (e: Exception) { + Resource.Error("Network error: ${e.message}") + } + } + + suspend fun getMyMeetings(): Resource> { + return try { + val token = dataStoreManager.token.first() + if (token != null) { + val response = RetrofitInstance.api.getMyMeetings("Bearer $token") + if (response.isSuccessful) { + response.body()?.let { + Resource.Success(it) + } ?: Resource.Error("No meetings data received") + } else { + Resource.Error("Failed to load meetings: ${response.message()}") + } + } else { + Resource.Error("No authentication token found") + } + } catch (e: Exception) { + Resource.Error("Network error: ${e.message}") + } + } + + suspend fun getMeetingById(meetingId: String): Resource { + return try { + val token = dataStoreManager.token.first() + if (token != null) { + val response = RetrofitInstance.api.getMeetingById("Bearer $token", meetingId) + if (response.isSuccessful) { + response.body()?.let { + Resource.Success(it) + } ?: Resource.Error("No meeting data received") + } else { + Resource.Error("Failed to load meeting: ${response.message()}") + } + } else { + Resource.Error("No authentication token found") + } + } catch (e: Exception) { + Resource.Error("Network error: ${e.message}") + } + } + + suspend fun updateMeeting( + meetingId: String, + title: String, + description: String, + startTime: String, + endTime: String, + meetingLink: String?, + participants: List + ): Resource { + return try { + val token = dataStoreManager.token.first() + if (token != null) { + val request = MeetingUpdateRequestDto( + title = title, + description = description, + startTime = startTime, + endTime = endTime, + meetingLink = meetingLink, + participants = participants + ) + val response = RetrofitInstance.api.updateMeeting("Bearer $token", meetingId, request) + if (response.isSuccessful) { + response.body()?.let { + Resource.Success(it) + } ?: Resource.Error("Meeting updated but no data received") + } else { + Resource.Error("Failed to update meeting: ${response.message()}") + } + } else { + Resource.Error("No authentication token found") + } + } catch (e: Exception) { + Resource.Error("Network error: ${e.message}") + } + } + + suspend fun cancelMeeting(meetingId: String): Resource { + return try { + val token = dataStoreManager.token.first() + if (token != null) { + val response = RetrofitInstance.api.cancelMeeting("Bearer $token", meetingId) + if (response.isSuccessful) { + response.body()?.let { + Resource.Success(it) + } ?: Resource.Error("Meeting cancelled but no confirmation received") + } else { + Resource.Error("Failed to cancel meeting: ${response.message()}") + } + } else { + Resource.Error("No authentication token found") + } + } catch (e: Exception) { + Resource.Error("Network error: ${e.message}") + } + } + + suspend fun respondToMeeting(meetingId: String, status: String): Resource { + return try { + val token = dataStoreManager.token.first() + if (token != null) { + val response = RetrofitInstance.api.respondToMeeting("Bearer $token", meetingId, status) + if (response.isSuccessful) { + response.body()?.let { + Resource.Success(it) + } ?: Resource.Error("Response sent but no confirmation received") + } else { + Resource.Error("Failed to respond to meeting: ${response.message()}") + } + } else { + Resource.Error("No authentication token found") + } + } catch (e: Exception) { + Resource.Error("Network error: ${e.message}") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/smarthr_app/presentation/navigation/NavGraph.kt b/app/src/main/java/com/example/smarthr_app/presentation/navigation/NavGraph.kt index 3c84c3d..60681f1 100644 --- a/app/src/main/java/com/example/smarthr_app/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/com/example/smarthr_app/presentation/navigation/NavGraph.kt @@ -23,16 +23,21 @@ import androidx.navigation.NavType import androidx.navigation.navArgument import com.example.smarthr_app.data.repository.AttendanceRepository import com.example.smarthr_app.data.repository.LeaveRepository +import com.example.smarthr_app.data.repository.MeetingRepository import com.example.smarthr_app.data.repository.TaskRepository +import com.example.smarthr_app.presentation.screen.dashboard.employee.EmployeeMeetingScreen import com.example.smarthr_app.presentation.screen.dashboard.employee.EmployeeTaskDetailScreen +import com.example.smarthr_app.presentation.screen.dashboard.hr.CreateMeetingScreen import com.example.smarthr_app.presentation.screen.dashboard.hr.CreateTaskScreen import com.example.smarthr_app.presentation.screen.dashboard.hr.HRCompanyAttendanceScreen import com.example.smarthr_app.presentation.screen.dashboard.hr.HRLeaveManagementScreen +import com.example.smarthr_app.presentation.screen.dashboard.hr.HRMeetingManagementScreen import com.example.smarthr_app.presentation.screen.dashboard.hr.HROfficeLocationScreen import com.example.smarthr_app.presentation.screen.dashboard.hr.HRTaskManagementScreen import com.example.smarthr_app.presentation.screen.dashboard.hr.TaskDetailScreen import com.example.smarthr_app.presentation.viewmodel.AttendanceViewModel import com.example.smarthr_app.presentation.viewmodel.LeaveViewModel +import com.example.smarthr_app.presentation.viewmodel.MeetingViewModel import com.example.smarthr_app.presentation.viewmodel.TaskViewModel @Composable @@ -43,11 +48,11 @@ fun NavGraph( val context = LocalContext.current val dataStoreManager = DataStoreManager(context) val authRepository = AuthRepository(dataStoreManager) - val companyRepository = CompanyRepository(dataStoreManager) - val taskRepository = TaskRepository(dataStoreManager) - val authViewModel: AuthViewModel = viewModel { AuthViewModel(authRepository) } + val companyRepository = CompanyRepository(dataStoreManager) val companyViewModel: CompanyViewModel = viewModel { CompanyViewModel(companyRepository) } + + val taskRepository = TaskRepository(dataStoreManager) val taskViewModel: TaskViewModel = viewModel { TaskViewModel(taskRepository) } val leaveRepository = LeaveRepository(dataStoreManager) @@ -56,6 +61,9 @@ fun NavGraph( val attendanceRepository = AttendanceRepository(dataStoreManager) val attendanceViewModel: AttendanceViewModel = viewModel { AttendanceViewModel(attendanceRepository) } + val meetingRepository = MeetingRepository(dataStoreManager) + val meetingViewModel: MeetingViewModel = viewModel { MeetingViewModel(meetingRepository) } + NavHost( navController = navController, startDestination = startDestination @@ -145,6 +153,9 @@ fun NavGraph( }, onNavigateToCompanyAttendance = { navController.navigate(Screen.HRCompanyAttendance.route) + }, + onNavigateToMeetings = { + navController.navigate(Screen.HRMeetingManagement.route) } ) } @@ -273,6 +284,9 @@ fun NavGraph( }, onNavigateToTaskDetail = { taskId -> navController.navigate(Screen.EmployeeTaskDetail.createRoute(taskId)) + }, + onNavigateToMeetings = { + navController.navigate(Screen.EmployeeMeeting.route) } ) } @@ -345,10 +359,61 @@ fun NavGraph( ) } + composable(Screen.HRMeetingManagement.route) { + HRMeetingManagementScreen( + meetingViewModel = meetingViewModel, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToCreateMeeting = { + navController.navigate(Screen.CreateMeeting.route) + }, + onNavigateToEditMeeting = { meetingId -> + navController.navigate(Screen.EditMeeting.createRoute(meetingId)) + } + ) + } + + composable(Screen.CreateMeeting.route) { + CreateMeetingScreen( + meetingViewModel = meetingViewModel, + companyViewModel = companyViewModel, + onNavigateBack = { + navController.popBackStack() + } + ) + } + + composable( + route = Screen.EditMeeting.route, + arguments = Screen.EditMeeting.arguments + ) { backStackEntry -> + val meetingId = backStackEntry.arguments?.getString("meetingId") ?: "" + CreateMeetingScreen( + meetingViewModel = meetingViewModel, + companyViewModel = companyViewModel, + meetingId = meetingId, + onNavigateBack = { + navController.popBackStack() + } + ) + } + + composable(Screen.EmployeeMeeting.route) { + EmployeeMeetingScreen( + meetingViewModel = meetingViewModel, + authViewModel = authViewModel, + onNavigateBack = { + navController.popBackStack() + } + ) + } + } } sealed class Screen(val route: String) { + // Authentication Routes object RoleSelection : Screen("role_selection") object Register : Screen("register") object Login : Screen("login") @@ -363,21 +428,18 @@ sealed class Screen(val route: String) { // Task Management Routes object HRTaskManagement : Screen("hr_task_management") object CreateTask : Screen("create_task") - object TaskDetail : Screen("task_detail/{taskId}") { fun createRoute(taskId: String) = "task_detail/$taskId" val arguments = listOf( navArgument("taskId") { type = NavType.StringType } ) } - object EditTask : Screen("edit_task/{taskId}") { fun createRoute(taskId: String) = "edit_task/$taskId" val arguments = listOf( navArgument("taskId") { type = NavType.StringType } ) } - object EmployeeTaskDetail : Screen("employee_task_detail/{taskId}") { fun createRoute(taskId: String) = "employee_task_detail/$taskId" val arguments = listOf( @@ -385,9 +447,22 @@ sealed class Screen(val route: String) { ) } + // Leave Management Routes object HRLeaveManagement : Screen("hr_leave_management") + // Office Location and Attendance Routes object HROfficeLocation : Screen("hr_office_location") object HRCompanyAttendance : Screen("hr_company_attendance") + // Meeting Management Routes + object HRMeetingManagement : Screen("hr_meeting_management") + object CreateMeeting : Screen("create_meeting") + object EditMeeting : Screen("edit_meeting/{meetingId}") { + fun createRoute(meetingId: String) = "edit_meeting/$meetingId" + val arguments = listOf( + navArgument("meetingId") { type = NavType.StringType } + ) + } + object EmployeeMeeting : Screen("employee_meeting") + } \ No newline at end of file diff --git a/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/employee/EmployeeDashboardScreen.kt b/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/employee/EmployeeDashboardScreen.kt index f61c4cd..cd55024 100644 --- a/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/employee/EmployeeDashboardScreen.kt +++ b/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/employee/EmployeeDashboardScreen.kt @@ -30,7 +30,8 @@ fun EmployeeDashboardScreen( attendanceViewModel: AttendanceViewModel, onLogout: () -> Unit, onNavigateToProfile: () -> Unit, - onNavigateToTaskDetail: (String) -> Unit + onNavigateToTaskDetail: (String) -> Unit, + onNavigateToMeetings: () -> Unit ) { val user by authViewModel.user.collectAsState(initial = null) var selectedTabIndex by remember { mutableStateOf(0) } @@ -82,7 +83,7 @@ fun EmployeeDashboardScreen( .background(MaterialTheme.colorScheme.background) ) { when (selectedTabIndex) { - 0 -> HomeTab(user = user, authViewModel = authViewModel, onNavigateToProfile = onNavigateToProfile) + 0 -> HomeTab(user = user, authViewModel = authViewModel, onNavigateToProfile = onNavigateToProfile, onNavigateToMeetings = onNavigateToMeetings) 1 -> EmployeeAttendanceScreen(attendanceViewModel = attendanceViewModel) 2 -> EmployeeTaskScreen( taskViewModel = taskViewModel, @@ -98,7 +99,8 @@ fun EmployeeDashboardScreen( fun HomeTab( user: com.example.smarthr_app.data.model.User?, authViewModel: AuthViewModel, - onNavigateToProfile: () -> Unit + onNavigateToProfile: () -> Unit, + onNavigateToMeetings: () -> Unit ) { Column( modifier = Modifier.fillMaxSize() @@ -170,260 +172,69 @@ fun HomeTab( } } - Spacer(modifier = Modifier.height(24.dp)) - - // Quick Actions Section - Text( - text = "Quick Actions", - style = MaterialTheme.typography.titleMedium, - color = Color.White, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Check In/Out Button - Card( - modifier = Modifier.weight(1f), - colors = CardDefaults.cardColors( - containerColor = Color.White.copy(alpha = 0.1f) - ), - shape = RoundedCornerShape(12.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.AccessTime, - contentDescription = "Check In", - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Check In", - style = MaterialTheme.typography.bodySmall, - color = Color.White, - fontWeight = FontWeight.Medium - ) - } - } - - // Apply Leave Button - Card( - modifier = Modifier.weight(1f), - colors = CardDefaults.cardColors( - containerColor = Color.White.copy(alpha = 0.1f) - ), - shape = RoundedCornerShape(12.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.EventNote, - contentDescription = "Apply Leave", - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Apply Leave", - style = MaterialTheme.typography.bodySmall, - color = Color.White, - fontWeight = FontWeight.Medium - ) - } - } - - // View Tasks Button - Card( - modifier = Modifier.weight(1f), - colors = CardDefaults.cardColors( - containerColor = Color.White.copy(alpha = 0.1f) - ), - shape = RoundedCornerShape(12.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.Assignment, - contentDescription = "View Tasks", - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "My Tasks", - style = MaterialTheme.typography.bodySmall, - color = Color.White, - fontWeight = FontWeight.Medium - ) - } - } - } - Spacer(modifier = Modifier.height(24.dp)) } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - // Today's Summary Section + // Meetings Card Card( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) + .clickable { onNavigateToMeetings() }, // Make it clickable colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { - Column( - modifier = Modifier.padding(16.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "Today's Summary", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = PrimaryPurple - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(Color(0xFFE91E63).copy(alpha = 0.1f)), + contentAlignment = Alignment.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "08:30 AM", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "Check In", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "8h 30m", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "Working Hours", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "3", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "Pending Tasks", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Icon( + imageVector = Icons.Default.VideoCall, + contentDescription = "Meetings", + modifier = Modifier.size(24.dp), + tint = Color(0xFFE91E63) + ) } - } - } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.width(16.dp)) - // Company Status Card (if user has company information) - if (!user?.companyCode.isNullOrBlank() || !user?.waitingCompanyCode.isNullOrBlank()) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + Column(modifier = Modifier.weight(1f)) { Text( - text = "Company Status", + text = "Scheduled Meetings", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = PrimaryPurple + color = Color(0xFFE91E63) + ) + Text( + text = "View your upcoming and past meetings", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - - Spacer(modifier = Modifier.height(12.dp)) - - when { - !user?.companyCode.isNullOrBlank() -> { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - tint = Color(0xFF4CAF50), - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Connected to ${user?.companyCode}", - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF4CAF50), - fontWeight = FontWeight.Medium - ) - } - } - !user?.waitingCompanyCode.isNullOrBlank() -> { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Schedule, - contentDescription = null, - tint = Color(0xFFFF9800), - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Waiting approval from ${user?.waitingCompanyCode}", - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFFFF9800), - fontWeight = FontWeight.Medium - ) - } - } - } } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = "Go to Meetings", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } } + + } } diff --git a/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/employee/EmployeeMeetingScreen.kt b/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/employee/EmployeeMeetingScreen.kt new file mode 100644 index 0000000..712b5db --- /dev/null +++ b/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/employee/EmployeeMeetingScreen.kt @@ -0,0 +1,702 @@ +package com.example.smarthr_app.presentation.screen.dashboard.employee + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import coil.compose.AsyncImage +import com.example.smarthr_app.data.model.MeetingResponseDto +import com.example.smarthr_app.data.model.UserInfo +import com.example.smarthr_app.presentation.theme.PrimaryPurple +import com.example.smarthr_app.presentation.viewmodel.MeetingViewModel +import com.example.smarthr_app.presentation.viewmodel.AuthViewModel +import com.example.smarthr_app.utils.Resource +import com.example.smarthr_app.utils.ToastHelper +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmployeeMeetingScreen( + meetingViewModel: MeetingViewModel, + authViewModel: AuthViewModel, // Add AuthViewModel to get current user + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + val meetingsState by meetingViewModel.meetingsState.collectAsState(initial = null) + val respondToMeetingState by meetingViewModel.respondToMeetingState.collectAsState(initial = null) + val currentUser by authViewModel.user.collectAsState(initial = null) + + var selectedFilter by remember { mutableStateOf("upcoming") } // "upcoming" or "past" + var showParticipantsDialog by remember { mutableStateOf(false) } + var selectedMeeting by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + meetingViewModel.loadMeetings() + } + + // Handle respond to meeting response + LaunchedEffect(respondToMeetingState) { + when (val state = respondToMeetingState) { + is Resource.Success -> { + ToastHelper.showSuccessToast(context, "Response sent successfully") + meetingViewModel.clearRespondToMeetingState() + meetingViewModel.loadMeetings() + } + is Resource.Error -> { + ToastHelper.showErrorToast(context, state.message) + meetingViewModel.clearRespondToMeetingState() + } + else -> {} + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding() + ) { + // Top Bar + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = PrimaryPurple), + shape = RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + tint = Color.White + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = "My Meetings", + style = MaterialTheme.typography.headlineSmall, + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + } + + // Filter Tabs + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + FilterChip( + onClick = { selectedFilter = "upcoming" }, + label = { Text("Upcoming") }, + selected = selectedFilter == "upcoming", + modifier = Modifier.weight(1f), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = PrimaryPurple, + selectedLabelColor = Color.White + ) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + FilterChip( + onClick = { selectedFilter = "past" }, + label = { Text("Past") }, + selected = selectedFilter == "past", + modifier = Modifier.weight(1f), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = PrimaryPurple, + selectedLabelColor = Color.White + ) + ) + } + } + + // Content + when (val state = meetingsState) { + is Resource.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = PrimaryPurple) + } + } + + is Resource.Success -> { + val meetings = state.data ?: emptyList() // Safe null check + val currentDateTime = LocalDateTime.now() + val filteredMeetings = meetings.filter { meeting -> + try { + val meetingDateTime = LocalDateTime.parse(meeting?.startTime ?: return@filter false) + when (selectedFilter) { + "upcoming" -> meetingDateTime.isAfter(currentDateTime) && (meeting.status != "CANCELLED") + "past" -> meetingDateTime.isBefore(currentDateTime) || (meeting.status == "CANCELLED") + else -> true + } + } catch (e: Exception) { + false // Skip meetings with invalid dates + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(filteredMeetings) { meeting -> + if (meeting != null) { + EmployeeMeetingCard( + meeting = meeting, + onCopyLink = { link -> + copyToClipboard(context, link) + ToastHelper.showSuccessToast(context, "Meeting link copied!") + }, + onRespondToMeeting = { meetingId, status -> + meetingViewModel.respondToMeeting(meetingId, status) + }, + onParticipantsClick = { + selectedMeeting = meeting + showParticipantsDialog = true + }, + currentUserId = currentUser?.userId, // Pass current user ID + isLoading = respondToMeetingState is Resource.Loading + ) + } + } + + if (filteredMeetings.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.EventNote, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No ${selectedFilter} meetings found", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + + is Resource.Error -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Error loading meetings", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { meetingViewModel.loadMeetings() }, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryPurple) + ) { + Text("Retry") + } + } + } + } + + null -> { + // Initial state + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = PrimaryPurple) + } + } + } + } + + // Participants Dialog + if (showParticipantsDialog && selectedMeeting != null) { + EmployeeParticipantsDialog( + meeting = selectedMeeting!!, + onDismiss = { + showParticipantsDialog = false + selectedMeeting = null + } + ) + } +} + +@Composable +fun EmployeeMeetingCard( + meeting: MeetingResponseDto, + onCopyLink: (String) -> Unit, + onRespondToMeeting: (String, String) -> Unit, + onParticipantsClick: () -> Unit, + isLoading: Boolean, + currentUserId: String? // Add current user ID to determine response status +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + // Meeting header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = meeting.title ?: "Untitled Meeting", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = meeting.description ?: "No description", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2 + ) + } + + Surface( + shape = RoundedCornerShape(12.dp), + color = when (meeting.status ?: "UNKNOWN") { + "SCHEDULED" -> Color(0xFF4CAF50) + "CANCELLED" -> Color(0xFFFF5722) + else -> Color.Gray + } + ) { + Text( + text = meeting.status ?: "UNKNOWN", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Meeting time - Updated to show end time + if (!meeting.startTime.isNullOrBlank()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = PrimaryPurple + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${formatDateTime(meeting.startTime)} - ${formatDateTime(meeting.endTime ?: "")}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + } + } + + // Meeting link + if (!meeting.meetingLink.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Link, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = PrimaryPurple + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = meeting.meetingLink, + style = MaterialTheme.typography.bodySmall, + color = PrimaryPurple, + maxLines = 1, + modifier = Modifier.weight(1f) + ) + TextButton( + onClick = { onCopyLink(meeting.meetingLink) } + ) { + Text("Copy", color = PrimaryPurple) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Participants - Clickable with count + val participants = meeting.participants ?: emptyList() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onParticipantsClick() } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.People, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = PrimaryPurple + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${participants.size} participants", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = PrimaryPurple + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = PrimaryPurple + ) + } + + // Response status and actions + if (meeting.status == "SCHEDULED") { + Spacer(modifier = Modifier.height(12.dp)) + + // Find current user's response + val responses = meeting.responses ?: emptyList() + val currentUserResponse = responses.find { response -> + response.participant?.id == currentUserId + } + val responseStatus = currentUserResponse?.status ?: "PENDING" + + if (responseStatus == "PENDING") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + meeting.id?.let { id -> + onRespondToMeeting(id, "ACCEPTED") + } + }, + modifier = Modifier.weight(1f), + enabled = !isLoading, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = Color.White + ) + } else { + Text("Accept") + } + } + + Button( + onClick = { + meeting.id?.let { id -> + onRespondToMeeting(id, "DECLINED") + } + }, + modifier = Modifier.weight(1f), + enabled = !isLoading, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFF5722)) + ) { + Text("Decline") + } + } + } else { + // Show response status + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = when (responseStatus) { + "ACCEPTED" -> Color(0xFF4CAF50).copy(alpha = 0.1f) + "DECLINED" -> Color(0xFFFF5722).copy(alpha = 0.1f) + else -> Color.Gray.copy(alpha = 0.1f) + } + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = when (responseStatus) { + "ACCEPTED" -> Icons.Default.CheckCircle + "DECLINED" -> Icons.Default.Cancel + else -> Icons.Default.Schedule + }, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = when (responseStatus) { + "ACCEPTED" -> Color(0xFF4CAF50) + "DECLINED" -> Color(0xFFFF5722) + else -> Color.Gray + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = when (responseStatus) { + "ACCEPTED" -> "You accepted this meeting" + "DECLINED" -> "You declined this meeting" + else -> "Response pending" + }, + style = MaterialTheme.typography.bodyMedium, + color = when (responseStatus) { + "ACCEPTED" -> Color(0xFF4CAF50) + "DECLINED" -> Color(0xFFFF5722) + else -> Color.Gray + }, + fontWeight = FontWeight.Medium + ) + } + } + } + } + } + } +} + +// Employee Participants Dialog +@Composable +fun EmployeeParticipantsDialog( + meeting: MeetingResponseDto, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 500.dp), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Meeting Participants", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = PrimaryPurple + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(meeting.participants ?: emptyList()) { participant -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(PrimaryPurple.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + if (!participant.imageUrl.isNullOrBlank()) { + AsyncImage( + model = participant.imageUrl, + contentDescription = "Profile Picture", + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = (participant.name?.take(1) ?: "?").uppercase(), + style = MaterialTheme.typography.labelMedium, + color = PrimaryPurple, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = participant.name ?: "Unknown", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = participant.email ?: "No email", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Response status + val response = meeting.responses?.find { it.participant?.id == participant.id } + val status = response?.status ?: "PENDING" + + Surface( + shape = RoundedCornerShape(8.dp), + color = when (status) { + "ACCEPTED" -> Color(0xFF4CAF50).copy(alpha = 0.1f) + "DECLINED" -> Color(0xFFFF5722).copy(alpha = 0.1f) + else -> Color(0xFFFF9800).copy(alpha = 0.1f) + } + ) { + Text( + text = status, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = when (status) { + "ACCEPTED" -> Color(0xFF4CAF50) + "DECLINED" -> Color(0xFFFF5722) + else -> Color(0xFFFF9800) + }, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = PrimaryPurple) + ) { + Text("Close") + } + } + } + } +} + +@Composable +fun ParticipantAvatar(participant: UserInfo) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(PrimaryPurple.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + if (!participant.imageUrl.isNullOrBlank()) { + AsyncImage( + model = participant.imageUrl, + contentDescription = participant.name ?: "Participant", + modifier = Modifier + .size(32.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = (participant.name?.take(1) ?: "?").uppercase(), + style = MaterialTheme.typography.labelSmall, + color = PrimaryPurple, + fontWeight = FontWeight.Bold + ) + } + } +} + +private fun formatDateTime(dateTimeString: String?): String { + return try { + if (dateTimeString.isNullOrBlank()) return "Invalid Date" + val dateTime = LocalDateTime.parse(dateTimeString) + val formatter = DateTimeFormatter.ofPattern("MMM dd, HH:mm") + dateTime.format(formatter) + } catch (e: Exception) { + dateTimeString ?: "Invalid Date" + } +} + +private fun formatTime(dateTimeString: String?): String { + return try { + if (dateTimeString.isNullOrBlank()) return "Invalid Time" + val dateTime = LocalDateTime.parse(dateTimeString) + val formatter = DateTimeFormatter.ofPattern("HH:mm") + dateTime.format(formatter) + } catch (e: Exception) { + dateTimeString ?: "Invalid Time" + } +} + +private fun copyToClipboard(context: Context, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Meeting Link", text) + clipboard.setPrimaryClip(clip) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/hr/CreateMeetingScreen.kt b/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/hr/CreateMeetingScreen.kt new file mode 100644 index 0000000..5af42fb --- /dev/null +++ b/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/hr/CreateMeetingScreen.kt @@ -0,0 +1,965 @@ +package com.example.smarthr_app.presentation.screen.dashboard.hr + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import coil.compose.AsyncImage +import com.example.smarthr_app.data.model.UserDto +import com.example.smarthr_app.presentation.theme.PrimaryPurple +import com.example.smarthr_app.presentation.viewmodel.CompanyViewModel +import com.example.smarthr_app.presentation.viewmodel.MeetingViewModel +import com.example.smarthr_app.utils.Resource +import com.example.smarthr_app.utils.ToastHelper +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateMeetingScreen( + meetingViewModel: MeetingViewModel, + companyViewModel: CompanyViewModel, + meetingId: String? = null, + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + val isEditing = meetingId != null + + // States + val createMeetingState by meetingViewModel.createMeetingState.collectAsState(initial = null) + val updateMeetingState by meetingViewModel.updateMeetingState.collectAsState(initial = null) + val meetingDetailState by meetingViewModel.meetingDetailState.collectAsState(initial = null) + val approvedEmployeesState by companyViewModel.approvedEmployees.collectAsState(initial = null) + + // Form states + var title by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var meetingLink by remember { mutableStateOf("") } + var startDate by remember { mutableStateOf("") } + var startTime by remember { mutableStateOf("") } + var endDate by remember { mutableStateOf("") } + var endTime by remember { mutableStateOf("") } + var selectedEmployees by remember { mutableStateOf>(emptyList()) } + + // Dialog states + var showStartDatePicker by remember { mutableStateOf(false) } + var showStartTimePicker by remember { mutableStateOf(false) } + var showEndDatePicker by remember { mutableStateOf(false) } + var showEndTimePicker by remember { mutableStateOf(false) } + var showEmployeeSelectionDialog by remember { mutableStateOf(false) } + + // Remove the automatic time picker LaunchedEffects - they prevent re-editing + // Users can now click on the time field to edit it + + LaunchedEffect(Unit) { + companyViewModel.loadApprovedEmployees() + if (isEditing && meetingId != null) { + meetingViewModel.loadMeetingDetail(meetingId) + } + } + + // Load existing meeting data for editing + LaunchedEffect(meetingDetailState) { + if (isEditing) { + when (val state = meetingDetailState) { + is Resource.Success -> { + val meeting = state.data + title = meeting.title + description = meeting.description + meetingLink = meeting.meetingLink ?: "" + + val startDateTime = LocalDateTime.parse(meeting.startTime) + startDate = startDateTime.toLocalDate().toString() + startTime = startDateTime.toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm")) + + val endDateTime = LocalDateTime.parse(meeting.endTime) + endDate = endDateTime.toLocalDate().toString() + endTime = endDateTime.toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm")) + + when (val empState = approvedEmployeesState) { + is Resource.Success -> { + selectedEmployees = empState.data.filter { employee -> + meeting.participants.any { participant -> participant.id == employee.userId } + } + } + else -> {} + } + } + else -> {} + } + } + } + + // Handle create/update response with conflict detection + LaunchedEffect(createMeetingState, updateMeetingState) { + val state = if (isEditing) updateMeetingState else createMeetingState + when (state) { + is Resource.Success -> { + ToastHelper.showSuccessToast( + context, + if (isEditing) "Meeting updated successfully!" else "Meeting created successfully!" + ) + if (isEditing) { + meetingViewModel.clearUpdateMeetingState() + } else { + meetingViewModel.clearCreateMeetingState() + } + onNavigateBack() + } + is Resource.Error -> { + // Check if it's a time conflict error + val errorMessage = state.message + if (errorMessage.contains("conflict", ignoreCase = true) || + errorMessage.contains("overlap", ignoreCase = true) || + errorMessage.contains("time", ignoreCase = true)) { + ToastHelper.showErrorToast(context, "⚠️ Time Conflict: $errorMessage") + } else { + ToastHelper.showErrorToast(context, errorMessage) + } + + if (isEditing) { + meetingViewModel.clearUpdateMeetingState() + } else { + meetingViewModel.clearCreateMeetingState() + } + } + else -> {} + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding() + ) { + // Top Bar + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = PrimaryPurple), + shape = RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + tint = Color.White + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = if (isEditing) "Edit Meeting" else "Create Meeting", + style = MaterialTheme.typography.headlineSmall, + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + } + + // Form Content + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Meeting Details Section + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Meeting Details", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = PrimaryPurple + ) + + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Meeting Title") }, + placeholder = { Text("Enter meeting title") }, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = PrimaryPurple, + focusedLabelColor = PrimaryPurple + ) + ) + + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description") }, + placeholder = { Text("Enter meeting description") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 5, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = PrimaryPurple, + focusedLabelColor = PrimaryPurple + ) + ) + + OutlinedTextField( + value = meetingLink, + onValueChange = { meetingLink = it }, + label = { Text("Meeting Link (Optional)") }, + placeholder = { Text("https://meet.google.com/xxx-xxxx-xxx") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = PrimaryPurple, + focusedLabelColor = PrimaryPurple + ) + ) + } + } + + // Date & Time Section - Updated with separate date and time fields + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Schedule", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = PrimaryPurple + ) + + // Start Date and Time Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Start Date + OutlinedTextField( + value = startDate, + onValueChange = {}, + readOnly = true, + label = { Text("Start Date") }, + trailingIcon = { + IconButton(onClick = { showStartDatePicker = true }) { + Icon(Icons.Default.DateRange, contentDescription = "Select Date") + } + }, + modifier = Modifier + .weight(1f) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { showStartDatePicker = true }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = PrimaryPurple, + focusedLabelColor = PrimaryPurple + ) + ) + + // Start Time - Always editable + OutlinedTextField( + value = startTime, + onValueChange = {}, + readOnly = true, + label = { Text("Start Time") }, + placeholder = { Text("Select time") }, + trailingIcon = { + IconButton(onClick = { showStartTimePicker = true }) { + Icon(Icons.Default.Schedule, contentDescription = "Select Time") + } + }, + modifier = Modifier + .weight(1f) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { showStartTimePicker = true }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = PrimaryPurple, + focusedLabelColor = PrimaryPurple + ) + ) + } + + // End Date and Time Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // End Date + OutlinedTextField( + value = endDate, + onValueChange = {}, + readOnly = true, + label = { Text("End Date") }, + trailingIcon = { + IconButton(onClick = { showEndDatePicker = true }) { + Icon(Icons.Default.DateRange, contentDescription = "Select Date") + } + }, + modifier = Modifier + .weight(1f) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { showEndDatePicker = true }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = PrimaryPurple, + focusedLabelColor = PrimaryPurple + ) + ) + + // End Time - Always editable + OutlinedTextField( + value = endTime, + onValueChange = {}, + readOnly = true, + label = { Text("End Time") }, + placeholder = { Text("Select time") }, + trailingIcon = { + IconButton(onClick = { showEndTimePicker = true }) { + Icon(Icons.Default.Schedule, contentDescription = "Select Time") + } + }, + modifier = Modifier + .weight(1f) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { showEndTimePicker = true }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = PrimaryPurple, + focusedLabelColor = PrimaryPurple + ) + ) + } + + // Helper text + if (startDate.isNotBlank() && startTime.isBlank()) { + Text( + text = "👆 Please select start time", + style = MaterialTheme.typography.bodySmall, + color = PrimaryPurple, + fontWeight = FontWeight.Medium + ) + } + if (endDate.isNotBlank() && endTime.isBlank()) { + Text( + text = "👆 Please select end time", + style = MaterialTheme.typography.bodySmall, + color = PrimaryPurple, + fontWeight = FontWeight.Medium + ) + } + } + } + + // Participants Section (keep the same as before) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Participants", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = PrimaryPurple + ) + + TextButton( + onClick = { showEmployeeSelectionDialog = true } + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Add Participants") + } + } + + if (selectedEmployees.isNotEmpty()) { + selectedEmployees.forEach { employee -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(PrimaryPurple.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + if (!employee.imageUrl.isNullOrBlank()) { + AsyncImage( + model = employee.imageUrl, + contentDescription = "Profile Picture", + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } else { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "Profile", + modifier = Modifier.size(20.dp), + tint = PrimaryPurple + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = employee.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = employee.email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton( + onClick = { + selectedEmployees = selectedEmployees.filter { it.userId != employee.userId } + } + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } else { + Text( + text = "No participants selected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Submit Button + Button( + onClick = { + if (validateForm(title, description, startDate, startTime, endDate, endTime, selectedEmployees)) { + val startDateTime = "$startDate" + "T$startTime:00" + val endDateTime = "$endDate" + "T$endTime:00" + val participantIds = selectedEmployees.mapNotNull { employee -> + employee.userId?.takeIf { it.isNotBlank() } + } + + if (isEditing && meetingId != null) { + meetingViewModel.updateMeeting( + meetingId = meetingId, + title = title, + description = description, + startTime = startDateTime, + endTime = endDateTime, + meetingLink = meetingLink.takeIf { it.isNotBlank() }, + participants = participantIds + ) + } else { + meetingViewModel.createMeeting( + title = title, + description = description, + startTime = startDateTime, + endTime = endDateTime, + meetingLink = meetingLink.takeIf { it.isNotBlank() }, + participants = participantIds + ) + } + } else { + ToastHelper.showErrorToast(context, "Please fill all required fields") + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = (createMeetingState !is Resource.Loading) && (updateMeetingState !is Resource.Loading), + colors = ButtonDefaults.buttonColors(containerColor = PrimaryPurple) + ) { + val isLoading = (createMeetingState is Resource.Loading) || (updateMeetingState is Resource.Loading) + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.White + ) + } else { + Text( + text = if (isEditing) "Update Meeting" else "Create Meeting", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + + // Employee Selection Dialog (keep the same as before with search and select all) + if (showEmployeeSelectionDialog) { + EmployeeSelectionDialog( + employees = when (val state = approvedEmployeesState) { + is Resource.Success -> state.data.filter { employee -> + !employee.userId.isNullOrBlank() + } + else -> emptyList() + }, + selectedEmployees = selectedEmployees, + onEmployeeToggle = { employee -> + selectedEmployees = if (selectedEmployees.any { it.userId == employee.userId }) { + selectedEmployees.filter { it.userId != employee.userId } + } else { + selectedEmployees + employee + } + }, + onSelectAll = { employees -> + selectedEmployees = employees + }, + onDismiss = { showEmployeeSelectionDialog = false }, + onConfirm = { showEmployeeSelectionDialog = false } + ) + } + + // Date and Time Pickers + if (showStartDatePicker) { + SimpleDatePickerDialog( + onDateSelected = { date -> + startDate = date + showStartDatePicker = false + // Auto-show time picker if time is not set + if (startTime.isBlank()) { + showStartTimePicker = true + } + }, + onDismiss = { showStartDatePicker = false }, + initialDate = startDate.ifBlank { LocalDateTime.now().toLocalDate().toString() } + ) + } + + if (showStartTimePicker) { + TimePickerDialog( + onTimeSelected = { time -> + startTime = time + showStartTimePicker = false + }, + onDismiss = { showStartTimePicker = false }, + initialTime = startTime.ifBlank { "09:00" } + ) + } + + if (showEndDatePicker) { + SimpleDatePickerDialog( + onDateSelected = { date -> + endDate = date + showEndDatePicker = false + // Auto-show time picker if time is not set + if (endTime.isBlank()) { + showEndTimePicker = true + } + }, + onDismiss = { showEndDatePicker = false }, + initialDate = endDate.ifBlank { startDate.ifBlank { LocalDateTime.now().toLocalDate().toString() } } + ) + } + + if (showEndTimePicker) { + TimePickerDialog( + onTimeSelected = { time -> + endTime = time + showEndTimePicker = false + }, + onDismiss = { showEndTimePicker = false }, + initialTime = endTime.ifBlank { "10:00" } + ) + } +} + +// Updated EmployeeSelectionDialog with Search and Select All +@Composable +fun EmployeeSelectionDialog( + employees: List, + selectedEmployees: List, + onEmployeeToggle: (UserDto) -> Unit, + onSelectAll: (List) -> Unit, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + var searchQuery by remember { mutableStateOf("") } + + val filteredEmployees = employees.filter { employee -> + employee.name.contains(searchQuery, ignoreCase = true) || + employee.email.contains(searchQuery, ignoreCase = true) + } + + val allSelected = filteredEmployees.isNotEmpty() && + filteredEmployees.all { employee -> selectedEmployees.any { it.userId == employee.userId } } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 600.dp), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Select Participants", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = PrimaryPurple + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Search Field + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + label = { Text("Search employees") }, + placeholder = { Text("Enter name or email") }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = "Search") + }, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = PrimaryPurple, + focusedLabelColor = PrimaryPurple + ) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Select All Row + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + if (allSelected) { + // Deselect all filtered employees + val remainingSelected = selectedEmployees.filter { selected -> + filteredEmployees.none { it.userId == selected.userId } + } + onSelectAll(remainingSelected) + } else { + // Select all filtered employees + val newSelected = (selectedEmployees + filteredEmployees).distinctBy { it.userId } + onSelectAll(newSelected) + } + }, + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = allSelected, + onCheckedChange = { checked -> + if (checked) { + val newSelected = (selectedEmployees + filteredEmployees).distinctBy { it.userId } + onSelectAll(newSelected) + } else { + val remainingSelected = selectedEmployees.filter { selected -> + filteredEmployees.none { it.userId == selected.userId } + } + onSelectAll(remainingSelected) + } + }, + colors = CheckboxDefaults.colors(checkedColor = PrimaryPurple) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = "Select All (${filteredEmployees.size})", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(filteredEmployees) { employee -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onEmployeeToggle(employee) }, + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = selectedEmployees.any { it.userId == employee.userId }, + onCheckedChange = { onEmployeeToggle(employee) }, + colors = CheckboxDefaults.colors(checkedColor = PrimaryPurple) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(PrimaryPurple.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + if (!employee.imageUrl.isNullOrBlank()) { + AsyncImage( + model = employee.imageUrl, + contentDescription = "Profile Picture", + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } else { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "Profile", + modifier = Modifier.size(20.dp), + tint = PrimaryPurple + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = employee.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = employee.email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.weight(1f) + ) { + Text("Cancel") + } + + Button( + onClick = onConfirm, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = PrimaryPurple) + ) { + Text("Done (${selectedEmployees.size})") + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SimpleDatePickerDialog( + onDateSelected: (String) -> Unit, + onDismiss: () -> Unit, + initialDate: String +) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = try { + java.time.LocalDate.parse(initialDate).toEpochDay() * 24 * 60 * 60 * 1000 + } catch (e: Exception) { + System.currentTimeMillis() + } + ) + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let { millis -> + val selectedDate = java.time.LocalDate.ofEpochDay(millis / (24 * 60 * 60 * 1000)) + onDateSelected(selectedDate.toString()) + } + } + ) { + Text("OK", color = PrimaryPurple) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) { + DatePicker( + state = datePickerState, + title = { + Text( + text = "Select Date", + modifier = Modifier.padding(16.dp) + ) + }, + headline = { + Text( + text = "Choose meeting date", + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimePickerDialog( + onTimeSelected: (String) -> Unit, + onDismiss: () -> Unit, + initialTime: String +) { + val timePickerState = rememberTimePickerState( + initialHour = try { + initialTime.split(":")[0].toInt() + } catch (e: Exception) { 9 }, + initialMinute = try { + initialTime.split(":")[1].toInt() + } catch (e: Exception) { 0 } + ) + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.wrapContentSize(), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Select Time", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + TimePicker(state = timePickerState) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.weight(1f) + ) { + Text("Cancel") + } + + Button( + onClick = { + val hour = String.format("%02d", timePickerState.hour) + val minute = String.format("%02d", timePickerState.minute) + onTimeSelected("$hour:$minute") + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = PrimaryPurple) + ) { + Text("OK") + } + } + } + } + } +} + +private fun validateForm( + title: String, + description: String, + startDate: String, + startTime: String, + endDate: String, + endTime: String, + selectedEmployees: List +): Boolean { + return title.isNotBlank() && + description.isNotBlank() && + startDate.isNotBlank() && + startTime.isNotBlank() && + endDate.isNotBlank() && + endTime.isNotBlank() && + selectedEmployees.isNotEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/hr/HRDashboardScreen.kt b/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/hr/HRDashboardScreen.kt index 2fe5160..8dd46d9 100644 --- a/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/hr/HRDashboardScreen.kt +++ b/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/hr/HRDashboardScreen.kt @@ -38,7 +38,8 @@ fun HRDashboardScreen( onNavigateToTasks: () -> Unit, onNavigateToLeaves: () -> Unit, onNavigateToOfficeLocation: () -> Unit, - onNavigateToCompanyAttendance: () -> Unit + onNavigateToCompanyAttendance: () -> Unit, + onNavigateToMeetings: () -> Unit ) { val user by authViewModel.user.collectAsState(initial = null) @@ -85,13 +86,7 @@ fun HRDashboardScreen( title = "Meetings", icon = Icons.Default.VideoCall, color = Color(0xFFE91E63), - onClick = { /* TODO: Navigate to meetings */ } - ), - DashboardCard( - title = "Reports", - icon = Icons.Default.Assessment, - color = Color(0xFF795548), - onClick = { /* TODO: Navigate to reports */ } + onClick = onNavigateToMeetings ) ) diff --git a/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/hr/HRMeetingManagementScreen.kt b/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/hr/HRMeetingManagementScreen.kt new file mode 100644 index 0000000..5e6c0c1 --- /dev/null +++ b/app/src/main/java/com/example/smarthr_app/presentation/screen/dashboard/hr/HRMeetingManagementScreen.kt @@ -0,0 +1,624 @@ +package com.example.smarthr_app.presentation.screen.dashboard.hr + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import coil.compose.AsyncImage +import com.example.smarthr_app.data.model.MeetingResponseDto +import com.example.smarthr_app.presentation.theme.PrimaryPurple +import com.example.smarthr_app.presentation.viewmodel.MeetingViewModel +import com.example.smarthr_app.utils.Resource +import com.example.smarthr_app.utils.ToastHelper +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HRMeetingManagementScreen( + meetingViewModel: MeetingViewModel, + onNavigateBack: () -> Unit, + onNavigateToCreateMeeting: () -> Unit, + onNavigateToEditMeeting: (String) -> Unit +) { + val context = LocalContext.current + val meetingsState by meetingViewModel.meetingsState.collectAsState(initial = null) + val cancelMeetingState by meetingViewModel.cancelMeetingState.collectAsState(initial = null) + + var selectedFilter by remember { mutableStateOf("upcoming") } // "upcoming" or "past" + var showParticipantsDialog by remember { mutableStateOf(false) } + var selectedMeeting by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + meetingViewModel.loadMeetings() + } + + // Handle cancel meeting response + LaunchedEffect(cancelMeetingState) { + when (val state = cancelMeetingState) { + is Resource.Success -> { + ToastHelper.showSuccessToast(context, "Meeting cancelled successfully") + meetingViewModel.clearCancelMeetingState() + meetingViewModel.loadMeetings() + } + is Resource.Error -> { + ToastHelper.showErrorToast(context, state.message) + meetingViewModel.clearCancelMeetingState() + } + else -> {} + } + } + + // Use Scaffold with FAB instead of nested Box + Scaffold( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding(), + floatingActionButton = { + FloatingActionButton( + onClick = onNavigateToCreateMeeting, + containerColor = PrimaryPurple, + contentColor = Color.White + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Create Meeting" + ) + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .background(MaterialTheme.colorScheme.background) + ) { + // Top Bar + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = PrimaryPurple), + shape = RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + tint = Color.White + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = "Meeting Management", + style = MaterialTheme.typography.headlineSmall, + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + } + } + + // Filter Tabs + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + FilterChip( + onClick = { selectedFilter = "upcoming" }, + label = { Text("Upcoming") }, + selected = selectedFilter == "upcoming", + modifier = Modifier.weight(1f), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = PrimaryPurple, + selectedLabelColor = Color.White + ) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + FilterChip( + onClick = { selectedFilter = "past" }, + label = { Text("Past") }, + selected = selectedFilter == "past", + modifier = Modifier.weight(1f), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = PrimaryPurple, + selectedLabelColor = Color.White + ) + ) + } + } + + // Content + when (val state = meetingsState) { + is Resource.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = PrimaryPurple) + } + } + + is Resource.Success -> { + val currentDateTime = LocalDateTime.now() + val filteredMeetings = state.data.filter { meeting -> + val meetingDateTime = LocalDateTime.parse(meeting.startTime) + when (selectedFilter) { + "upcoming" -> meetingDateTime.isAfter(currentDateTime) && meeting.status != "CANCELLED" + "past" -> meetingDateTime.isBefore(currentDateTime) || meeting.status == "CANCELLED" + else -> true + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(filteredMeetings) { meeting -> + MeetingCard( + meeting = meeting, + onEditClick = { onNavigateToEditMeeting(meeting.id) }, + onCancelClick = { meetingViewModel.cancelMeeting(meeting.id) }, + onParticipantsClick = { + selectedMeeting = meeting + showParticipantsDialog = true + }, + onCopyLink = { link -> + copyToClipboard(context, link) + ToastHelper.showSuccessToast(context, "Meeting link copied!") + }, + isLoading = cancelMeetingState is Resource.Loading + ) + } + + if (filteredMeetings.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.EventNote, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No ${selectedFilter} meetings found", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Tap the + button to create your first meeting", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + + is Resource.Error -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Error loading meetings", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { meetingViewModel.loadMeetings() }, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryPurple) + ) { + Text("Retry") + } + } + } + } + + null -> { + // Initial state - show loading + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = PrimaryPurple) + } + } + } + } + } + + // Participants Dialog + if (showParticipantsDialog && selectedMeeting != null) { + ParticipantsDialog( + meeting = selectedMeeting!!, + onDismiss = { + showParticipantsDialog = false + selectedMeeting = null + } + ) + } +} + +@Composable +fun MeetingCard( + meeting: MeetingResponseDto, + onEditClick: () -> Unit, + onCancelClick: () -> Unit, + onParticipantsClick: () -> Unit, + onCopyLink: (String) -> Unit, + isLoading: Boolean +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + // Meeting header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = meeting.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = meeting.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2 + ) + } + + Surface( + shape = RoundedCornerShape(12.dp), + color = when (meeting.status) { + "SCHEDULED" -> Color(0xFF4CAF50) + "CANCELLED" -> Color(0xFFFF5722) + else -> Color.Gray + } + ) { + Text( + text = meeting.status, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Meeting details + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Start and End Time + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = PrimaryPurple + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${formatDateTime(meeting.startTime)} - ${formatDateTime(meeting.endTime)}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + } + + // Meeting Link (if available) + if (!meeting.meetingLink.isNullOrBlank()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Link, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = PrimaryPurple + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = meeting.meetingLink, + style = MaterialTheme.typography.bodySmall, + color = PrimaryPurple, + maxLines = 1, + modifier = Modifier.weight(1f) + ) + TextButton( + onClick = { onCopyLink(meeting.meetingLink) } + ) { + Text("Copy", color = PrimaryPurple) + } + } + } + + // Participants - Clickable + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onParticipantsClick() } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.People, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = PrimaryPurple + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${meeting.participants.size} participants", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = PrimaryPurple + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = PrimaryPurple + ) + } + } + + if (meeting.status != "CANCELLED") { + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onEditClick, + modifier = Modifier.weight(1f), + enabled = !isLoading + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Edit") + } + + Button( + onClick = onCancelClick, + modifier = Modifier.weight(1f), + enabled = !isLoading, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFF5722)) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = Color.White + ) + } else { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Cancel") + } + } + } + } + } + } +} + +// Add Participants Dialog +@Composable +fun ParticipantsDialog( + meeting: MeetingResponseDto, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 500.dp), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Meeting Participants", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = PrimaryPurple + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(meeting.participants ?: emptyList()) { participant -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(PrimaryPurple.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + if (!participant.imageUrl.isNullOrBlank()) { + AsyncImage( + model = participant.imageUrl, + contentDescription = "Profile Picture", + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = (participant.name?.take(1) ?: "?").uppercase(), + style = MaterialTheme.typography.labelMedium, + color = PrimaryPurple, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = participant.name ?: "Unknown", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = participant.email ?: "No email", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Response status + val response = meeting.responses?.find { it.participant?.id == participant.id } + val status = response?.status ?: "PENDING" + + Surface( + shape = RoundedCornerShape(8.dp), + color = when (status) { + "ACCEPTED" -> Color(0xFF4CAF50).copy(alpha = 0.1f) + "DECLINED" -> Color(0xFFFF5722).copy(alpha = 0.1f) + else -> Color(0xFFFF9800).copy(alpha = 0.1f) + } + ) { + Text( + text = status, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = when (status) { + "ACCEPTED" -> Color(0xFF4CAF50) + "DECLINED" -> Color(0xFFFF5722) + else -> Color(0xFFFF9800) + }, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = PrimaryPurple) + ) { + Text("Close") + } + } + } + } +} + +private fun formatDateTime(dateTimeString: String?): String { + return try { + if (dateTimeString.isNullOrBlank()) return "Invalid Date" + val dateTime = LocalDateTime.parse(dateTimeString) + val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm") + dateTime.format(formatter) + } catch (e: Exception) { + dateTimeString ?: "Invalid Date" + } +} + +private fun copyToClipboard(context: Context, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Meeting Link", text) + clipboard.setPrimaryClip(clip) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/smarthr_app/presentation/viewmodel/MeetingViewModel.kt b/app/src/main/java/com/example/smarthr_app/presentation/viewmodel/MeetingViewModel.kt new file mode 100644 index 0000000..6600468 --- /dev/null +++ b/app/src/main/java/com/example/smarthr_app/presentation/viewmodel/MeetingViewModel.kt @@ -0,0 +1,108 @@ +package com.example.smarthr_app.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.smarthr_app.data.model.* +import com.example.smarthr_app.data.repository.MeetingRepository +import com.example.smarthr_app.utils.Resource +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class MeetingViewModel(private val meetingRepository: MeetingRepository) : ViewModel() { + + private val _meetingsState = MutableStateFlow>?>(null) + val meetingsState: StateFlow>?> = _meetingsState + + private val _createMeetingState = MutableStateFlow?>(null) + val createMeetingState: StateFlow?> = _createMeetingState + + private val _updateMeetingState = MutableStateFlow?>(null) + val updateMeetingState: StateFlow?> = _updateMeetingState + + private val _cancelMeetingState = MutableStateFlow?>(null) + val cancelMeetingState: StateFlow?> = _cancelMeetingState + + private val _meetingDetailState = MutableStateFlow?>(null) + val meetingDetailState: StateFlow?> = _meetingDetailState + + private val _respondToMeetingState = MutableStateFlow?>(null) + val respondToMeetingState: StateFlow?> = _respondToMeetingState + + fun createMeeting( + title: String, + description: String, + startTime: String, + endTime: String, + meetingLink: String?, + participants: List + ) { + viewModelScope.launch { + _createMeetingState.value = Resource.Loading() + _createMeetingState.value = meetingRepository.createMeeting( + title, description, startTime, endTime, meetingLink, participants + ) + } + } + + fun loadMeetings() { + viewModelScope.launch { + _meetingsState.value = Resource.Loading() + _meetingsState.value = meetingRepository.getMyMeetings() + } + } + + fun loadMeetingDetail(meetingId: String) { + viewModelScope.launch { + _meetingDetailState.value = Resource.Loading() + _meetingDetailState.value = meetingRepository.getMeetingById(meetingId) + } + } + + fun updateMeeting( + meetingId: String, + title: String, + description: String, + startTime: String, + endTime: String, + meetingLink: String?, + participants: List + ) { + viewModelScope.launch { + _updateMeetingState.value = Resource.Loading() + _updateMeetingState.value = meetingRepository.updateMeeting( + meetingId, title, description, startTime, endTime, meetingLink, participants + ) + } + } + + fun cancelMeeting(meetingId: String) { + viewModelScope.launch { + _cancelMeetingState.value = Resource.Loading() + _cancelMeetingState.value = meetingRepository.cancelMeeting(meetingId) + } + } + + fun respondToMeeting(meetingId: String, status: String) { + viewModelScope.launch { + _respondToMeetingState.value = Resource.Loading() + _respondToMeetingState.value = meetingRepository.respondToMeeting(meetingId, status) + } + } + + // Clear states + fun clearCreateMeetingState() { + _createMeetingState.value = null + } + + fun clearUpdateMeetingState() { + _updateMeetingState.value = null + } + + fun clearCancelMeetingState() { + _cancelMeetingState.value = null + } + + fun clearRespondToMeetingState() { + _respondToMeetingState.value = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/smarthr_app/utils/ToastHelper.kt b/app/src/main/java/com/example/smarthr_app/utils/ToastHelper.kt index 5e58472..9ed10b0 100644 --- a/app/src/main/java/com/example/smarthr_app/utils/ToastHelper.kt +++ b/app/src/main/java/com/example/smarthr_app/utils/ToastHelper.kt @@ -10,6 +10,13 @@ object ToastHelper { } fun showErrorToast(context: Context, message: String) { + // Special handling for meetings-time conflicts + val displayMessage = when { + message.contains("conflict", ignoreCase = true) -> "⚠️ Time Conflict: $message" + message.contains("overlap", ignoreCase = true) -> "⚠️ Schedule Overlap: $message" + message.contains("already scheduled", ignoreCase = true) -> "⚠️ Already Scheduled: $message" + else -> "❌ $message" + } Toast.makeText(context, "❌ $message", Toast.LENGTH_LONG).show() }