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.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<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 ================= */
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) ================= */
@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

View File

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