Commit 7067e9e7 by Samuel Taniel Mulyadi

Merge branch 'sam' into 'master'

Sam

See merge request !17
2 parents 28ea9917 8b75d2d6
...@@ -75,7 +75,7 @@ ...@@ -75,7 +75,7 @@
min-block-size: 100vh; /* Ensures the element covers the full viewport height */ min-block-size: 100vh; /* Ensures the element covers the full viewport height */
padding-block-start: 10px; padding-block-start: 10px;
// margin: 5% 5%;marginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmargin // margin: 5% 5%;marginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmargin
} }
.main-component-margin { .main-component-margin {
...@@ -115,9 +115,9 @@ body { ...@@ -115,9 +115,9 @@ body {
.form-component-login { .form-component-login {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: block; display: flex; /* changed from block to flex */
overflow: hidden; overflow: hidden;
flex-flow: row; flex-direction: column; /* or row depending on layout */
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 1.7rem; border-radius: 1.7rem;
...@@ -128,6 +128,7 @@ body { ...@@ -128,6 +128,7 @@ body {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-rows: auto; grid-template-rows: auto;
margin-inline: 20% 0%; margin-inline: 20% 0%;
min-block-size: 300px;
min-inline-size: 345px; min-inline-size: 345px;
outline: 0.5px solid #747474; outline: 0.5px solid #747474;
outline-offset: 0; outline-offset: 0;
...@@ -169,11 +170,13 @@ body { ...@@ -169,11 +170,13 @@ body {
font-family: "Open Sans", sans-serif; font-family: "Open Sans", sans-serif;
font-size: 14px; font-size: 14px;
grid-gap: 0.7rem; grid-gap: 0.7rem;
inline-size: 100%;
margin-block: 1%; margin-block: 1%;
margin-inline: 20px; margin-inline: 20px;
object-fit: fill; object-fit: fill;
padding-block: 10%; padding-block: 10%;
padding-inline: 5%; padding-inline: 5%;
padding-inline: 35px;
} }
.heading-8 { .heading-8 {
......
...@@ -243,7 +243,7 @@ function getColorClass(index: number) { ...@@ -243,7 +243,7 @@ function getColorClass(index: number) {
v-else v-else
class="timetable" class="timetable"
> >
<div class="scrollable"> <div class="scrollable2">
<table class="w-100 text-left table-schedule fixed-table"> <table class="w-100 text-left table-schedule fixed-table">
<thead> <thead>
<tr> <tr>
...@@ -279,16 +279,16 @@ function getColorClass(index: number) { ...@@ -279,16 +279,16 @@ function getColorClass(index: number) {
getScheduleByDay(day)[rowIndex - 1].end getScheduleByDay(day)[rowIndex - 1].end
}} }}
</span> </span>
<span class="room text-xs">{{ getScheduleByDay(day)[rowIndex - 1].room }}</span> <span class="text-xs">{{ getScheduleByDay(day)[rowIndex - 1].room }}</span>
</div> </div>
<div class="course-title font-semibold text-sm mb-1"> <div class="course-title2 font-semibold text-sm mb-1">
<span <span
v-if="getScheduleByDay(day)[rowIndex - 1].course.includes('-')" v-if="getScheduleByDay(day)[rowIndex - 1].course.includes('-')"
style=" padding-inline-end: 4px;text-decoration: underline;" style=" padding-inline-end: 4px;text-decoration: underline;"
> >
{{ getScheduleByDay(day)[rowIndex - 1].course.split(' - ')[0] }} {{ getScheduleByDay(day)[rowIndex - 1].course.split(' - ')[0] }}
</span> </span>
<span class="text-sm"> <span class="course-name">
{{ {{
getScheduleByDay(day)[rowIndex - 1].course.includes('-') getScheduleByDay(day)[rowIndex - 1].course.includes('-')
? getScheduleByDay(day)[rowIndex - 1].course.split(' - ')[1] ? getScheduleByDay(day)[rowIndex - 1].course.split(' - ')[1]
...@@ -322,6 +322,18 @@ function getColorClass(index: number) { ...@@ -322,6 +322,18 @@ function getColorClass(index: number) {
min-inline-size: 500px; 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-box,
.date-range-box2 { .date-range-box2 {
border-radius: 5px; border-radius: 5px;
...@@ -385,21 +397,64 @@ function getColorClass(index: number) { ...@@ -385,21 +397,64 @@ function getColorClass(index: number) {
} }
.course-title { .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; border-block-end: 1px solid;
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: bold;
gap: 4px; /* Optional: space between prefix and name */
inline-size: 100%; inline-size: 100%;
margin-block-end: 5px; padding-block-end: 4px;
padding-block-end: 5px;
text-overflow: ellipsis; /* Show "..." */
} }
.course-prefix { .course-prefix {
color: color-mix(in srgb, rgba(var(--v-global-theme-primary)) 70%, black 30%); color: color-mix(in srgb, rgba(var(--v-global-theme-primary)) 70%, black 30%);
padding-inline-end: 4px;
text-decoration: underline; 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 { .grid-body {
display: grid; display: grid;
grid-template-columns: 80px auto; /* Time column + Schedule grid */ grid-template-columns: 80px auto; /* Time column + Schedule grid */
...@@ -451,31 +506,13 @@ function getColorClass(index: number) { ...@@ -451,31 +506,13 @@ function getColorClass(index: number) {
grid-template-rows: repeat(840, 1px); /* 1px per minute */ grid-template-rows: repeat(840, 1px); /* 1px per minute */
} }
.course-header { .building {
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; font-weight: normal;
text-align: end; opacity: 0.8;
text-overflow: ellipsis; text-align: start; /* Aligns room to the right */
white-space: nowrap;
} }
.building { .building2 {
font-weight: normal; font-weight: normal;
opacity: 0.8; opacity: 0.8;
text-align: start; /* Aligns room to the right */ text-align: start; /* Aligns room to the right */
......
<script setup lang="ts"> <script lang="ts" setup>
import { onMounted } from 'vue' import { useKeycloakStore } from '@core/stores/keycloakStore'
import { useKeycloakStore } from '@/@core/stores/keycloakStore' import { computed, onMounted, ref } from 'vue'
const data = ref<Record<string, any> | null>(null) const keycloakStore = useKeycloakStore()
const error = ref<Record<string, any> | null>(null) const isAuthenticated = computed(() => keycloakStore.authenticated)
const items = ref<any[]>([]) const items = ref<any[]>([])
const expandedRows = ref<any[]>([]) const expandedRows = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
const error = ref('')
const searchQuery = ref('')
onMounted(() => { // Headers
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()
const riwayatHeaders = [ const riwayatHeaders = [
{ title: '', key: 'data-table-expand', sortable: false }, { title: '', key: 'data-table-expand', sortable: false },
{ title: 'Tahun', key: 'THN', sortable: false }, { title: 'Tahun', key: 'THN', sortable: false },
...@@ -45,41 +31,48 @@ const expandedHeaders = [ ...@@ -45,41 +31,48 @@ const expandedHeaders = [
{ title: 'Pengajar', key: 'PENGAJAR', sortable: false }, { 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() { async function getData() {
loading.value = true loading.value = true
error.value = '' error.value = ''
// Reset items sebelum fetch data baru
items.value = [] items.value = []
try { try {
const apiEndpoint = 'https://api.ui.ac.id/my/ac' const res = await fetch('https://api.ui.ac.id/my/ac', {
const response = await fetch(apiEndpoint, {
headers: { headers: {
Authorization: `Bearer ${keycloakStore.accessToken}`, Authorization: `Bearer ${keycloakStore.accessToken}`,
}, },
}) })
if (!response.ok) if (!res.ok)
throw new Error('Gagal mengambil data') throw new Error('Gagal mengambil data')
const dataku = await response.json() const raw = await res.json()
items.value = dataku.map((item: any) => ({ items.value = raw.map((item: any, idx: number) => {
...item, const expandedFiltered = Object.values(item.IRS || {})
expanded: Object.values(item.IRS || {}).map((mk: any) => ({ .filter((mk: any) => mk.STATUS !== 'Kosong') // Remove STATUS: Kosong
.map((mk: any) => ({
KD_MK: mk.KD_MK, KD_MK: mk.KD_MK,
NM_MK: mk.NM_MK, NM_MK: mk.NM_MK,
NM_KLS_MK: mk.NM_KLS_MK, NM_KLS_MK: mk.NM_KLS_MK,
STATUS: mk.STATUS, STATUS: mk.STATUS,
NILAI_HURUF: mk.NILAI_HURUF, NILAI_HURUF: mk.NILAI_HURUF,
PENGAJAR: mk.PENGAJAR ? Object.values(mk.PENGAJAR).join(', ') : '-', PENGAJAR: mk.PENGAJAR ? Object.values(mk.PENGAJAR).join(', ') : '-',
})),
})) }))
// Pastikan data adalah array sebelum dimasukkan ke items return {
// items.value = Array.isArray(dataku) ? dataku : []; id: `${item.THN}-${item.SEMESTER}-${item.TERM}-${idx}`,
// items.value = dataku; ...item,
expanded: expandedFiltered,
}
}).filter(item => item.expanded.length > 0) // Optionally remove parent rows with no expanded items
} }
catch (err: any) { catch (err: any) {
error.value = err.message || 'Terjadi kesalahan saat mengambil data' error.value = err.message || 'Terjadi kesalahan saat mengambil data'
...@@ -89,74 +82,50 @@ async function getData() { ...@@ -89,74 +82,50 @@ async function getData() {
} }
} }
// Fetch data from API
onMounted(() => { onMounted(() => {
getData() getData()
}) })
// Search query state
const searchQuery = ref('')
// Filter aplikasi berdasarkan pencarian
const filteredRiwayat = computed(() => { const filteredRiwayat = computed(() => {
if (!searchQuery.value) if (!searchQuery.value)
return items.value return items.value
const query = searchQuery.value.toLowerCase() const query = searchQuery.value.toLowerCase()
return items.value return items.value
.map(item => { .map(item => {
// Filter mata kuliah (MK) berdasarkan NM_MK
const filteredMK = item.expanded.filter((mk: any) => const filteredMK = item.expanded.filter((mk: any) =>
mk.NM_MK.toLowerCase().includes(query), mk.NM_MK.toLowerCase().includes(query),
) )
// Jika ada MK yang cocok, tetap tampilkan item tetapi hanya dengan MK yang cocok const matchParent = [item.THN, item.SEMESTER, item.STATUS].some(field =>
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 =>
String(field).toLowerCase().includes(query), String(field).toLowerCase().includes(query),
) )
)
return item
return null // Tidak cocok, hapus dari hasil pencarian if (filteredMK.length > 0 || matchParent) {
}) return {
.filter(Boolean) // Hapus item yang null ...structuredClone(item),
expanded: filteredMK.length > 0 ? filteredMK : item.expanded,
}
}
// return items.value.filter((item) => return null
// [item.THN, item.SEMESTER, item.STATUS].some((field) => })
// String(field).toLowerCase().includes(query) .filter(Boolean)
// )
// );
}) })
const resolveStatusColor = (status: string) => {
if (status === 'Aktif')
return 'primary'
if (status === 'Lulus')
return 'success'
}
</script> </script>
<template> <template>
<!--
<div class="mb-10">
<h1>Welcome, {{ keycloakStore.name }}</h1>
</div>
-->
<VCard <VCard
title="Riwayat Mata Kuliah" title="Riwayat Mata Kuliah"
class="riwayatList" class="riwayatList"
> >
<!-- Search Input --> <!-- Search -->
<div class="search-container mb-4 pl-2 pr-2"> <div class="search-container mb-4 pl-2 pr-2">
<VTextField <VTextField
v-model="searchQuery" v-model="searchQuery"
label="Search" label="Cari Mata Kuliah atau Semester"
placeholder="Search ..." placeholder="Contoh: Pancasila, 2023"
append-inner-icon="ri-search-line" append-inner-icon="ri-search-line"
clearable clearable
single-line single-line
...@@ -166,78 +135,62 @@ const resolveStatusColor = (status: string) => { ...@@ -166,78 +135,62 @@ const resolveStatusColor = (status: string) => {
/> />
</div> </div>
<!-- SECTION Datatable --> <!-- Data Table -->
<VDataTable <VDataTable
v-model:expanded="expandedRows" v-model:expanded="expandedRows"
:headers="riwayatHeaders" :headers="riwayatHeaders"
:items="filteredRiwayat" :items="filteredRiwayat"
item-value="id"
hide-default-footer hide-default-footer
fixed-header fixed-header
item-value="SEMESTER" show-expand
:sort-by="['SEMESTER']" :sort-by="['SEMESTER']"
:sort-asc="[true]" :sort-asc="[true]"
show-expand
> >
<template #expanded-row="{ item }"> <template #expanded-row="{ item }">
<tr> <tr>
<td colspan="6"> <td colspan="100%">
<VDataTable <VDataTable
density="compact"
:headers="expandedHeaders" :headers="expandedHeaders"
:items="item.expanded" :items="item.expanded"
density="compact"
class="ml-4" class="ml-4"
hide-default-footer
/> />
</td> </td>
</tr> </tr>
</template> </template>
<!-- Tahun Ajar -->
<template #item.THN="{ item }"> <template #item.THN="{ item }">
<div class="d-flex align-center gap-x-3">
<div> <div>
<h6 class="text-h6 text-no-wrap"> <h6 class="text-h6">
{{ `${Number(item.THN)}/${Number(item.THN) + 1}` }} {{ `${item.THN}/${Number(item.THN) + 1}` }}
</h6> </h6>
</div> </div>
</div>
</template> </template>
<!-- Status -->
<template #item.STATUS="{ item }"> <template #item.STATUS="{ item }">
<VChip <VChip
:color="resolveStatusColor(item.STATUS)" :color="resolveStatusColor(item.STATUS)"
:class="`text-${resolveStatusColor(item.STATUS)}`"
size="small"
class="font-weight-medium" class="font-weight-medium"
size="small"
> >
{{ item.STATUS }} {{ item.STATUS }}
</VChip> </VChip>
</template> </template>
<!-- TODO Refactor this after vuetify provides proper solution for removing default footer -->
<template #bottom /> <template #bottom />
</VDataTable> </VDataTable>
<!-- !SECTION -->
</VCard> </VCard>
</template> </template>
<style lang="scss"> <style scoped lang="scss">
.riwayatList { .riwayatList {
.v-table {
&--density-default {
.v-table__wrapper { .v-table__wrapper {
table { table tbody tr td {
tbody {
tr {
td {
block-size: 56px; block-size: 56px;
} }
} }
}
}
}
}
}
} }
.search-container { .search-container {
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useKeycloakStore } from '@/@core/stores/keycloakStore' import { useKeycloakStore } from '@/@core/stores/keycloakStore'
import UserBerita from '@/components/beranda/UserBerita.vue'
import UserJadwal from '@/components/beranda/UserJadwal.vue' import UserJadwal from '@/components/beranda/UserJadwal.vue'
import UserLib from '@/components/beranda/UserLib.vue' import UserLib from '@/components/beranda/UserLib.vue'
import UserLog from '@/components/beranda/UserLog.vue' import UserLog from '@/components/beranda/UserLog.vue'
...@@ -35,6 +34,7 @@ const activeTab = computed({ ...@@ -35,6 +34,7 @@ const activeTab = computed({
const tabs = [ const tabs = [
{ title: 'Profil', icon: 'ri-user-line', tab: 'profile' }, { title: 'Profil', icon: 'ri-user-line', tab: 'profile' },
{ title: 'Keamanan', icon: 'ri-lock-line', tab: 'keamanan' }, { title: 'Keamanan', icon: 'ri-lock-line', tab: 'keamanan' },
// { title: 'Berita', icon: 'ri-news-line', tab: 'berita' }, // { title: 'Berita', icon: 'ri-news-line', tab: 'berita' },
{ title: 'Riwayat', icon: 'ri-file-history-line', tab: 'riwayat' }, { title: 'Riwayat', icon: 'ri-file-history-line', tab: 'riwayat' },
{ title: 'Jadwal', icon: 'ri-calendar-line', tab: 'jadwal' }, { title: 'Jadwal', icon: 'ri-calendar-line', tab: 'jadwal' },
...@@ -46,14 +46,23 @@ const tabs = [ ...@@ -46,14 +46,23 @@ const tabs = [
] ]
// Filter tab berdasarkan civitas // Filter tab berdasarkan civitas
const filteredTabs = computed(() => const tabsByCivitas: Record<string, string[]> = {
tabs.filter(tab => { staf: ['profile', 'keamanan', 'log', 'peta', 'lib'],
// Jangan sembunyikan "riwayat", hanya "log-absen" untuk mahasiswa dosen: ['profile', 'keamanan', 'jadwal', 'log', 'peta', 'lib'],
if (tab.tab === 'log' && keycloakStore.civitas === 'mahasiswa') return false; mahasiswa: ['profile', 'keamanan', 'riwayat', 'jadwal', 'peta', 'lib'],
return true; }
})
); // 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 // Fungsi untuk navigasi tab
const navigateTab = (tab: string) => { const navigateTab = (tab: string) => {
...@@ -103,9 +112,12 @@ const navigateTab = (tab: string) => { ...@@ -103,9 +112,12 @@ const navigateTab = (tab: string) => {
</VWindowItem> </VWindowItem>
<!-- Berita --> <!-- Berita -->
<!-- <VWindowItem value="berita"> <!--
<VWindowItem value="berita">
<UserBerita /> <UserBerita />
</VWindowItem> --> </VWindowItem>
-->
<!-- <div> -->
<!-- Riwayat --> <!-- Riwayat -->
<VWindowItem value="riwayat"> <VWindowItem value="riwayat">
......
<script setup lang="ts">
import { useKeycloakStore } from '@/@core/stores/keycloakStore'
import AuthProvider from '@/views/pages/authentication/AuthProvider.vue'
import authLoginBg from '/assets/images/naput/bg-blue-yellow.png'
import authLoginLogo from '/assets/images/naput/illust-login.png'
const authThemeImg = authLoginLogo
const authThemeBg = authLoginBg
const keycloakStore = useKeycloakStore()
definePageMeta({
layout: 'blank',
unauthenticatedOnly: true,
})
</script>
<template>
<body
:style="{ backgroundImage: `url(${authThemeBg})` }"
class="body-2"
>
<section class="global-main-login-component">
<div class="card-main-login-component4">
<div class="logo-login-component4">
<VImg
:src="authThemeImg"
max-width="650"
max-height="300"
class="image-10 lazyload"
/>
</div>
<div class="form-login-component">
<VCol cols="12">
<img
src="/assets/images/naput/logo-ui-hitam.png"
width="100"
height="auto"
class="mb-0"
>
</VCol>
<VCol cols="12">
<AuthProvider />
</VCol>
</div>
</div>
</section>
</body>
<!-- </VRow> -->
</template>
<style lang="scss">
@use "@core/scss/template/pages/page-auth";
</style>
<script setup lang="ts"> <script setup lang="ts">
import { useKeycloakStore } from '@/@core/stores/keycloakStore' import ImgDark from '@images/dstipro/auth-bg-dark.png'
import ImgLight from '@images/dstipro/auth-bg-light.png'
import authLoginBg from '/assets/images/naput/bg-blue-yellow.png' import authV2LoginLogoLight from '@images/dstipro/ui-logo-light.png'
import authLoginLogo from '/assets/images/naput/illust-login.png' import authV2LoginBgDark from '@images/naput/header-bg-dark.png'
import authV2LoginBgLight from '@images/naput/header-bg.png'
import AuthProvider from '@/views/pages/authentication/AuthProvider.vue' import AuthProvider from '@/views/pages/authentication/AuthProvider.vue'
const authThemeImg = authLoginLogo const authThemeImg = useGenerateImageVariant(
authV2LoginLogoLight,
authV2LoginLogoLight,
)
const authThemeBg = authLoginBg const authThemeColor = useGenerateColorVariant(
'#FFFFFF',
'#303030',
)
const keycloakStore = useKeycloakStore() const authThemeBgElement = useGenerateImageVariant(
ImgLight,
ImgDark,
)
const authThemeBg = useGenerateImageVariant(
authV2LoginBgLight,
authV2LoginBgDark,
)
definePageMeta({ definePageMeta({
layout: 'blank', layout: 'blank',
...@@ -19,38 +35,61 @@ definePageMeta({ ...@@ -19,38 +35,61 @@ definePageMeta({
</script> </script>
<template> <template>
<VRow
no-gutters
class="auth-wrapper d-flex align-center"
>
<body <body
:style="{ backgroundImage: `url(${authThemeBg})` }" :style="{ backgroundImage: `url(${authThemeBg})` }"
class="body-2" class="body-4"
> >
<section class="global-main-login-component"> <div class="main-component-margin">
<div class="card-main-login-component4"> <section class="main-component-login">
<div class="logo-login-component4"> <div
:style="{
backgroundImage: `linear-gradient(rgba(${authThemeColorRGB}, 0.5), rgba(${authThemeColorRGB}, 0.5)), url(${authThemeBgElement})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundBlendMode: 'normal, normal',
width: '100%',
opacity: 1,
}"
class="form-component-login"
>
<div class="combine-contact4_content">
<h2 class="heading-8">
SSO
</h2>
<h2 class="text-h5 heading-7">
Single Sign On
</h2>
<AuthProvider />
</div>
</div>
<div
class="logo-component-login"
:style="{
backgroundImage: `url(${ImgDark})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
width: '100%',
}"
>
<div class="logo-image-component-login">
<VImg <VImg
:src="authThemeImg" :src="authThemeImg"
max-width="650" max-height="100"
max-height="300"
class="image-10 lazyload" class="image-10 lazyload"
/> />
</div> </div>
<div class="form-login-component">
<VCol cols="12">
<img
src="/assets/images/naput/logo-ui-hitam.png"
width="100"
height="auto"
class="mb-0"
>
</VCol>
<VCol cols="12">
<AuthProvider />
</VCol>
</div>
</div> </div>
</section> </section>
</div>
</body> </body>
<!-- </VRow> --> </VRow>
</template> </template>
<style lang="scss"> <style lang="scss">
......
<script lang="ts" setup> <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(); const keycloakStore = useKeycloakStore()
// Gunakan computed agar selalu reaktif const isAuthenticated = computed(() => keycloakStore.authenticated)
const isAuthenticated = computed(() => keycloakStore.authenticated);
// const isAuthenticated = keycloakStore.authenticated;
// State management const items = ref<any[]>([])
const items = ref<any[]>([]); const expandedRows = ref<any[]>([])
const expandedRows = ref<any[]>([]); const loading = ref(false)
const loading = ref(false); const error = ref('')
const error = ref(''); const searchQuery = ref('')
// Project Riwayat Header // Headers
const riwayatHeaders = [ const riwayatHeaders = [
{ title: "", key: "data-table-expand", sortable: false }, { title: '', key: 'data-table-expand', sortable: false },
{ title: 'Tahun', key: 'THN', sortable: false }, { title: 'Tahun', key: 'THN', sortable: false },
{ title: 'Term', key: 'TERM', sortable: false }, { title: 'Term', key: 'TERM', sortable: false },
{ title: 'Semester', key: 'SEMESTER', sortable: true }, { title: 'Semester', key: 'SEMESTER', sortable: true },
...@@ -24,171 +23,174 @@ const riwayatHeaders = [ ...@@ -24,171 +23,174 @@ const riwayatHeaders = [
] ]
const expandedHeaders = [ const expandedHeaders = [
{ title: "Kode MK", key: "KD_MK", sortable: true }, { title: 'Kode MK', key: 'KD_MK', sortable: true },
{ title: "Nama MK", key: "NM_MK", sortable: true }, { title: 'Nama MK', key: 'NM_MK', sortable: true },
{ title: "Kelas", key: "NM_KLS_MK", sortable: false }, { title: 'Kelas', key: 'NM_KLS_MK', sortable: false },
{ title: "Status", key: "STATUS", sortable: false }, { title: 'Status', key: 'STATUS', sortable: false },
{ title: "Nilai", key: "NILAI_HURUF", sortable: true }, { title: 'Nilai', key: 'NILAI_HURUF', sortable: true },
{ title: "Pengajar", key: "PENGAJAR", sortable: false }, { title: 'Pengajar', key: 'PENGAJAR', sortable: false },
]; ]
async function getData() { function resolveStatusColor(status: string) {
loading.value = true; switch (status) {
error.value = ''; case 'Aktif': return 'primary'
case 'Lulus': return 'success'
default: return 'default'
}
}
// Reset items sebelum fetch data baru async function getData() {
items.value = []; loading.value = true
error.value = ''
items.value = []
try { try {
const apiEndpoint = `https://api.ui.ac.id/my/ac`; const res = await fetch('https://api.ui.ac.id/my/ac', {
const response = await fetch(apiEndpoint, {
headers: { headers: {
Authorization: `Bearer ${keycloakStore.accessToken}`, Authorization: `Bearer ${keycloakStore.accessToken}`,
}, },
}); })
if (!response.ok) throw new Error('Gagal mengambil data'); if (!res.ok)
const dataku = await response.json(); throw new Error('Gagal mengambil data')
const raw = await res.json()
items.value = dataku.map((item: any) => ({ items.value = raw.map((item: any, idx: number) => {
...item, const expandedFiltered = Object.values(item.IRS || {})
expanded: Object.values(item.IRS || {}).map((mk: any) => ({ .filter((mk: any) => mk.STATUS !== 'Kosong') // Remove STATUS: Kosong
.map((mk: any) => ({
KD_MK: mk.KD_MK, KD_MK: mk.KD_MK,
NM_MK: mk.NM_MK, NM_MK: mk.NM_MK,
NM_KLS_MK: mk.NM_KLS_MK, NM_KLS_MK: mk.NM_KLS_MK,
STATUS: mk.STATUS, STATUS: mk.STATUS,
NILAI_HURUF: mk.NILAI_HURUF, NILAI_HURUF: mk.NILAI_HURUF,
PENGAJAR: mk.PENGAJAR ? Object.values(mk.PENGAJAR).join(", ") : "-", PENGAJAR: mk.PENGAJAR ? Object.values(mk.PENGAJAR).join(', ') : '-',
})), }))
}));
return {
// Pastikan data adalah array sebelum dimasukkan ke items id: `${item.THN}-${item.SEMESTER}-${item.TERM}-${idx}`,
// items.value = Array.isArray(dataku) ? dataku : []; ...item,
// items.value = dataku; expanded: expandedFiltered,
}
} catch (err: any) { }).filter(item => item.expanded.length > 0) // Optionally remove parent rows with no expanded items
error.value = err.message || 'Terjadi kesalahan saat mengambil data'; }
} finally { catch (err: any) {
loading.value = false; error.value = err.message || 'Terjadi kesalahan saat mengambil data'
}
finally {
loading.value = false
} }
} }
// Fetch data from API
onMounted(() => { onMounted(() => {
getData(); getData()
}); })
// Search query state
const searchQuery = ref('');
// Filter aplikasi berdasarkan pencarian
const filteredRiwayat = computed(() => { const filteredRiwayat = computed(() => {
if (!searchQuery.value) return items.value; if (!searchQuery.value)
const query = searchQuery.value.toLowerCase();
return items.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 const query = searchQuery.value.toLowerCase()
if (filteredMK.length > 0) {
return { ...item, expanded: filteredMK };
}
// Jika tidak ada MK yang cocok, cek apakah tahun, semester, atau status cocok return items.value
if ( .map(item => {
[item.THN, item.SEMESTER, item.STATUS].some((field) => const filteredMK = item.expanded.filter((mk: any) =>
String(field).toLowerCase().includes(query) 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 .filter(Boolean)
})
// 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'
}
</script> </script>
<template> <template>
<VCard title="Riwayat Mata Kuliah" class="riwayatList"> <VCard
title="Riwayat Mata Kuliah"
<!-- Search Input --> class="riwayatList"
>
<!-- Search -->
<div class="search-container mb-4 pl-2 pr-2"> <div class="search-container mb-4 pl-2 pr-2">
<VTextField v-model="searchQuery" label="Search" placeholder="Search ..." append-inner-icon="ri-search-line" <VTextField
clearable single-line hide-details dense outlined /> 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> </div>
<!-- SECTION Datatable --> <!-- Data Table -->
<VDataTable :headers="riwayatHeaders" :items="filteredRiwayat" hide-default-footer fixed-header <VDataTable
item-value="SEMESTER" :sort-by="['SEMESTER']" :sort-asc="[true]" v-model:expanded="expandedRows" show-expand> v-model:expanded="expandedRows"
:headers="riwayatHeaders"
<template v-slot:expanded-row="{ item }"> :items="filteredRiwayat"
item-value="id"
hide-default-footer
fixed-header
show-expand
:sort-by="['SEMESTER']"
:sort-asc="[true]"
>
<template #expanded-row="{ item }">
<tr> <tr>
<td colspan="6"> <td colspan="100%">
<VDataTable density="compact" :headers="expandedHeaders" :items="item.expanded" class="ml-4" /> <VDataTable
:headers="expandedHeaders"
:items="item.expanded"
density="compact"
class="ml-4"
hide-default-footer
/>
</td> </td>
</tr> </tr>
</template> </template>
<!-- Tahun Ajar -->
<template #item.THN="{ item }"> <template #item.THN="{ item }">
<div class="d-flex align-center gap-x-3">
<div> <div>
<h6 class="text-h6 text-no-wrap"> <h6 class="text-h6">
{{ Number(item.THN) + '/' + (Number(item.THN) + 1) }} {{ `${item.THN}/${Number(item.THN) + 1}` }}
</h6> </h6>
</div> </div>
</div>
</template> </template>
<!-- Status -->
<template #item.STATUS="{ item }"> <template #item.STATUS="{ item }">
<VChip :color="resolveStatusColor(item.STATUS)" :class="`text-${resolveStatusColor(item.STATUS)}`" size="small" <VChip
class="font-weight-medium"> :color="resolveStatusColor(item.STATUS)"
class="font-weight-medium"
size="small"
>
{{ item.STATUS }} {{ item.STATUS }}
</VChip> </VChip>
</template> </template>
<!-- TODO Refactor this after vuetify provides proper solution for removing default footer -->
<template #bottom /> <template #bottom />
</VDataTable> </VDataTable>
<!-- !SECTION -->
</VCard> </VCard>
</template> </template>
<style lang="scss"> <style scoped lang="scss">
.riwayatList { .riwayatList {
.v-table {
&--density-default {
.v-table__wrapper { .v-table__wrapper {
table { table tbody tr td {
tbody {
tr {
td {
block-size: 56px; block-size: 56px;
} }
} }
}
}
}
}
}
} }
.search-container { .search-container {
......
...@@ -11,6 +11,12 @@ function login() { ...@@ -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 // Cek apakah user sudah login saat komponen dimuat
onMounted(() => { onMounted(() => {
if (keycloakInstance.authenticated) if (keycloakInstance.authenticated)
...@@ -20,12 +26,59 @@ onMounted(() => { ...@@ -20,12 +26,59 @@ onMounted(() => {
<template> <template>
<VBtn <VBtn
class="font-weight-bold" class="sso-btn font-weight-medium"
color="#FFDC01" color="#3d3d3d"
block block
type="submit" type="submit"
rounded="xl"
@click="login" @click="login"
> >
Login SSO Sign in SSO
</VBtn>
<VBtn
class="google-btn font-weight-medium"
color="#3d3d3d"
block
type="submit"
rounded="xl"
variant="outlined"
style="margin-block-start: 5px;"
@click="loginGoogle"
>
<template #prepend>
<VImg
src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg"
alt="Google"
max-width="20"
class="mr-2"
/>
</template>
Sign in with Google
</VBtn> </VBtn>
</template> </template>
<style scoped>
/* Use :deep() correctly to apply styles to the Vuetify button */
.v-btn.sso-btn {
background-color: #535666 !important; /* semi-transparent dark */
transition: background-color 0.3s ease, transform 0.2s ease;
}
.v-btn.sso-btn:hover {
background-color: #3d3e50 !important;
transform: translateY(-2px);
}
/* Google button */
.v-btn.google-btn {
border: 2px solid #a3a3a3;
background-color: rgba(255, 255, 255, 80%) !important;
transition: background-color 0.3s ease, transform 0.2s ease;
}
.v-btn.google-btn:hover {
background-color: rgba(61, 61, 61, 100%);
transform: translateY(-2px);
}
</style>
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!