diff --git a/.kotlin/errors/errors-1768321427846.log b/.kotlin/errors/errors-1768321427846.log new file mode 100644 index 0000000..1219b50 --- /dev/null +++ b/.kotlin/errors/errors-1768321427846.log @@ -0,0 +1,4 @@ +kotlin version: 2.0.21 +error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: + 1. Kotlin compile daemon is ready + diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt index 99a1f61..881234a 100644 --- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt @@ -18,6 +18,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -27,6 +28,8 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,6 +40,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.core.content.ContextCompat import androidx.lint.kotlin.metadata.Visibility import com.google.android.gms.location.LocationServices @@ -45,14 +49,20 @@ import org.json.JSONObject import java.io.ByteArrayOutputStream import java.net.HttpURLConnection import java.net.URL -import java.text.SimpleDateFormat import java.util.Date -import java.util.Locale import kotlin.concurrent.thread import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt +//import androidx.compose.foundation.background +//import androidx.compose.foundation.gestures.detectTapGestures +//import androidx.compose.ui.input.pointer.pointerInput +//import androidx.compose.ui.window.Dialog +//import androidx.compose.foundation.lazy.LazyColumn +//import androidx.compose.foundation.lazy.items +import java.text.SimpleDateFormat +import java.util.Locale /* ================= CONSTANTS ================= */ @@ -109,6 +119,26 @@ data class JadwalKelas( val sudahAbsen: Boolean ) +data class RiwayatAbsensi( + val idAbsensi: Int, + val npm: String, + val nama: String, + val mataKuliah: String? = null, // Tambahkan ini + val latitude: Double, + val longitude: Double, + val timestamp: String, + val status: String, + val createdAt: String, + val jamMulai: String? = null, + val jamSelesai: String? = null +) + +data class AbsensiStats( + val totalAbsensi: Int, + val absensiMingguIni: Int, + val absensiBulanIni: Int +) + /* ================= USER PREFERENCES ================= */ class UserPreferences(private val context: Context) { @@ -368,8 +398,6 @@ fun submitAbsensi( } } -// TAMBAHKAN FUNGSI API BARU setelah fungsi submitAbsensi - fun getJadwalToday( token: String, onSuccess: (List) -> Unit, @@ -491,6 +519,178 @@ fun submitAbsensiWithJadwal( } } +fun getAbsensiHistory( + token: String, + startDate: String? = null, + endDate: String? = null, + onSuccess: (List) -> Unit, + onError: (String) -> Unit +) { + thread { + try { + // 1. Setup URL + var urlString = "${AppConstants.BASE_URL}/api/absensi/history" + + // Tambahkan query parameters jika ada filter + val params = mutableListOf() + if (startDate != null) params.add("start_date=$startDate") + if (endDate != null) params.add("end_date=$endDate") + + if (params.isNotEmpty()) { + urlString += "?${params.joinToString("&")}" + } + + val url = URL(urlString) + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.setRequestProperty("Authorization", "Bearer $token") + conn.connectTimeout = 15000 + conn.readTimeout = 15000 + + // 2. Definisi Variabel responseCode dan response (PENTING: Jangan dihapus) + val responseCode = conn.responseCode + val response = if (responseCode == 200) { + conn.inputStream.bufferedReader().use { it.readText() } + } else { + conn.errorStream?.bufferedReader()?.use { it.readText() } ?: "Error" + } + + conn.disconnect() + + // 3. Logika Parsing + if (responseCode == 200) { + // Parse String response menjadi JSONObject + val jsonResponse = JSONObject(response) + + // Ambil Array 'data' + val dataArray = jsonResponse.getJSONArray("data") + val riwayatList = mutableListOf() + + for (i in 0 until dataArray.length()) { + val item = dataArray.getJSONObject(i) + riwayatList.add( + RiwayatAbsensi( + idAbsensi = item.getInt("id_absensi"), + npm = item.getString("npm"), + nama = item.getString("nama"), + mataKuliah = item.optString("mata_kuliah", null), + latitude = item.getDouble("latitude"), + longitude = item.getDouble("longitude"), + timestamp = item.getString("timestamp"), + status = item.getString("status"), + createdAt = item.getString("created_at"), + // Ambil data jadwal (jam mulai & selesai) + jamMulai = item.optString("jam_mulai", null), + jamSelesai = item.optString("jam_selesai", null) + ) + ) + } + + onSuccess(riwayatList) + } else { + val error = try { + JSONObject(response).optString("error", "Gagal mengambil riwayat") + } catch (e: Exception) { + "Gagal mengambil riwayat" + } + onError(error) + } + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } +} + +fun getAbsensiStats( + token: String, + onSuccess: (AbsensiStats) -> Unit, + onError: (String) -> Unit +) { + thread { + try { + val url = URL("${AppConstants.BASE_URL}/api/absensi/stats") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.setRequestProperty("Authorization", "Bearer $token") + conn.connectTimeout = 15000 + conn.readTimeout = 15000 + + val responseCode = conn.responseCode + val response = if (responseCode == 200) { + conn.inputStream.bufferedReader().use { it.readText() } + } else { + conn.errorStream?.bufferedReader()?.use { it.readText() } ?: "Error" + } + + conn.disconnect() + + if (responseCode == 200) { + val jsonResponse = JSONObject(response) + val data = jsonResponse.getJSONObject("data") + + val stats = AbsensiStats( + totalAbsensi = data.getInt("total_absensi"), + absensiMingguIni = data.getInt("absensi_minggu_ini"), + absensiBulanIni = data.getInt("absensi_bulan_ini") + ) + + onSuccess(stats) + } else { + val error = try { + JSONObject(response).optString("error", "Gagal mengambil statistik") + } catch (e: Exception) { + "Gagal mengambil statistik" + } + onError(error) + } + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } +} + +fun getFotoAbsensi( + token: String, + idAbsensi: Int, + onSuccess: (String) -> Unit, + onError: (String) -> Unit +) { + thread { + try { + val url = URL("${AppConstants.BASE_URL}/api/absensi/photo/$idAbsensi") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.setRequestProperty("Authorization", "Bearer $token") + conn.connectTimeout = 15000 + conn.readTimeout = 15000 + + val responseCode = conn.responseCode + val response = if (responseCode == 200) { + conn.inputStream.bufferedReader().use { it.readText() } + } else { + conn.errorStream?.bufferedReader()?.use { it.readText() } ?: "Error" + } + + conn.disconnect() + + if (responseCode == 200) { + val jsonResponse = JSONObject(response) + val fotoBase64 = jsonResponse.getJSONObject("data").getString("foto_base64") + onSuccess(fotoBase64) + } else { + val error = try { + JSONObject(response).optString("error", "Gagal mengambil foto") + } catch (e: Exception) { + "Gagal mengambil foto" + } + onError(error) + } + } catch (e: Exception) { + onError("Error: ${e.message}") + } + } +} + /* ================= MAIN ACTIVITY ================= */ class MainActivity : ComponentActivity() { @@ -1135,6 +1335,590 @@ fun LoginScreen( } } +// ========== SCREEN BARU: RIWAYAT ABSENSI ========== + +@Composable +fun RiwayatScreen( + modifier: Modifier = Modifier, + activity: ComponentActivity, + token: String +) { + val context = LocalContext.current + + var riwayatList by remember { mutableStateOf>(emptyList()) } + var stats by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } + var showFilterDialog by remember { mutableStateOf(false) } + var showFotoDialog by remember { mutableStateOf(false) } + var selectedFoto by remember { mutableStateOf(null) } + var isLoadingFoto by remember { mutableStateOf(false) } + + // Filter states + var startDate by remember { mutableStateOf(null) } + var endDate by remember { mutableStateOf(null) } + var filterActive by remember { mutableStateOf(false) } + + val scrollState = rememberScrollState() + + // Load data function + fun loadData() { + isLoading = true + getAbsensiHistory( + token = token, + startDate = startDate, + endDate = endDate, + onSuccess = { riwayat -> + activity.runOnUiThread { + riwayatList = riwayat + isLoading = false + } + }, + onError = { error -> + activity.runOnUiThread { + isLoading = false + Toast.makeText(context, "❌ $error", Toast.LENGTH_SHORT).show() + } + } + ) + + getAbsensiStats( + token = token, + onSuccess = { statsData -> + activity.runOnUiThread { + stats = statsData + } + }, + onError = {} + ) + } + + // Initial load + LaunchedEffect(Unit) { + loadData() + } + + // Dialog Foto Full Screen + if (showFotoDialog && selectedFoto != null) { + Dialog(onDismissRequest = { showFotoDialog = false }) { + Card( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.large + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "📸 Foto Absensi", + style = MaterialTheme.typography.titleLarge + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Image( + bitmap = selectedFoto!!.asImageBitmap(), + contentDescription = "Foto Absensi", + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 500.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { showFotoDialog = false }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Tutup") + } + } + } + } + } + + // Dialog Filter + if (showFilterDialog) { + FilterDateDialog( + startDate = startDate, + endDate = endDate, + onDismiss = { showFilterDialog = false }, + onApply = { start, end -> + startDate = start + endDate = end + filterActive = (start != null || end != null) + isLoading = true + loadData() + showFilterDialog = false + }, + onReset = { + startDate = null + endDate = null + filterActive = false + isLoading = true + loadData() + showFilterDialog = false + } + ) + } + + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(24.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "📋 Riwayat Absensi", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Refresh Button + IconButton(onClick = { loadData() }) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh" + ) + } + + // Filter Button + IconButton(onClick = { showFilterDialog = true }) { + Icon( + imageVector = if (filterActive) Icons.Default.FilterAlt else Icons.Default.FilterList, + contentDescription = "Filter", + tint = if (filterActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Statistik Cards + if (stats != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + StatsCard( + title = "Total", + value = stats!!.totalAbsensi.toString(), + icon = Icons.Default.CheckCircle, + modifier = Modifier.weight(1f) + ) + StatsCard( + title = "Minggu Ini", + value = stats!!.absensiMingguIni.toString(), + icon = Icons.Default.DateRange, + modifier = Modifier.weight(1f) + ) + StatsCard( + title = "Bulan Ini", + value = stats!!.absensiBulanIni.toString(), + icon = Icons.Default.CalendarToday, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + // Filter Info + if (filterActive) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.FilterAlt, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Filter aktif: ${startDate ?: "..."} s/d ${endDate ?: "..."}", + style = MaterialTheme.typography.bodySmall + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + + // Loading + if (isLoading) { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + // Empty State + else if (riwayatList.isEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(32.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "📭", + style = MaterialTheme.typography.displayMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Belum ada riwayat absensi", + style = MaterialTheme.typography.titleMedium + ) + } + } + } + + // List Riwayat + else { + riwayatList.forEach { riwayat -> + RiwayatCard( + riwayat = riwayat, + onLihatFoto = { idAbsensi -> + isLoadingFoto = true + getFotoAbsensi( + token = token, + idAbsensi = idAbsensi, + onSuccess = { fotoBase64 -> + val bitmap = base64ToBitmap(fotoBase64) + activity.runOnUiThread { + isLoadingFoto = false + if (bitmap != null) { + selectedFoto = bitmap + showFotoDialog = true + } else { + Toast.makeText(context, "❌ Gagal load foto", Toast.LENGTH_SHORT).show() + } + } + }, + onError = { error -> + activity.runOnUiThread { + isLoadingFoto = false + Toast.makeText(context, "❌ $error", Toast.LENGTH_SHORT).show() + } + } + ) + } + ) + Spacer(modifier = Modifier.height(12.dp)) + } + } + } + } + + // Loading Foto Overlay + if (isLoadingFoto) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } +} + + +// ========== COMPONENTS ========== + +@Composable +fun StatsCard( + title: String, + value: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier.padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = title, + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +@Composable +fun RiwayatCard( + riwayat: RiwayatAbsensi, + onLihatFoto: (Int) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + // Warna background kartu transparan gelap (sesuai tema) + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + // === HEADER: Tanggal (Kiri) & Status (Kanan) === + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatTanggalCard(riwayat.timestamp), + style = MaterialTheme.typography.titleSmall.copy( + color = MaterialTheme.colorScheme.primary + ) + ) + + Surface( + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), + color = if (riwayat.status.uppercase() == "HADIR") + androidx.compose.ui.graphics.Color(0xFF6552A8) // Ungu gelap + else + MaterialTheme.colorScheme.errorContainer + ) { + Text( + text = riwayat.status.uppercase(), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold + ), + color = androidx.compose.ui.graphics.Color.White + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // === BODY: Mata Kuliah === + if (!riwayat.mataKuliah.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Book, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = androidx.compose.ui.graphics.Color(0xFF4FC3F7) // Cyan + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = riwayat.mataKuliah, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.onSurface + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + // === BODY: Waktu (LOGIKA BARU) === + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(12.dp)) + + // Logika: Jika ada jamMulai/Selesai, pakai itu. Jika tidak, pakai timestamp. + val waktuText = if (riwayat.jamMulai != null && riwayat.jamSelesai != null) { + val mulai = if (riwayat.jamMulai.length >= 5) riwayat.jamMulai.substring(0, 5) else riwayat.jamMulai + val selesai = if (riwayat.jamSelesai.length >= 5) riwayat.jamSelesai.substring(0, 5) else riwayat.jamSelesai + "Pukul $mulai - $selesai" + } else { + "Absen: ${formatJam(riwayat.timestamp)}" + } + + Text( + text = waktuText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // === BODY: Lokasi (INI YANG HILANG SEBELUMNYA) === + Row(verticalAlignment = Alignment.Top) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "${String.format("%.6f", riwayat.latitude)}, ${String.format("%.6f", riwayat.longitude)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // === FOOTER: Tombol Lihat Foto === + Button( + onClick = { onLihatFoto(riwayat.idAbsensi) }, + modifier = Modifier + .fillMaxWidth() + .height(45.dp), + shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = androidx.compose.ui.graphics.Color(0xFF006064), // Teal gelap + contentColor = androidx.compose.ui.graphics.Color.White + ) + ) { + Icon( + imageVector = Icons.Default.Image, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Lihat Foto", style = MaterialTheme.typography.labelLarge) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilterDateDialog( + startDate: String?, + endDate: String?, + onDismiss: () -> Unit, + onApply: (String?, String?) -> Unit, + onReset: () -> Unit +) { + var tempStartDate by remember { mutableStateOf(startDate) } + var tempEndDate by remember { mutableStateOf(endDate) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Filter Tanggal") }, + text = { + Column { + OutlinedTextField( + value = tempStartDate ?: "", + onValueChange = { tempStartDate = it }, + label = { Text("Tanggal Mulai") }, + placeholder = { Text("YYYY-MM-DD") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = tempEndDate ?: "", + onValueChange = { tempEndDate = it }, + label = { Text("Tanggal Akhir") }, + placeholder = { Text("YYYY-MM-DD") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Format: YYYY-MM-DD (contoh: 2026-01-13)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + TextButton(onClick = { onApply(tempStartDate, tempEndDate) }) { + Text("Terapkan") + } + }, + dismissButton = { + Row { + TextButton(onClick = onReset) { + Text("Reset") + } + TextButton(onClick = onDismiss) { + Text("Batal") + } + } + } + ) +} + +// ========== HELPER FUNCTIONS ========== + +// Helper untuk memparsing tanggal dari String MySQL (YYYY-MM-DD HH:mm:ss) +fun parseTimestamp(timestamp: String): Date? { + return try { + // Ganti 'T' dengan spasi jaga-jaga jika formatnya ISO8601 + val cleanTimestamp = timestamp.replace("T", " ") + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + sdf.parse(cleanTimestamp) + } catch (e: Exception) { + null + } +} + +fun formatTanggalCard(timestamp: String): String { + val date = parseTimestamp(timestamp) ?: return timestamp + // Format: "Sel, 13 Jan 2026" + val outputFormat = SimpleDateFormat("EEE, d MMM yyyy", Locale("id", "ID")) + return outputFormat.format(date) +} + +fun formatJam(timestamp: String): String { + val date = parseTimestamp(timestamp) ?: return "00:00" + // Format: "14:30" + val outputFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + return outputFormat.format(date) +} + /* ================= MAIN SCREEN (Bottom Navigation) ================= */ @Composable @@ -1163,11 +1947,17 @@ fun MainScreen( onClick = { selectedTab = 1 } ) NavigationBarItem( - icon = { Icon(Icons.Default.Person, "Profil") }, - label = { Text("Profil") }, + icon = { Icon(Icons.Default.History, "Riwayat") }, + label = { Text("Riwayat") }, selected = selectedTab == 2, onClick = { selectedTab = 2 } ) + NavigationBarItem( + icon = { Icon(Icons.Default.Person, "Profil") }, + label = { Text("Profil") }, + selected = selectedTab == 3, + onClick = { selectedTab = 3 } + ) } } ) { padding -> @@ -1182,7 +1972,12 @@ fun MainScreen( modifier = Modifier.padding(padding), token = token ) - 2 -> ProfilScreen( + 2 -> RiwayatScreen( + modifier = Modifier.padding(padding), + activity = activity, + token = token + ) + 3 -> ProfilScreen( modifier = Modifier.padding(padding), mahasiswa = mahasiswa, onLogout = onLogout diff --git a/backend/app.py b/backend/app.py index 43a1dc8..b41be45 100644 --- a/backend/app.py +++ b/backend/app.py @@ -600,10 +600,7 @@ def submit_absensi(): def get_history(): """ Endpoint untuk mendapatkan riwayat absensi - - Query Parameters: - - start_date (optional): YYYY-MM-DD - - end_date (optional): YYYY-MM-DD + UPDATE: Join dengan jadwal_kelas untuk ambil jam_mulai & jam_selesai """ try: id_mahasiswa = request.user_data['id_mahasiswa'] @@ -616,30 +613,48 @@ def get_history(): cursor = connection.cursor(dictionary=True) - # Query dasar + # QUERY UPDATE: Join ke tabel jadwal_kelas (alias j) query = """ - SELECT id_absensi, npm, nama, latitude, longitude, timestamp, status, created_at - FROM absensi - WHERE id_mahasiswa = %s + SELECT + a.id_absensi, + a.npm, + a.nama, + a.mata_kuliah, + a.latitude, + a.longitude, + a.timestamp, + a.status, + a.created_at, + j.jam_mulai, + j.jam_selesai + FROM absensi a + LEFT JOIN jadwal_kelas j ON a.id_jadwal = j.id_jadwal + WHERE a.id_mahasiswa = %s """ params = [id_mahasiswa] - # Filter berdasarkan tanggal if start_date and end_date: - query += " AND DATE(timestamp) BETWEEN %s AND %s" + query += " AND DATE(a.timestamp) BETWEEN %s AND %s" params.extend([start_date, end_date]) elif start_date: - query += " AND DATE(timestamp) >= %s" + query += " AND DATE(a.timestamp) >= %s" params.append(start_date) elif end_date: - query += " AND DATE(timestamp) <= %s" + query += " AND DATE(a.timestamp) <= %s" params.append(end_date) - query += " ORDER BY timestamp DESC" + query += " ORDER BY a.timestamp DESC" cursor.execute(query, params) history = cursor.fetchall() + # Konversi objek timedelta/time ke string + for item in history: + if item['jam_mulai']: + item['jam_mulai'] = str(item['jam_mulai']) + if item['jam_selesai']: + item['jam_selesai'] = str(item['jam_selesai']) + cursor.close() connection.close()