Commit e5f2a991 by Samuel Taniel Mulyadi

Merge branch 'sam' into 'staging'

Sam

See merge request !20
2 parents 2c99d243 fb62f636
......@@ -75,7 +75,7 @@
min-block-size: 100vh; /* Ensures the element covers the full viewport height */
padding-block-start: 10px;
// margin: 5% 5%;marginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmargin
// margin: 5% 5%;marginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmarginmargin
}
.main-component-margin {
......@@ -115,9 +115,9 @@ body {
.form-component-login {
position: relative;
z-index: 1;
display: block;
display: flex; /* changed from block to flex */
overflow: hidden;
flex-flow: row;
flex-direction: column; /* or row depending on layout */
align-items: center;
justify-content: center;
border-radius: 1.7rem;
......@@ -128,6 +128,7 @@ body {
grid-template-columns: 1fr 1fr;
grid-template-rows: auto;
margin-inline: 20% 0%;
min-block-size: 300px;
min-inline-size: 345px;
outline: 0.5px solid #747474;
outline-offset: 0;
......@@ -169,11 +170,13 @@ body {
font-family: "Open Sans", sans-serif;
font-size: 14px;
grid-gap: 0.7rem;
inline-size: 100%;
margin-block: 1%;
margin-inline: 20px;
object-fit: fill;
padding-block: 10%;
padding-inline: 5%;
padding-inline: 35px;
}
.heading-8 {
......@@ -203,6 +206,14 @@ body {
padding-inline: 30px;
}
.image-11 {
margin-block: 80px;
margin-inline: 0;
max-inline-size: 100px;
padding-block: 100px;
padding-inline: 30px;
}
.logo-image-component-login {
position: static;
display: flex;
......
......@@ -13,6 +13,7 @@ const username = ref<string>('')
const civitas = ref<string>('')
const kodeIdentitas = ref<string>('')
const accessToken = ref<string>('')
const tokenParsedExp = ref<number>(0)
const refreshTokenExp = ref<number>(0)
const roles = ref<string[]>([])
const selectedRole = useStorage('selectedRole', 'admin')
......@@ -28,6 +29,7 @@ const refresh = (): void => {
username.value = tokenParsed.preferred_username || ''
civitas.value = tokenParsed.civitas || ''
kodeIdentitas.value = tokenParsed.kodeIdentitas || ''
tokenParsedExp.value = tokenParsed.exp || 0
accessToken.value = keycloakInstance.token || ''
refreshTokenExp.value = refreshedTokenParsed.exp || 0
roles.value = keycloakInstance.resourceAccess?.vueplayground?.roles ?? []
......
......@@ -13,15 +13,6 @@ const endHour = 21
const currentTime = ref(new Date())
// Function to check if the current time is within a schedule item range
function isCurrentScheduleItem(item: any) {
const now = currentTime.value
const startTime = parseTime(item.start)
const endTime = parseTime(item.end)
const currentMinutes = (now.getHours() - startHour) * 60 + now.getMinutes()
// Check if the current time is within the range (startTime < currentMinutes < endTime)
return currentMinutes >= startTime && currentMinutes <= endTime
}
async function getData() {
loading.value = true
......@@ -91,8 +82,6 @@ onMounted(() => {
})
function parseTime(time: string) {
console.log(`Parsing time: ${time}`)
const formattedTime = time.replace('.', ':')
const [hour, minute] = formattedTime.split(':').map(Number)
......@@ -102,17 +91,11 @@ function parseTime(time: string) {
return Number.NaN
}
console.log((hour - startHour) * 60 + minute)
return (hour - startHour) * 60 + minute
}
function calculateRowSpan(start: string, end: string) {
const rowSpan = (parseTime(end) - parseTime(start))
console.log(`RowSpan for ${start} - ${end}:`, rowSpan)
return rowSpan
return parseTime(end) - parseTime(start)
}
// Group schedule by day
......@@ -260,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>
......@@ -289,23 +272,23 @@ function getColorClass(index: number) {
:class="[getColorClass(rowIndex - 1)]"
>
<div class="course-header mb-1">
<span class="time text-sm">
<span class="text-sm">
<i class="ri-time-line" /> {{
getScheduleByDay(day)[rowIndex - 1].start
}} - {{
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]
......@@ -339,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;
......@@ -402,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%;
margin-block-end: 5px;
padding-block-end: 5px;
text-overflow: ellipsis; /* Show "..." */
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%;
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 */
......@@ -468,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 { useKeycloakStore } from "@/@core/stores/keycloakStore";
import { onMounted, ref, watchEffect } from "vue";
<script lang="ts" setup>
import { useKeycloakStore } from "@core/stores/keycloakStore";
// Store Keycloak
const keycloakStore = useKeycloakStore();
const isAuthenticated = computed(() => keycloakStore.authenticated);
interface ShiftData {
shift_date: string;
shift_start: string;
shift_end: string;
shift: string;
start_time?: string;
end_time?: string;
}
// Data dan state
const items = ref<any[]>([]);
const shifts = ref<ShiftData[]>([]);
const loading = ref(false);
const error = ref("");
const searchQuery = ref("");
// Fungsi ambil tanggal dari shift_start atau shift_end
const getTanggal = (waktu: string) => (waktu ? waktu.split(" ")[0] : "-");
// Fungsi ambil jam dari start_time atau end_time, kasih "-" kalau kosong
const getJam = (waktu: string) => (waktu ? waktu.split(" ")[1].slice(0, 5) : "-");
// Fungsi ambil nama hari dari tanggal
const getNamaHari = (tanggal: string) => {
if (!tanggal || tanggal === "-") return "-";
const date = new Date(tanggal);
if (isNaN(date.getTime())) return "-";
return new Intl.DateTimeFormat("id-ID", { weekday: "long" }).format(date);
};
const getJadwalShift = (start: string, end: string) => {
const jamStart = start ? start.split(" ")[1].slice(0, 5) : "-";
const jamEnd = end ? end.split(" ")[1].slice(0, 5) : "-";
return `${jamStart} - ${jamEnd}`;
};
const getStatus = (start: string | undefined, end: string | undefined) => {
if (!start && !end) return "Tidak Ada";
if (!start || !end) return "Belum Hitung";
return "On Time";
};
// Header tabel
const logHeaders = [
{ title: "TANGGAL", key: "tanggal" },
{ title: "NAMA HARI", key: "namaHari" },
{ title: "SHIFT", key: "shift" },
{ title: "JADWAL SHIFT", key: "jadwalShift" },
{ title: "MULAI AKTUAL", key: "start_time" },
{ title: "SELESAI AKTUAL", key: "end_time" },
{ title: "STATUS", key: "status" },
const headersShift = [
{ title: "Tanggal", key: "shift_date", sortable: true },
{ title: "Nama Hari", key: "day_name", sortable: false },
{ title: "Shift", key: "shift", sortable: false },
{ title: "Jadwal Shift", key: "shift_schedule", sortable: false },
{ title: "Mulai Aktual", key: "start_time", sortable: false },
{ title: "Selesai Aktual", key: "end_time", sortable: false },
{ title: "Status", key: "status", sortable: false },
];
// Fungsi ambil data dari API
async function getData() {
async function fetchShiftData() {
loading.value = true;
items.value = [];
error.value = "";
try {
const apiEndpoint = "https://api.ui.ac.id/my/hr/attendance";
const apiEndpoint = `https://api.ui.ac.id/my/hr/attendance`;
const response = await fetch(apiEndpoint, {
headers: {
Authorization: `Bearer ${keycloakStore.accessToken}`,
},
});
if (!response.ok) throw new Error("Gagal mengambil data");
const dataku = await response.json();
if (!response.ok) {
throw new Error("Gagal fetch data");
}
const data = await response.json();
// Tambahkan properti tanggal, namaHari, mulai aktual, dan selesai aktual ke setiap item
items.value = dataku.map((item: any) => {
const tanggal = getTanggal(item.shift_start) || getTanggal(item.shift_end);
return {
shifts.value = data
.map((item: any) => ({
...item,
tanggal: tanggal,
namaHari: getNamaHari(tanggal),
jadwalShift: getJadwalShift(item.shift_start, item.shift_end),
start_time: getJam(item.start_time),
end_time: getJam(item.end_time),
status: getStatus(item.start_time, item.end_time),
};
});
} catch (err) {
console.error("Gagal mengambil data:", err);
shift_start: `${item.shift_date} ${item.shift_start || "00:00"}`,
shift_end: `${item.shift_date} ${item.shift_end || "00:00"}`,
}))
.sort((a, b) => new Date(b.shift_date).getTime() - new Date(a.shift_date).getTime());
} catch (err: any) {
error.value = err.message || "Terjadi kesalahan saat mengambil data";
} finally {
loading.value = false;
}
}
// Fetch data saat mounted
onMounted(() => {
keycloakStore.refresh();
getData();
});
function getDayName(date: string) {
const days = ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"];
const dayIndex = new Date(date).getDay();
return days[dayIndex];
}
// Auto refresh data saat token berubah
watchEffect(async () => {
if (!keycloakStore.accessToken) return;
await getData();
});
const resolveDayColor = (day: string) => {
if (day === "Sabtu" || day === "Minggu") return "error";
};
const filteredItems = computed(() => {
if (!searchQuery.value) return items.value;
function getHour(time: string | undefined) {
if (!time) return "-";
const parts = time.split(" ");
return parts.length === 2 ? parts[1] : time;
}
const query = searchQuery.value.toLowerCase();
function getStatus(start: string | undefined, end: string | undefined) {
if (!start && !end) return "Tidak Ada";
if (!start || !end) return "Belum Hitung";
return "On Time";
}
const resolveStatusColor = (status: string) => {
if (status === "On Time") return "success";
if (status === "Belum Hitung") return "primary";
if (status === "Tidak Ada") return "error";
};
return items.value.filter((item) =>
logHeaders.some(
(header) => item[header.key] && String(item[header.key]).toLowerCase().includes(query)
)
);
const filteredShifts = computed(() => {
if (!searchQuery.value) return shifts.value;
return shifts.value.filter((shift) => {
const dayName = getDayName(shift.shift_date);
const status = getStatus(shift.start_time, shift.end_time);
return (
shift.shift_date.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
dayName.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
shift.shift.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
shift.start_time?.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
shift.end_time?.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
status.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
});
onMounted(() => {
fetchShiftData();
});
</script>
<template>
<VCard title="Log Absen" class="recentnamaHariCard">
<AppCardActions
:title="`Log Absen`"
class="jadwalShift"
action-collapsed
action-remove
>
<!-- Search Input -->
<div class="search-container mb-4 pl-2 pr-2">
<VTextField
v-model="searchQuery"
......@@ -124,41 +132,101 @@ const filteredItems = computed(() => {
outlined
/>
</div>
<VDataTable
:headers="logHeaders"
:items="filteredItems"
hide-default-footer
fixed-header
item-value="tanggal"
:sort-by="['tanggal']"
:sort-asc="[true]"
:headers="headersShift"
:items="filteredShifts"
:loading="loading"
loading-text="Memuat data..."
>
<template #item.namaHari="{ item }">
<VChip
:color="item.namaHari === 'Sabtu' || item.namaHari === 'Minggu' ? 'error' : 'default'"
:class="`text-${item.namaHari === 'Sabtu' || item.namaHari === 'Minggu' ? 'error' : 'default'}`"
size="small"
class="font-weight-medium"
>
{{ item.namaHari }}
</VChip>
<!-- Tanggal -->
<template #item.shift_date="{ item }">
{{ item.shift_date }}
</template>
<!-- Nama Hari -->
<template #item.day_name="{ item }">
<VChip
:color="resolveDayColor(getDayName(item.shift_date))"
:class="`text-${resolveDayColor(getDayName(item.shift_date))}`"
size="small"
class="font-weight-medium"
>
{{ getDayName(item.shift_date) }}
</VChip>
</template>
<!-- Jadwal Shift -->
<template #item.shift_schedule="{ item }">
{{ `${getHour(item.shift_start)} - ${getHour(item.shift_end)}` }}
</template>
<!-- Mulai Aktual -->
<template #item.start_time="{ item }">
{{ getHour(item.start_time) }}
</template>
<!-- Selesai Aktual -->
<template #item.end_time="{ item }">
{{ getHour(item.end_time) }}
</template>
<!-- Status -->
<template #item.status="{ item }">
<VChip
:color="resolveStatusColor(getStatus(item.start_time, item.end_time))"
:class="`text-${resolveStatusColor(getStatus(item.start_time, item.end_time))}`"
size="small"
class="font-weight-medium"
>
{{ getStatus(item.start_time, item.end_time) }}
</VChip>
</template>
</VDataTable>
</AppCardActions>
</template>
<template #item.status="{ item }">
<VChip
:color="item.status === 'Tidak Ada' ? 'error' : 'primary'"
:class="`text-${item.status === 'Tidak Ada' ? 'error' : 'primary'}`"
size="small"
class="font-weight-medium"
>
{{ item.status }}
</VChip>
</template>
</VDataTable>
<style lang="scss">
.jadwalShift {
.v-table {
&--density-default {
.v-table__wrapper {
table {
thead {
th {
background-color: #f5f5f5;
border-block-end: 2px solid #ddd;
color: #2c2c2c;
font-weight: 600;
padding-block: 12px;
padding-inline: 1.5em;
}
}
tbody {
tr {
td {
border-block-end: 1px solid #eee;
min-block-size: auto;
padding-block: 8px;
padding-inline: 1em;
vertical-align: top;
&.vti-table__td--Jadwal {
color: #4a4a4a;
font-weight: 500;
}
}
}
}
}
}
}
}
}
</VCard>
</template>
.search-container {
display: flex;
justify-content: flex-end;
}
</style>
<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()
items.value = dataku.map((item: any) => ({
...item,
expanded: Object.values(item.IRS || {}).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;
const raw = await res.json()
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(', ') : '-',
}))
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 =>
String(field).toLowerCase().includes(query),
)
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,76 +135,60 @@ 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>
</div>
<div>
<h6 class="text-h6">
{{ `${item.THN}/${Number(item.THN) + 1}` }}
</h6>
</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 {
block-size: 56px;
}
}
}
}
}
.v-table__wrapper {
table tbody tr td {
block-size: 56px;
}
}
}
......
......@@ -4,8 +4,8 @@ const keycloakConfig = {
url: 'https://login.ui.ac.id',
realm: 'main',
// clientId: "civitas", //harus minta ijin localhost dulu
clientId: 'vueplayground',
clientId: 'civitas', // harus minta ijin localhost dulu
// clientId: 'vueplayground',
}
// Directly instantiate the Keycloak instance to avoid 'null' issues
......@@ -13,4 +13,3 @@ const keycloakInstance = new Keycloak(keycloakConfig)
export default keycloakInstance
export { keycloakConfig }
<script lang="ts" setup>
import navItems from '@/navigation/horizontal'
import { themeConfig } from '@themeConfig'
import { HorizontalNavLayout } from '@layouts'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useKeycloakStore } from '@/@core/stores/keycloakStore'
import keycloakInstance from '@/keycloak'
// Components
import Footer from '@/layouts/components/Footer.vue'
import NavBarNotifications from '@/layouts/components/NavBarNotifications.vue'
import NavSearchBar from '@/layouts/components/NavSearchBar.vue'
import NavbarShortcuts from '@/layouts/components/NavbarShortcuts.vue'
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import NavbarTokenExpiredTime from '@/layouts/components/NavbarTokenExpiredTime.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import NavBarI18n from '@core/components/I18n.vue'
import { HorizontalNavLayout } from '@layouts'
import navItems from '@/navigation/horizontal'
// Keycloak & state
const keycloakStore = useKeycloakStore()
const authenticated = computed(() => keycloakStore.authenticated)
const now = ref(Math.floor(Date.now() / 1000))
const tokenLifetime = ref(0)
let timer: ReturnType<typeof setInterval>
onMounted(() => {
// Set tokenLifetime only once on mount
if (keycloakInstance.tokenParsed?.exp && keycloakInstance.tokenParsed?.iat)
tokenLifetime.value = keycloakInstance.tokenParsed.exp - keycloakInstance.tokenParsed.iat
// Start timer
timer = setInterval(() => {
now.value = Math.floor(Date.now() / 1000)
}, 1000)
})
onUnmounted(() => {
clearInterval(timer)
})
// Computed expiration values
const computedExpIn = computed(() => {
return authenticated.value && keycloakInstance.tokenParsed?.exp
? Math.max(keycloakInstance.tokenParsed?.exp - now.value, 0)
: 0
})
</script>
<template>
......@@ -24,23 +52,48 @@ import { HorizontalNavLayout } from '@layouts'
>
<!-- <VNodeRenderer :nodes="themeConfig.app.logo" /> -->
<div style="display: inline-flex; align-items: center; gap: 8px;">
<img src="@/assets/images/naput/logo-UI.png" alt="Logo UI" width="35" height="auto" />
<h1 style="font-size: 1.25rem; text-transform: uppercase; margin-left: 10px;">CIVITAS UI</h1>
</div>
<img
src="@images/naput/logo-UI.png"
alt="Logo UI"
width="35"
height="auto"
>
<h1 style="font-size: 1.25rem; margin-inline-start: 10px; text-transform: uppercase;">
CIVITAS UI
</h1>
</div>
</NuxtLink>
<VSpacer />
<NavSearchBar />
<!-- <NavSearchBar /> -->
<NavBarI18n
<!--
<NavBarI18n
v-if="themeConfig.app.i18n.enable && themeConfig.app.i18n.langConfig?.length"
:languages="themeConfig.app.i18n.langConfig"
/>
/>
-->
<NavbarTokenExpiredTime />
<NavbarThemeSwitcher />
<NavbarShortcuts />
<NavBarNotifications class="me-2" />
<!-- <NavbarShortcuts /> -->
<!-- <NavBarNotifications class="me-2" /> -->
<!--
<div class="pa-4">
<VAlert
v-if="authenticated"
type="info"
border="start"
variant="outlined"
class="mb-4"
>
<h3 class="text-h6 font-weight-bold">
Access token expires in {{ computedExpIn }} sec,
Refresh token in {{ computedRefExpIn }} sec,
Session: {{ computedSessionTimer }}
</h3>
</VAlert>
</div>
-->
<UserProfile />
</template>
......
<script lang="ts" setup>
import { VerticalNavLayout } from '@layouts'
import navItems from '@/navigation/vertical'
import { themeConfig } from '@themeConfig'
// Components
import Footer from '@/layouts/components/Footer.vue'
import NavBarNotifications from '@/layouts/components/NavBarNotifications.vue'
import NavSearchBar from '@/layouts/components/NavSearchBar.vue'
import NavbarShortcuts from '@/layouts/components/NavbarShortcuts.vue'
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import NavbarTokenExpiredTime from '@/layouts/components/NavbarTokenExpiredTime.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import NavBarI18n from '@core/components/I18n.vue'
// @layouts plugin
import { VerticalNavLayout } from '@layouts'
</script>
<template>
......@@ -20,25 +16,36 @@ import { VerticalNavLayout } from '@layouts'
<!-- 👉 navbar -->
<template #navbar="{ toggleVerticalOverlayNavActive }">
<div class="d-flex h-100 align-center">
<IconBtn
id="vertical-nav-toggle-btn"
class="ms-n2 d-lg-none"
@click="toggleVerticalOverlayNavActive(true)"
<NuxtLink
to="/"
class="d-flex align-start gap-x-4"
>
<VIcon icon="ri-menu-line" />
</IconBtn>
<NavSearchBar class="ms-lg-n2" />
<!-- <VNodeRenderer :nodes="themeConfig.app.logo" /> -->
<div style="display: inline-flex; align-items: center; gap: 8px;">
<img
src="@images/naput/logo-UI.png"
alt="Logo UI"
width="35"
height="auto"
>
<h1 style="font-size: 1.25rem; margin-inline-start: 10px; text-transform: uppercase;">
CIVITAS UI
</h1>
</div>
</NuxtLink>
<VSpacer />
<NavBarI18n
<!--
<NavBarI18n
v-if="themeConfig.app.i18n.enable && themeConfig.app.i18n.langConfig?.length"
:languages="themeConfig.app.i18n.langConfig"
/>
/>
-->
<NavbarTokenExpiredTime />
<NavbarThemeSwitcher />
<NavbarShortcuts />
<NavBarNotifications class="me-2" />
<!-- <NavbarShortcuts /> -->
<!-- <NavBarNotifications class="me-2" /> -->
<UserProfile />
</div>
</template>
......
......@@ -11,15 +11,10 @@
size="1.25rem"
class="mx-1"
/>
By <a
href="https://themeselection.com"
target="_blank"
rel="noopener noreferrer"
class="text-primary ms-1"
>ThemeSelection</a>
By Universitas Indonesia
</span>
<!-- 👉 Footer: right content -->
<span class="d-md-flex gap-x-4 text-primary d-none">
<!-- <span class="d-md-flex gap-x-4 text-primary d-none">
<a
href="https://themeselection.com/license/"
target="noopener noreferrer"
......@@ -36,6 +31,6 @@
href="https://themeselection.com/support/"
target="noopener noreferrer"
>Support</a>
</span>
</span> -->
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useKeycloakStore } from '@/@core/stores/keycloakStore'
import keycloakInstance from '@/keycloak'
const keycloakStore = useKeycloakStore()
const authenticated = computed(() => keycloakStore.authenticated)
const now = ref(Math.floor(Date.now() / 1000))
const tokenLifetime = ref(0)
let timer: ReturnType<typeof setInterval>
onMounted(() => {
// Set tokenLifetime only once
if (keycloakInstance.tokenParsed?.exp && keycloakInstance.tokenParsed?.iat)
tokenLifetime.value = keycloakInstance.tokenParsed.exp - keycloakInstance.tokenParsed.iat
timer = setInterval(() => {
now.value = Math.floor(Date.now() / 1000)
}, 1000)
})
onUnmounted(() => {
clearInterval(timer)
})
const computedExpIn = computed(() => {
return authenticated.value && keycloakInstance.tokenParsed?.exp
? Math.max(keycloakInstance.tokenParsed.exp - now.value, 0)
: 0
})
</script>
<template>
<div
v-if="authenticated"
class="me-4"
style="position: relative; block-size: 40px; inline-size: 40px;"
>
<VProgressCircular
:model-value="(computedExpIn / tokenLifetime) * 100"
:size="40"
:width="3"
color="primary"
/>
<div
class="text-subtitle-1 font-weight-bold"
style="position: absolute; inset-block-start: 50%; inset-inline-start: 50%; transform: translate(-50%, -50%);"
>
{{ computedExpIn }}
</div>
</div>
</template>
......@@ -2,29 +2,27 @@
import { onMounted, ref } from 'vue'
import { useKeycloakStore } from '@/@core/stores/keycloakStore'
import profileImg from '@images/avatars/avatar-1.png'
const keycloakStore = useKeycloakStore()
const defaultAvatarUrl = 'https://api.ui.ac.id/public/photo/male.jpg'
const avatarUrl = ref('')
const avatarUrl = ref(profileImg) // default fallback as initial value
async function checkImageExists(url: string) {
return new Promise((resolve, reject) => {
return new Promise(resolve => {
const img = new Image()
img.onload = () => resolve(true)
img.onerror = () => reject(false)
img.onerror = () => resolve(false)
img.src = url
})
}
onMounted(async () => {
if (keycloakStore.kodeIdentitas) {
let imageUrl = `https://api.ui.ac.id/public/photo/${keycloakStore.kodeIdentitas}.jpg`
const checkImageUrl = await checkImageExists(imageUrl)
if (!checkImageUrl)
imageUrl = defaultAvatarUrl
avatarUrl.value = imageUrl
const imageUrl = `https://api.ui.ac.id/public/photo/${keycloakStore.kodeIdentitas}.jpg`
const exists = await checkImageExists(imageUrl)
if (exists)
avatarUrl.value = imageUrl
}
})
</script>
......
......@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
const logoutUrl = ref(
'https://login.ui.ac.id/realms/main/protocol/openid-connect/logout?client_id=vueplayground',
'https://login.ui.ac.id/realms/main/protocol/openid-connect/logout?client_id=civitas',
)
const logoutUser = () => {
......
export default defineNuxtRouteMiddleware(to => {
return undefined
})
import { defineNuxtRouteMiddleware, navigateTo } from "nuxt/app"
import { useKeycloakStore } from "~/@core/stores/keycloakStore"
export default defineNuxtRouteMiddleware((to) => {
const keycloakStore = useKeycloakStore()
if (process.client) {
const isLoginPage = to.path === '/login'
// Jika belum login dan bukan sedang menuju halaman login, redirect ke login
if (!keycloakStore.authenticated && !isLoginPage) {
return navigateTo('/login')
}
// Kalau sudah login dan mencoba ke /login, bisa arahkan ke dashboard atau home
if (keycloakStore.authenticated && isLoginPage) {
return navigateTo('/') // atau ke '/dashboard' misalnya
}
}
})
\ No newline at end of file
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useKeycloakStore } from '@/@core/stores/keycloakStore'
import UserJadwal from '@/components/beranda/UserJadwal.vue'
import UserLib from '@/components/beranda/UserLib.vue'
import UserLog from '@/components/beranda/UserLog.vue'
import UserRiwayat from '@/components/beranda/UserRiwayat.vue'
import UserKeamanan from '@/views/dstipro/beranda/keamanan/index.vue'
import UserPeta from '@/views/dstipro/beranda/peta/index.vue'
import UserProfile from '@/views/dstipro/beranda/profile/index.vue'
import UserProfileHeader from '@/views/dstipro/beranda/UserProfileHeader.vue'
const keycloakStore = useKeycloakStore()
definePageMeta({
navActiveLink: 'tab',
key: 'tab',
})
const route = useRoute('tab')
const router = useRouter()
// Pastikan activeTab bertipe string agar tidak ada error TypeScript
// const activeTab = computed(() => String(route.params.tab))
const activeTab = computed({
get: () => route.params.tab,
set: () => route.params.tab,
})
// Semua tab
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' },
{ title: 'Log Absen', icon: 'ri-history-line', tab: 'log' },
{ title: 'Peta', icon: 'ri-history-line', tab: 'peta' },
{ title: 'Peminjaman Buku', icon: 'ri-book-line', tab: 'lib' },
]
// Filter tab berdasarkan civitas
const tabsByCivitas: Record<string, string[]> = {
staf: ['profile', 'keamanan', 'log', 'peta', 'lib'],
dosen: ['profile', 'keamanan', 'jadwal', 'log', 'peta', 'lib'],
mahasiswa: ['profile', 'keamanan', 'riwayat', '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) => {
router.push(`${tab}`)
}
</script>
<template>
<UserProfileHeader class="mb-5" />
<!--
<div class="mb-10">
<h1>Welcome, {{ keycloakStore.name }}</h1>
</div>
-->
<div>
<VTabs
v-model="activeTab"
class="v-tabs-pill"
>
<VTab
v-for="item in filteredTabs"
:key="item.icon"
:value="item.tab"
:to="{ name: 'tab', params: { tab: item.tab } }"
>
<VIcon
start
:icon="item.icon"
/>
{{ item.title }}
</VTab>
</VTabs>
<VWindow
v-model="activeTab"
class="mt-5 disable-tab-transition"
:touch="false"
>
<!-- Profile -->
<VWindowItem value="profile">
<UserProfile />
</VWindowItem>
<!-- Keamanan -->
<VWindowItem value="keamanan">
<UserKeamanan />
</VWindowItem>
<!-- Berita -->
<!--
<VWindowItem value="berita">
<UserBerita />
</VWindowItem>
-->
<!-- <div> -->
<!-- Riwayat -->
<VWindowItem value="riwayat">
<UserRiwayat />
</VWindowItem>
<!-- Jadwal -->
<VWindowItem value="jadwal">
<UserJadwal />
</VWindowItem>
<!-- Log Absen -->
<VWindowItem value="log">
<UserLog />
</VWindowItem>
<!-- Peta UI -->
<VWindowItem value="peta">
<UserPeta />
</VWindowItem>
<VWindowItem value="lib">
<UserLib />
</VWindowItem>
</vwindow>
</div>
</template>
......@@ -8,13 +8,13 @@ import keycloakInstance from '@/keycloak'
function login() {
keycloakInstance.login({
redirectUri: `${window.location.origin}/naputpro/beranda/profile`,
redirectUri: `${window.location.origin}/profile`,
})
}
onMounted(() => {
if (keycloakInstance.authenticated)
router.push('/naputpro/beranda/profile')
router.push('/profile')
})
const authThemeImg = useGenerateImageVariant(
......
<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">
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 authLoginLogo from '/assets/images/naput/illust-login.png'
import authV2LoginLogoLight from '@images/dstipro/ui-logo-light.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'
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({
layout: 'blank',
......@@ -19,40 +35,63 @@ definePageMeta({
</script>
<template>
<body
:style="{ backgroundImage: `url(${authThemeBg})` }"
class="body-2"
<VRow
no-gutters
class="auth-wrapper d-flex align-center"
>
<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>
<body
:style="{ backgroundImage: `url(${authThemeBg})` }"
class="body-4"
>
<div class="main-component-margin">
<section class="main-component-login">
<div
:style="{
backgroundImage: `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
:src="authThemeImg"
max-height="100"
class="image-11 lazyload"
/>
</div>
</div>
</section>
</div>
</section>
</body>
<!-- </VRow> -->
</body>
</VRow>
</template>
<style lang="scss">
@use "@core/scss/template/pages/page-auth";
</style>
\ No newline at end of file
</style>
......@@ -35,7 +35,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: 'Berita', icon: 'ri-news-line', tab: 'berita' },
{ title: 'Riwayat', icon: 'ri-file-history-line', tab: 'riwayat' },
{ title: 'Jadwal', icon: 'ri-calendar-line', tab: 'jadwal' },
{ title: 'Log Absen', icon: 'ri-history-line', tab: 'log' },
......@@ -98,9 +98,9 @@ const navigateTab = (tab: string) => {
</VWindowItem>
<!-- Berita -->
<VWindowItem value="berita">
<!-- <VWindowItem value="berita">
<UserBerita />
</VWindowItem>
</VWindowItem> -->
<!-- Riwayat -->
<VWindowItem value="riwayat">
......
......@@ -14,7 +14,20 @@ export default defineNuxtPlugin(async nuxtApp => {
if (authenticated) {
keycloakStore.refresh()
console.log('User is authenticated')
navigateTo('/naputpro/beranda/profile')
navigateTo('/profile')
setInterval(() => {
const now = Math.floor(Date.now() / 1000)
const tokenExp = keycloakInstance.tokenParsed?.exp ?? 0
if (tokenExp <= now) {
console.warn('Token expired. Logging out...')
keycloakInstance.logout({ redirectUri: `${window.location.origin}/login` })
}
else {
console.log('Token expires in:', tokenExp - now, 'seconds')
}
}, 10_000)
}
else {
console.log('User is not authenticated')
......
<script lang="ts" setup>
import type { ProfileHeader } from '@db/dstipro/profile/types'
import profileImg from '@images/avatars/avatar-1.png'
import coverImg from '@images/pages/user-profile-header-bg.png'
// import coverImg from '@images/pages/user-profile-header-bg.png'
import coverImgLight from '@images/naput/header-bg.png'
import coverImgDark from '@images/naput/header-bg-dark.png'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import PersonAvatar from '@/layouts/components/PersonAvatar.vue'
......@@ -12,6 +14,11 @@ dayjs.extend(duration)
const profileHeaderData = ref<ProfileHeader | null>(null)
const coverImg = useGenerateImageVariant(coverImgLight, coverImgDark)
// const userImg = computed(() => profileHeaderData.profileImg || profileImg)
const keycloakStore = useKeycloakStore()
// Gunakan computed agar selalu reaktif
......@@ -22,7 +29,7 @@ function login() {
}
const logoutUrl = ref(
'https://login.ui.ac.id/realms/main/protocol/openid-connect/logout?client_id=vueplayground',
'https://login.ui.ac.id/realms/main/protocol/openid-connect/logout?client_id=civitas',
)
const logoutUser = () => {
......@@ -111,10 +118,6 @@ onMounted(() => {
onMounted(() => {
profileHeaderData.value = {
coverImg,
designation: 'UX Designer',
fullName: 'Abdullah',
joiningDate: 'April 2021',
location: 'Madinah',
profileImg,
}
})
......@@ -217,13 +220,14 @@ function openDialog() {
>
<div class="d-flex h-0">
<VAvatar
rounded
size="130"
:image="profileHeaderData.profileImg"
class="user-profile-avatar mx-auto"
>
<PersonAvatar style="block-size: 120px; inline-size: 120px;" />
</VAvatar>
rounded="circle"
size="130"
:image="profileHeaderData.profileImg"
class="user-profile-avatar mx-auto"
>
<PersonAvatar style="block-size: 120px; inline-size: 120px;" />
</VAvatar>
</div>
<div class="user-profile-info w-100 mt-16 pt-6 pt-sm-0 mt-sm-0">
......@@ -380,19 +384,19 @@ function openDialog() {
</div>
<!-- tombol test aksi ultah -->
<div class="d-flex align-center gap-x-2">
<!-- <div class="d-flex align-center gap-x-2">
<VBtn @click="isBirthday = true">
<VIcon
size="24"
icon="ri-hand-heart-line"
/>
</VBtn>
</div>
</div> -->
</div>
<VBtn prepend-icon="ri-user-follow-line">
<!-- <VBtn prepend-icon="ri-user-follow-line">
Connected
</VBtn>
</VBtn> -->
<VBtn
class="bg-error"
......@@ -405,7 +409,7 @@ function openDialog() {
</div>
</VCardText>
<VCardText
<!-- <VCardText
v-else
class="d-flex align-bottom flex-sm-row flex-column justify-center gap-x-6"
>
......@@ -478,7 +482,7 @@ function openDialog() {
</VBtn>
</div>
</div>
</VCardText>
</VCardText> -->
</VCard>
</template>
......
......@@ -4,13 +4,13 @@ import keycloakInstance from '@/keycloak'
function login() {
keycloakInstance.login({
redirectUri: `${window.location.origin}/naputpro/beranda/profile`,
redirectUri: `${window.location.origin}/profile`,
})
}
onMounted(() => {
if (keycloakInstance.authenticated)
router.push('/naputpro/beranda/profile')
router.push('/profile')
})
const { global } = useTheme()
......
<script lang="ts" setup>
import { useKeycloakStore } from '@core/stores/keycloakStore'
const keycloakStore = useKeycloakStore()
const isCurrentPasswordVisible = ref(false)
const isNewPasswordVisible = ref(false)
const isConfirmPasswordVisible = ref(false)
const currentPassword = ref('')
const newPassword = ref('')
const newPasswordError = ref('')
const confirmPassword = ref('')
const isSubmitting = ref(false)
watch(newPassword, newValue => {
if (currentPassword.value && newValue === currentPassword.value) {
newPasswordError.value
= 'Kata sandi baru tidak boleh sama dengan kata sandi lama.'
}
else {
newPasswordError.value = ''
}
})
const passwordRequirements = [
'Panjang minimal 8 karakter, maksimal 20 karakter',
......@@ -21,10 +37,14 @@ const oldPasswordRules = [
const passwordRules = [
(v: string) => !!v || 'Kata sandi diperlukan',
(v: string) => v.length >= 8 || 'Kata sandi minimal terdiri dari 8 karakter',
(v: string) => /[a-z]/.test(v) || 'Kata sandi setidaknya mengandung satu huruf kecil',
(v: string) => /[A-Z]/.test(v) || 'Kata sandi setidaknya mengandung satu huruf besar',
(v: string) =>
/[a-z]/.test(v) || 'Kata sandi setidaknya mengandung satu huruf kecil',
(v: string) =>
/[A-Z]/.test(v) || 'Kata sandi setidaknya mengandung satu huruf besar',
(v: string) => /\d/.test(v) || 'Kata sandi setidaknya berisi satu angka',
(v: string) => /[ !"#$%&'()*+,-./:;<=>?@[\\\]^_`{|}~]/.test(v) || 'Kata sandi setidaknya mengandung satu simbol atau spasi',
(v: string) =>
/[ !"#$%&'()*+,-./:;<=>?@[\\\]^_`{|}~]/.test(v)
|| 'Kata sandi setidaknya mengandung satu simbol atau spasi',
]
const confirmPasswordRules = [
......@@ -54,7 +74,8 @@ function generatePassword(length: number = 10): string {
// Fill the rest of the password with random characters from the combined charset
for (let i = 0; i < remainingLength; i++) {
const randomChar = allCharacters[Math.floor(Math.random() * allCharacters.length)]
const randomChar
= allCharacters[Math.floor(Math.random() * allCharacters.length)]
password.push(randomChar)
}
......@@ -66,16 +87,42 @@ function generatePassword(length: number = 10): string {
return password.join('')
}
// fungsi untuk encoding Base64
function toBase64(str: string) {
return btoa(unescape(encodeURIComponent(str)))
}
// Set Password
function setPassword() {
async function setPassword() {
isSubmitting.value = true
if (isEmpty(currentPassword.value)) {
alert('Kata Sandi Lama tidak boleh kosong')
return
}
if (newPassword.value.length < 8 || !/[a-z]/.test(newPassword.value) || !/\d/.test(newPassword.value) || !/[ !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/.test(newPassword.value)) {
alert('Kata Sandi harus memiliki minimal 8 karakter, setidaknya satu huruf besar, satu angka, dan satu karakter khusus.')
// Buat objek data dengan password yang sudah di-encoding
const requestData = {
oldPassword: toBase64(currentPassword.value),
newPassword: toBase64(newPassword.value),
}
if (
newPassword.value.length < 8
|| !/[a-z]/.test(newPassword.value)
|| !/\d/.test(newPassword.value)
|| !/[ !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/.test(newPassword.value)
) {
alert(
'Kata Sandi harus memiliki minimal 8 karakter, setidaknya satu huruf besar, satu angka, dan satu karakter khusus.',
)
return
}
if (newPassword.value === currentPassword.value) {
alert('Kata sandi baru tidak boleh sama dengan kata sandi lama.')
return
}
......@@ -86,41 +133,52 @@ function setPassword() {
return
}
// Simulasi pengubahan password
// Di sini Anda bisa menambahkan logika untuk mengupdate password ke server
alert('Kata sandi Anda sudah diubah, silahkan logout dan login kembali dengan kata sandi yang baru')
try {
// Panggil API untuk mengganti password
const apiEndpoint = 'https://api.ui.ac.id/my/pw'
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${keycloakStore.accessToken}`,
},
body: JSON.stringify(requestData),
})
// Check for HTTP status code 401 which indicates old password is incorrect
if (response.status === 401)
throw new Error('Kata sandi lama tidak sesuai')
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Gagal mengubah password')
}
// Jika berhasil, tampilkan pesan dan reset form
alert(
'Kata sandi berhasil diubah. Silakan logout dan login kembali dengan kata sandi baru.',
)
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
}
catch (error) {
// Handle error
if (error instanceof Error)
alert(error.message)
else
alert('Terjadi kesalahan saat mengubah kata sandi')
}
finally {
isSubmitting.value = false
}
// Reset input
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
}
// const serverKeys = [
// {
// name: 'Server Key 1',
// key: '23eaf7f0-f4f7-495e-8b86-fad3261282ac',
// createdOn: '28 Apr 2021, 18:20 GTM+4:10',
// permission: 'Full Access',
// },
// {
// name: 'Server Key 2',
// key: 'bb98e571-a2e2-4de8-90a9-2e231b5e99',
// createdOn: '12 Feb 2021, 10:30 GTM+2:30',
// permission: 'Read Only',
// },
// {
// name: 'Server Key 3',
// key: '2e915e59-3105-47f2-8838-6e46bf83b711',
// createdOn: '28 Dec 2020, 12:21 GTM+4:10',
// permission: 'Full Access',
// },
// ]
// 👉 Change the image as per theme change
// const sittingGirlImg = useGenerateImageVariant(sittingGirlWithLaptopLight, sittingGirlWithLaptopDark)
// const isOneTimePasswordDialogVisible = ref(false)
</script>
<template>
......@@ -144,12 +202,16 @@ function setPassword() {
v-model="currentPassword"
:type="isCurrentPasswordVisible ? 'text' : 'password'"
:maxlength="20"
:append-inner-icon="isCurrentPasswordVisible ? 'ri-eye-off-line' : 'ri-eye-line'"
:append-inner-icon="
isCurrentPasswordVisible ? 'ri-eye-off-line' : 'ri-eye-line'
"
autocomplete="on"
label="Kata Sandi Lama"
:rules="oldPasswordRules"
clearable
@click:append-inner="isCurrentPasswordVisible = !isCurrentPasswordVisible"
@click:append-inner="
isCurrentPasswordVisible = !isCurrentPasswordVisible
"
/>
</VCol>
</VRow>
......@@ -165,12 +227,17 @@ function setPassword() {
v-model="newPassword"
:type="isNewPasswordVisible ? 'text' : 'password'"
:maxlength="20"
:append-inner-icon="isNewPasswordVisible ? 'ri-eye-off-line' : 'ri-eye-line'"
:append-inner-icon="
isNewPasswordVisible ? 'ri-eye-off-line' : 'ri-eye-line'
"
label="Kata Sandi Baru"
autocomplete="on"
:rules="passwordRules"
:error-messages="newPasswordError"
clearable
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
@click:append-inner="
isNewPasswordVisible = !isNewPasswordVisible
"
/>
</VCol>
......@@ -183,12 +250,16 @@ function setPassword() {
v-model="confirmPassword"
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:maxlength="20"
:append-inner-icon="isConfirmPasswordVisible ? 'ri-eye-off-line' : 'ri-eye-line'"
:append-inner-icon="
isConfirmPasswordVisible ? 'ri-eye-off-line' : 'ri-eye-line'
"
autocomplete="on"
label="Konfirmasi Kata Sandi"
:rules="confirmPasswordRules"
clearable
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
@click:append-inner="
isConfirmPasswordVisible = !isConfirmPasswordVisible
"
/>
</VCol>
</VRow>
......@@ -221,8 +292,13 @@ function setPassword() {
<!-- 👉 Action Buttons -->
<div class="d-flex flex-wrap gap-4">
<VBtn @click="setPassword">
Save changes
<VBtn
:disabled="isSubmitting"
@click="setPassword"
>
{{
isSubmitting ? "Sedang Memproses..." : "Simpan Perubahan"
}}
</VBtn>
<VBtn
......@@ -235,7 +311,10 @@ function setPassword() {
<VBtn
color="secondary"
variant="outlined"
@click="newPassword = generatePassword(); confirmPassword = newPassword"
@click="
newPassword = generatePassword();
confirmPassword = newPassword;
"
>
Generate Kata Sandi
</VBtn>
......@@ -245,126 +324,5 @@ function setPassword() {
</VCard>
</VCol>
<!-- !SECTION -->
<!-- SECTION Two-steps verification -->
<!--
<VCol cols="12">
<VCard>
<VCardItem class="pb-6">
<VCardTitle>Two-steps verification</VCardTitle>
</VCardItem>
<VCardText>
<p>
Two factor authentication is not enabled yet.
</p>
<p class="mb-6">
Two-factor authentication adds an additional layer of security to your account by requiring more than just a
password to log in.
<a href="javascript:void(0)" class="text-decoration-none">Learn more.</a>
</p>
<VBtn @click="isOneTimePasswordDialogVisible = true">
Enable 2FA
</VBtn>
</VCardText>
</VCard>
</VCol>
-->
<!-- !SECTION -->
<!-- <VCol cols="12"> -->
<!-- SECTION: Create an API key -->
<!--
<VCard title="Create an API key">
<VRow>
-->
<!-- 👉 Choose API Key -->
<!--
<VCol cols="12" md="5" order-md="0" order="1">
<VCardText class="pt-7">
<VForm @submit.prevent="() => { }">
-->
<!-- 👉 Choose API Key -->
<!--
<VSelect label="Choose the API key type you want to create" placeholder="Select API key type"
:items="['Full Control', 'Modify', 'Read & Execute', 'List Folder Contents', 'Read Only', 'Read & Write']" />
-->
<!-- 👉 Name the API Key -->
<!-- <VTextField label="Name the API key" placeholder="Name the API key" class="my-5" /> -->
<!-- 👉 Create Key Button -->
<!--
<VBtn type="submit" block>
Create Key
</VBtn>
</VForm>
</VCardText>
</VCol>
-->
<!-- 👉 Lady image -->
<!--
<VCol cols="12" md="7" order="0" order-md="1" class="d-flex flex-column justify-center align-center">
<VImg :src="sittingGirlImg" :width="310"
:style="$vuetify.display.smAndDown ? '' : 'position: absolute; bottom: 0;'" />
</VCol>
</VRow>
</VCard>
-->
<!-- !SECTION -->
<!-- </VCol> -->
<!-- <VCol cols="12"> -->
<!-- SECTION: API Keys List -->
<!--
<VCard>
<VCardItem class="pb-4">
<VCardTitle>API Key List &amp; Access</VCardTitle>
</VCardItem>
<VCardText>
<p class="mb-6">
An API key is a simple encrypted string that identifies an application without any principal. They are
useful
for accessing public data anonymously, and are used to associate API requests with your project for quota
and
billing.
</p>
-->
<!-- 👉 Server Status -->
<!--
<div class="d-flex flex-column gap-y-6">
<div v-for="serverKey in serverKeys" :key="serverKey.key" class="bg-var-theme-background pa-4">
<div class="d-flex align-center flex-wrap mb-2 gap-x-3">
<h6 class="text-h6">
{{ serverKey.name }}
</h6>
<VChip color="primary" size="small">
{{ serverKey.permission }}
</VChip>
</div>
<h6 class="text-h6 d-flex gap-x-3 text-medium-emphasis align-center mb-2">
{{ serverKey.key }}
<VIcon :size="20" icon="ri-file-copy-line" class="cursor-pointer" />
</h6>
<div class="text-disabled">
Created on {{ serverKey.createdOn }}
</div>
</div>
</div>
-->
<!--
</VCardText>
</VCard>
-->
<!-- !SECTION -->
<!-- </VCol> -->
</VRow>
<!-- SECTION Enable One time password -->
<!-- <TwoFactorAuthDialog v-model:isDialogVisible="isOneTimePasswordDialogVisible" /> -->
<!-- !SECTION -->
</template>
......@@ -13,6 +13,17 @@ const applicationTableHeaders = [
{ title: 'Link', key: 'link' },
]
const viewMode = ref('grid'); // Set default to 'grid'
const itemsPerPage = ref(8) // Default rows per page
const page = ref(1) // Default page number
const paginatedApplications = computed(() => {
const start = (page.value - 1) * itemsPerPage.value
return filteredApplications.value.slice(start, start + itemsPerPage.value)
})
const applications = [
{
logo: react,
......@@ -79,8 +90,29 @@ const filteredApplications = computed(() => {
</script>
<template>
<VCard title="List Aplikasi untuk Dosen" class="projectList">
<!-- 👉 User Project List Table -->
<VCard class="projectList">
<!-- Custom Header -->
<div class="d-flex justify-space-between align-center pa-4">
<div class="text-h5">List Aplikasi untuk Dosen</div>
<div class="d-flex gap-2">
<VBtn
icon
variant="text"
:color="viewMode === 'grid' ? 'primary' : 'default'"
@click="viewMode = 'grid'"
>
<VIcon icon="ri-layout-grid-line" />
</VBtn>
<VBtn
icon
variant="text"
:color="viewMode === 'horizontal' ? 'primary' : 'default'"
@click="viewMode = 'horizontal'"
>
<VIcon icon="ri-layout-horizontal-line" />
</VBtn>
</div>
</div>
<!-- Search Input -->
<div class="search-container mb-4 pl-2 pr-2">
......@@ -89,38 +121,97 @@ const filteredApplications = computed(() => {
clearable @click:clear="searchQuery = ''" single-line hide-details dense outlined />
</div>
<!-- SECTION Datatable -->
<VDataTable :headers="applicationTableHeaders" :items="filteredApplications" hide-default-footer fixed-header
item-value="name">
<!-- application -->
<template #item.application="{ item }">
<div class="d-flex align-center gap-x-3">
<VAvatar :size="34" :image="item.logo" />
<div>
<h6 class="text-h6 text-no-wrap">
{{ item.name }}
</h6>
<div class="text-body-2">
{{ item.nameShort }}
<!-- Search Input -->
<!-- Grid View -->
<VRow v-if="viewMode === 'grid'" class="app-grid">
<VCol
v-for="app in paginatedApplications"
:key="app.name"
cols="12"
sm="6"
md="3"
>
<VCard
class="app-card"
:href="app.link"
target="_blank"
rel="noopener"
>
<VImg
:src="app.logo"
class="app-image"
/>
<VCardText class="app-text">
<a
:href="app.link"
target="_blank"
class="app-link"
>{{ app.link }} ></a>
<div class="app-short">
{{ app.nameShort }}
</div>
<div class="app-name">
{{ app.name }}
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- SECTION Datatable -->
<VDataTable v-if="viewMode === 'horizontal'" :headers="applicationTableHeaders" :items="paginatedApplications" hide-default-footer fixed-header
item-value="name">
<!-- application -->
<template #item.application="{ item }">
<div class="d-flex align-center gap-x-3">
<VAvatar :size="34" :image="item.logo" />
<div>
<h6 class="text-h6 text-no-wrap">
{{ item.name }}
</h6>
<div class="text-body-2">
{{ item.nameShort }}
</div>
</div>
</div>
</div>
</div>
</template>
<!-- link -->
<template #item.link="{ item }">
<div class="d-flex align-center gap-3">
<div class="text-high-emphasis">
<a :href="item.link" target="_blank" rel="noopener">{{ item.link }}</a>
</div>
</div>
</template>
<!-- TODO Refactor this after vuetify provides proper solution for removing default footer -->
<template #bottom />
</VDataTable>
<!-- !SECTION -->
</template>
<!-- link -->
<template #item.link="{ item }">
<div class="d-flex align-center gap-3">
<div class="text-high-emphasis">
<a :href="item.link" target="_blank" rel="noopener">{{ item.link }}</a>
</div>
</div>
</template>
<!-- TODO Refactor this after vuetify provides proper solution for removing default footer -->
<template #bottom />
</VDataTable>
<!-- !SECTION -->
<div class="pagination-container pt-4 px-4 mb-7">
<div class="d-flex flex-wrap align-center justify-space-between gap-4">
<VSelect
v-model="itemsPerPage"
:items="[4, 8, 16, 32]"
label="Rows per page:"
variant="underlined"
density="comfortable"
style="max-width: 10rem;"
/>
<VPagination
v-model="page"
:total-visible="5"
:length="Math.ceil(filteredApplications.length / itemsPerPage)"
/>
</div>
</div>
</VCard>
</template>
......@@ -147,4 +238,76 @@ const filteredApplications = computed(() => {
display: flex;
justify-content: flex-end;
}
.content-container {
padding-block: 0 20px;
padding-inline: 20px; /* No padding on top */
}
.project-list {
padding: 0;
}
.search-container {
display: flex;
justify-content: flex-end;
margin-block-end: 20px;
}
.app-card {
display: flex;
overflow: hidden;
flex-direction: column;
block-size: 100%;
cursor: pointer;
transition: transform 0.2s ease-in-out;
}
.app-card:hover {
transform: scale(1.02);
}
.app-image {
overflow: hidden;
inline-size: 100%;
max-block-size: 100px;
object-fit: cover;
}
.app-text {
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: space-between;
padding-block: 8px;
padding-inline: 12px;
}
.app-link {
display: block;
color: rgb(var(--v-global-theme-primary));
font-size: 14px;
text-decoration: none;
}
.app-name {
overflow: hidden;
color: gray;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-card:hover .app-name {
overflow: visible;
text-overflow: unset;
white-space: normal;
}
.app-short {
font-size: 16px;
font-weight: bold;
margin-block-start: 4px;
}
</style>
......@@ -6,123 +6,240 @@ import react from '@images/icons/project-icons/react.png'
import sketch from '@images/icons/project-icons/sketch.png'
import vue from '@images/icons/project-icons/vue.png'
import xamarin from '@images/icons/project-icons/xamarin.png'
import { computed, ref } from 'vue'
const applications = [
{ logo: react, name: 'Human Resource Information System Universitas Indonesia', nameShort: 'HRIS UI', link: 'https://hris.ui.ac.id/' },
{ logo: figma, name: 'Sistem Informasi Akademik NextGeneration', nameShort: 'SIAKNG', link: 'https://academic.ui.ac.id/' },
{ logo: figma, name: 'E-learning Management System', nameShort: 'EMAS-2', link: 'https://emas2.ui.ac.id/' },
{ logo: vue, name: 'Dashboard App', nameShort: 'Vuejs Project', link: '#' },
{ logo: xamarin, name: 'Foodista mobile app', nameShort: 'Xamarin Project', link: '#' },
{ logo: python, name: 'Dojo Email App', nameShort: 'Python Project', link: '#' },
{ logo: sketch, name: 'Blockchain App', nameShort: 'Sketch Project', link: '#' },
{ logo: html5, name: 'Hoffman App', nameShort: 'HTML Project', link: '#' },
// Project Table Header
const applicationTableHeaders = [
{ title: 'UI App', key: 'application' },
{ title: 'Link', key: 'link' },
]
const searchQuery = ref('')
const viewMode = ref('grid'); // Set default to 'grid'
const itemsPerPage = ref(8) // Default rows per page
const page = ref(1) // Default page number
// Filtered applications based on search query
const filteredApplications = computed(() => {
const query = searchQuery.value.toLowerCase()
return applications.filter(app =>
[app.name, app.nameShort, app.link].some(field =>
field.toLowerCase().includes(query),
),
)
})
// Paginate the filtered applications
const paginatedApplications = computed(() => {
const start = (page.value - 1) * itemsPerPage.value
return filteredApplications.value.slice(start, start + itemsPerPage.value)
})
const applications = [
{
logo: react,
name: 'Human Resource Information System Universitas Indonesia',
nameShort: 'HRIS UI',
link: 'https://hris.ui.ac.id/',
},
{
logo: figma,
name: 'Sistem Informasi Akademik NextGeneration',
nameShort: 'SIAKNG',
link: 'https://academic.ui.ac.id/',
},
{
logo: figma,
name: 'E-learning Management System',
nameShort: 'EMAS-2',
link: 'https://emas2.ui.ac.id/',
},
{
logo: vue,
name: 'Dashboard App',
nameShort: 'Vuejs Project',
link: '#',
},
{
logo: xamarin,
name: 'Foodista mobile app',
nameShort: 'Xamarin Project',
link: '#',
},
{
logo: python,
name: 'Dojo Email App',
nameShort: 'Python Project',
link: '#',
},
{
logo: sketch,
name: 'Blockchain App',
nameShort: 'Sketch Project',
link: '#',
},
{
logo: html5,
name: 'Hoffman App',
nameShort: 'HTML Project',
link: '#',
},
]
// Search query state
const searchQuery = ref('');
// Filter aplikasi berdasarkan pencarian
const filteredApplications = computed(() => {
const query = searchQuery.value.toLowerCase();
return applications.filter((app) =>
[app.name, app.nameShort, app.link].some((field) =>
field.toLowerCase().includes(query)
)
);
});
</script>
<template>
<VCard
title="List Aplikasi untuk Mahasiswa"
class="project-list"
>
<div class="content-container">
<!-- Search Input -->
<div class="search-container">
<VTextField
v-model="searchQuery"
label="Search"
placeholder="Search ..."
append-inner-icon="ri-search-line"
clearable
single-line
hide-details
dense
outlined
@click:clear="searchQuery = ''"
/>
</div>
<!-- Applications Grid -->
<VRow class="app-grid">
<VCol
v-for="app in paginatedApplications"
:key="app.name"
cols="12"
sm="6"
md="3"
>
<VCard
class="app-card"
:href="app.link"
target="_blank"
rel="noopener"
<VCard class="projectList">
<!-- Custom Header -->
<div class="d-flex justify-space-between align-center pa-4">
<div class="text-h5">List Aplikasi untuk Mahasiswa</div>
<div class="d-flex gap-2">
<VBtn
icon
variant="text"
:color="viewMode === 'grid' ? 'primary' : 'default'"
@click="viewMode = 'grid'"
>
<VIcon icon="ri-layout-grid-line" />
</VBtn>
<VBtn
icon
variant="text"
:color="viewMode === 'horizontal' ? 'primary' : 'default'"
@click="viewMode = 'horizontal'"
>
<VIcon icon="ri-layout-horizontal-line" />
</VBtn>
</div>
</div>
<!-- Search Input -->
<div class="search-container mb-4 pl-2 pr-2">
<!-- <VTextField v-model="searchQuery" label="Search" clearable variant="outlined" density="comfortable" /> -->
<VTextField v-model="searchQuery" label="Search" placeholder="Search ..." append-inner-icon="ri-search-line"
clearable @click:clear="searchQuery = ''" single-line hide-details dense outlined />
</div>
<!-- Search Input -->
<!-- Grid View -->
<VRow v-if="viewMode === 'grid'" class="app-grid">
<VCol
v-for="app in paginatedApplications"
:key="app.name"
cols="12"
sm="6"
md="3"
>
<VImg
:src="app.logo"
class="app-image"
/>
<VCardText class="app-text">
<a
:href="app.link"
target="_blank"
class="app-link"
>{{ app.link }} ></a>
<div class="app-short">
{{ app.nameShort }}
<VCard
class="app-card"
:href="app.link"
target="_blank"
rel="noopener"
>
<VImg
:src="app.logo"
class="app-image"
/>
<VCardText class="app-text">
<a
:href="app.link"
target="_blank"
class="app-link"
>{{ app.link }} ></a>
<div class="app-short">
{{ app.nameShort }}
</div>
<div class="app-name">
{{ app.name }}
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- SECTION Datatable -->
<VDataTable v-if="viewMode === 'horizontal'" :headers="applicationTableHeaders" :items="paginatedApplications" hide-default-footer fixed-header
item-value="name">
<!-- application -->
<template #item.application="{ item }">
<div class="d-flex align-center gap-x-3">
<VAvatar :size="34" :image="item.logo" />
<div>
<h6 class="text-h6 text-no-wrap">
{{ item.name }}
</h6>
<div class="text-body-2">
{{ item.nameShort }}
</div>
</div>
<div class="app-name">
{{ app.name }}
</div>
</template>
<!-- link -->
<template #item.link="{ item }">
<div class="d-flex align-center gap-3">
<div class="text-high-emphasis">
<a :href="item.link" target="_blank" rel="noopener">{{ item.link }}</a>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Pagination Controls -->
<div class="pt-2">
<div class="d-flex flex-wrap justify-center justify-sm-space-between gap-y-2 mt-2">
<VSelect
v-model="itemsPerPage"
:items="[4, 8, 16, 32]"
label="Rows per page:"
variant="underlined"
style="max-inline-size: 8rem; min-inline-size: 5rem;"
/>
<VPagination
v-model="page"
:total-visible="3"
:length="Math.ceil(filteredApplications.length / itemsPerPage)"
/>
</div>
</div>
</div>
</div>
</template>
<!-- TODO Refactor this after vuetify provides proper solution for removing default footer -->
<template #bottom />
</VDataTable>
<!-- !SECTION -->
<div class="pagination-container pt-4 px-4 mb-7">
<div class="d-flex flex-wrap align-center justify-space-between gap-4">
<VSelect
v-model="itemsPerPage"
:items="[4, 8, 16, 32]"
label="Rows per page:"
variant="underlined"
density="comfortable"
style="max-width: 10rem;"
/>
<VPagination
v-model="page"
:total-visible="5"
:length="Math.ceil(filteredApplications.length / itemsPerPage)"
/>
</div>
</div>
</VCard>
</template>
<style scoped>
<style lang="scss">
.projectList {
.v-table {
&--density-default {
.v-table__wrapper {
table {
tbody {
tr {
td {
block-size: 56px;
}
}
}
}
}
}
}
}
.search-container {
display: flex;
justify-content: flex-end;
}
.content-container {
padding-block: 0 20px;
padding-inline: 20px; /* No padding on top */
......
<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,169 +23,172 @@ 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();
items.value = dataku.map((item: any) => ({
...item,
expanded: Object.values(item.IRS || {}).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;
})
if (!res.ok)
throw new Error('Gagal mengambil data')
const raw = await res.json()
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(', ') : '-',
}))
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();
});
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();
if (!searchQuery.value)
return items.value
const query = searchQuery.value.toLowerCase()
return items.value
.map((item) => {
// Filter mata kuliah (MK) berdasarkan NM_MK
.map(item => {
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
if (filteredMK.length > 0) {
return { ...item, expanded: filteredMK };
}
const matchParent = [item.THN, item.SEMESTER, item.STATUS].some(field =>
String(field).toLowerCase().includes(query),
)
// 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 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>
</div>
<div>
<h6 class="text-h6">
{{ `${item.THN}/${Number(item.THN) + 1}` }}
</h6>
</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 {
block-size: 56px;
}
}
}
}
}
.v-table__wrapper {
table tbody tr td {
block-size: 56px;
}
}
}
......
......@@ -7,25 +7,72 @@ const router = useRouter() // Inisialisasi router
function login() {
keycloakInstance.login({
redirectUri: `${window.location.origin}/naputpro/beranda/profile`,
redirectUri: `${window.location.origin}/profile`,
})
}
function loginGoogle() {
keycloakInstance.login({
redirectUri: `${window.location.origin}/profile`, idpHint: 'google',
})
}
// Cek apakah user sudah login saat komponen dimuat
onMounted(() => {
if (keycloakInstance.authenticated)
router.push('/naputpro/beranda/profile')
router.push('/profile')
})
</script>
<template>
<VBtn
class="font-weight-bold"
color="#FFDC01"
block
type="submit"
@click="login"
>
Login SSO
</VBtn>
class="sso-btn font-weight-medium"
color="#3d3d3d"
prepend-icon="ri-key-fill"
block
type="submit"
rounded="xl"
@click="login"
>
Single Sign On
</VBtn>
<VBtn
class="google-btn font-weight-medium"
color="#3d3d3d"
prepend-icon="ri-google-fill"
block
type="submit"
rounded="xl"
variant="outlined"
style="margin-block-start: 5px"
@click="loginGoogle"
>
Sign in with Google
</VBtn>
</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!