Commit 7accbf0f by Samuel Taniel Mulyadi

Update filtering dan responsiveness

1 parent 28ea9917
......@@ -243,7 +243,7 @@ function getColorClass(index: number) {
v-else
class="timetable"
>
<div class="scrollable">
<div class="scrollable2">
<table class="w-100 text-left table-schedule fixed-table">
<thead>
<tr>
......@@ -279,16 +279,16 @@ function getColorClass(index: number) {
getScheduleByDay(day)[rowIndex - 1].end
}}
</span>
<span class="room text-xs">{{ getScheduleByDay(day)[rowIndex - 1].room }}</span>
<span class="text-xs">{{ getScheduleByDay(day)[rowIndex - 1].room }}</span>
</div>
<div class="course-title font-semibold text-sm mb-1">
<div class="course-title2 font-semibold text-sm mb-1">
<span
v-if="getScheduleByDay(day)[rowIndex - 1].course.includes('-')"
style=" padding-inline-end: 4px;text-decoration: underline;"
>
{{ getScheduleByDay(day)[rowIndex - 1].course.split(' - ')[0] }}
</span>
<span class="text-sm">
<span class="course-name">
{{
getScheduleByDay(day)[rowIndex - 1].course.includes('-')
? getScheduleByDay(day)[rowIndex - 1].course.split(' - ')[1]
......@@ -322,6 +322,18 @@ function getColorClass(index: number) {
min-inline-size: 500px;
}
.scrollable2 {
overflow: auto hidden; /* Optional: you can allow vertical scroll if needed */
box-sizing: border-box;
inline-size: 100%;
min-inline-size: 1000px;
}
/* Optional: make inner content grow past 700px if needed */
.scrollable2 > * {
min-inline-size: 1000px;
}
.date-range-box,
.date-range-box2 {
border-radius: 5px;
......@@ -385,21 +397,64 @@ function getColorClass(index: number) {
}
.course-title {
display: flex;
flex-wrap: wrap; /* Allow wrapping when needed */
border-block-end: 1px solid;
font-size: 12px;
font-weight: bold;
gap: 4px; /* Optional: space between prefix and name */
inline-size: 100%;
padding-block-end: 4px;
}
.course-title2 {
display: flex;
flex-wrap: wrap; /* Allow wrapping when needed */
border-block-end: 1px solid;
font-size: 12px;
font-weight: bold;
gap: 4px; /* Optional: space between prefix and name */
inline-size: 100%;
margin-block-end: 5px;
padding-block-end: 5px;
text-overflow: ellipsis; /* Show "..." */
padding-block-end: 4px;
}
.course-prefix {
color: color-mix(in srgb, rgba(var(--v-global-theme-primary)) 70%, black 30%);
padding-inline-end: 4px;
text-decoration: underline;
}
.course-name {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
}
.course-header {
display: flex;
justify-content: space-between;
gap: 8px; /* optional spacing */
inline-size: 100%;
}
.time {
flex-shrink: 0; /* Don't shrink */
font-weight: normal;
text-align: start;
white-space: nowrap;
}
.room {
overflow: hidden;
flex-grow: 1;
flex-shrink: 1;
font-weight: normal;
text-align: end;
text-overflow: ellipsis;
white-space: nowrap;
}
.grid-body {
display: grid;
grid-template-columns: 80px auto; /* Time column + Schedule grid */
......@@ -451,31 +506,13 @@ function getColorClass(index: number) {
grid-template-rows: repeat(840, 1px); /* 1px per minute */
}
.course-header {
display: flex;
justify-content: space-between;
gap: 8px; /* optional spacing */
inline-size: 100%;
}
.time {
flex-shrink: 0; /* Don't shrink */
font-weight: normal;
text-align: start;
white-space: nowrap;
}
.room {
overflow: hidden;
flex-grow: 1;
flex-shrink: 1;
.building {
font-weight: normal;
text-align: end;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.8;
text-align: start; /* Aligns room to the right */
}
.building {
.building2 {
font-weight: normal;
opacity: 0.8;
text-align: start; /* Aligns room to the right */
......
<script setup lang="ts">
import { onMounted } from 'vue'
import { useKeycloakStore } from '@/@core/stores/keycloakStore'
<script lang="ts" setup>
import { useKeycloakStore } from '@core/stores/keycloakStore'
import { computed, onMounted, ref } from 'vue'
const data = ref<Record<string, any> | null>(null)
const error = ref<Record<string, any> | null>(null)
const keycloakStore = useKeycloakStore()
const isAuthenticated = computed(() => keycloakStore.authenticated)
const items = ref<any[]>([])
const expandedRows = ref<any[]>([])
const loading = ref(false)
const error = ref('')
const searchQuery = ref('')
onMounted(() => {
keycloakStore.refresh()
})
watchEffect(async () => {
if (!keycloakStore.accessToken)
return// Hindari fetch jika token masih kosong
console.log('Fetching data dengan token baru...')
const { data: newData, error: newError } = await useAuthFetch('https://api.ui.ac.id/my/ac/st')
data.value = newData.value || null
error.value = newError.value || null
})
const keycloakStore = useKeycloakStore()
// Headers
const riwayatHeaders = [
{ title: '', key: 'data-table-expand', sortable: false },
{ title: 'Tahun', key: 'THN', sortable: false },
......@@ -45,41 +31,48 @@ const expandedHeaders = [
{ title: 'Pengajar', key: 'PENGAJAR', sortable: false },
]
function resolveStatusColor(status: string) {
switch (status) {
case 'Aktif': return 'primary'
case 'Lulus': return 'success'
default: return 'default'
}
}
async function getData() {
loading.value = true
error.value = ''
// Reset items sebelum fetch data baru
items.value = []
try {
const apiEndpoint = 'https://api.ui.ac.id/my/ac'
const response = await fetch(apiEndpoint, {
const res = await fetch('https://api.ui.ac.id/my/ac', {
headers: {
Authorization: `Bearer ${keycloakStore.accessToken}`,
},
})
if (!response.ok)
if (!res.ok)
throw new Error('Gagal mengambil data')
const dataku = await response.json()
const raw = await res.json()
items.value = dataku.map((item: any) => ({
...item,
expanded: Object.values(item.IRS || {}).map((mk: any) => ({
items.value = raw.map((item: any, idx: number) => {
const expandedFiltered = Object.values(item.IRS || {})
.filter((mk: any) => mk.STATUS !== 'Kosong') // Remove STATUS: Kosong
.map((mk: any) => ({
KD_MK: mk.KD_MK,
NM_MK: mk.NM_MK,
NM_KLS_MK: mk.NM_KLS_MK,
STATUS: mk.STATUS,
NILAI_HURUF: mk.NILAI_HURUF,
PENGAJAR: mk.PENGAJAR ? Object.values(mk.PENGAJAR).join(', ') : '-',
})),
}))
// Pastikan data adalah array sebelum dimasukkan ke items
// items.value = Array.isArray(dataku) ? dataku : [];
// items.value = dataku;
return {
id: `${item.THN}-${item.SEMESTER}-${item.TERM}-${idx}`,
...item,
expanded: expandedFiltered,
}
}).filter(item => item.expanded.length > 0) // Optionally remove parent rows with no expanded items
}
catch (err: any) {
error.value = err.message || 'Terjadi kesalahan saat mengambil data'
......@@ -89,74 +82,50 @@ async function getData() {
}
}
// Fetch data from API
onMounted(() => {
getData()
})
// Search query state
const searchQuery = ref('')
// Filter aplikasi berdasarkan pencarian
const filteredRiwayat = computed(() => {
if (!searchQuery.value)
return items.value
const query = searchQuery.value.toLowerCase()
return items.value
.map(item => {
// Filter mata kuliah (MK) berdasarkan NM_MK
const filteredMK = item.expanded.filter((mk: any) =>
mk.NM_MK.toLowerCase().includes(query),
)
// Jika ada MK yang cocok, tetap tampilkan item tetapi hanya dengan MK yang cocok
if (filteredMK.length > 0)
return { ...item, expanded: filteredMK }
// Jika tidak ada MK yang cocok, cek apakah tahun, semester, atau status cocok
if (
[item.THN, item.SEMESTER, item.STATUS].some(field =>
const matchParent = [item.THN, item.SEMESTER, item.STATUS].some(field =>
String(field).toLowerCase().includes(query),
)
)
return item
return null // Tidak cocok, hapus dari hasil pencarian
})
.filter(Boolean) // Hapus item yang null
if (filteredMK.length > 0 || matchParent) {
return {
...structuredClone(item),
expanded: filteredMK.length > 0 ? filteredMK : item.expanded,
}
}
// return items.value.filter((item) =>
// [item.THN, item.SEMESTER, item.STATUS].some((field) =>
// String(field).toLowerCase().includes(query)
// )
// );
return null
})
.filter(Boolean)
})
const resolveStatusColor = (status: string) => {
if (status === 'Aktif')
return 'primary'
if (status === 'Lulus')
return 'success'
}
</script>
<template>
<!--
<div class="mb-10">
<h1>Welcome, {{ keycloakStore.name }}</h1>
</div>
-->
<VCard
title="Riwayat Mata Kuliah"
class="riwayatList"
>
<!-- Search Input -->
<!-- Search -->
<div class="search-container mb-4 pl-2 pr-2">
<VTextField
v-model="searchQuery"
label="Search"
placeholder="Search ..."
label="Cari Mata Kuliah atau Semester"
placeholder="Contoh: Pancasila, 2023"
append-inner-icon="ri-search-line"
clearable
single-line
......@@ -166,78 +135,62 @@ const resolveStatusColor = (status: string) => {
/>
</div>
<!-- SECTION Datatable -->
<!-- Data Table -->
<VDataTable
v-model:expanded="expandedRows"
:headers="riwayatHeaders"
:items="filteredRiwayat"
item-value="id"
hide-default-footer
fixed-header
item-value="SEMESTER"
show-expand
:sort-by="['SEMESTER']"
:sort-asc="[true]"
show-expand
>
<template #expanded-row="{ item }">
<tr>
<td colspan="6">
<td colspan="100%">
<VDataTable
density="compact"
:headers="expandedHeaders"
:items="item.expanded"
density="compact"
class="ml-4"
hide-default-footer
/>
</td>
</tr>
</template>
<!-- Tahun Ajar -->
<template #item.THN="{ item }">
<div class="d-flex align-center gap-x-3">
<div>
<h6 class="text-h6 text-no-wrap">
{{ `${Number(item.THN)}/${Number(item.THN) + 1}` }}
<h6 class="text-h6">
{{ `${item.THN}/${Number(item.THN) + 1}` }}
</h6>
</div>
</div>
</template>
<!-- Status -->
<template #item.STATUS="{ item }">
<VChip
:color="resolveStatusColor(item.STATUS)"
:class="`text-${resolveStatusColor(item.STATUS)}`"
size="small"
class="font-weight-medium"
size="small"
>
{{ item.STATUS }}
</VChip>
</template>
<!-- TODO Refactor this after vuetify provides proper solution for removing default footer -->
<template #bottom />
</VDataTable>
<!-- !SECTION -->
</VCard>
</template>
<style lang="scss">
<style scoped lang="scss">
.riwayatList {
.v-table {
&--density-default {
.v-table__wrapper {
table {
tbody {
tr {
td {
table tbody tr td {
block-size: 56px;
}
}
}
}
}
}
}
}
.search-container {
......
......@@ -2,7 +2,6 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useKeycloakStore } from '@/@core/stores/keycloakStore'
import UserBerita from '@/components/beranda/UserBerita.vue'
import UserJadwal from '@/components/beranda/UserJadwal.vue'
import UserLib from '@/components/beranda/UserLib.vue'
import UserLog from '@/components/beranda/UserLog.vue'
......@@ -35,6 +34,7 @@ const activeTab = computed({
const tabs = [
{ title: 'Profil', icon: 'ri-user-line', tab: 'profile' },
{ title: 'Keamanan', icon: 'ri-lock-line', tab: 'keamanan' },
// { title: 'Berita', icon: 'ri-news-line', tab: 'berita' },
{ title: 'Riwayat', icon: 'ri-file-history-line', tab: 'riwayat' },
{ title: 'Jadwal', icon: 'ri-calendar-line', tab: 'jadwal' },
......@@ -46,14 +46,23 @@ const tabs = [
]
// Filter tab berdasarkan civitas
const filteredTabs = computed(() =>
tabs.filter(tab => {
// Jangan sembunyikan "riwayat", hanya "log-absen" untuk mahasiswa
if (tab.tab === 'log' && keycloakStore.civitas === 'mahasiswa') return false;
return true;
})
);
const tabsByCivitas: Record<string, string[]> = {
staf: ['profile', 'keamanan', 'log', 'peta', 'lib'],
dosen: ['profile', 'keamanan', 'jadwal', 'log', 'peta', 'lib'],
mahasiswa: ['profile', 'keamanan', 'riwayat', 'jadwal', 'peta', 'lib'],
}
// Compute allowed tabs for the current user
const allowedTabs = computed(() => {
const civitas = keycloakStore.civitas
return tabsByCivitas[civitas] ?? []
})
// Filter tabs based on the allowlist
const filteredTabs = computed(() =>
tabs.filter(tab => allowedTabs.value.includes(tab.tab)),
)
// Fungsi untuk navigasi tab
const navigateTab = (tab: string) => {
......@@ -103,9 +112,12 @@ const navigateTab = (tab: string) => {
</VWindowItem>
<!-- Berita -->
<!-- <VWindowItem value="berita">
<!--
<VWindowItem value="berita">
<UserBerita />
</VWindowItem> -->
</VWindowItem>
-->
<!-- <div> -->
<!-- Riwayat -->
<VWindowItem value="riwayat">
......
<script lang="ts" setup>
import { useKeycloakStore } from "@core/stores/keycloakStore";
import { useKeycloakStore } from '@core/stores/keycloakStore'
import { computed, onMounted, ref } from 'vue'
const keycloakStore = useKeycloakStore();
// Gunakan computed agar selalu reaktif
const isAuthenticated = computed(() => keycloakStore.authenticated);
// const isAuthenticated = keycloakStore.authenticated;
const keycloakStore = useKeycloakStore()
const isAuthenticated = computed(() => keycloakStore.authenticated)
// State management
const items = ref<any[]>([]);
const expandedRows = ref<any[]>([]);
const loading = ref(false);
const error = ref('');
const items = ref<any[]>([])
const expandedRows = ref<any[]>([])
const loading = ref(false)
const error = ref('')
const searchQuery = ref('')
// Project Riwayat Header
// Headers
const riwayatHeaders = [
{ title: "", key: "data-table-expand", sortable: false },
{ title: '', key: 'data-table-expand', sortable: false },
{ title: 'Tahun', key: 'THN', sortable: false },
{ title: 'Term', key: 'TERM', sortable: false },
{ title: 'Semester', key: 'SEMESTER', sortable: true },
......@@ -24,171 +23,174 @@ const riwayatHeaders = [
]
const expandedHeaders = [
{ title: "Kode MK", key: "KD_MK", sortable: true },
{ title: "Nama MK", key: "NM_MK", sortable: true },
{ title: "Kelas", key: "NM_KLS_MK", sortable: false },
{ title: "Status", key: "STATUS", sortable: false },
{ title: "Nilai", key: "NILAI_HURUF", sortable: true },
{ title: "Pengajar", key: "PENGAJAR", sortable: false },
];
{ title: 'Kode MK', key: 'KD_MK', sortable: true },
{ title: 'Nama MK', key: 'NM_MK', sortable: true },
{ title: 'Kelas', key: 'NM_KLS_MK', sortable: false },
{ title: 'Status', key: 'STATUS', sortable: false },
{ title: 'Nilai', key: 'NILAI_HURUF', sortable: true },
{ title: 'Pengajar', key: 'PENGAJAR', sortable: false },
]
async function getData() {
loading.value = true;
error.value = '';
function resolveStatusColor(status: string) {
switch (status) {
case 'Aktif': return 'primary'
case 'Lulus': return 'success'
default: return 'default'
}
}
// Reset items sebelum fetch data baru
items.value = [];
async function getData() {
loading.value = true
error.value = ''
items.value = []
try {
const apiEndpoint = `https://api.ui.ac.id/my/ac`;
const response = await fetch(apiEndpoint, {
const res = await fetch('https://api.ui.ac.id/my/ac', {
headers: {
Authorization: `Bearer ${keycloakStore.accessToken}`,
},
});
})
if (!response.ok) throw new Error('Gagal mengambil data');
const dataku = await response.json();
if (!res.ok)
throw new Error('Gagal mengambil data')
const raw = await res.json()
items.value = dataku.map((item: any) => ({
...item,
expanded: Object.values(item.IRS || {}).map((mk: any) => ({
items.value = raw.map((item: any, idx: number) => {
const expandedFiltered = Object.values(item.IRS || {})
.filter((mk: any) => mk.STATUS !== 'Kosong') // Remove STATUS: Kosong
.map((mk: any) => ({
KD_MK: mk.KD_MK,
NM_MK: mk.NM_MK,
NM_KLS_MK: mk.NM_KLS_MK,
STATUS: mk.STATUS,
NILAI_HURUF: mk.NILAI_HURUF,
PENGAJAR: mk.PENGAJAR ? Object.values(mk.PENGAJAR).join(", ") : "-",
})),
}));
// Pastikan data adalah array sebelum dimasukkan ke items
// items.value = Array.isArray(dataku) ? dataku : [];
// items.value = dataku;
} catch (err: any) {
error.value = err.message || 'Terjadi kesalahan saat mengambil data';
} finally {
loading.value = false;
PENGAJAR: mk.PENGAJAR ? Object.values(mk.PENGAJAR).join(', ') : '-',
}))
return {
id: `${item.THN}-${item.SEMESTER}-${item.TERM}-${idx}`,
...item,
expanded: expandedFiltered,
}
}).filter(item => item.expanded.length > 0) // Optionally remove parent rows with no expanded items
}
catch (err: any) {
error.value = err.message || 'Terjadi kesalahan saat mengambil data'
}
finally {
loading.value = false
}
}
// Fetch data from API
onMounted(() => {
getData();
});
// Search query state
const searchQuery = ref('');
getData()
})
// Filter aplikasi berdasarkan pencarian
const filteredRiwayat = computed(() => {
if (!searchQuery.value) return items.value;
const query = searchQuery.value.toLowerCase();
if (!searchQuery.value)
return items.value
.map((item) => {
// Filter mata kuliah (MK) berdasarkan NM_MK
const filteredMK = item.expanded.filter((mk: any) =>
mk.NM_MK.toLowerCase().includes(query)
);
// Jika ada MK yang cocok, tetap tampilkan item tetapi hanya dengan MK yang cocok
if (filteredMK.length > 0) {
return { ...item, expanded: filteredMK };
}
const query = searchQuery.value.toLowerCase()
// Jika tidak ada MK yang cocok, cek apakah tahun, semester, atau status cocok
if (
[item.THN, item.SEMESTER, item.STATUS].some((field) =>
String(field).toLowerCase().includes(query)
return items.value
.map(item => {
const filteredMK = item.expanded.filter((mk: any) =>
mk.NM_MK.toLowerCase().includes(query),
)
const matchParent = [item.THN, item.SEMESTER, item.STATUS].some(field =>
String(field).toLowerCase().includes(query),
)
) {
return item;
if (filteredMK.length > 0 || matchParent) {
return {
...structuredClone(item),
expanded: filteredMK.length > 0 ? filteredMK : item.expanded,
}
}
return null; // Tidak cocok, hapus dari hasil pencarian
return null
})
.filter(Boolean); // Hapus item yang null
// return items.value.filter((item) =>
// [item.THN, item.SEMESTER, item.STATUS].some((field) =>
// String(field).toLowerCase().includes(query)
// )
// );
});
const resolveStatusColor = (status: string) => {
if (status === 'Aktif')
return 'primary'
if (status === 'Lulus')
return 'success'
}
.filter(Boolean)
})
</script>
<template>
<VCard title="Riwayat Mata Kuliah" class="riwayatList">
<!-- Search Input -->
<VCard
title="Riwayat Mata Kuliah"
class="riwayatList"
>
<!-- Search -->
<div class="search-container mb-4 pl-2 pr-2">
<VTextField v-model="searchQuery" label="Search" placeholder="Search ..." append-inner-icon="ri-search-line"
clearable single-line hide-details dense outlined />
<VTextField
v-model="searchQuery"
label="Cari Mata Kuliah atau Semester"
placeholder="Contoh: Pancasila, 2023"
append-inner-icon="ri-search-line"
clearable
single-line
hide-details
dense
outlined
/>
</div>
<!-- SECTION Datatable -->
<VDataTable :headers="riwayatHeaders" :items="filteredRiwayat" hide-default-footer fixed-header
item-value="SEMESTER" :sort-by="['SEMESTER']" :sort-asc="[true]" v-model:expanded="expandedRows" show-expand>
<template v-slot:expanded-row="{ item }">
<!-- Data Table -->
<VDataTable
v-model:expanded="expandedRows"
:headers="riwayatHeaders"
:items="filteredRiwayat"
item-value="id"
hide-default-footer
fixed-header
show-expand
:sort-by="['SEMESTER']"
:sort-asc="[true]"
>
<template #expanded-row="{ item }">
<tr>
<td colspan="6">
<VDataTable density="compact" :headers="expandedHeaders" :items="item.expanded" class="ml-4" />
<td colspan="100%">
<VDataTable
:headers="expandedHeaders"
:items="item.expanded"
density="compact"
class="ml-4"
hide-default-footer
/>
</td>
</tr>
</template>
<!-- Tahun Ajar -->
<template #item.THN="{ item }">
<div class="d-flex align-center gap-x-3">
<div>
<h6 class="text-h6 text-no-wrap">
{{ Number(item.THN) + '/' + (Number(item.THN) + 1) }}
<h6 class="text-h6">
{{ `${item.THN}/${Number(item.THN) + 1}` }}
</h6>
</div>
</div>
</template>
<!-- Status -->
<template #item.STATUS="{ item }">
<VChip :color="resolveStatusColor(item.STATUS)" :class="`text-${resolveStatusColor(item.STATUS)}`" size="small"
class="font-weight-medium">
<VChip
:color="resolveStatusColor(item.STATUS)"
class="font-weight-medium"
size="small"
>
{{ item.STATUS }}
</VChip>
</template>
<!-- TODO Refactor this after vuetify provides proper solution for removing default footer -->
<template #bottom />
</VDataTable>
<!-- !SECTION -->
</VCard>
</template>
<style lang="scss">
<style scoped lang="scss">
.riwayatList {
.v-table {
&--density-default {
.v-table__wrapper {
table {
tbody {
tr {
td {
table tbody tr td {
block-size: 56px;
}
}
}
}
}
}
}
}
.search-container {
......
......@@ -11,6 +11,12 @@ function login() {
})
}
function loginGoogle() {
keycloakInstance.login({
redirectUri: `${window.location.origin}/profile`, idpHint: 'google',
})
}
// Cek apakah user sudah login saat komponen dimuat
onMounted(() => {
if (keycloakInstance.authenticated)
......@@ -28,4 +34,15 @@ onMounted(() => {
>
Login SSO
</VBtn>
<VBtn
class="font-weight-bold"
color="#FFDC01"
block
type="submit"
style="margin-block-start: 10px;"
@click="loginGoogle"
>
Login Google
</VBtn>
</template>
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!