Riwayat Absensi Mahasiswa

This commit is contained in:
202310715297 RAIHAN ARIQ MUZAKKI 2026-01-14 00:17:01 +07:00
parent 89355bfbb7
commit be950a83da
3 changed files with 834 additions and 20 deletions

View File

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

View File

@ -18,6 +18,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState 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.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lint.kotlin.metadata.Visibility import androidx.lint.kotlin.metadata.Visibility
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
@ -45,14 +49,20 @@ import org.json.JSONObject
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.math.atan2 import kotlin.math.atan2
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.sqrt 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 ================= */ /* ================= CONSTANTS ================= */
@ -109,6 +119,26 @@ data class JadwalKelas(
val sudahAbsen: Boolean 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 ================= */ /* ================= USER PREFERENCES ================= */
class UserPreferences(private val context: Context) { class UserPreferences(private val context: Context) {
@ -368,8 +398,6 @@ fun submitAbsensi(
} }
} }
// TAMBAHKAN FUNGSI API BARU setelah fungsi submitAbsensi
fun getJadwalToday( fun getJadwalToday(
token: String, token: String,
onSuccess: (List<JadwalKelas>) -> Unit, onSuccess: (List<JadwalKelas>) -> Unit,
@ -491,6 +519,178 @@ fun submitAbsensiWithJadwal(
} }
} }
fun getAbsensiHistory(
token: String,
startDate: String? = null,
endDate: String? = null,
onSuccess: (List<RiwayatAbsensi>) -> 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<String>()
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<RiwayatAbsensi>()
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 ================= */ /* ================= MAIN ACTIVITY ================= */
class MainActivity : ComponentActivity() { 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<List<RiwayatAbsensi>>(emptyList()) }
var stats by remember { mutableStateOf<AbsensiStats?>(null) }
var isLoading by remember { mutableStateOf(true) }
var showFilterDialog by remember { mutableStateOf(false) }
var showFotoDialog by remember { mutableStateOf(false) }
var selectedFoto by remember { mutableStateOf<Bitmap?>(null) }
var isLoadingFoto by remember { mutableStateOf(false) }
// Filter states
var startDate by remember { mutableStateOf<String?>(null) }
var endDate by remember { mutableStateOf<String?>(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) ================= */ /* ================= MAIN SCREEN (Bottom Navigation) ================= */
@Composable @Composable
@ -1163,11 +1947,17 @@ fun MainScreen(
onClick = { selectedTab = 1 } onClick = { selectedTab = 1 }
) )
NavigationBarItem( NavigationBarItem(
icon = { Icon(Icons.Default.Person, "Profil") }, icon = { Icon(Icons.Default.History, "Riwayat") },
label = { Text("Profil") }, label = { Text("Riwayat") },
selected = selectedTab == 2, selected = selectedTab == 2,
onClick = { selectedTab = 2 } onClick = { selectedTab = 2 }
) )
NavigationBarItem(
icon = { Icon(Icons.Default.Person, "Profil") },
label = { Text("Profil") },
selected = selectedTab == 3,
onClick = { selectedTab = 3 }
)
} }
} }
) { padding -> ) { padding ->
@ -1182,7 +1972,12 @@ fun MainScreen(
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
token = token token = token
) )
2 -> ProfilScreen( 2 -> RiwayatScreen(
modifier = Modifier.padding(padding),
activity = activity,
token = token
)
3 -> ProfilScreen(
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
mahasiswa = mahasiswa, mahasiswa = mahasiswa,
onLogout = onLogout onLogout = onLogout

View File

@ -600,10 +600,7 @@ def submit_absensi():
def get_history(): def get_history():
""" """
Endpoint untuk mendapatkan riwayat absensi Endpoint untuk mendapatkan riwayat absensi
UPDATE: Join dengan jadwal_kelas untuk ambil jam_mulai & jam_selesai
Query Parameters:
- start_date (optional): YYYY-MM-DD
- end_date (optional): YYYY-MM-DD
""" """
try: try:
id_mahasiswa = request.user_data['id_mahasiswa'] id_mahasiswa = request.user_data['id_mahasiswa']
@ -616,30 +613,48 @@ def get_history():
cursor = connection.cursor(dictionary=True) cursor = connection.cursor(dictionary=True)
# Query dasar # QUERY UPDATE: Join ke tabel jadwal_kelas (alias j)
query = """ query = """
SELECT id_absensi, npm, nama, latitude, longitude, timestamp, status, created_at SELECT
FROM absensi a.id_absensi,
WHERE id_mahasiswa = %s 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] params = [id_mahasiswa]
# Filter berdasarkan tanggal
if start_date and end_date: 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]) params.extend([start_date, end_date])
elif start_date: elif start_date:
query += " AND DATE(timestamp) >= %s" query += " AND DATE(a.timestamp) >= %s"
params.append(start_date) params.append(start_date)
elif end_date: elif end_date:
query += " AND DATE(timestamp) <= %s" query += " AND DATE(a.timestamp) <= %s"
params.append(end_date) params.append(end_date)
query += " ORDER BY timestamp DESC" query += " ORDER BY a.timestamp DESC"
cursor.execute(query, params) cursor.execute(query, params)
history = cursor.fetchall() 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() cursor.close()
connection.close() connection.close()