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