Commit 1710b41f by Nabiilah Putri Safa

edit log

1 parent 17be0b75
<script setup lang="ts"> <script lang="ts" setup>
import { onMounted, ref, watchEffect } from 'vue' import { useKeycloakStore } from "@core/stores/keycloakStore";
import { useKeycloakStore } from '@/@core/stores/keycloakStore'
const keycloakStore = useKeycloakStore();
// Store Keycloak const isAuthenticated = computed(() => keycloakStore.authenticated);
const keycloakStore = useKeycloakStore()
interface ShiftData {
// Data dan state shift_date: string;
const items = ref<any[]>([]) shift_start: string;
const loading = ref(false) shift_end: string;
const searchQuery = ref('') shift: string;
start_time?: string;
// Fungsi ambil tanggal dari shift_start atau shift_end end_time?: string;
const getTanggal = (waktu: string) => (waktu ? waktu.split(' ')[0] : '-')
// Fungsi ambil jam dari start_time atau end_time, kasih "-" kalau kosong
const getJam = (waktu: string) => {
if (!waktu)
return '-'
return waktu.length >= 5 ? waktu.slice(0, 5) : waktu
}
// 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.length >= 5 ? start.slice(0, 5) : '-'
const jamEnd = end && end.length >= 5 ? end.slice(0, 5) : '-'
return `${jamStart} - ${jamEnd}`
} }
const getStatus = (start: string | undefined, end: string | undefined) => { const shifts = ref<ShiftData[]>([]);
if (!start && !end) const loading = ref(false);
return 'Tidak Ada' const error = ref("");
if (!start || !end) const searchQuery = ref("");
return 'Belum Hitung'
const headersShift = [
return 'On Time' { title: "Tanggal", key: "shift_date", sortable: true },
} { title: "Nama Hari", key: "day_name", sortable: false },
{ title: "Shift", key: "shift", sortable: false },
// Header tabel { title: "Jadwal Shift", key: "shift_schedule", sortable: false },
const logHeaders = [ { title: "Mulai Aktual", key: "start_time", sortable: false },
{ title: 'TANGGAL', key: 'tanggal' }, { title: "Selesai Aktual", key: "end_time", sortable: false },
{ title: 'NAMA HARI', key: 'namaHari' }, { title: "Status", key: "status", sortable: false },
{ title: 'SHIFT', key: 'shift' }, ];
{ title: 'JADWAL SHIFT', key: 'jadwalShift' },
{ title: 'MULAI AKTUAL', key: 'start_time' }, async function fetchShiftData() {
{ title: 'SELESAI AKTUAL', key: 'end_time' }, loading.value = true;
{ title: 'STATUS', key: 'status' }, error.value = "";
]
// Fungsi ambil data dari API
async function getData() {
loading.value = true
items.value = []
try { 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, { const response = await fetch(apiEndpoint, {
headers: { headers: {
Authorization: `Bearer ${keycloakStore.accessToken}`, Authorization: `Bearer ${keycloakStore.accessToken}`,
}, },
}) });
if (!response.ok) if (!response.ok) {
throw new Error('Gagal mengambil data') throw new Error("Gagal fetch data");
const dataku = await response.json() }
// Tambahkan properti tanggal, namaHari, mulai aktual, dan selesai aktual ke setiap item const data = await response.json();
items.value = dataku.map((item: any) => {
const tanggal = getTanggal(item.shift_start) || getTanggal(item.shift_end)
return { shifts.value = data
.map((item: any) => ({
...item, ...item,
tanggal, shift_start: `${item.shift_date} ${item.shift_start || "00:00"}`,
namaHari: getNamaHari(tanggal), shift_end: `${item.shift_date} ${item.shift_end || "00:00"}`,
jadwalShift: getJadwalShift(item.shift_start, item.shift_end), }))
start_time: getJam(item.start_time), .sort((a, b) => new Date(b.shift_date).getTime() - new Date(a.shift_date).getTime());
end_time: getJam(item.end_time), } catch (err: any) {
status: getStatus(item.start_time, item.end_time), error.value = err.message || "Terjadi kesalahan saat mengambil data";
} } finally {
}) loading.value = false;
}
catch (err) {
console.error('Gagal mengambil data:', err)
}
finally {
loading.value = false
} }
} }
// Fetch data saat mounted function getDayName(date: string) {
const days = ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"];
const dayIndex = new Date(date).getDay();
return days[dayIndex];
}
const resolveDayColor = (day: string) => {
if (day === "Sabtu" || day === "Minggu") return "error";
};
function getHour(time: string | undefined) {
if (!time) return "-";
const parts = time.split(" ");
return parts.length === 2 ? parts[1] : time;
}
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";
};
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(() => { onMounted(() => {
keycloakStore.refresh() fetchShiftData();
getData() });
})
// Auto refresh data saat token berubah
watchEffect(async () => {
if (!keycloakStore.accessToken)
return
await getData()
})
const filteredItems = computed(() => {
if (!searchQuery.value)
return items.value
const query = searchQuery.value.toLowerCase()
return items.value.filter(item =>
logHeaders.some(
header => item[header.key] && String(item[header.key]).toLowerCase().includes(query),
),
)
})
</script> </script>
<template> <template>
<VCard <AppCardActions
title="Log Absen" :title="`Log Absen`"
class="recentnamaHariCard" class="jadwalShift"
action-collapsed
action-remove
> >
<!-- Search Input -->
<div class="search-container mb-4 pl-2 pr-2"> <div class="search-container mb-4 pl-2 pr-2">
<VTextField <VTextField
v-model="searchQuery" v-model="searchQuery"
...@@ -145,36 +132,101 @@ const filteredItems = computed(() => { ...@@ -145,36 +132,101 @@ const filteredItems = computed(() => {
outlined outlined
/> />
</div> </div>
<VDataTable <VDataTable
:headers="logHeaders" :headers="headersShift"
:items="filteredItems" :items="filteredShifts"
hide-default-footer :loading="loading"
fixed-header loading-text="Memuat data..."
item-value="tanggal"
:sort-by="['tanggal']"
:sort-asc="[true]"
> >
<template #item.namaHari="{ item }"> <!-- Tanggal -->
<template #item.shift_date="{ item }">
{{ item.shift_date }}
</template>
<!-- Nama Hari -->
<template #item.day_name="{ item }">
<VChip <VChip
:color="item.namaHari === 'Sabtu' || item.namaHari === 'Minggu' ? 'error' : 'default'" :color="resolveDayColor(getDayName(item.shift_date))"
:class="`text-${item.namaHari === 'Sabtu' || item.namaHari === 'Minggu' ? 'error' : 'default'}`" :class="`text-${resolveDayColor(getDayName(item.shift_date))}`"
size="small" size="small"
class="font-weight-medium" class="font-weight-medium"
> >
{{ item.namaHari }} {{ getDayName(item.shift_date) }}
</VChip> </VChip>
</template> </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 }"> <template #item.status="{ item }">
<VChip <VChip
:color="item.status === 'Tidak Ada' ? 'error' : 'primary'" :color="resolveStatusColor(getStatus(item.start_time, item.end_time))"
:class="`text-${item.status === 'Tidak Ada' ? 'error' : 'primary'}`" :class="`text-${resolveStatusColor(getStatus(item.start_time, item.end_time))}`"
size="small" size="small"
class="font-weight-medium" class="font-weight-medium"
> >
{{ item.status }} {{ getStatus(item.start_time, item.end_time) }}
</VChip> </VChip>
</template> </template>
</VDataTable> </VDataTable>
</VCard> </AppCardActions>
</template> </template>
<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;
}
}
}
}
}
}
}
}
}
.search-container {
display: flex;
justify-content: flex-end;
}
</style>
...@@ -2,231 +2,302 @@ ...@@ -2,231 +2,302 @@
import { useKeycloakStore } from "@core/stores/keycloakStore"; import { useKeycloakStore } from "@core/stores/keycloakStore";
const keycloakStore = useKeycloakStore(); const keycloakStore = useKeycloakStore();
const isAuthenticated = computed(() => keycloakStore.authenticated);
interface ShiftData {
shift_start: string;
shift_end: string;
shift: string;
start_time?: string;
end_time?: string;
}
const shifts = ref<ShiftData[]>([]); const isCurrentPasswordVisible = ref(false);
const loading = ref(false); const isNewPasswordVisible = ref(false);
const error = ref(""); const isConfirmPasswordVisible = ref(false);
const searchQuery = ref(""); const currentPassword = ref("");
const newPassword = ref("");
const headersShift = [ const newPasswordError = ref("");
{ title: "Tanggal", key: "shift_start", sortable: true }, const confirmPassword = ref("");
{ title: "Nama Hari", key: "day_name", sortable: false }, const isSubmitting = ref(false);
{ title: "Shift", key: "shift", sortable: false },
{ title: "Jadwal Shift", key: "shift_schedule", sortable: false }, watch(newPassword, (newValue) => {
{ title: "Mulai Aktual", key: "start_time", sortable: false }, if (currentPassword.value && newValue === currentPassword.value) {
{ title: "Selesai Aktual", key: "end_time", sortable: false }, newPasswordError.value =
{ title: "Status", key: "status", sortable: false }, "Kata sandi baru tidak boleh sama dengan kata sandi lama.";
} else {
newPasswordError.value = "";
}
});
const passwordRequirements = [
"Panjang minimal 8 karakter, maksimal 20 karakter",
"Minimal satu karakter huruf besar",
"Minimal satu angka",
"Minimal satu simbol, atau karakter spasi",
];
// Aturan Validasi
const oldPasswordRules = [
(v: string) => !!v || "Konfirmasi kata sandi diperlukan",
]; ];
async function fetchShiftData() { const passwordRules = [
loading.value = true; (v: string) => !!v || "Kata sandi diperlukan",
error.value = ""; (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) => /\d/.test(v) || "Kata sandi setidaknya berisi satu angka",
(v: string) =>
/[ !"#$%&'()*+,-./:;<=>?@[\\\]^_`{|}~]/.test(v) ||
"Kata sandi setidaknya mengandung satu simbol atau spasi",
];
const confirmPasswordRules = [
(v: string) => !!v || "Konfirmasi kata sandi diperlukan",
(v: string) => v === newPassword.value || "Kata sandi tidak cocok",
];
// Generate Password
function generatePassword(length: number = 10): string {
const lowercase = "abcdefghijklmnopqrstuvwxyz";
const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const numbers = "0123456789";
const symbols = ` !"#$%&'()*+,-./:;<=>?@[\\]^_\`{|}~`;
// At least one character from each required category
const mandatoryCharacters = [
lowercase[Math.floor(Math.random() * lowercase.length)],
uppercase[Math.floor(Math.random() * uppercase.length)],
numbers[Math.floor(Math.random() * numbers.length)],
symbols[Math.floor(Math.random() * symbols.length)],
];
// Combine all character sets into one charset
const allCharacters = lowercase + uppercase + numbers + symbols;
const remainingLength = length - mandatoryCharacters.length;
let password = mandatoryCharacters;
// 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)];
password.push(randomChar);
}
// Shuffle the password to ensure randomness
password = password.sort(() => Math.random() - 0.5);
// Return the password as a string
return password.join("");
}
// fungsi untuk encoding Base64
function toBase64(str: string) {
return btoa(unescape(encodeURIComponent(str)));
}
// Set Password
async function setPassword() {
isSubmitting.value = true;
if (isEmpty(currentPassword.value)) {
alert("Kata Sandi Lama tidak boleh kosong");
return;
}
// 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;
}
if (newPassword.value !== confirmPassword.value) {
alert("Kata Sandi Baru dan Konfirmasi Kata Sandi tidak cocok.");
return;
}
try { try {
const apiEndpoint = `https://api.ui.ac.id/my/hr/attendance`; // Panggil API untuk mengganti password
const apiEndpoint = `https://api.ui.ac.id/my/pw`;
const response = await fetch(apiEndpoint, { const response = await fetch(apiEndpoint, {
method: "POST",
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${keycloakStore.accessToken}`, 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) { if (!response.ok) {
throw new Error("Gagal fetch data"); const errorData = await response.json();
throw new Error(errorData.message || "Gagal mengubah password");
} }
const data = await response.json(); // Jika berhasil, tampilkan pesan dan reset form
shifts.value = data; alert(
} catch (err: any) { "Kata sandi berhasil diubah. Silakan logout dan login kembali dengan kata sandi baru."
error.value = err.message || "Terjadi kesalahan saat mengambil data"; );
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 { } finally {
loading.value = false; isSubmitting.value = false;
} }
}
// function getDayName(date: string) {
// const options: Intl.DateTimeFormatOptions = { weekday: "long" };
// return new Date(date).toLocaleDateString("en-US", options);
// }
function getDayName(date: string) { // Reset input
const days = ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"]; currentPassword.value = "";
const dayIndex = new Date(date).getDay(); newPassword.value = "";
return days[dayIndex]; confirmPassword.value = "";
} }
</script>
const resolveDayColor = (day: string) => { <template>
if (day === "Sabtu" || day === "Minggu") return "error"; <VRow>
}; <!-- SECTION: Change Password -->
<VCol cols="12">
function getHour(time: string | undefined) { <VCard>
if (!time) return "-"; <VCardItem class="pb-6">
const [date, hour] = time.split(" "); <VCardTitle>Ganti Kata Sandi</VCardTitle>
return hour; </VCardItem>
} <VForm>
<VCardText class="pt-0">
<!-- 👉 Current Password -->
<VRow>
<VCol cols="12" md="6">
<!-- 👉 current password -->
<VTextField
v-model="currentPassword"
:type="isCurrentPasswordVisible ? 'text' : 'password'"
:maxlength="20"
:append-inner-icon="
isCurrentPasswordVisible ? 'ri-eye-off-line' : 'ri-eye-line'
"
autocomplete="on"
label="Kata Sandi Lama"
@click:append-inner="
isCurrentPasswordVisible = !isCurrentPasswordVisible
"
:rules="oldPasswordRules"
clearable
/>
</VCol>
</VRow>
function getStatus(start: string | undefined, end: string | undefined) { <!-- 👉 New Password -->
if (!start && !end) return "Tidak Ada"; <VRow>
if (!start || !end) return "Belum Hitung"; <VCol cols="12" md="6">
return "On Time"; <!-- 👉 new password -->
} <VTextField
v-model="newPassword"
:type="isNewPasswordVisible ? 'text' : 'password'"
:maxlength="20"
:append-inner-icon="
isNewPasswordVisible ? 'ri-eye-off-line' : 'ri-eye-line'
"
label="Kata Sandi Baru"
autocomplete="on"
@click:append-inner="
isNewPasswordVisible = !isNewPasswordVisible
"
:rules="passwordRules"
:error-messages="newPasswordError"
clearable
/>
</VCol>
const resolveStatusColor = (status: string) => { <VCol cols="12" md="6">
if (status === "On Time") return "success"; <!-- 👉 confirm password -->
if (status === "Belum Hitung") return "primary"; <VTextField
if (status === "Tidak Ada") return "error"; v-model="confirmPassword"
}; :type="isConfirmPasswordVisible ? 'text' : 'password'"
:maxlength="20"
const filteredShifts = computed(() => { :append-inner-icon="
if (!searchQuery.value) return shifts.value; isConfirmPasswordVisible ? 'ri-eye-off-line' : 'ri-eye-line'
"
return shifts.value.filter((shift) => { autocomplete="on"
const dayName = getDayName(shift.shift_start.split(" ")[0]); label="Konfirmasi Kata Sandi"
const status = getStatus(shift.start_time, shift.end_time); @click:append-inner="
isConfirmPasswordVisible = !isConfirmPasswordVisible
return ( "
shift.shift_start :rules="confirmPasswordRules"
.toLowerCase() clearable
.includes(searchQuery.value.toLowerCase()) || />
dayName.toLowerCase().includes(searchQuery.value.toLowerCase()) || </VCol>
shift.shift.toLowerCase().includes(searchQuery.value.toLowerCase()) || </VRow>
shift.start_time </VCardText>
?.toLowerCase()
.includes(searchQuery.value.toLowerCase()) ||
shift.end_time?.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
status.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
});
onMounted(() => { <!-- 👉 Password Requirements -->
fetchShiftData(); <VCardText>
}); <h6 class="text-h6 text-medium-emphasis mt-1">
</script> Persyaratan Kata Sandi:
</h6>
<template> <VList>
<AppCardActions <VListItem
:title="`Log Absen`" v-for="(item, index) in passwordRequirements"
class="jadwalShift" :key="index"
action-collapsed class="px-0 mt-n4 mb-n2"
action-remove >
> <template #prepend>
<!-- Search Input --> <VIcon
<div class="search-container mb-4 pl-2 pr-2"> size="8"
<VTextField icon="ri-circle-fill"
v-model="searchQuery" color="rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity))"
label="Search" />
placeholder="Search ..." </template>
append-inner-icon="ri-search-line" <VListItemTitle class="text-medium-emphasis text-wrap">
clearable {{ item }}
single-line </VListItemTitle>
hide-details </VListItem>
dense </VList>
outlined
/>
</div>
<VDataTable
:headers="headersShift"
:items="filteredShifts"
:loading="loading"
loading-text="Memuat data..."
>
<template #item.shift_start="{ item }">
{{ item.shift_start.split(" ")[0] }}
</template>
<template #item.day_name="{ item }">
<VChip
:color="resolveDayColor(getDayName(item.shift_start.split(' ')[0]))"
:class="`text-${resolveDayColor(
getDayName(item.shift_start.split(' ')[0])
)}`"
size="small"
class="font-weight-medium"
>
{{ getDayName(item.shift_start.split(" ")[0]) }}
</VChip>
</template>
<template #item.shift_schedule="{ item }">
{{ `${getHour(item.shift_start)} - ${getHour(item.shift_end)}` }}
</template>
<template #item.start_time="{ item }">
{{ item.start_time ? getHour(item.start_time) : "-" }}
</template>
<template #item.end_time="{ item }">
{{ item.end_time ? 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>
<style lang="scss"> <!-- 👉 Action Buttons -->
.jadwalShift { <div class="d-flex flex-wrap gap-4">
.v-table { <VBtn @click="setPassword" :disabled="isSubmitting">{{
&--density-default { isSubmitting ? "Sedang Memproses..." : "Simpan Perubahan"
.v-table__wrapper { }}</VBtn>
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;
}
}
}
}
}
}
}
}
}
.search-container { <VBtn type="reset" color="secondary" variant="outlined">
display: flex; Reset
justify-content: flex-end; </VBtn>
} <VBtn
</style> color="secondary"
variant="outlined"
@click="
newPassword = generatePassword();
confirmPassword = newPassword;
"
>
Generate Kata Sandi</VBtn
>
</div>
</VCardText>
</VForm>
</VCard>
</VCol>
<!-- !SECTION -->
</VRow>
</template>
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!