Feature/phone register (#11)
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local> Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local> Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
export const EMPLOYERS = [
|
||||
{
|
||||
name: 'Infra Quest Sdn. Bhd. (IQSB)',
|
||||
address: 'Lot 1045, Jalan Dato’ Lundang, 15200 Kota Bharu, Kelantan',
|
||||
},
|
||||
{
|
||||
name: 'Permodalan Kelantan Berhad (PKB)',
|
||||
address:
|
||||
'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
|
||||
},
|
||||
{
|
||||
name: 'Koperasi Permodalan Kelantan Berhad (KOPKB)',
|
||||
address:
|
||||
'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
|
||||
},
|
||||
{
|
||||
name: "An-Nisa'",
|
||||
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
|
||||
},
|
||||
{
|
||||
name: 'Kel Infra Sdn. Bhd.',
|
||||
address: 'Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.',
|
||||
},
|
||||
] as const
|
||||
@@ -6,6 +6,7 @@ import { activityMenu } from '@/modules/activity'
|
||||
import { dashboardMenu } from '@/modules/dashboard/menu'
|
||||
import { externalSystemMenu } from '@/modules/external-system/menu'
|
||||
import { activityLogMenu } from '@/modules/activity-log/menu'
|
||||
import { feedbackMenu } from '@/modules/feedback'
|
||||
|
||||
export type { Menu }
|
||||
|
||||
@@ -17,6 +18,7 @@ const mainMenu: (string | Menu)[] = [
|
||||
'Teknologi Maklumat',
|
||||
...roleMenu,
|
||||
...activityLogMenu,
|
||||
...feedbackMenu,
|
||||
'Pentadbiran',
|
||||
...membershipApplicationMenu,
|
||||
...userMenu,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { sendVerificationEmail } from '@/modules/auth'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
|
||||
const dismissed = ref(false)
|
||||
const loading = ref(false)
|
||||
const feedbackMessage = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const handleSend = async () => {
|
||||
loading.value = true
|
||||
feedbackMessage.value = ''
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await sendVerificationEmail()
|
||||
feedbackMessage.value = response.message
|
||||
} catch (error) {
|
||||
errorMessage.value = getApiErrorMessage(error, 'Gagal menghantar pautan pengesahan. Sila cuba lagi.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertRoot v-if="!dismissed" class="mb-4" variant="primary">
|
||||
<AlertTitle>Pengesahan emel diperlukan.</AlertTitle>
|
||||
<AlertDescription>
|
||||
Sila sahkan emel anda dengan menekan butang di bawah.
|
||||
<span v-if="feedbackMessage" class="mt-2 block">{{ feedbackMessage }}</span>
|
||||
<span v-if="errorMessage" class="mt-2 block text-danger">{{ errorMessage }}</span>
|
||||
</AlertDescription>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="primary" look="outline" type="button" :disabled="loading" @click="handleSend">
|
||||
{{ loading ? 'Menghantar...' : 'Hantar Pautan Pengesahan' }}
|
||||
</Button>
|
||||
<Button size="sm" type="button" @click="dismissed = true">
|
||||
Abaikan
|
||||
</Button>
|
||||
</div>
|
||||
</AlertRoot>
|
||||
</template>
|
||||
@@ -4,28 +4,36 @@ export {
|
||||
login,
|
||||
logout,
|
||||
register,
|
||||
verifyEmail,
|
||||
resendVerificationEmail,
|
||||
sendPhoneVerificationOtp,
|
||||
verifyPhoneVerificationOtp,
|
||||
sendAuthenticatedPhoneVerificationOtp,
|
||||
verifyAuthenticatedPhoneVerificationOtp,
|
||||
sendVerificationEmail,
|
||||
requestForgotPassword,
|
||||
resetPassword,
|
||||
fetchCurrentUser,
|
||||
getAuthErrorMessage,
|
||||
getRegisterErrorMessage,
|
||||
getVerifyEmailErrorMessage,
|
||||
getPhoneVerificationErrorMessage,
|
||||
getForgotPasswordErrorMessage,
|
||||
getResetPasswordErrorMessage,
|
||||
resolvePostLoginRoute,
|
||||
resolvePostAuthRoute,
|
||||
isAccountPending,
|
||||
isLoginVerificationRequired,
|
||||
isEmailVerified,
|
||||
isPhoneVerified,
|
||||
} from './services/auth.service'
|
||||
export type {
|
||||
LoginCredentials,
|
||||
LoginResponse,
|
||||
RegisterCredentials,
|
||||
RegisterResponse,
|
||||
VerifyEmailPayload,
|
||||
VerifyEmailResponse,
|
||||
SendPhoneVerificationOtpPayload,
|
||||
SendPhoneVerificationOtpResponse,
|
||||
VerifyPhoneVerificationOtpPayload,
|
||||
VerifyPhoneVerificationOtpResponse,
|
||||
VerifyAuthenticatedPhoneVerificationOtpResponse,
|
||||
ResendVerificationResponse,
|
||||
ForgotPasswordPayload,
|
||||
ForgotPasswordResponse,
|
||||
ResetPasswordPayload,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
@@ -10,6 +10,7 @@ import logoUrl from '@/assets/images/logo.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loggingOut = ref(false)
|
||||
@@ -44,6 +45,12 @@ const handleLogout = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const verified = route.query.verified
|
||||
if (typeof verified === 'string' && verified) {
|
||||
authStore.fetchSession()
|
||||
router.replace({ query: { ...route.query, verified: undefined } })
|
||||
}
|
||||
|
||||
statusPollInterval = setInterval(checkActivationStatus, 30000)
|
||||
})
|
||||
|
||||
@@ -92,21 +99,21 @@ const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
</p>
|
||||
|
||||
<AlertRoot class="mt-8" variant="primary">
|
||||
<AlertTitle>E-mel disahkan</AlertTitle>
|
||||
<AlertTitle>Menunggu pengaktifan</AlertTitle>
|
||||
<AlertDescription>
|
||||
Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem. Anda akan dapat
|
||||
mengakses sistem selepas akaun diaktifkan.
|
||||
Pendaftaran anda berjaya. Akaun anda sedang menunggu pengaktifan daripada pentadbir
|
||||
sistem. Anda akan dapat mengakses sistem selepas akaun diaktifkan.
|
||||
</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-4">
|
||||
<Button class="box w-full px-4 py-5" variant="primary" type="button" :disabled="checkingStatus"
|
||||
@click="checkActivationStatus">
|
||||
{{ checkingStatus ? 'Checking...' : 'Semak Status' }}
|
||||
{{ checkingStatus ? 'Menyemak...' : 'Semak Status' }}
|
||||
</Button>
|
||||
<Button class="box w-full px-4 py-5" look="outline" type="button" :disabled="loggingOut"
|
||||
@click="handleLogout">
|
||||
{{ loggingOut ? 'Logging out...' : 'Log Keluar' }}
|
||||
{{ loggingOut ? 'Log keluar...' : 'Log Keluar' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
login,
|
||||
resolvePostAuthRoute,
|
||||
} from '@/modules/auth'
|
||||
import { HelpdeskFab } from '@/modules/feedback'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
@@ -47,18 +48,12 @@ const handleLogin = async () => {
|
||||
remember: remember.value,
|
||||
})
|
||||
|
||||
const loginData = response.data
|
||||
|
||||
if ('requires_email_verification' in loginData) {
|
||||
await router.push({
|
||||
name: 'verify-email',
|
||||
query: { email: loginData.email },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
authStore.setSession(loginData.user, response.active_role ?? null, response.can_switch_role ?? false)
|
||||
await router.push(resolvePostAuthRoute(loginData.user, response.redirect_path))
|
||||
authStore.setSession(
|
||||
response.data.user,
|
||||
response.active_role ?? null,
|
||||
response.can_switch_role ?? false,
|
||||
)
|
||||
await router.push(resolvePostAuthRoute(response.data.user, response.redirect_path))
|
||||
} catch (error) {
|
||||
errorMessage.value = getAuthErrorMessage(error)
|
||||
} finally {
|
||||
@@ -76,6 +71,7 @@ const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
'before:hidden before:xl:block before:content-[\'\'] before:w-[57%] before:mt-[-28%] before:mb-[-16%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:rotate-6 before:bg-primary/95 before:bg-noise before:rounded-[35%]',
|
||||
'after:hidden after:xl:block after:content-[\'\'] after:w-[57%] after:mt-[-28%] after:mb-[-16%] after:ml-[-12%] after:absolute after:inset-y-0 after:left-0 after:transform after:rotate-6 after:border after:bg-accent after:bg-cover after:blur-xl after:rounded-[35%] after:border-primary',
|
||||
]">
|
||||
<HelpdeskFab />
|
||||
<div :class="[
|
||||
'p-3 sm:px-8 relative h-full',
|
||||
'before:hidden before:xl:block before:w-[57%] before:mt-[-20%] before:mb-[-13%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:-rotate-6 before:bg-primary/40 before:bg-noise before:border before:border-primary/50 before:opacity-60 before:rounded-[20%]',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -7,12 +7,33 @@ import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/ch
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { PasswordInput } from '@/components/ui/password-input'
|
||||
import { getRegisterErrorMessage, register } from '@/modules/auth'
|
||||
import {
|
||||
getPhoneVerificationErrorMessage,
|
||||
getRegisterErrorMessage,
|
||||
register,
|
||||
resolvePostAuthRoute,
|
||||
sendPhoneVerificationOtp,
|
||||
verifyPhoneVerificationOtp,
|
||||
} from '@/modules/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { HelpdeskFab } from '@/modules/feedback'
|
||||
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const router = useRouter()
|
||||
const steps = [
|
||||
{ id: 1, label: 'No. Telefon' },
|
||||
{ id: 2, label: 'Sahkan OTP' },
|
||||
{ id: 3, label: 'Maklumat Akaun' },
|
||||
{ id: 4, label: 'Kata Laluan' },
|
||||
] as const
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const currentStep = ref(1)
|
||||
const phoneNumber = ref('')
|
||||
const otp = ref('')
|
||||
const phoneVerificationToken = ref('')
|
||||
const name = ref('')
|
||||
const email = ref('')
|
||||
const icNumber = ref('')
|
||||
@@ -21,6 +42,61 @@ const passwordConfirmation = ref('')
|
||||
const termsAccepted = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const stepTitle = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 1:
|
||||
return 'Sahkan Nombor Telefon'
|
||||
case 2:
|
||||
return 'Masukkan Kod OTP'
|
||||
case 3:
|
||||
return 'Maklumat Akaun'
|
||||
default:
|
||||
return 'Tetapkan Kata Laluan'
|
||||
}
|
||||
})
|
||||
|
||||
const stepDescription = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 1:
|
||||
return 'Masukkan nombor telefon anda untuk menerima kod OTP melalui SMS.'
|
||||
case 2:
|
||||
return 'Masukkan kod OTP 6 digit yang telah dihantar ke nombor telefon anda.'
|
||||
case 3:
|
||||
return 'Lengkapkan maklumat asas akaun anda.'
|
||||
default:
|
||||
return 'Tetapkan kata laluan dan bersetuju dengan terma pendaftaran.'
|
||||
}
|
||||
})
|
||||
|
||||
const primaryActionLabel = computed(() => {
|
||||
if (loading.value) {
|
||||
switch (currentStep.value) {
|
||||
case 1:
|
||||
return 'Menghantar OTP...'
|
||||
case 2:
|
||||
return 'Mengesahkan OTP...'
|
||||
case 3:
|
||||
return 'Seterusnya'
|
||||
default:
|
||||
return 'Mendaftar...'
|
||||
}
|
||||
}
|
||||
|
||||
switch (currentStep.value) {
|
||||
case 1:
|
||||
return 'Hantar OTP'
|
||||
case 2:
|
||||
return 'Sahkan OTP'
|
||||
case 3:
|
||||
return 'Seterusnya'
|
||||
default:
|
||||
return 'Daftar'
|
||||
}
|
||||
})
|
||||
|
||||
const inputClass = 'box block min-w-full px-4 py-4 xl:min-w-md'
|
||||
|
||||
const handleNameInput = () => {
|
||||
name.value = sanitizeNameInput(name.value)
|
||||
@@ -30,13 +106,102 @@ const handleIcNumberInput = () => {
|
||||
icNumber.value = sanitizeIcNumberInput(icNumber.value)
|
||||
}
|
||||
|
||||
const handlePhoneInput = () => {
|
||||
phoneNumber.value = phoneNumber.value.replace(/[^\d+]/g, '')
|
||||
phoneVerificationToken.value = ''
|
||||
}
|
||||
|
||||
const handleOtpInput = () => {
|
||||
otp.value = otp.value.replace(/\D/g, '').slice(0, 6)
|
||||
}
|
||||
|
||||
const clearMessages = () => {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
}
|
||||
|
||||
const handleSendOtp = async () => {
|
||||
clearMessages()
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await sendPhoneVerificationOtp({
|
||||
phone_number: phoneNumber.value,
|
||||
})
|
||||
|
||||
successMessage.value = response.message
|
||||
currentStep.value = 2
|
||||
} catch (error) {
|
||||
errorMessage.value = getPhoneVerificationErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyOtp = async () => {
|
||||
clearMessages()
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await verifyPhoneVerificationOtp({
|
||||
phone_number: phoneNumber.value,
|
||||
otp: otp.value,
|
||||
})
|
||||
|
||||
phoneNumber.value = response.data.phone_number
|
||||
phoneVerificationToken.value = response.data.verification_token
|
||||
successMessage.value = response.message
|
||||
currentStep.value = 3
|
||||
} catch (error) {
|
||||
errorMessage.value = getPhoneVerificationErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const validateAccountStep = (): boolean => {
|
||||
clearMessages()
|
||||
|
||||
if (!name.value.trim()) {
|
||||
errorMessage.value = 'Nama penuh diperlukan.'
|
||||
return false
|
||||
}
|
||||
|
||||
if (!email.value.trim()) {
|
||||
errorMessage.value = 'Emel diperlukan.'
|
||||
return false
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
|
||||
errorMessage.value = 'Emel tidak sah.'
|
||||
return false
|
||||
}
|
||||
|
||||
if (!icNumber.value.trim()) {
|
||||
errorMessage.value = 'No. kad pengenalan diperlukan.'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!password.value || !passwordConfirmation.value) {
|
||||
errorMessage.value = 'Kata laluan diperlukan.'
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value !== passwordConfirmation.value) {
|
||||
errorMessage.value = 'Pengesahan kata laluan tidak sepadan.'
|
||||
return
|
||||
}
|
||||
|
||||
if (!termsAccepted.value) {
|
||||
errorMessage.value = 'Sila bersetuju dengan Dasar Privasi dan Terma dan Syarat.'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
clearMessages()
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
@@ -44,34 +209,88 @@ const handleRegister = async () => {
|
||||
name: name.value,
|
||||
email: email.value,
|
||||
ic_number: icNumber.value,
|
||||
phone_number: phoneNumber.value,
|
||||
phone_verification_token: phoneVerificationToken.value,
|
||||
password: password.value,
|
||||
password_confirmation: passwordConfirmation.value,
|
||||
})
|
||||
|
||||
await router.push({
|
||||
name: 'verify-email',
|
||||
query: { email: response.data.email },
|
||||
})
|
||||
authStore.setSession(
|
||||
response.data.user,
|
||||
response.active_role ?? null,
|
||||
response.can_switch_role ?? false,
|
||||
)
|
||||
await router.push(resolvePostAuthRoute(response.data.user, response.redirect_path))
|
||||
} catch (error) {
|
||||
errorMessage.value = getRegisterErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrimaryAction = async () => {
|
||||
if (currentStep.value === 1) {
|
||||
await handleSendOtp()
|
||||
return
|
||||
}
|
||||
|
||||
if (currentStep.value === 2) {
|
||||
await handleVerifyOtp()
|
||||
return
|
||||
}
|
||||
|
||||
if (currentStep.value === 3) {
|
||||
if (!validateAccountStep()) {
|
||||
return
|
||||
}
|
||||
|
||||
currentStep.value = 4
|
||||
return
|
||||
}
|
||||
|
||||
await handleRegister()
|
||||
}
|
||||
|
||||
const goPrevious = () => {
|
||||
clearMessages()
|
||||
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
const stepButtonClass = (stepId: number) => {
|
||||
if (stepId === currentStep.value) {
|
||||
return 'size-10 rounded-full shadow-none'
|
||||
}
|
||||
|
||||
if (stepId < currentStep.value) {
|
||||
return 'size-10 rounded-full shadow-none bg-primary text-primary-foreground'
|
||||
}
|
||||
|
||||
return 'bg-background border border-foreground/15 shadow-none size-10 rounded-full'
|
||||
}
|
||||
|
||||
const stepLabelClass = (stepId: number) => {
|
||||
return stepId === currentStep.value
|
||||
? 'mt-2 text-xs font-medium text-primary'
|
||||
: 'mt-2 text-xs opacity-70'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'relative h-screen lg:overflow-hidden bg-primary bg-noise xl:bg-background xl:bg-none',
|
||||
'relative min-h-dvh bg-primary bg-noise xl:h-screen xl:overflow-hidden xl:bg-background xl:bg-none',
|
||||
'before:hidden before:xl:block before:content-[\'\'] before:w-[57%] before:mt-[-28%] before:mb-[-16%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:rotate-6 before:bg-primary/95 before:bg-noise before:rounded-[35%]',
|
||||
'after:hidden after:xl:block after:content-[\'\'] after:w-[57%] after:mt-[-28%] after:mb-[-16%] after:ml-[-12%] after:absolute after:inset-y-0 after:left-0 after:transform after:rotate-6 after:border after:bg-accent after:bg-cover after:blur-xl after:rounded-[35%] after:border-primary',
|
||||
]">
|
||||
<HelpdeskFab />
|
||||
<div :class="[
|
||||
'p-3 sm:px-8 relative h-full',
|
||||
'relative p-3 sm:px-8',
|
||||
'before:hidden before:xl:block before:w-[57%] before:mt-[-20%] before:mb-[-13%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:-rotate-6 before:bg-primary/40 before:bg-noise before:border before:border-primary/50 before:opacity-60 before:rounded-[20%]',
|
||||
]">
|
||||
<div class="container relative z-10 mx-auto sm:px-20">
|
||||
<div class="block grid-cols-2 gap-4 xl:grid">
|
||||
<div class="block xl:grid xl:grid-cols-2 xl:gap-4">
|
||||
<div class="hidden min-h-screen flex-col xl:flex">
|
||||
<div class="my-auto">
|
||||
<img class="-mt-16 w-1/2" :src="illustrationUrl" alt="logo-RAJD" />
|
||||
@@ -84,61 +303,115 @@ const handleRegister = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-10 flex h-screen py-5 xl:my-0 xl:h-auto xl:py-0">
|
||||
<div class="my-6 py-4 xl:my-0 xl:flex xl:min-h-screen xl:items-center xl:py-0">
|
||||
<Box raised="double"
|
||||
class="mx-auto my-auto w-full px-5 py-8 sm:w-3/4 sm:px-8 lg:w-2/4 xl:ml-24 xl:w-auto xl:p-0 xl:before:hidden xl:after:hidden xl:shadow-none xl:border-none xl:bg-none">
|
||||
class="mx-auto w-full px-5 py-6 sm:w-3/4 sm:px-8 lg:w-2/4 xl:ml-24 xl:w-auto xl:p-0 xl:before:hidden xl:after:hidden xl:shadow-none xl:border-none xl:bg-none">
|
||||
<h2 class="text-center text-2xl font-semibold xl:text-left xl:text-3xl">Daftar Akaun</h2>
|
||||
<div class="mt-2 text-center opacity-70 xl:hidden">
|
||||
Daftar Akaun
|
||||
|
||||
<div
|
||||
class="before:bg-foreground/10 relative mt-5 flex flex-row justify-between gap-1 px-1 before:absolute before:bottom-[calc(50%-0.75rem)] before:left-[12%] before:right-[12%] before:h-0.5 before:w-auto">
|
||||
<div v-for="step in steps" :key="step.id" class="z-10 flex flex-1 flex-col items-center text-center">
|
||||
<Button :class="stepButtonClass(step.id)" :variant="step.id === currentStep ? 'primary' : 'ghost'"
|
||||
type="button">
|
||||
{{ step.id }}
|
||||
</Button>
|
||||
<div :class="stepLabelClass(step.id)">
|
||||
{{ step.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
|
||||
<AlertTitle>Daftar gagal</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleRegister">
|
||||
<Input v-model="name" class="box block min-w-full px-5 py-6 xl:min-w-md" type="text"
|
||||
placeholder="Nama Penuh" autocomplete="name" required @input="handleNameInput" />
|
||||
<Input v-model="email" class="box block min-w-full px-5 py-6 xl:min-w-md" type="email"
|
||||
placeholder="Email" autocomplete="email" required />
|
||||
<Input v-model="icNumber" class="box block min-w-full px-5 py-6 xl:min-w-md" type="text"
|
||||
inputmode="numeric" maxlength="15" placeholder="Contoh: 900101011234" required
|
||||
@input="handleIcNumberInput" />
|
||||
<PasswordInput v-model="password" class="box block min-w-full px-5 py-6 xl:min-w-md" type="password"
|
||||
placeholder="Kata Laluan" autocomplete="new-password" minlength="8" required />
|
||||
<PasswordInput v-model="passwordConfirmation" class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
placeholder="Sahkan Kata Laluan" autocomplete="new-password" minlength="8" required />
|
||||
|
||||
<div class="flex text-xs sm:text-sm">
|
||||
<CheckboxRoot :checked="termsAccepted"
|
||||
@checked-change="({ checked }) => (termsAccepted = checked === true)">
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>
|
||||
Dengan mendaftar, anda bersetuju dengan
|
||||
<RouterLink class="text-primary ml-1" to="/privacy-policy">
|
||||
Dasar Privasi
|
||||
</RouterLink>
|
||||
&
|
||||
<RouterLink class="text-primary ml-1" to="/terms">
|
||||
Terma dan Syarat
|
||||
</RouterLink>
|
||||
.
|
||||
</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
<div class="mt-5 border-t border-foreground/10 pt-5">
|
||||
<div class="text-center xl:text-left">
|
||||
<div class="text-base font-medium">{{ stepTitle }}</div>
|
||||
<div class="mt-1 text-sm opacity-70">{{ stepDescription }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-center xl:mt-10 xl:text-left">
|
||||
<Button class="box w-full px-4 py-5" variant="primary" type="submit"
|
||||
:disabled="loading || !termsAccepted">
|
||||
{{ loading ? 'Mendaftar...' : 'Daftar' }}
|
||||
</Button>
|
||||
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
|
||||
@click="router.push({ name: 'login' })">
|
||||
Log masuk
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<AlertRoot v-if="errorMessage" class="mt-4 py-3" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" class="mt-4 py-3" variant="success">
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<form class="mt-4 flex flex-col gap-5" @submit.prevent="handlePrimaryAction">
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="currentStep === 1">
|
||||
<Input v-model="phoneNumber" :class="inputClass" type="tel" inputmode="tel"
|
||||
placeholder="No. Telefon, contoh: 0123456790" autocomplete="tel" required
|
||||
@input="handlePhoneInput" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="currentStep === 2">
|
||||
<Input v-model="otp" :class="`${inputClass} text-center tracking-[0.4em]`" type="text"
|
||||
inputmode="numeric" maxlength="6" placeholder="000000" autocomplete="one-time-code" required
|
||||
@input="handleOtpInput" />
|
||||
<Button class="box w-full px-4 py-4" look="outline" type="button" :disabled="loading"
|
||||
@click="handleSendOtp">
|
||||
Hantar Semula OTP
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="currentStep === 3">
|
||||
<div class="rounded-lg border border-foreground/10 bg-foreground/5 px-4 py-3 text-sm">
|
||||
<span class="opacity-70">Telefon disahkan:</span>
|
||||
<span class="ml-1 font-medium">{{ phoneNumber }}</span>
|
||||
</div>
|
||||
<Input v-model="name" :class="inputClass" type="text" placeholder="Nama Penuh" autocomplete="name"
|
||||
required @input="handleNameInput" />
|
||||
<Input v-model="email" :class="inputClass" type="email" placeholder="Email" autocomplete="email"
|
||||
required />
|
||||
<Input v-model="icNumber" :class="inputClass" type="text" inputmode="numeric" maxlength="15"
|
||||
placeholder="Contoh: 900101011234" required @input="handleIcNumberInput" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<PasswordInput v-model="password" :class="inputClass" placeholder="Kata Laluan"
|
||||
autocomplete="new-password" minlength="8" required />
|
||||
<PasswordInput v-model="passwordConfirmation" :class="inputClass" placeholder="Sahkan Kata Laluan"
|
||||
autocomplete="new-password" minlength="8" required />
|
||||
|
||||
<div class="flex text-xs sm:text-sm">
|
||||
<CheckboxRoot :checked="termsAccepted"
|
||||
@checked-change="({ checked }) => (termsAccepted = checked === true)">
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>
|
||||
Dengan mendaftar, anda bersetuju dengan
|
||||
<RouterLink class="text-primary ml-1" to="/privacy-policy">
|
||||
Dasar Privasi
|
||||
</RouterLink>
|
||||
&
|
||||
<RouterLink class="text-primary ml-1" to="/terms">
|
||||
Terma dan Syarat
|
||||
</RouterLink>
|
||||
.
|
||||
</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-3">
|
||||
<Button v-if="currentStep > 1" class="box w-full px-4 py-4" look="outline" type="button"
|
||||
:disabled="loading" @click="goPrevious">
|
||||
Sebelum
|
||||
</Button>
|
||||
<Button class="box w-full px-4 py-4" variant="primary" type="submit"
|
||||
:disabled="loading || (currentStep === 4 && !termsAccepted)">
|
||||
{{ primaryActionLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
<button type="button" class="w-full text-sm opacity-70 hover:opacity-100"
|
||||
@click="router.push({ name: 'login' })">
|
||||
Sudah mempunyai akaun? Log masuk
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
getVerifyEmailErrorMessage,
|
||||
resendVerificationEmail,
|
||||
resolvePostAuthRoute,
|
||||
verifyEmail,
|
||||
} from '@/modules/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
const otp = ref('')
|
||||
const loading = ref(false)
|
||||
const resendLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const resendMessage = ref('')
|
||||
|
||||
const canSubmit = computed(() => email.value.length > 0 && otp.value.length === 6)
|
||||
|
||||
onMounted(() => {
|
||||
const queryEmail = route.query.email
|
||||
if (typeof queryEmail === 'string' && queryEmail) {
|
||||
email.value = queryEmail
|
||||
}
|
||||
})
|
||||
|
||||
const handleVerify = async () => {
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await verifyEmail({
|
||||
email: email.value,
|
||||
otp: otp.value,
|
||||
})
|
||||
|
||||
authStore.setSession(response.data.user, response.active_role, response.can_switch_role)
|
||||
await router.push(resolvePostAuthRoute(response.data.user))
|
||||
} catch (error) {
|
||||
errorMessage.value = getVerifyEmailErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!email.value) {
|
||||
errorMessage.value = 'Sila masukkan alamat e-mel.'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
resendMessage.value = ''
|
||||
resendLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await resendVerificationEmail(email.value)
|
||||
resendMessage.value = response.message
|
||||
} catch (error) {
|
||||
errorMessage.value = getVerifyEmailErrorMessage(error)
|
||||
} finally {
|
||||
resendLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onOtpInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
otp.value = target.value.replace(/\D/g, '').slice(0, 6)
|
||||
}
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'relative h-screen lg:overflow-hidden bg-primary bg-noise xl:bg-background xl:bg-none',
|
||||
'before:hidden before:xl:block before:content-[\'\'] before:w-[57%] before:mt-[-28%] before:mb-[-16%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:rotate-6 before:bg-primary/95 before:bg-noise before:rounded-[35%]',
|
||||
'after:hidden after:xl:block after:content-[\'\'] after:w-[57%] after:mt-[-28%] after:mb-[-16%] after:ml-[-12%] after:absolute after:inset-y-0 after:left-0 after:transform after:rotate-6 after:border after:bg-accent after:bg-cover after:blur-xl after:rounded-[35%] after:border-primary',
|
||||
]">
|
||||
<div :class="[
|
||||
'p-3 sm:px-8 relative h-full',
|
||||
'before:hidden before:xl:block before:w-[57%] before:mt-[-20%] before:mb-[-13%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:-rotate-6 before:bg-primary/40 before:bg-noise before:border before:border-primary/50 before:opacity-60 before:rounded-[20%]',
|
||||
]">
|
||||
<div class="container relative z-10 mx-auto sm:px-20">
|
||||
<div class="block grid-cols-2 gap-4 xl:grid">
|
||||
<div class="hidden min-h-screen flex-col xl:flex">
|
||||
<a class="flex items-center pt-10" href="">
|
||||
<img class="w-6" :src="logoUrl" alt="logo-RAJD" />
|
||||
<span class="ml-3 text-xl font-medium text-white">
|
||||
{{ appName }} {{ appVersion }}
|
||||
</span>
|
||||
</a>
|
||||
<div class="my-auto">
|
||||
<img class="-mt-16 w-1/2" :src="illustrationUrl" alt="logo-RAJD" />
|
||||
<div class="mt-10 text-4xl font-medium leading-tight text-white">
|
||||
Sahkan E-mel
|
||||
</div>
|
||||
<div class="mt-5 text-lg text-white opacity-60">
|
||||
Masukkan kod 6 digit yang dihantar ke e-mel anda.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-10 flex h-screen py-5 xl:my-0 xl:h-auto xl:py-0">
|
||||
<Box raised="double"
|
||||
class="mx-auto my-auto w-full px-5 py-8 sm:w-3/4 sm:px-8 lg:w-2/4 xl:ml-24 xl:w-auto xl:p-0 xl:before:hidden xl:after:hidden xl:shadow-none xl:border-none xl:bg-none">
|
||||
<h2 class="text-center text-2xl font-semibold xl:text-left xl:text-3xl">
|
||||
Verify Email
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm opacity-70 xl:text-left">
|
||||
Kod OTP 6 digit telah dihantar ke e-mel anda.
|
||||
</p>
|
||||
|
||||
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
|
||||
<AlertTitle>Verification failed</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="resendMessage" class="mt-6" variant="primary">
|
||||
<AlertDescription>{{ resendMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleVerify">
|
||||
<Input v-model="email" class="box block min-w-full px-5 py-6 xl:min-w-md" type="email"
|
||||
placeholder="Email" autocomplete="email" required />
|
||||
<Input :model-value="otp"
|
||||
class="box block min-w-full px-5 py-6 xl:min-w-md text-center tracking-[0.5em] text-lg" type="text"
|
||||
inputmode="numeric" pattern="[0-9]*" maxlength="6" placeholder="000000" autocomplete="one-time-code"
|
||||
required @input="onOtpInput" />
|
||||
|
||||
<div class="mt-5 text-center xl:mt-10 xl:text-left">
|
||||
<Button class="box w-full px-4 py-5" variant="primary" type="submit"
|
||||
:disabled="loading || !canSubmit">
|
||||
{{ loading ? 'Verifying...' : 'Verify Email' }}
|
||||
</Button>
|
||||
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
|
||||
:disabled="resendLoading || !email" @click="handleResend">
|
||||
{{ resendLoading ? 'Sending...' : 'Resend Code' }}
|
||||
</Button>
|
||||
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
|
||||
@click="router.push({ name: 'login' })">
|
||||
Back to Login
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,12 +13,6 @@ export const authPublicRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('./pages/Register.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/verify-email',
|
||||
name: 'verify-email',
|
||||
component: () => import('./pages/VerifyEmail.vue'),
|
||||
meta: { module: 'auth' },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
|
||||
@@ -5,16 +5,18 @@ import type {
|
||||
ForgotPasswordResponse,
|
||||
LoginCredentials,
|
||||
LoginResponse,
|
||||
LoginVerificationRequiredData,
|
||||
RegisterCredentials,
|
||||
RegisterResponse,
|
||||
ResendVerificationResponse,
|
||||
ResetPasswordPayload,
|
||||
ResetPasswordResponse,
|
||||
ResendVerificationResponse,
|
||||
SessionResponse,
|
||||
SendPhoneVerificationOtpPayload,
|
||||
SendPhoneVerificationOtpResponse,
|
||||
SwitchRoleResponse,
|
||||
VerifyEmailPayload,
|
||||
VerifyEmailResponse,
|
||||
VerifyPhoneVerificationOtpPayload,
|
||||
VerifyPhoneVerificationOtpResponse,
|
||||
VerifyAuthenticatedPhoneVerificationOtpResponse,
|
||||
} from '../types/auth.types'
|
||||
|
||||
export async function login(credentials: LoginCredentials): Promise<LoginResponse> {
|
||||
@@ -27,13 +29,48 @@ export async function register(credentials: RegisterCredentials): Promise<Regist
|
||||
return data
|
||||
}
|
||||
|
||||
export async function verifyEmail(payload: VerifyEmailPayload): Promise<VerifyEmailResponse> {
|
||||
const { data } = await api.post<VerifyEmailResponse>('/verify-email', payload)
|
||||
export async function sendPhoneVerificationOtp(
|
||||
payload: SendPhoneVerificationOtpPayload,
|
||||
): Promise<SendPhoneVerificationOtpResponse> {
|
||||
const { data } = await api.post<SendPhoneVerificationOtpResponse>(
|
||||
'/phone-verification/send',
|
||||
payload,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function resendVerificationEmail(email: string): Promise<ResendVerificationResponse> {
|
||||
const { data } = await api.post<ResendVerificationResponse>('/verify-email/resend', { email })
|
||||
export async function verifyPhoneVerificationOtp(
|
||||
payload: VerifyPhoneVerificationOtpPayload,
|
||||
): Promise<VerifyPhoneVerificationOtpResponse> {
|
||||
const { data } = await api.post<VerifyPhoneVerificationOtpResponse>(
|
||||
'/phone-verification/verify',
|
||||
payload,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function sendAuthenticatedPhoneVerificationOtp(
|
||||
payload: SendPhoneVerificationOtpPayload,
|
||||
): Promise<SendPhoneVerificationOtpResponse> {
|
||||
const { data } = await api.post<SendPhoneVerificationOtpResponse>(
|
||||
'/v1/phone-verification/send',
|
||||
payload,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function verifyAuthenticatedPhoneVerificationOtp(
|
||||
payload: VerifyPhoneVerificationOtpPayload,
|
||||
): Promise<VerifyAuthenticatedPhoneVerificationOtpResponse> {
|
||||
const { data } = await api.post<VerifyAuthenticatedPhoneVerificationOtpResponse>(
|
||||
'/v1/phone-verification/verify',
|
||||
payload,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function sendVerificationEmail(): Promise<ResendVerificationResponse> {
|
||||
const { data } = await api.post<ResendVerificationResponse>('/v1/email/verification-notification')
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -73,8 +110,8 @@ export function getRegisterErrorMessage(error: unknown): string {
|
||||
return getApiErrorMessage(error, 'Registration failed. Please try again.')
|
||||
}
|
||||
|
||||
export function getVerifyEmailErrorMessage(error: unknown): string {
|
||||
return getApiErrorMessage(error, 'Email verification failed. Please try again.')
|
||||
export function getPhoneVerificationErrorMessage(error: unknown): string {
|
||||
return getApiErrorMessage(error, 'Gagal mengesahkan nombor telefon. Sila cuba lagi.')
|
||||
}
|
||||
|
||||
export function getForgotPasswordErrorMessage(error: unknown): string {
|
||||
@@ -85,15 +122,16 @@ export function getResetPasswordErrorMessage(error: unknown): string {
|
||||
return getApiErrorMessage(error, 'Gagal menetapkan semula kata laluan. Sila cuba lagi.')
|
||||
}
|
||||
|
||||
export function isAccountPending(user: { status: string } | null | undefined): boolean {
|
||||
return user?.status === 'pending'
|
||||
export function isEmailVerified(user: { email_verified_at?: string | null } | null | undefined): boolean {
|
||||
return Boolean(user?.email_verified_at)
|
||||
}
|
||||
|
||||
export function isLoginVerificationRequired(
|
||||
response: LoginResponse,
|
||||
): response is LoginResponse & { data: LoginVerificationRequiredData } {
|
||||
return 'requires_email_verification' in response.data
|
||||
&& response.data.requires_email_verification === true
|
||||
export function isPhoneVerified(user: { phone_verified_at?: string | null } | null | undefined): boolean {
|
||||
return Boolean(user?.phone_verified_at)
|
||||
}
|
||||
|
||||
export function isAccountPending(user: { status: string } | null | undefined): boolean {
|
||||
return user?.status === 'pending'
|
||||
}
|
||||
|
||||
export function resolvePostAuthRoute(
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface AuthUser {
|
||||
ic_number: string | null
|
||||
position: string | null
|
||||
phone_number: string | null
|
||||
phone_verified_at: string | null
|
||||
image_url: string | null
|
||||
member_number: number | null
|
||||
member_type: string | null
|
||||
@@ -45,6 +46,7 @@ export interface AuthUser {
|
||||
birth_date: string | null
|
||||
birth_place: string | null
|
||||
onboarding_completed_at: string | null
|
||||
email_verified_at: string | null
|
||||
roles?: Array<AuthRole & { permissions?: AuthPermission[] }>
|
||||
}
|
||||
|
||||
@@ -54,20 +56,17 @@ export interface LoginSessionData {
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface LoginVerificationRequiredData {
|
||||
email: string
|
||||
requires_email_verification: true
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
export interface AuthSessionResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data: LoginSessionData | LoginVerificationRequiredData
|
||||
data: LoginSessionData
|
||||
active_role?: AuthRole | null
|
||||
can_switch_role?: boolean
|
||||
redirect_path?: string
|
||||
}
|
||||
|
||||
export type LoginResponse = AuthSessionResponse
|
||||
|
||||
export interface SwitchRoleResponse extends SessionResponse {
|
||||
message: string
|
||||
}
|
||||
@@ -76,25 +75,42 @@ export interface RegisterCredentials {
|
||||
name: string
|
||||
email: string
|
||||
ic_number: string
|
||||
phone_number: string
|
||||
phone_verification_token: string
|
||||
password: string
|
||||
password_confirmation: string
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data: {
|
||||
email: string
|
||||
requires_email_verification: boolean
|
||||
}
|
||||
export type RegisterResponse = AuthSessionResponse
|
||||
|
||||
export interface SendPhoneVerificationOtpPayload {
|
||||
phone_number: string
|
||||
}
|
||||
|
||||
export interface VerifyEmailPayload {
|
||||
email: string
|
||||
export interface SendPhoneVerificationOtpResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface VerifyPhoneVerificationOtpPayload {
|
||||
phone_number: string
|
||||
otp: string
|
||||
}
|
||||
|
||||
export type VerifyEmailResponse = LoginResponse
|
||||
export interface VerifyPhoneVerificationOtpResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data: {
|
||||
phone_number: string
|
||||
verification_token: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface VerifyAuthenticatedPhoneVerificationOtpResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data: AuthUser
|
||||
}
|
||||
|
||||
export interface ResendVerificationResponse {
|
||||
success: boolean
|
||||
|
||||
@@ -1,32 +1,52 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useExternalSystemList } from './useExternalSystemList'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getExternalSystem } from '../services/external-system.service'
|
||||
import {
|
||||
getExternalSystemStatus,
|
||||
isExternalSystemAccessible,
|
||||
} from '../utils/external-system.utils'
|
||||
import type { ExternalSystem } from '../types/external-system.types'
|
||||
|
||||
export function useExternalSystemDetail() {
|
||||
const route = useRoute()
|
||||
const { getSystemById } = useExternalSystemList()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const system = ref<ExternalSystem | null>(null)
|
||||
|
||||
const systemId = computed(() => String(route.params.id ?? ''))
|
||||
|
||||
const system = computed(() => getSystemById(systemId.value) ?? null)
|
||||
|
||||
const status = computed(() => (system.value ? getExternalSystemStatus(system.value) : null))
|
||||
|
||||
const isAccessible = computed(() =>
|
||||
system.value ? isExternalSystemAccessible(system.value) : false,
|
||||
)
|
||||
|
||||
async function fetchSystem(id: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
system.value = await getExternalSystem(id)
|
||||
} catch (err) {
|
||||
system.value = null
|
||||
error.value = getApiErrorMessage(err, 'Sistem luaran tidak dijumpai.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
systemId,
|
||||
() => {
|
||||
error.value = system.value ? null : 'Sistem luaran tidak dijumpai.'
|
||||
(id) => {
|
||||
if (!id) {
|
||||
system.value = null
|
||||
error.value = 'Sistem luaran tidak dijumpai.'
|
||||
return
|
||||
}
|
||||
|
||||
fetchSystem(id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ref } from 'vue'
|
||||
import Swal from 'sweetalert2'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { launchExternalSystemSso } from '../services/external-system.service'
|
||||
import { isExternalSystemAccessible } from '../utils/external-system.utils'
|
||||
import type { ExternalSystem } from '../types/external-system.types'
|
||||
|
||||
export function useExternalSystemLaunch() {
|
||||
const launching = ref(false)
|
||||
|
||||
async function launchExternalSystem(system: ExternalSystem) {
|
||||
if (!isExternalSystemAccessible(system) || launching.value) {
|
||||
return
|
||||
}
|
||||
|
||||
launching.value = true
|
||||
|
||||
try {
|
||||
if (system.sso_enabled) {
|
||||
const response = await launchExternalSystemSso(system.code)
|
||||
window.open(
|
||||
response.data.launch_url,
|
||||
system.opens_in_new_tab ? '_blank' : '_self',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
window.open(
|
||||
system.url,
|
||||
system.opens_in_new_tab ? '_blank' : '_self',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Gagal membuka sistem',
|
||||
text: getApiErrorMessage(error, 'Tidak dapat membuka sistem luaran.'),
|
||||
})
|
||||
} finally {
|
||||
launching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
launching,
|
||||
launchExternalSystem,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { dummyExternalSystems } from '../data/dummy-external-systems'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listExternalSystems } from '../services/external-system.service'
|
||||
import {
|
||||
externalSystemStatusLabel,
|
||||
getExternalSystemStatus,
|
||||
@@ -9,14 +10,16 @@ import type { ExternalSystem } from '../types/external-system.types'
|
||||
export function useExternalSystemList() {
|
||||
const search = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const allSystems = ref<ExternalSystem[]>([])
|
||||
|
||||
const systems = computed(() => {
|
||||
const query = search.value.trim().toLowerCase()
|
||||
if (!query) {
|
||||
return dummyExternalSystems
|
||||
return allSystems.value
|
||||
}
|
||||
|
||||
return dummyExternalSystems.filter((system) => {
|
||||
return allSystems.value.filter((system) => {
|
||||
const haystack = [
|
||||
system.name,
|
||||
system.code,
|
||||
@@ -35,14 +38,34 @@ export function useExternalSystemList() {
|
||||
)
|
||||
|
||||
function getSystemById(id: string): ExternalSystem | undefined {
|
||||
return dummyExternalSystems.find((system) => system.id === id)
|
||||
return allSystems.value.find((system) => system.id === id)
|
||||
}
|
||||
|
||||
async function fetchSystems() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
allSystems.value = await listExternalSystems()
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai sistem luaran.')
|
||||
allSystems.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSystems()
|
||||
})
|
||||
|
||||
return {
|
||||
systems,
|
||||
search,
|
||||
loading,
|
||||
error,
|
||||
availableCount,
|
||||
getSystemById,
|
||||
fetchSystems,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { ExternalSystem } from '../types/external-system.types'
|
||||
|
||||
export const dummyExternalSystems: ExternalSystem[] = [
|
||||
{
|
||||
id: 'ext-001',
|
||||
code: 'portal-mykopkb',
|
||||
name: 'Portal Ahli KOPKB',
|
||||
description:
|
||||
'Sistem utama keahlian Koperasi Permodalan Kelantan Berhad untuk semakan dividen, penyata dan maklumat ahli.',
|
||||
url: 'https://mykopkb.koppkb.com',
|
||||
icon: 'Users',
|
||||
is_active: true,
|
||||
starts_at: '2026-01-01T00:00:00+08:00',
|
||||
ends_at: null,
|
||||
opens_in_new_tab: true,
|
||||
contact_email: 'dev_kopkb@gmail.com',
|
||||
notes: 'Log masuk menggunakan e-mel berdaftar ahli KOPKB.',
|
||||
created_at: '2026-01-15T09:00:00+08:00',
|
||||
updated_at: '2026-06-01T14:30:00+08:00',
|
||||
},
|
||||
{
|
||||
id: 'ext-002',
|
||||
code: 'e-vote',
|
||||
name: 'Sistem Pengundian AGM KOPKB',
|
||||
description:
|
||||
'Platform pengundian dalam talian untuk Mesyuarat Agung Tahunan. Hanya tersedia semasa tempoh pengundian.',
|
||||
url: 'https://e-vote.erahn.com.my/login',
|
||||
icon: 'Vote',
|
||||
is_active: true,
|
||||
starts_at: '2026-05-01T08:00:00+08:00',
|
||||
ends_at: null,
|
||||
opens_in_new_tab: true,
|
||||
contact_email: 'dev_kopkb@gmail.com',
|
||||
notes: 'Sila lengkapkan profil sebelum mengundi.',
|
||||
created_at: '2026-05-20T10:00:00+08:00',
|
||||
updated_at: '2026-06-28T11:15:00+08:00',
|
||||
},
|
||||
]
|
||||
@@ -6,15 +6,16 @@ import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { useExternalSystemDetail } from '../composables/useExternalSystemDetail'
|
||||
import { useExternalSystemLaunch } from '../composables/useExternalSystemLaunch'
|
||||
import {
|
||||
externalSystemStatusLabel,
|
||||
externalSystemStatusVariant,
|
||||
formatExternalSystemDateTime,
|
||||
openExternalSystem,
|
||||
} from '../utils/external-system.utils'
|
||||
|
||||
const router = useRouter()
|
||||
const { system, error, status, isAccessible } = useExternalSystemDetail()
|
||||
const { launching, launchExternalSystem } = useExternalSystemLaunch()
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'list-external-systems' })
|
||||
@@ -22,7 +23,7 @@ function goBack() {
|
||||
|
||||
function handleOpen() {
|
||||
if (!system.value) return
|
||||
openExternalSystem(system.value)
|
||||
launchExternalSystem(system.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -93,7 +94,7 @@ function handleOpen() {
|
||||
<Button
|
||||
look="outline"
|
||||
variant="primary"
|
||||
:disabled="!isAccessible"
|
||||
:disabled="!isAccessible || launching"
|
||||
@click="handleOpen"
|
||||
>
|
||||
Buka Sistem
|
||||
|
||||
@@ -7,24 +7,25 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { useExternalSystemList } from '../composables/useExternalSystemList'
|
||||
import { useExternalSystemLaunch } from '../composables/useExternalSystemLaunch'
|
||||
import {
|
||||
externalSystemStatusLabel,
|
||||
externalSystemStatusVariant,
|
||||
formatExternalSystemDateTime,
|
||||
getExternalSystemStatus,
|
||||
openExternalSystem,
|
||||
} from '../utils/external-system.utils'
|
||||
import type { ExternalSystem } from '../types/external-system.types'
|
||||
|
||||
const router = useRouter()
|
||||
const { systems, search, loading, availableCount } = useExternalSystemList()
|
||||
const { systems, search, loading, error, availableCount } = useExternalSystemList()
|
||||
const { launching, launchExternalSystem } = useExternalSystemLaunch()
|
||||
|
||||
function goToDetail(system: ExternalSystem) {
|
||||
router.push({ name: 'view-external-system', params: { id: system.id } })
|
||||
}
|
||||
|
||||
function handleOpen(system: ExternalSystem) {
|
||||
openExternalSystem(system)
|
||||
launchExternalSystem(system)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -39,6 +40,11 @@ function handleOpen(system: ExternalSystem) {
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mb-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5 grid grid-cols-12 gap-x-6 gap-y-8">
|
||||
<div class="col-span-12 mt-2 flex flex-wrap items-center sm:flex-nowrap">
|
||||
<div class="w-full sm:w-auto">
|
||||
@@ -82,7 +88,8 @@ function handleOpen(system: ExternalSystem) {
|
||||
|
||||
<div class="mt-5 flex flex-col gap-2 sm:flex-row">
|
||||
<Button class="w-full sm:flex-1" look="outline" variant="primary"
|
||||
:disabled="getExternalSystemStatus(system) !== 'available'" @click="handleOpen(system)">
|
||||
:disabled="getExternalSystemStatus(system) !== 'available' || launching"
|
||||
@click="handleOpen(system)">
|
||||
Buka Sistem
|
||||
<Lucide icon="ExternalLink" class="size-4" />
|
||||
</Button>
|
||||
@@ -94,7 +101,7 @@ function handleOpen(system: ExternalSystem) {
|
||||
</Box>
|
||||
</template>
|
||||
|
||||
<Box v-else class="col-span-12 p-8 text-center">
|
||||
<Box v-else-if="!loading" class="col-span-12 p-8 text-center">
|
||||
<Lucide icon="SearchX" class="mx-auto size-8 opacity-40" />
|
||||
<div class="mt-3 text-base font-medium">Tiada sistem dijumpai</div>
|
||||
<p class="mt-1 text-sm opacity-70">Cuba istilah carian yang berbeza.</p>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type {
|
||||
ExternalSystem,
|
||||
ExternalSystemListResponse,
|
||||
ExternalSystemResponse,
|
||||
ExternalSystemSsoLaunchResponse,
|
||||
} from '../types/external-system.types'
|
||||
|
||||
export async function listExternalSystems(): Promise<ExternalSystem[]> {
|
||||
const { data } = await api.get<ExternalSystemListResponse>('/v1/external-systems')
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan senarai sistem luaran.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function getExternalSystem(id: string): Promise<ExternalSystem> {
|
||||
const { data } = await api.get<ExternalSystemResponse>(`/v1/external-systems/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Sistem luaran tidak dijumpai.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function launchExternalSystemSso(
|
||||
systemCode: string,
|
||||
): Promise<ExternalSystemSsoLaunchResponse> {
|
||||
const { data } = await api.post<ExternalSystemSsoLaunchResponse>(
|
||||
`/v1/external-systems/${systemCode}/sso/launch`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal membuka sistem.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -13,8 +13,30 @@ export type ExternalSystem = {
|
||||
starts_at: string | null
|
||||
ends_at: string | null
|
||||
opens_in_new_tab: boolean
|
||||
sso_enabled: boolean
|
||||
contact_email: string | null
|
||||
notes: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ExternalSystemSsoLaunchResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: {
|
||||
launch_url: string
|
||||
expires_at: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ExternalSystemListResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: ExternalSystem[]
|
||||
}
|
||||
|
||||
export type ExternalSystemResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: ExternalSystem
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Vertical offset from vertical center, in rem (matches Layout side tabs). */
|
||||
offsetRem?: number
|
||||
/** Open feedback form in a new tab instead of navigating in place. */
|
||||
openInNewTab?: boolean
|
||||
}>(),
|
||||
{
|
||||
offsetRem: 3.5,
|
||||
openInNewTab: true,
|
||||
},
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function openHelpdesk(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
const { href } = router.resolve({ name: 'feedback-submit' })
|
||||
|
||||
if (props.openInNewTab) {
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
router.push({ name: 'feedback-submit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" aria-label="Helpdesk" :style="{
|
||||
top: `calc(50% + ${offsetRem}rem)`,
|
||||
['--color' as string]: 'var(--color-primary)',
|
||||
}" :class="[
|
||||
'group fixed right-0 z-50 flex h-12 cursor-pointer items-center overflow-hidden rounded-l-full border border-(--color)/50 bg-background/80 shadow-lg transition-all',
|
||||
'w-14 hover:w-44',
|
||||
'before:absolute before:inset-0 before:bg-(--color)/20',
|
||||
]" @click="openHelpdesk">
|
||||
<span class="relative z-10 flex items-center gap-2 px-5">
|
||||
<Lucide icon="MessageCircle" />
|
||||
<span :class="[
|
||||
'whitespace-nowrap text-sm',
|
||||
'max-w-0 overflow-hidden transition-[max-width] duration-200 ease-out',
|
||||
'group-hover:max-w-40',
|
||||
]">
|
||||
Maklum Balas
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listFeedback } from '../services/feedback.service'
|
||||
import type {
|
||||
FeedbackListItem,
|
||||
FeedbackPriority,
|
||||
FeedbackStatus,
|
||||
FeedbackType,
|
||||
} from '../types/feedback.types'
|
||||
|
||||
export function useFeedbackList() {
|
||||
const items = ref<FeedbackListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const typeFilter = ref<FeedbackType | ''>('')
|
||||
const statusFilter = ref<FeedbackStatus | ''>('')
|
||||
const priorityFilter = ref<FeedbackPriority | ''>('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'created_at', order: 'desc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchItems(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const activeSort = sortBy.value[0]
|
||||
const data = await listFeedback({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: activeSort?.key ?? 'created_at',
|
||||
sort_order: activeSort?.order ?? 'desc',
|
||||
search: search.value.trim() || undefined,
|
||||
type: typeFilter.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
priority: priorityFilter.value || undefined,
|
||||
})
|
||||
|
||||
items.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai maklum balas.')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
fetchItems(1)
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchItems(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch([typeFilter, statusFilter, priorityFilter], () => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchItems(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchItems(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
typeFilter,
|
||||
statusFilter,
|
||||
priorityFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export { feedbackPublicRoutes, feedbackLayoutRoutes } from './routes'
|
||||
export { feedbackMenu } from './menu'
|
||||
export { default as HelpdeskFab } from './components/HelpdeskFab.vue'
|
||||
export {
|
||||
submitFeedback,
|
||||
listFeedback,
|
||||
getFeedback,
|
||||
updateFeedback,
|
||||
deleteFeedback,
|
||||
getMyFeedback,
|
||||
getFeedbackStatistics,
|
||||
} from './services/feedback.service'
|
||||
export type {
|
||||
Feedback,
|
||||
FeedbackFormState,
|
||||
FeedbackListItem,
|
||||
FeedbackStatus,
|
||||
FeedbackType,
|
||||
FeedbackPriority,
|
||||
SubmitFeedbackPayload,
|
||||
UpdateFeedbackPayload,
|
||||
} from './types/feedback.types'
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const feedbackMenu: Menu[] = [
|
||||
{
|
||||
icon: 'MessageCircle',
|
||||
route_name: 'list-feedback',
|
||||
title: 'Maklum Balas',
|
||||
permission: 'lihat maklum balas',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,486 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { CircleAlert, CircleCheck, Play } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
AlertCloseTrigger,
|
||||
} from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
deleteFeedback,
|
||||
fetchFeedbackDocument,
|
||||
getFeedback,
|
||||
updateFeedback,
|
||||
} from '../services/feedback.service'
|
||||
import {
|
||||
FEEDBACK_STATUS_OPTIONS,
|
||||
feedbackPriorityLabel,
|
||||
feedbackStatusLabel,
|
||||
feedbackTypeLabel,
|
||||
type Feedback,
|
||||
type FeedbackDocument,
|
||||
type FeedbackStatus,
|
||||
} from '../types/feedback.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
type MediaPreviewKind = 'image' | 'video'
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return options[0]?.value ?? ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : options[0] ? [options[0].label] : []
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const feedback = ref<Feedback | null>(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const mediaObjectUrls = ref<Record<string, string>>({})
|
||||
const mediaLoadErrors = ref<Record<string, boolean>>({})
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewDocument = ref<FeedbackDocument | null>(null)
|
||||
const previewKind = ref<MediaPreviewKind>('image')
|
||||
const previewUrl = ref<string | null>(null)
|
||||
|
||||
const adminForm = reactive({
|
||||
status: 'open' as FeedbackStatus,
|
||||
admin_notes: '',
|
||||
})
|
||||
|
||||
const canUpdate = computed(() => hasPermission('kemaskini maklum balas'))
|
||||
const canDelete = computed(() => hasPermission('padam maklum balas'))
|
||||
|
||||
const statusCollection = createSelectCollection(FEEDBACK_STATUS_OPTIONS)
|
||||
const statusInitial = computed(() => valueToLabel(FEEDBACK_STATUS_OPTIONS, adminForm.status))
|
||||
|
||||
function setStatusValue(details: { value: string[] }) {
|
||||
adminForm.status = labelToValue(FEEDBACK_STATUS_OPTIONS, details.value[0]) as FeedbackStatus
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number | null | undefined): string {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function revokeUrl(url: string | null | undefined) {
|
||||
if (url) {
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
function clearMediaObjectUrls() {
|
||||
Object.values(mediaObjectUrls.value).forEach((url) => revokeUrl(url))
|
||||
mediaObjectUrls.value = {}
|
||||
mediaLoadErrors.value = {}
|
||||
}
|
||||
|
||||
function revokePreviewUrl() {
|
||||
if (previewUrl.value && !Object.values(mediaObjectUrls.value).includes(previewUrl.value)) {
|
||||
revokeUrl(previewUrl.value)
|
||||
}
|
||||
previewUrl.value = null
|
||||
}
|
||||
|
||||
async function loadMediaDocument(document: FeedbackDocument): Promise<string | null> {
|
||||
if (mediaObjectUrls.value[document.id]) {
|
||||
return mediaObjectUrls.value[document.id] ?? null
|
||||
}
|
||||
|
||||
if (!feedback.value) return null
|
||||
|
||||
try {
|
||||
const blob = await fetchFeedbackDocument(
|
||||
feedback.value.id,
|
||||
document.id,
|
||||
document.mime_type,
|
||||
)
|
||||
const objectUrl = window.URL.createObjectURL(blob)
|
||||
mediaObjectUrls.value = {
|
||||
...mediaObjectUrls.value,
|
||||
[document.id]: objectUrl,
|
||||
}
|
||||
return objectUrl
|
||||
} catch {
|
||||
mediaLoadErrors.value = {
|
||||
...mediaLoadErrors.value,
|
||||
[document.id]: true,
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function preloadAttachments(item: Feedback) {
|
||||
clearMediaObjectUrls()
|
||||
|
||||
const documents = [...(item.images ?? []), ...(item.videos ?? [])]
|
||||
await Promise.all(documents.map((document) => loadMediaDocument(document)))
|
||||
}
|
||||
|
||||
async function openMediaPreview(document: FeedbackDocument, kind: MediaPreviewKind) {
|
||||
previewDocument.value = document
|
||||
previewKind.value = kind
|
||||
previewOpen.value = true
|
||||
previewLoading.value = true
|
||||
revokePreviewUrl()
|
||||
|
||||
const objectUrl = await loadMediaDocument(document)
|
||||
previewUrl.value = objectUrl
|
||||
previewLoading.value = false
|
||||
|
||||
if (!objectUrl) {
|
||||
error.value = 'Gagal memuatkan fail lampiran.'
|
||||
closeMediaPreview()
|
||||
}
|
||||
}
|
||||
|
||||
function closeMediaPreview() {
|
||||
previewOpen.value = false
|
||||
previewDocument.value = null
|
||||
previewLoading.value = false
|
||||
revokePreviewUrl()
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
closeMediaPreview()
|
||||
|
||||
try {
|
||||
const id = String(route.params.id)
|
||||
feedback.value = await getFeedback(id)
|
||||
adminForm.status = feedback.value.status
|
||||
adminForm.admin_notes = feedback.value.admin_notes ?? ''
|
||||
await preloadAttachments(feedback.value)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan maklum balas.')
|
||||
feedback.value = null
|
||||
clearMediaObjectUrls()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!feedback.value || saving.value || !canUpdate.value) return
|
||||
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const response = await updateFeedback(feedback.value.id, {
|
||||
status: adminForm.status,
|
||||
admin_notes: adminForm.admin_notes.trim() || null,
|
||||
})
|
||||
feedback.value = response.data
|
||||
adminForm.status = response.data.status
|
||||
adminForm.admin_notes = response.data.admin_notes ?? ''
|
||||
successMessage.value = response.message ?? 'Maklum balas berjaya dikemas kini.'
|
||||
await preloadAttachments(response.data)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemas kini maklum balas.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!feedback.value || deleting.value || !canDelete.value) return
|
||||
if (!window.confirm('Padam maklum balas ini?')) return
|
||||
|
||||
deleting.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await deleteFeedback(feedback.value.id)
|
||||
router.push({ name: 'list-feedback' })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memadam maklum balas.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
fetchDetail()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(fetchDetail)
|
||||
|
||||
onUnmounted(() => {
|
||||
closeMediaPreview()
|
||||
clearMediaObjectUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||
<div class="mr-auto">
|
||||
<Button look="outline" size="sm" @click="router.push({ name: 'list-feedback' })">
|
||||
Kembali
|
||||
</Button>
|
||||
<h2 class="mt-3 text-lg font-medium">Butiran Maklum Balas</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Semak dan kemas kini status laporan.</p>
|
||||
</div>
|
||||
<div v-if="canDelete" class="flex gap-2">
|
||||
<Button look="outline" variant="danger" :disabled="deleting" @click="handleDelete">
|
||||
{{ deleting ? 'Memadam...' : 'Padam' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<CircleAlert class="size-4" />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="error = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" class="mt-6" variant="success">
|
||||
<CircleCheck class="size-4" />
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="successMessage = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<div v-if="loading" class="mt-8 opacity-70">Memuatkan...</div>
|
||||
|
||||
<template v-else-if="feedback">
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-3">
|
||||
<Box class="space-y-4 p-6 lg:col-span-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge look="outline">{{ feedbackTypeLabel(feedback.type) }}</Badge>
|
||||
<Badge look="outline">{{ feedbackPriorityLabel(feedback.priority) }}</Badge>
|
||||
<Badge look="outline">{{ feedbackStatusLabel(feedback.status) }}</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold">{{ feedback.title }}</h3>
|
||||
<p class="mt-2 whitespace-pre-wrap text-sm opacity-80">{{ feedback.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="feedback.page_url" class="text-sm">
|
||||
<span class="opacity-60">URL:</span>
|
||||
<a :href="feedback.page_url" target="_blank" rel="noopener" class="ml-2 text-primary underline">
|
||||
{{ feedback.page_url }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="feedback.steps_to_reproduce" class="text-sm">
|
||||
<div class="font-medium">Langkah menghasilkan semula</div>
|
||||
<p class="mt-1 whitespace-pre-wrap opacity-80">{{ feedback.steps_to_reproduce }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div v-if="feedback.expected_behavior" class="text-sm">
|
||||
<div class="font-medium">Kelakuan dijangka</div>
|
||||
<p class="mt-1 whitespace-pre-wrap opacity-80">{{ feedback.expected_behavior }}</p>
|
||||
</div>
|
||||
<div v-if="feedback.actual_behavior" class="text-sm">
|
||||
<div class="font-medium">Kelakuan sebenar</div>
|
||||
<p class="mt-1 whitespace-pre-wrap opacity-80">{{ feedback.actual_behavior }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="feedback.additional_notes" class="text-sm">
|
||||
<div class="font-medium">Nota tambahan</div>
|
||||
<p class="mt-1 whitespace-pre-wrap opacity-80">{{ feedback.additional_notes }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="feedback.images?.length" class="text-sm">
|
||||
<div class="font-medium">Imej</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<button v-for="image in feedback.images" :key="image.id" type="button"
|
||||
class="group relative overflow-hidden rounded-lg border border-foreground/10 bg-foreground/5 text-left"
|
||||
@click="openMediaPreview(image, 'image')">
|
||||
<img v-if="mediaObjectUrls[image.id]" :src="mediaObjectUrls[image.id]" :alt="image.name"
|
||||
class="aspect-video w-full object-cover transition group-hover:scale-[1.02]" />
|
||||
<div v-else class="flex aspect-video items-center justify-center px-2 text-center text-xs opacity-60">
|
||||
{{ mediaLoadErrors[image.id] ? 'Gagal dimuatkan' : 'Memuatkan...' }}
|
||||
</div>
|
||||
<div class="truncate border-t border-foreground/10 px-2 py-1.5 text-xs opacity-70">
|
||||
{{ image.name }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="feedback.videos?.length" class="text-sm">
|
||||
<div class="font-medium">Video</div>
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<button v-for="video in feedback.videos" :key="video.id" type="button"
|
||||
class="group relative overflow-hidden rounded-lg border border-foreground/10 bg-foreground/5 text-left"
|
||||
@click="openMediaPreview(video, 'video')">
|
||||
<div class="relative aspect-video bg-black/80">
|
||||
<video v-if="mediaObjectUrls[video.id]" :src="mediaObjectUrls[video.id]"
|
||||
class="size-full object-cover opacity-80" muted preload="metadata" />
|
||||
<div v-else class="flex size-full items-center justify-center px-2 text-center text-xs text-white/70">
|
||||
{{ mediaLoadErrors[video.id] ? 'Gagal dimuatkan' : 'Memuatkan...' }}
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/20 transition group-hover:bg-black/35">
|
||||
<span class="flex size-12 items-center justify-center rounded-full bg-white/90 text-foreground">
|
||||
<Play class="size-5 fill-current" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="truncate border-t border-foreground/10 px-2 py-1.5 text-xs opacity-70">
|
||||
{{ video.name }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Box class="space-y-3 p-6 text-sm">
|
||||
<div>
|
||||
<div class="opacity-60">Penghantar</div>
|
||||
<div class="mt-0.5 font-medium">{{ feedback.user?.name ?? 'Tetamu' }}</div>
|
||||
<div v-if="feedback.user?.email" class="opacity-70">{{ feedback.user.email }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="opacity-60">Dihantar</div>
|
||||
<div class="mt-0.5">{{ formatDate(feedback.created_at) }}</div>
|
||||
</div>
|
||||
<div v-if="feedback.resolved_at">
|
||||
<div class="opacity-60">Diselesaikan</div>
|
||||
<div class="mt-0.5">{{ formatDate(feedback.resolved_at) }}</div>
|
||||
</div>
|
||||
<div v-if="feedback.assigned_user">
|
||||
<div class="opacity-60">Ditugaskan kepada</div>
|
||||
<div class="mt-0.5 font-medium">{{ feedback.assigned_user.name }}</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="canUpdate" class="space-y-4 p-6">
|
||||
<h3 class="font-medium">Tindakan Admin</h3>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status</FieldLabel>
|
||||
<SelectRoot :key="statusInitial[0]" :collection="statusCollection" :default-value="statusInitial"
|
||||
@value-change="setStatusValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Nota admin</FieldLabel>
|
||||
<Textarea v-model="adminForm.admin_notes" rows="4" />
|
||||
</Field>
|
||||
|
||||
<Button variant="primary" :disabled="saving" @click="handleSave">
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="previewOpen" class="fixed inset-0 z-70 flex items-center justify-center p-4 sm:p-6" role="dialog"
|
||||
aria-modal="true" :aria-label="previewDocument?.name ?? 'Pratonton lampiran'">
|
||||
<button type="button" class="absolute inset-0 bg-black/80" aria-label="Tutup pratonton"
|
||||
@click="closeMediaPreview" />
|
||||
|
||||
<div
|
||||
class="relative z-10 flex w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-foreground/10 bg-background shadow-2xl">
|
||||
<div class="border-b border-foreground/10 px-5 py-4">
|
||||
<div class="text-lg font-medium">
|
||||
{{ previewKind === 'video' ? 'Main Video' : 'Pratonton Imej' }}
|
||||
</div>
|
||||
<div v-if="previewDocument" class="mt-1 text-sm opacity-70">
|
||||
{{ previewDocument.name }} · {{ formatFileSize(previewDocument.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-auto p-5">
|
||||
<div v-if="previewLoading" class="py-12 text-center opacity-70">
|
||||
Memuatkan...
|
||||
</div>
|
||||
|
||||
<div v-else-if="previewUrl && previewKind === 'image'" class="flex justify-center">
|
||||
<img :src="previewUrl" :alt="previewDocument?.name ?? 'Pratonton imej'"
|
||||
class="block h-auto max-h-[calc(90vh-12rem)] w-auto max-w-full object-contain" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="previewUrl && previewKind === 'video'" class="flex justify-center">
|
||||
<video :src="previewUrl" class="block max-h-[calc(90vh-12rem)] w-full max-w-full rounded-lg bg-black"
|
||||
controls autoplay />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 border-t border-foreground/10 px-5 py-4">
|
||||
<Button type="button" look="outline" @click="closeMediaPreview">
|
||||
Tutup
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,289 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { CircleAlert, Eye } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription, AlertCloseTrigger } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
|
||||
import { useFeedbackList } from '../composables/useFeedbackList'
|
||||
import {
|
||||
FEEDBACK_PRIORITY_OPTIONS,
|
||||
FEEDBACK_STATUS_OPTIONS,
|
||||
FEEDBACK_TYPE_OPTIONS,
|
||||
feedbackPriorityLabel,
|
||||
feedbackStatusLabel,
|
||||
feedbackTypeLabel,
|
||||
type FeedbackListItem,
|
||||
type FeedbackPriority,
|
||||
type FeedbackStatus,
|
||||
type FeedbackType,
|
||||
} from '../types/feedback.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const TYPE_FILTER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Semua Jenis', value: '' },
|
||||
...FEEDBACK_TYPE_OPTIONS,
|
||||
]
|
||||
|
||||
const STATUS_FILTER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Semua Status', value: '' },
|
||||
...FEEDBACK_STATUS_OPTIONS,
|
||||
]
|
||||
|
||||
const PRIORITY_FILTER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Semua Keutamaan', value: '' },
|
||||
...FEEDBACK_PRIORITY_OPTIONS,
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [options[0]?.label ?? '']
|
||||
}
|
||||
|
||||
function statusVariant(status: FeedbackStatus): BadgeVariants['variant'] {
|
||||
switch (status) {
|
||||
case 'resolved':
|
||||
case 'closed':
|
||||
return 'success'
|
||||
case 'in_progress':
|
||||
return 'primary'
|
||||
case 'rejected':
|
||||
return 'danger'
|
||||
case 'open':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
function priorityVariant(priority: FeedbackPriority): BadgeVariants['variant'] {
|
||||
switch (priority) {
|
||||
case 'critical':
|
||||
case 'high':
|
||||
return 'danger'
|
||||
case 'medium':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Tajuk', key: 'title', sortable: true },
|
||||
{ title: 'Jenis', key: 'type', sortable: true },
|
||||
{ title: 'Keutamaan', key: 'priority', sortable: true },
|
||||
{ title: 'Status', key: 'status', sortable: true },
|
||||
{ title: 'Penghantar', key: 'user' },
|
||||
{ title: 'Dihantar', key: 'created_at', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
const typeCollection = createSelectCollection(TYPE_FILTER_OPTIONS)
|
||||
const statusCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
|
||||
const priorityCollection = createSelectCollection(PRIORITY_FILTER_OPTIONS)
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
typeFilter,
|
||||
statusFilter,
|
||||
priorityFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
} = useFeedbackList()
|
||||
|
||||
const canView = computed(() => hasPermission('lihat maklum balas'))
|
||||
|
||||
const typeInitial = computed(() => valueToLabel(TYPE_FILTER_OPTIONS, typeFilter.value))
|
||||
const statusInitial = computed(() => valueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
|
||||
const priorityInitial = computed(() => valueToLabel(PRIORITY_FILTER_OPTIONS, priorityFilter.value))
|
||||
|
||||
function setTypeFilter(details: { value: string[] }) {
|
||||
typeFilter.value = labelToValue(TYPE_FILTER_OPTIONS, details.value[0]) as FeedbackType | ''
|
||||
}
|
||||
|
||||
function setStatusFilter(details: { value: string[] }) {
|
||||
statusFilter.value = labelToValue(STATUS_FILTER_OPTIONS, details.value[0]) as FeedbackStatus | ''
|
||||
}
|
||||
|
||||
function setPriorityFilter(details: { value: string[] }) {
|
||||
priorityFilter.value = labelToValue(PRIORITY_FILTER_OPTIONS, details.value[0]) as FeedbackPriority | ''
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function goToDetail(id: string) {
|
||||
router.push({ name: 'view-feedback', params: { id } })
|
||||
}
|
||||
|
||||
function goToSubmit() {
|
||||
router.push({ name: 'feedback-submit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Senarai Maklum Balas</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Urus laporan ralat, cadangan dan isu pengguna.</p>
|
||||
</div>
|
||||
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||
<Button look="outline" variant="primary" class="shadow-sm" @click="goToSubmit">
|
||||
Hantar Maklum Balas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="error = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5">
|
||||
<DataTable :headers="headers" :items="items" :loading="loading" :pagination="pagination" :current-sort="sortBy"
|
||||
show-pagination exportable export-file-name="maklum-balas" v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage" @update:sort-by="handleSortUpdate">
|
||||
<template #toolbar>
|
||||
<div class="flex w-full flex-wrap items-center gap-3">
|
||||
<Input v-model="search" class="w-full max-w-md" type="search" placeholder="Cari tajuk atau penerangan..."
|
||||
aria-label="Cari maklum balas" />
|
||||
|
||||
<SelectRoot class="w-full sm:w-52" :collection="typeCollection" :default-value="typeInitial"
|
||||
@value-change="setTypeFilter">
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis jenis">
|
||||
<SelectValueText placeholder="Semua Jenis" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jenis</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in typeCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
|
||||
<SelectRoot class="w-full sm:w-52" :collection="statusCollection" :default-value="statusInitial"
|
||||
@value-change="setStatusFilter">
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis status">
|
||||
<SelectValueText placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
|
||||
<SelectRoot class="w-full sm:w-52" :collection="priorityCollection" :default-value="priorityInitial"
|
||||
@value-change="setPriorityFilter">
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis keutamaan">
|
||||
<SelectValueText placeholder="Semua Keutamaan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Keutamaan</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in priorityCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.title="{ item }">
|
||||
<div class="font-medium">{{ (item as FeedbackListItem).title }}</div>
|
||||
<div class="mt-0.5 line-clamp-1 text-xs opacity-60">
|
||||
{{ (item as FeedbackListItem).description }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.type="{ item }">
|
||||
{{ feedbackTypeLabel((item as FeedbackListItem).type) }}
|
||||
</template>
|
||||
|
||||
<template #item.priority="{ item }">
|
||||
<Badge look="outline" :variant="priorityVariant((item as FeedbackListItem).priority)">
|
||||
{{ feedbackPriorityLabel((item as FeedbackListItem).priority) }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge look="outline" :variant="statusVariant((item as FeedbackListItem).status)">
|
||||
{{ feedbackStatusLabel((item as FeedbackListItem).status) }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.user="{ item }">
|
||||
{{ (item as FeedbackListItem).user?.name ?? 'Tetamu' }}
|
||||
</template>
|
||||
|
||||
<template #item.created_at="{ item }">
|
||||
{{ formatDate((item as FeedbackListItem).created_at) }}
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<Button v-if="canView" type="button" variant="ghost" size="sm" class="bg-green-600 text-white"
|
||||
title="Lihat butiran" @click="goToDetail((item as FeedbackListItem).id)">
|
||||
<Eye class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,347 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import * as select from '@zag-js/select'
|
||||
import { CircleAlert, CircleCheck, Trash } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
} from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { submitFeedback } from '../services/feedback.service'
|
||||
import {
|
||||
FEEDBACK_PRIORITY_OPTIONS,
|
||||
FEEDBACK_TYPE_OPTIONS,
|
||||
createEmptyFeedbackForm,
|
||||
type FeedbackFormState,
|
||||
type FeedbackPriority,
|
||||
type FeedbackType,
|
||||
} from '../types/feedback.types'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return options[0]?.value ?? ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : options[0] ? [options[0].label] : []
|
||||
}
|
||||
|
||||
const MAX_IMAGE_MB = 10
|
||||
const MAX_VIDEO_MB = 50
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { hasPermission } = usePermissions()
|
||||
const form = reactive<FeedbackFormState>(createEmptyFeedbackForm())
|
||||
const fieldErrors = reactive<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
const submitted = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const typeCollection = createSelectCollection(FEEDBACK_TYPE_OPTIONS)
|
||||
const priorityCollection = createSelectCollection(FEEDBACK_PRIORITY_OPTIONS)
|
||||
const typeInitial = computed(() => valueToLabel(FEEDBACK_TYPE_OPTIONS, form.type))
|
||||
const priorityInitial = computed(() => valueToLabel(FEEDBACK_PRIORITY_OPTIONS, form.priority))
|
||||
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const canViewList = computed(() => hasPermission('lihat maklum balas'))
|
||||
|
||||
function setTypeValue(details: { value: string[] }) {
|
||||
form.type = labelToValue(FEEDBACK_TYPE_OPTIONS, details.value[0]) as FeedbackType
|
||||
}
|
||||
|
||||
function setPriorityValue(details: { value: string[] }) {
|
||||
form.priority = labelToValue(FEEDBACK_PRIORITY_OPTIONS, details.value[0]) as FeedbackPriority
|
||||
}
|
||||
|
||||
function clearFieldError(key: string) {
|
||||
delete fieldErrors[key]
|
||||
}
|
||||
|
||||
function onImagesChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
clearFieldError('images')
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_IMAGE_MB * 1024 * 1024) {
|
||||
fieldErrors.images = `Setiap imej mesti bawah ${MAX_IMAGE_MB}MB.`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
form.images.push(...files)
|
||||
}
|
||||
|
||||
function onVideosChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
clearFieldError('videos')
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_VIDEO_MB * 1024 * 1024) {
|
||||
fieldErrors.videos = `Setiap video mesti bawah ${MAX_VIDEO_MB}MB.`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
form.videos.push(...files)
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
form.images.splice(index, 1)
|
||||
}
|
||||
|
||||
function removeVideo(index: number) {
|
||||
form.videos.splice(index, 1)
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
Object.keys(fieldErrors).forEach((key) => delete fieldErrors[key])
|
||||
|
||||
if (!form.title.trim()) fieldErrors.title = 'Tajuk diperlukan.'
|
||||
if (!form.description.trim()) fieldErrors.description = 'Penerangan diperlukan.'
|
||||
|
||||
return Object.keys(fieldErrors).length === 0
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, createEmptyFeedbackForm())
|
||||
submitted.value = false
|
||||
successMessage.value = ''
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (loading.value || !validate()) return
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await submitFeedback({
|
||||
type: form.type,
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
priority: form.priority,
|
||||
page_url: form.page_url.trim() || undefined,
|
||||
steps_to_reproduce: form.steps_to_reproduce.trim() || undefined,
|
||||
expected_behavior: form.expected_behavior.trim() || undefined,
|
||||
actual_behavior: form.actual_behavior.trim() || undefined,
|
||||
additional_notes: form.additional_notes.trim() || undefined,
|
||||
images: form.images,
|
||||
videos: form.videos,
|
||||
screen_resolution: `${window.screen.width}x${window.screen.height}`,
|
||||
viewport_size: `${window.innerWidth}x${window.innerHeight}`,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
|
||||
submitted.value = true
|
||||
successMessage.value = response.message ?? 'Maklum balas berjaya dihantar. Terima kasih!'
|
||||
} catch (err) {
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
if (validationErrors) {
|
||||
Object.assign(fieldErrors, validationErrors)
|
||||
}
|
||||
errorMessage.value = getApiErrorMessage(err, 'Gagal menghantar maklum balas.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background px-4 py-10">
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<div class="mb-8 flex items-center gap-3">
|
||||
<img :src="illustrationUrl" alt="MyKOPKB" class="h-10 w-auto" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Maklum Balas</h1>
|
||||
<p class="text-sm opacity-70">
|
||||
Laporkan ralat, cadangan atau isu.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Box v-if="submitted" class="p-8 text-center">
|
||||
<CircleCheck class="mx-auto size-12 text-success" />
|
||||
<h2 class="mt-4 text-xl font-medium">Terima kasih!</h2>
|
||||
<p class="mt-2 opacity-70">{{ successMessage }}</p>
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<Button variant="primary" @click="resetForm">Hantar lagi</Button>
|
||||
<RouterLink v-if="isAuthenticated && canViewList" :to="{ name: 'list-feedback' }">
|
||||
<Button look="outline">Lihat senarai</Button>
|
||||
</RouterLink>
|
||||
<RouterLink v-else-if="!isAuthenticated" :to="{ name: 'login' }">
|
||||
<Button look="outline">Log masuk</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-else class="p-6 sm:p-8">
|
||||
<AlertRoot v-if="errorMessage" class="mb-6" variant="danger">
|
||||
<CircleAlert class="size-4" />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<form class="space-y-5" @submit.prevent="handleSubmit">
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Jenis</FieldLabel>
|
||||
<SelectRoot :key="typeInitial[0]" :collection="typeCollection" :default-value="typeInitial"
|
||||
@value-change="setTypeValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih jenis" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem v-for="item in typeCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Keutamaan</FieldLabel>
|
||||
<SelectRoot :key="priorityInitial[0]" :collection="priorityCollection" :default-value="priorityInitial"
|
||||
@value-change="setPriorityValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih keutamaan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem v-for="item in priorityCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field :invalid="!!fieldErrors.title">
|
||||
<FieldLabel>Tajuk</FieldLabel>
|
||||
<Input v-model="form.title" placeholder="Ringkasan isu atau cadangan" @input="clearFieldError('title')" />
|
||||
<FieldError v-if="fieldErrors.title">{{ fieldErrors.title }}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field :invalid="!!fieldErrors.description">
|
||||
<FieldLabel>Penerangan</FieldLabel>
|
||||
<Textarea v-model="form.description" rows="5" placeholder="Terangkan dengan terperinci..."
|
||||
@input="clearFieldError('description')" />
|
||||
<FieldError v-if="fieldErrors.description">{{ fieldErrors.description }}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>URL halaman (pilihan)</FieldLabel>
|
||||
<Input v-model="form.page_url" type="url" placeholder="https://" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Langkah untuk menghasilkan semula (pilihan)</FieldLabel>
|
||||
<Textarea v-model="form.steps_to_reproduce" rows="3" />
|
||||
</Field>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Kelakuan dijangka (pilihan)</FieldLabel>
|
||||
<Textarea v-model="form.expected_behavior" rows="3" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Kelakuan sebenar (pilihan)</FieldLabel>
|
||||
<Textarea v-model="form.actual_behavior" rows="3" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Nota tambahan (pilihan)</FieldLabel>
|
||||
<Textarea v-model="form.additional_notes" rows="2" />
|
||||
</Field>
|
||||
|
||||
<Field :invalid="!!fieldErrors.images">
|
||||
<FieldLabel>Imej (pilihan)</FieldLabel>
|
||||
<Input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple @change="onImagesChange" />
|
||||
<p class="mt-1 text-xs opacity-60">JPEG, PNG, GIF, WebP — maks {{ MAX_IMAGE_MB }}MB setiap fail</p>
|
||||
<FieldError v-if="fieldErrors.images">{{ fieldErrors.images }}</FieldError>
|
||||
<ul v-if="form.images.length" class="mt-2 space-y-1">
|
||||
<li v-for="(file, index) in form.images" :key="`${file.name}-${index}`"
|
||||
class="flex items-center justify-between rounded border border-foreground/10 px-3 py-2 text-sm">
|
||||
<span class="truncate">{{ file.name }}</span>
|
||||
<button type="button" class="text-danger" @click="removeImage(index)">
|
||||
<Trash class="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Field>
|
||||
|
||||
<Field :invalid="!!fieldErrors.videos">
|
||||
<FieldLabel>Video (pilihan)</FieldLabel>
|
||||
<Input type="file" accept="video/mp4,video/quicktime,video/webm" multiple @change="onVideosChange" />
|
||||
<p class="mt-1 text-xs opacity-60">MP4, MOV, WebM — maks {{ MAX_VIDEO_MB }}MB setiap fail</p>
|
||||
<FieldError v-if="fieldErrors.videos">{{ fieldErrors.videos }}</FieldError>
|
||||
<ul v-if="form.videos.length" class="mt-2 space-y-1">
|
||||
<li v-for="(file, index) in form.videos" :key="`${file.name}-${index}`"
|
||||
class="flex items-center justify-between rounded border border-foreground/10 px-3 py-2 text-sm">
|
||||
<span class="truncate">{{ file.name }}</span>
|
||||
<button type="button" class="text-danger" @click="removeVideo(index)">
|
||||
<Trash class="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-wrap gap-3 pt-2">
|
||||
<Button type="submit" variant="primary" :disabled="loading">
|
||||
<Lucide v-if="loading" icon="LoaderCircle" class="mr-2 size-4 animate-spin" />
|
||||
{{ loading ? 'Menghantar...' : 'Hantar Maklum Balas' }}
|
||||
</Button>
|
||||
<RouterLink v-if="!isAuthenticated" :to="{ name: 'login' }">
|
||||
<Button type="button" look="outline">Kembali ke log masuk</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const feedbackPublicRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/feedback/submit',
|
||||
name: 'feedback-submit',
|
||||
component: () => import('./pages/FeedbackSubmit.vue'),
|
||||
meta: { public: true, module: 'feedback', title: 'Maklum Balas' },
|
||||
},
|
||||
]
|
||||
|
||||
export const feedbackLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'feedback',
|
||||
name: 'list-feedback',
|
||||
component: () => import('./pages/FeedbackList.vue'),
|
||||
meta: {
|
||||
title: 'Senarai Maklum Balas',
|
||||
module: 'feedback',
|
||||
permission: 'lihat maklum balas',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'feedback/:id',
|
||||
name: 'view-feedback',
|
||||
component: () => import('./pages/FeedbackDetail.vue'),
|
||||
meta: {
|
||||
title: 'Butiran Maklum Balas',
|
||||
module: 'feedback',
|
||||
permission: 'lihat maklum balas',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,170 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type {
|
||||
Feedback,
|
||||
FeedbackApiResponse,
|
||||
FeedbackListItem,
|
||||
FeedbackStatistics,
|
||||
ListFeedbackParams,
|
||||
SubmitFeedbackPayload,
|
||||
UpdateFeedbackPayload,
|
||||
} from '../types/feedback.types'
|
||||
|
||||
function appendIfPresent(formData: FormData, key: string, value: string | undefined | null) {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
formData.append(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFeedbackFormData(payload: SubmitFeedbackPayload): FormData {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('type', payload.type)
|
||||
formData.append('title', payload.title)
|
||||
formData.append('description', payload.description)
|
||||
formData.append('priority', payload.priority)
|
||||
|
||||
appendIfPresent(formData, 'page_url', payload.page_url)
|
||||
appendIfPresent(formData, 'steps_to_reproduce', payload.steps_to_reproduce)
|
||||
appendIfPresent(formData, 'expected_behavior', payload.expected_behavior)
|
||||
appendIfPresent(formData, 'actual_behavior', payload.actual_behavior)
|
||||
appendIfPresent(formData, 'additional_notes', payload.additional_notes)
|
||||
appendIfPresent(formData, 'screen_resolution', payload.screen_resolution)
|
||||
appendIfPresent(formData, 'viewport_size', payload.viewport_size)
|
||||
appendIfPresent(formData, 'timezone', payload.timezone)
|
||||
|
||||
payload.images?.forEach((file, index) => {
|
||||
formData.append(`images[${index}]`, file)
|
||||
})
|
||||
|
||||
payload.videos?.forEach((file, index) => {
|
||||
formData.append(`videos[${index}]`, file)
|
||||
})
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
/** Public + authenticated: POST /v1/feedback (auth cookie attaches user when present). */
|
||||
export async function submitFeedback(payload: SubmitFeedbackPayload): Promise<FeedbackApiResponse> {
|
||||
const formData = buildFeedbackFormData(payload)
|
||||
|
||||
const { data } = await api.post<FeedbackApiResponse>('/v1/feedback', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menghantar maklum balas.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listFeedback(
|
||||
params: ListFeedbackParams = {},
|
||||
): Promise<PaginatedApiResponse<FeedbackListItem>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<FeedbackListItem>>('/v1/feedback', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan senarai maklum balas.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getFeedback(id: string): Promise<Feedback> {
|
||||
const { data } = await api.get<FeedbackApiResponse>(`/v1/feedback/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan maklum balas.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateFeedback(
|
||||
id: string,
|
||||
payload: UpdateFeedbackPayload,
|
||||
): Promise<FeedbackApiResponse> {
|
||||
const { data } = await api.patch<FeedbackApiResponse>(`/v1/feedback/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemas kini maklum balas.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteFeedback(id: string): Promise<void> {
|
||||
const { data } = await api.delete<{ success: boolean; message?: string }>(`/v1/feedback/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam maklum balas.')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMyFeedback(
|
||||
params: ListFeedbackParams = {},
|
||||
): Promise<PaginatedApiResponse<FeedbackListItem>> {
|
||||
const { data } = await api.get<{
|
||||
success: boolean
|
||||
data: FeedbackListItem[]
|
||||
meta: {
|
||||
current_page: number
|
||||
last_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
}
|
||||
message?: string
|
||||
}>('/v1/my-feedback', { params })
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan maklum balas anda.')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data.data,
|
||||
pagination: {
|
||||
current_page: data.meta.current_page,
|
||||
per_page: data.meta.per_page,
|
||||
total: data.meta.total,
|
||||
last_page: data.meta.last_page,
|
||||
from: null,
|
||||
to: null,
|
||||
has_more_pages: data.meta.current_page < data.meta.last_page,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFeedbackStatistics(): Promise<FeedbackStatistics> {
|
||||
const { data } = await api.get<{ success: boolean; data: FeedbackStatistics; message?: string }>(
|
||||
'/v1/feedback-statistics',
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan statistik maklum balas.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchFeedbackDocument(
|
||||
feedbackId: string,
|
||||
documentId: string,
|
||||
mimeType?: string | null,
|
||||
): Promise<Blob> {
|
||||
const response = await api.get(`/v1/feedback/${feedbackId}/documents/${documentId}/download`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
|
||||
const contentType =
|
||||
mimeType ||
|
||||
(typeof response.headers['content-type'] === 'string' ? response.headers['content-type'] : null) ||
|
||||
'application/octet-stream'
|
||||
|
||||
return new Blob([response.data], { type: contentType })
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
export type FeedbackType =
|
||||
| 'bug'
|
||||
| 'feature_request'
|
||||
| 'general_feedback'
|
||||
| 'ui_issue'
|
||||
| 'performance_issue'
|
||||
|
||||
export type FeedbackPriority = 'low' | 'medium' | 'high' | 'critical'
|
||||
|
||||
export type FeedbackStatus = 'open' | 'in_progress' | 'resolved' | 'closed' | 'rejected'
|
||||
|
||||
export type FeedbackDocument = {
|
||||
id: string
|
||||
name: string
|
||||
mime_type: string | null
|
||||
file_size: number | null
|
||||
type: 'image' | 'video' | string
|
||||
url?: string | null
|
||||
}
|
||||
|
||||
export type FeedbackUserSummary = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
army_number?: string | null
|
||||
}
|
||||
|
||||
export type FeedbackAssignedUser = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export type Feedback = {
|
||||
id: string
|
||||
type: FeedbackType
|
||||
title: string
|
||||
description: string
|
||||
priority: FeedbackPriority
|
||||
status: FeedbackStatus
|
||||
page_url: string | null
|
||||
browser_info: Record<string, unknown> | null
|
||||
images?: FeedbackDocument[]
|
||||
videos?: FeedbackDocument[]
|
||||
steps_to_reproduce: string | null
|
||||
expected_behavior: string | null
|
||||
actual_behavior: string | null
|
||||
additional_notes: string | null
|
||||
admin_notes: string | null
|
||||
resolved_at: string | null
|
||||
user?: FeedbackUserSummary | null
|
||||
assigned_user?: FeedbackAssignedUser | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type FeedbackListItem = Feedback
|
||||
|
||||
export type FeedbackFormState = {
|
||||
type: FeedbackType
|
||||
title: string
|
||||
description: string
|
||||
priority: FeedbackPriority
|
||||
page_url: string
|
||||
steps_to_reproduce: string
|
||||
expected_behavior: string
|
||||
actual_behavior: string
|
||||
additional_notes: string
|
||||
images: File[]
|
||||
videos: File[]
|
||||
}
|
||||
|
||||
export type SubmitFeedbackPayload = {
|
||||
type: FeedbackType
|
||||
title: string
|
||||
description: string
|
||||
priority: FeedbackPriority
|
||||
page_url?: string
|
||||
steps_to_reproduce?: string
|
||||
expected_behavior?: string
|
||||
actual_behavior?: string
|
||||
additional_notes?: string
|
||||
images?: File[]
|
||||
videos?: File[]
|
||||
screen_resolution?: string
|
||||
viewport_size?: string
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export type UpdateFeedbackPayload = {
|
||||
type?: FeedbackType
|
||||
title?: string
|
||||
description?: string
|
||||
priority?: FeedbackPriority
|
||||
page_url?: string | null
|
||||
steps_to_reproduce?: string | null
|
||||
expected_behavior?: string | null
|
||||
actual_behavior?: string | null
|
||||
additional_notes?: string | null
|
||||
status?: FeedbackStatus
|
||||
assigned_to?: string | null
|
||||
admin_notes?: string | null
|
||||
}
|
||||
|
||||
export type ListFeedbackParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
type?: FeedbackType | ''
|
||||
status?: FeedbackStatus | ''
|
||||
priority?: FeedbackPriority | ''
|
||||
}
|
||||
|
||||
export type FeedbackApiResponse = {
|
||||
success: boolean
|
||||
data: Feedback
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type FeedbackStatistics = {
|
||||
total: number
|
||||
open: number
|
||||
resolved: number
|
||||
by_type: Record<string, number>
|
||||
by_priority: Record<string, number>
|
||||
by_status: Record<string, number>
|
||||
}
|
||||
|
||||
export const FEEDBACK_TYPE_OPTIONS: { label: string; value: FeedbackType }[] = [
|
||||
{ label: 'Ralat / Bug', value: 'bug' },
|
||||
{ label: 'Permintaan Ciri Baharu', value: 'feature_request' },
|
||||
{ label: 'Maklum Balas Umum', value: 'general_feedback' },
|
||||
{ label: 'Isu Antara Muka', value: 'ui_issue' },
|
||||
{ label: 'Isu Prestasi', value: 'performance_issue' },
|
||||
]
|
||||
|
||||
export const FEEDBACK_PRIORITY_OPTIONS: { label: string; value: FeedbackPriority }[] = [
|
||||
{ label: 'Rendah', value: 'low' },
|
||||
{ label: 'Sederhana', value: 'medium' },
|
||||
{ label: 'Tinggi', value: 'high' },
|
||||
{ label: 'Kritikal', value: 'critical' },
|
||||
]
|
||||
|
||||
export const FEEDBACK_STATUS_OPTIONS: { label: string; value: FeedbackStatus }[] = [
|
||||
{ label: 'Terbuka', value: 'open' },
|
||||
{ label: 'Dalam Proses', value: 'in_progress' },
|
||||
{ label: 'Diselesaikan', value: 'resolved' },
|
||||
{ label: 'Ditutup', value: 'closed' },
|
||||
{ label: 'Ditolak', value: 'rejected' },
|
||||
]
|
||||
|
||||
export function feedbackTypeLabel(type: FeedbackType | string): string {
|
||||
return FEEDBACK_TYPE_OPTIONS.find((option) => option.value === type)?.label ?? type
|
||||
}
|
||||
|
||||
export function feedbackPriorityLabel(priority: FeedbackPriority | string): string {
|
||||
return FEEDBACK_PRIORITY_OPTIONS.find((option) => option.value === priority)?.label ?? priority
|
||||
}
|
||||
|
||||
export function feedbackStatusLabel(status: FeedbackStatus | string): string {
|
||||
return FEEDBACK_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
export function createEmptyFeedbackForm(): FeedbackFormState {
|
||||
return {
|
||||
type: 'general_feedback',
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
page_url: typeof window !== 'undefined' ? window.location.href : '',
|
||||
steps_to_reproduce: '',
|
||||
expected_behavior: '',
|
||||
actual_behavior: '',
|
||||
additional_notes: '',
|
||||
images: [],
|
||||
videos: [],
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
import { AlertRoot, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { HelpdeskFab } from '@/modules/feedback'
|
||||
import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
|
||||
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
|
||||
import type {
|
||||
@@ -567,6 +568,7 @@ function stepLabelClass(stepId: number) {
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<HelpdeskFab />
|
||||
<div class="border-b border-foreground/10 bg-primary/5">
|
||||
<div class="container mx-auto flex items-center justify-between px-5 py-4 sm:px-8">
|
||||
<div class="flex items-center gap-4">
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
getPhoneVerificationErrorMessage,
|
||||
sendAuthenticatedPhoneVerificationOtp,
|
||||
verifyAuthenticatedPhoneVerificationOtp,
|
||||
} from '@/modules/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const changingPhone = ref(false)
|
||||
const otpSent = ref(false)
|
||||
const phoneNumber = ref(authStore.user?.phone_number ?? '')
|
||||
const otp = ref('')
|
||||
const feedbackMessage = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const isVerified = computed(() => authStore.isPhoneVerified)
|
||||
|
||||
const sectionDescription = computed(() => {
|
||||
if (changingPhone.value) {
|
||||
return 'Masukkan nombor telefon baharu dan sahkan dengan kod OTP SMS.'
|
||||
}
|
||||
|
||||
if (isVerified.value) {
|
||||
return 'Nombor telefon anda telah disahkan.'
|
||||
}
|
||||
|
||||
return 'Sahkan nombor telefon anda melalui kod OTP SMS.'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => authStore.user?.phone_number,
|
||||
(value) => {
|
||||
if (!otpSent.value && !changingPhone.value) {
|
||||
phoneNumber.value = value ?? ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const resetOtpState = () => {
|
||||
otp.value = ''
|
||||
otpSent.value = false
|
||||
clearMessages()
|
||||
}
|
||||
|
||||
const clearMessages = () => {
|
||||
feedbackMessage.value = ''
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
const handlePhoneInput = () => {
|
||||
phoneNumber.value = phoneNumber.value.replace(/[^\d+]/g, '')
|
||||
otp.value = ''
|
||||
otpSent.value = false
|
||||
}
|
||||
|
||||
const handleOtpInput = () => {
|
||||
otp.value = otp.value.replace(/\D/g, '').slice(0, 6)
|
||||
}
|
||||
|
||||
const startChangePhone = () => {
|
||||
changingPhone.value = true
|
||||
phoneNumber.value = ''
|
||||
resetOtpState()
|
||||
}
|
||||
|
||||
const cancelChangePhone = () => {
|
||||
changingPhone.value = false
|
||||
phoneNumber.value = authStore.user?.phone_number ?? ''
|
||||
resetOtpState()
|
||||
}
|
||||
|
||||
const handleSendOtp = async () => {
|
||||
loading.value = true
|
||||
clearMessages()
|
||||
|
||||
try {
|
||||
const response = await sendAuthenticatedPhoneVerificationOtp({
|
||||
phone_number: phoneNumber.value,
|
||||
})
|
||||
|
||||
feedbackMessage.value = response.message
|
||||
otpSent.value = true
|
||||
} catch (error) {
|
||||
errorMessage.value = getPhoneVerificationErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyOtp = async () => {
|
||||
loading.value = true
|
||||
clearMessages()
|
||||
|
||||
try {
|
||||
const response = await verifyAuthenticatedPhoneVerificationOtp({
|
||||
phone_number: phoneNumber.value,
|
||||
otp: otp.value,
|
||||
})
|
||||
|
||||
authStore.setUserProfile(response.data)
|
||||
phoneNumber.value = response.data.phone_number ?? phoneNumber.value
|
||||
changingPhone.value = false
|
||||
otp.value = ''
|
||||
otpSent.value = false
|
||||
feedbackMessage.value = response.message
|
||||
} catch (error) {
|
||||
errorMessage.value = getPhoneVerificationErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Box raised="single" class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Nombor Telefon</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ sectionDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isVerified && !changingPhone" class="space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Input id="profile-phone-verified" :model-value="authStore.user?.phone_number ?? '-'" type="tel" disabled
|
||||
class="max-w-sm" />
|
||||
<Badge variant="success">Disahkan</Badge>
|
||||
</div>
|
||||
|
||||
<p v-if="feedbackMessage" class="text-sm text-slate-600">{{ feedbackMessage }}</p>
|
||||
|
||||
<Button type="button" look="outline" :disabled="loading" @click="startChangePhone">
|
||||
Tukar Nombor Telefon
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<FieldGroup>
|
||||
<Field class="max-w-sm">
|
||||
<FieldLabel for="profile-phone-verify">No. Telefon</FieldLabel>
|
||||
<Input id="profile-phone-verify" v-model="phoneNumber" type="tel" inputmode="tel"
|
||||
placeholder="No. Telefon, contoh: 0123456790" autocomplete="tel" :disabled="loading || otpSent"
|
||||
@input="handlePhoneInput" />
|
||||
</Field>
|
||||
<Field v-if="otpSent" class="max-w-sm">
|
||||
<FieldLabel for="profile-phone-otp">Kod OTP</FieldLabel>
|
||||
<Input id="profile-phone-otp" v-model="otp" type="text" inputmode="numeric" maxlength="6" placeholder="000000"
|
||||
autocomplete="one-time-code" class="text-center tracking-[0.4em]" :disabled="loading"
|
||||
@input="handleOtpInput" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<p v-if="feedbackMessage" class="text-sm text-slate-600">{{ feedbackMessage }}</p>
|
||||
<p v-if="errorMessage" class="text-sm text-danger">{{ errorMessage }}</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button v-if="!otpSent" type="button" variant="primary" :disabled="loading || !phoneNumber"
|
||||
@click="handleSendOtp">
|
||||
{{ loading ? 'Menghantar...' : 'Hantar Kod OTP' }}
|
||||
</Button>
|
||||
<template v-else>
|
||||
<Button type="button" variant="primary" :disabled="loading || otp.length !== 6" @click="handleVerifyOtp">
|
||||
{{ loading ? 'Mengesahkan...' : 'Sahkan OTP' }}
|
||||
</Button>
|
||||
<Button type="button" look="outline" :disabled="loading" @click="handleSendOtp">
|
||||
Hantar Semula OTP
|
||||
</Button>
|
||||
</template>
|
||||
<Button v-if="changingPhone" type="button" look="outline" :disabled="loading" @click="cancelChangePhone">
|
||||
Batal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</template>
|
||||
@@ -72,7 +72,7 @@ const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
|
||||
// TODO: replace with API lookup
|
||||
const EMPLOYERS = [
|
||||
{
|
||||
name: 'Infra Quest Sdn Bhd (IQSB)',
|
||||
name: 'Infra Quest Sdn. Bhd. (IQSB)',
|
||||
address: 'Lot 1045, Jalan Dato’ Lundang, 15200 Kota Bharu, Kelantan',
|
||||
},
|
||||
{
|
||||
@@ -89,6 +89,10 @@ const EMPLOYERS = [
|
||||
name: "An-Nisa'",
|
||||
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
|
||||
},
|
||||
{
|
||||
name: "Kel Infra Sdn. Bhd.",
|
||||
address: "Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.",
|
||||
}
|
||||
] as const
|
||||
|
||||
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
|
||||
|
||||
@@ -31,6 +31,7 @@ import { updateProfile } from '@/modules/profile/services/profile.service'
|
||||
import type { Address, AddressPayload } from '@/modules/profile/types/address.types'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import HeirTab from './HeirTab.vue'
|
||||
import PhoneVerificationSection from '../components/PhoneVerificationSection.vue'
|
||||
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
@@ -44,7 +45,6 @@ const form = reactive({
|
||||
name: '',
|
||||
ic_number: '',
|
||||
position: '',
|
||||
phone_number: '',
|
||||
birth_date: '',
|
||||
birth_place: '',
|
||||
})
|
||||
@@ -350,7 +350,6 @@ function syncFormFromUser() {
|
||||
form.name = user.name ?? ''
|
||||
form.ic_number = user.ic_number ?? ''
|
||||
form.position = user.position ?? ''
|
||||
form.phone_number = user.phone_number ?? ''
|
||||
form.birth_date = toDateInputValue(user.birth_date)
|
||||
form.birth_place = user.birth_place ?? ''
|
||||
syncProfileSelectValues()
|
||||
@@ -368,7 +367,6 @@ async function onSaveProfile() {
|
||||
name: form.name.trim(),
|
||||
ic_number: form.ic_number.trim(),
|
||||
position: form.position.trim(),
|
||||
phone_number: form.phone_number.trim(),
|
||||
gender: gender ?? undefined,
|
||||
marriage_status: marriageStatus ?? undefined,
|
||||
birth_date: form.birth_date || undefined,
|
||||
@@ -591,11 +589,6 @@ onMounted(async () => {
|
||||
<Input id="profile-ic" v-model="form.ic_number" type="text" inputmode="numeric" maxlength="15"
|
||||
placeholder="Contoh: 900101011234" @input="handleIcNumberInput" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="profile-phone">No. Telefon</FieldLabel>
|
||||
<Input id="profile-phone" v-model="form.phone_number" type="tel" pattern="[0-9]*"
|
||||
placeholder="0123456789" />
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel for="profile-position">Jawatan</FieldLabel>
|
||||
<Input id="profile-position" v-model="form.position" type="text" placeholder="Jawatan" />
|
||||
@@ -677,6 +670,8 @@ onMounted(async () => {
|
||||
</form>
|
||||
</Box>
|
||||
|
||||
<PhoneVerificationSection />
|
||||
|
||||
<Box raised="single" class="p-6">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
@@ -18,6 +18,7 @@ export function useUserList() {
|
||||
const joinDateTo = ref('')
|
||||
const leaveDateFrom = ref('')
|
||||
const leaveDateTo = ref('')
|
||||
const unitFilter = ref('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
@@ -55,6 +56,7 @@ export function useUserList() {
|
||||
join_date_to: joinDateTo.value || undefined,
|
||||
leave_date_from: leaveDateFrom.value || undefined,
|
||||
leave_date_to: leaveDateTo.value || undefined,
|
||||
company_name: unitFilter.value.trim() || undefined,
|
||||
})
|
||||
|
||||
users.value = data.data
|
||||
@@ -79,6 +81,7 @@ export function useUserList() {
|
||||
join_date_to: joinDateTo.value || undefined,
|
||||
leave_date_from: leaveDateFrom.value || undefined,
|
||||
leave_date_to: leaveDateTo.value || undefined,
|
||||
company_name: unitFilter.value.trim() || undefined,
|
||||
})
|
||||
|
||||
stats.value = res.data
|
||||
@@ -109,6 +112,11 @@ export function useUserList() {
|
||||
fetchStats()
|
||||
})
|
||||
|
||||
watch(unitFilter, () => {
|
||||
fetchUsers(1)
|
||||
fetchStats()
|
||||
})
|
||||
|
||||
watch([joinDateFrom, joinDateTo, leaveDateFrom, leaveDateTo], () => {
|
||||
fetchUsers(1)
|
||||
fetchStats()
|
||||
@@ -144,6 +152,7 @@ export function useUserList() {
|
||||
joinDateTo,
|
||||
leaveDateFrom,
|
||||
leaveDateTo,
|
||||
unitFilter,
|
||||
hasJoinDateFilters,
|
||||
hasLeaveDateFilters,
|
||||
clearJoinDateFilters,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as select from '@zag-js/select'
|
||||
import dayjs from 'dayjs'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -102,6 +103,7 @@ const form = reactive({
|
||||
phone_number: '',
|
||||
member_number: '',
|
||||
join_date: '',
|
||||
leave_date: '',
|
||||
birth_date: '',
|
||||
birth_place: '',
|
||||
})
|
||||
@@ -154,6 +156,7 @@ async function handleSubmit() {
|
||||
member_number: Number(form.member_number),
|
||||
member_type: memberType!,
|
||||
join_date: form.join_date,
|
||||
leave_date: isInactiveStatus.value ? form.leave_date || null : null,
|
||||
birth_date: form.birth_date,
|
||||
birth_place: form.birth_place.trim(),
|
||||
})
|
||||
@@ -170,6 +173,17 @@ async function handleSubmit() {
|
||||
}
|
||||
|
||||
const formDisabled = computed(() => saving.value)
|
||||
|
||||
const selectedStatus = computed(
|
||||
() => labelToApiValue(STATUS_OPTIONS, statusValue.value[0]) ?? 'pending',
|
||||
)
|
||||
const isInactiveStatus = computed(() => selectedStatus.value === 'inactive')
|
||||
|
||||
watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
if (newStatus === 'inactive' && oldStatus !== 'inactive' && !form.leave_date) {
|
||||
form.leave_date = dayjs().format('YYYY-MM-DD')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -417,6 +431,17 @@ const formDisabled = computed(() => saving.value)
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field v-if="isInactiveStatus">
|
||||
<FieldLabel for="user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel>
|
||||
<Input
|
||||
id="user-leave-date"
|
||||
v-model="form.leave_date"
|
||||
class="w-full"
|
||||
type="date"
|
||||
:disabled="formDisabled"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
|
||||
<Input
|
||||
|
||||
@@ -3,11 +3,15 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import Swal from 'sweetalert2'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
@@ -19,8 +23,14 @@ import {
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import type { Employment, EmploymentPayload } from '@/modules/profile/types/employment.types'
|
||||
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
|
||||
import {
|
||||
createUserEmployment,
|
||||
deleteUserEmployment,
|
||||
updateUserEmployment,
|
||||
} from '../services/userEmployment.service'
|
||||
import { getUser, updateUser } from '../services/user.service'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
@@ -49,6 +59,62 @@ const MEMBER_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Pesara', value: 'Pesara' },
|
||||
]
|
||||
|
||||
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Tetap', value: 'Permanent' },
|
||||
{ label: 'Kontrak', value: 'Contract' },
|
||||
{ label: 'Latihan Industri', value: 'Internship' },
|
||||
{ label: 'Freelance', value: 'Freelance' },
|
||||
]
|
||||
|
||||
const EMPLOYERS = [
|
||||
{
|
||||
name: 'Infra Quest Sdn. Bhd. (IQSB)',
|
||||
address: 'Lot 1045, Jalan Dato’ Lundang, 15200 Kota Bharu, Kelantan',
|
||||
},
|
||||
{
|
||||
name: 'Permodalan Kelantan Berhad (PKB)',
|
||||
address:
|
||||
'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
|
||||
},
|
||||
{
|
||||
name: 'Koperasi Permodalan Kelantan Berhad (KOPKB)',
|
||||
address:
|
||||
'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
|
||||
},
|
||||
{
|
||||
name: "An-Nisa'",
|
||||
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
|
||||
},
|
||||
{
|
||||
name: 'Kel Infra Sdn. Bhd.',
|
||||
address: 'Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.',
|
||||
},
|
||||
] as const
|
||||
|
||||
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
|
||||
label: employer.name,
|
||||
value: employer.name,
|
||||
}))
|
||||
|
||||
type EmploymentFieldKey =
|
||||
| 'company_name'
|
||||
| 'job_title'
|
||||
| 'employment_type'
|
||||
| 'salary'
|
||||
| 'start_date'
|
||||
| 'end_date'
|
||||
| 'is_current'
|
||||
|
||||
const EMPLOYMENT_FIELD_KEYS: EmploymentFieldKey[] = [
|
||||
'company_name',
|
||||
'job_title',
|
||||
'employment_type',
|
||||
'salary',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'is_current',
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
@@ -71,6 +137,8 @@ const statusCollection = createSelectCollection(STATUS_OPTIONS)
|
||||
const genderCollection = createSelectCollection(GENDER_OPTIONS)
|
||||
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
|
||||
const memberTypeCollection = createSelectCollection(MEMBER_TYPE_OPTIONS)
|
||||
const employmentTypeCollection = createSelectCollection(EMPLOYMENT_TYPE_OPTIONS)
|
||||
const companyNameCollection = createSelectCollection(COMPANY_OPTIONS)
|
||||
|
||||
const statusValue = ref<string[]>([])
|
||||
const genderValue = ref<string[]>([])
|
||||
@@ -82,6 +150,31 @@ const genderInitial = ref<string[]>([])
|
||||
const marriageStatusInitial = ref<string[]>([])
|
||||
const memberTypeInitial = ref<string[]>([])
|
||||
|
||||
const employmentTypeValue = ref<string[]>([])
|
||||
const employmentTypeInitial = ref<string[]>([])
|
||||
const companyNameValue = ref<string[]>([])
|
||||
const companyNameInitial = ref<string[]>([])
|
||||
|
||||
const employments = ref<Employment[]>([])
|
||||
const savingEmployment = ref(false)
|
||||
const deletingEmploymentId = ref<string | null>(null)
|
||||
const editingEmploymentId = ref<string | null>(null)
|
||||
const employmentErrors = reactive<Partial<Record<EmploymentFieldKey, string>>>({})
|
||||
|
||||
function emptyEmploymentForm() {
|
||||
return {
|
||||
company_name: '',
|
||||
job_title: '',
|
||||
employment_type: '',
|
||||
salary: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
is_current: true,
|
||||
}
|
||||
}
|
||||
|
||||
const employmentForm = reactive(emptyEmploymentForm())
|
||||
|
||||
function setStatusValue(details: { value: string[] }) {
|
||||
statusValue.value = details.value
|
||||
}
|
||||
@@ -98,6 +191,59 @@ function setMemberTypeValue(details: { value: string[] }) {
|
||||
memberTypeValue.value = details.value
|
||||
}
|
||||
|
||||
function setEmploymentTypeValue(details: { value: string[] }) {
|
||||
employmentTypeValue.value = details.value
|
||||
clearEmploymentFieldError('employment_type')
|
||||
employmentForm.employment_type =
|
||||
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function setCompanyNameValue(details: { value: string[] }) {
|
||||
companyNameValue.value = details.value
|
||||
clearEmploymentFieldError('company_name')
|
||||
employmentForm.company_name = details.value[0] ?? ''
|
||||
}
|
||||
|
||||
function clearEmploymentFieldError(field: EmploymentFieldKey) {
|
||||
delete employmentErrors[field]
|
||||
}
|
||||
|
||||
function clearEmploymentErrors() {
|
||||
for (const field of EMPLOYMENT_FIELD_KEYS) {
|
||||
delete employmentErrors[field]
|
||||
}
|
||||
}
|
||||
|
||||
function setEmploymentErrorsFromApi(error: unknown): boolean {
|
||||
const apiErrors = getApiValidationErrors(error)
|
||||
if (!apiErrors) return false
|
||||
|
||||
for (const [field, messages] of Object.entries(apiErrors)) {
|
||||
if (EMPLOYMENT_FIELD_KEYS.includes(field as EmploymentFieldKey) && messages[0]) {
|
||||
employmentErrors[field as EmploymentFieldKey] = messages[0]
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(employmentErrors).length > 0
|
||||
}
|
||||
|
||||
function syncEmploymentSelectValues() {
|
||||
employmentTypeValue.value = apiValueToLabel(
|
||||
EMPLOYMENT_TYPE_OPTIONS,
|
||||
employmentForm.employment_type,
|
||||
)
|
||||
employmentTypeInitial.value = [...employmentTypeValue.value]
|
||||
companyNameValue.value = apiValueToLabel(COMPANY_OPTIONS, employmentForm.company_name)
|
||||
companyNameInitial.value = [...companyNameValue.value]
|
||||
}
|
||||
|
||||
function resetEmploymentForm() {
|
||||
Object.assign(employmentForm, emptyEmploymentForm())
|
||||
editingEmploymentId.value = null
|
||||
clearEmploymentErrors()
|
||||
syncEmploymentSelectValues()
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -154,6 +300,219 @@ function syncFormFromUser(user: Awaited<ReturnType<typeof getUser>>['data']) {
|
||||
genderInitial.value = [...genderValue.value]
|
||||
marriageStatusInitial.value = [...marriageStatusValue.value]
|
||||
memberTypeInitial.value = [...memberTypeValue.value]
|
||||
employments.value = user.employments ?? []
|
||||
}
|
||||
|
||||
const employmentTypeLabel = computed(() =>
|
||||
Object.fromEntries(EMPLOYMENT_TYPE_OPTIONS.map((option) => [option.value, option.label])),
|
||||
)
|
||||
|
||||
const isEditingEmployment = computed(() => editingEmploymentId.value !== null)
|
||||
|
||||
const canAddEmployment = computed(() => !loading.value && employments.value.length === 0)
|
||||
|
||||
const showEmploymentForm = computed(() => isEditingEmployment.value || canAddEmployment.value)
|
||||
|
||||
function formatSalary(value: number | string | null | undefined): string {
|
||||
const amount = Number(value)
|
||||
if (Number.isNaN(amount)) return '-'
|
||||
return new Intl.NumberFormat('ms-MY', {
|
||||
style: 'currency',
|
||||
currency: 'MYR',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
function formatDateLabel(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat('ms-MY', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function formatEmploymentPeriod(employment: Employment): string {
|
||||
const start = formatDateLabel(employment.start_date)
|
||||
if (employment.is_current) {
|
||||
return `${start} - Kini`
|
||||
}
|
||||
const end = formatDateLabel(employment.end_date)
|
||||
return end ? `${start} - ${end}` : start
|
||||
}
|
||||
|
||||
function validateEmploymentForm(): boolean {
|
||||
clearEmploymentErrors()
|
||||
|
||||
let valid = true
|
||||
|
||||
if (!companyNameValue.value[0]?.trim()) {
|
||||
employmentErrors.company_name = 'Nama syarikat diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!employmentForm.job_title.trim()) {
|
||||
employmentErrors.job_title = 'Jawatan diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (
|
||||
!employmentTypeValue.value[0] ||
|
||||
!labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, employmentTypeValue.value[0])
|
||||
) {
|
||||
employmentErrors.employment_type = 'Jenis kerja diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
const salary = Number(employmentForm.salary)
|
||||
if (!employmentForm.salary.toString().trim() || Number.isNaN(salary) || salary < 0) {
|
||||
employmentErrors.salary = 'Gaji diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!employmentForm.start_date) {
|
||||
employmentErrors.start_date = 'Tarikh mula diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!employmentForm.is_current && !employmentForm.end_date) {
|
||||
employmentErrors.end_date = 'Tarikh tamat diperlukan jika bukan pekerjaan semasa.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (
|
||||
!employmentForm.is_current &&
|
||||
employmentForm.start_date &&
|
||||
employmentForm.end_date &&
|
||||
employmentForm.end_date < employmentForm.start_date
|
||||
) {
|
||||
employmentErrors.end_date = 'Tarikh tamat mesti selepas tarikh mula.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
function buildEmploymentPayload(): EmploymentPayload {
|
||||
return {
|
||||
company_name: employmentForm.company_name.trim(),
|
||||
job_title: employmentForm.job_title.trim(),
|
||||
employment_type:
|
||||
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, employmentTypeValue.value[0]) ??
|
||||
employmentForm.employment_type,
|
||||
salary: Number(employmentForm.salary),
|
||||
start_date: employmentForm.start_date,
|
||||
end_date: employmentForm.is_current ? null : employmentForm.end_date || null,
|
||||
is_current: employmentForm.is_current,
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshEmployments() {
|
||||
const response = await getUser(userId.value)
|
||||
employments.value = response.data.employments ?? []
|
||||
}
|
||||
|
||||
function startEditEmployment(employment: Employment) {
|
||||
clearEmploymentErrors()
|
||||
editingEmploymentId.value = employment.id
|
||||
employmentForm.company_name = employment.company_name
|
||||
employmentForm.job_title = employment.job_title
|
||||
employmentForm.employment_type = employment.employment_type
|
||||
employmentForm.salary = String(employment.salary)
|
||||
employmentForm.start_date = toDateInputValue(employment.start_date)
|
||||
employmentForm.end_date = toDateInputValue(employment.end_date)
|
||||
employmentForm.is_current = employment.is_current
|
||||
syncEmploymentSelectValues()
|
||||
}
|
||||
|
||||
async function onSaveEmployment() {
|
||||
if (!validateEmploymentForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
savingEmployment.value = true
|
||||
const wasEditing = isEditingEmployment.value
|
||||
const payload = buildEmploymentPayload()
|
||||
|
||||
try {
|
||||
const res = wasEditing
|
||||
? await updateUserEmployment(userId.value, editingEmploymentId.value!, payload)
|
||||
: await createUserEmployment(userId.value, payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal menyimpan pekerjaan.')
|
||||
}
|
||||
|
||||
await refreshEmployments()
|
||||
resetEmploymentForm()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: wasEditing ? 'Pekerjaan berjaya dikemas kini.' : 'Pekerjaan berjaya ditambah.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (err) {
|
||||
if (!setEmploymentErrorsFromApi(err)) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(err, 'Gagal menyimpan pekerjaan.'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
savingEmployment.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteEmployment(employment: Employment) {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Padam pekerjaan?',
|
||||
text: 'Tindakan ini tidak boleh dibatalkan.',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Padam',
|
||||
cancelButtonText: 'Batal',
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
deletingEmploymentId.value = employment.id
|
||||
|
||||
try {
|
||||
const res = await deleteUserEmployment(userId.value, employment.id)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal memadam pekerjaan.')
|
||||
}
|
||||
|
||||
if (editingEmploymentId.value === employment.id) {
|
||||
resetEmploymentForm()
|
||||
}
|
||||
|
||||
await refreshEmployments()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: 'Pekerjaan berjaya dipadam.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (err) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(err, 'Gagal memadam pekerjaan.'),
|
||||
})
|
||||
} finally {
|
||||
deletingEmploymentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUser() {
|
||||
@@ -182,6 +541,16 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => employmentForm.is_current,
|
||||
(isCurrent) => {
|
||||
if (isCurrent) {
|
||||
employmentForm.end_date = ''
|
||||
clearEmploymentFieldError('end_date')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
@@ -218,6 +587,7 @@ async function handleSubmit() {
|
||||
const formDisabled = computed(() => loading.value || saving.value)
|
||||
|
||||
onMounted(() => {
|
||||
syncEmploymentSelectValues()
|
||||
fetchUser()
|
||||
})
|
||||
</script>
|
||||
@@ -405,5 +775,249 @@ onMounted(() => {
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Pekerjaan</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Urus maklumat pekerjaan pengguna.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-slate-500">Memuatkan pekerjaan...</div>
|
||||
|
||||
<div v-else-if="employments.length" class="space-y-3">
|
||||
<div
|
||||
v-for="employment in employments"
|
||||
:key="employment.id"
|
||||
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-slate-900">{{ employment.company_name }}</span>
|
||||
<Badge v-if="employment.is_current" class="bg-green-500 text-white">Semasa</Badge>
|
||||
<Badge look="outline">
|
||||
{{ employmentTypeLabel[employment.employment_type] ?? employment.employment_type }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-medium text-slate-700">{{ employment.job_title }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ formatEmploymentPeriod(employment) }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ formatSalary(employment.salary) }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="deletingEmploymentId === employment.id || saving"
|
||||
@click="startEditEmployment(employment)"
|
||||
>
|
||||
<Lucide class="mr-2 size-4" icon="Pencil" />
|
||||
Kemaskini
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none text-danger"
|
||||
:disabled="deletingEmploymentId === employment.id || saving"
|
||||
@click="onDeleteEmployment(employment)"
|
||||
>
|
||||
<Lucide
|
||||
class="mr-2 size-4"
|
||||
:icon="deletingEmploymentId === employment.id ? 'LoaderCircle' : 'Trash'"
|
||||
:class="{ 'animate-spin': deletingEmploymentId === employment.id }"
|
||||
/>
|
||||
Padam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
Tiada pekerjaan direkodkan.
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="showEmploymentForm"
|
||||
class="space-y-6 border-t border-foreground/10 pt-6"
|
||||
@submit.prevent="onSaveEmployment"
|
||||
>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-slate-900">
|
||||
{{ isEditingEmployment ? 'Kemaskini Pekerjaan' : 'Tambah Pekerjaan' }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{
|
||||
isEditingEmployment
|
||||
? 'Kemas kini maklumat pekerjaan yang dipilih.'
|
||||
: 'Tambah rekod pekerjaan baharu untuk pengguna ini.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="isEditingEmployment"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="savingEmployment"
|
||||
@click="resetEmploymentForm"
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="savingEmployment || saving">
|
||||
{{ savingEmployment ? 'Menyimpan...' : isEditingEmployment ? 'Kemaskini' : 'Tambah' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Nama Syarikat</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`company-name-${editingEmploymentId ?? 'new'}`"
|
||||
class="w-full"
|
||||
:collection="companyNameCollection"
|
||||
:default-value="companyNameInitial"
|
||||
:disabled="savingEmployment || saving"
|
||||
@value-change="setCompanyNameValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!employmentErrors.company_name">
|
||||
<SelectValueText placeholder="Pilih syarikat" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in companyNameCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="employmentErrors.company_name">
|
||||
{{ employmentErrors.company_name }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="employment-job-title">Jawatan</FieldLabel>
|
||||
<Input
|
||||
id="employment-job-title"
|
||||
v-model="employmentForm.job_title"
|
||||
type="text"
|
||||
placeholder="Jawatan"
|
||||
:disabled="savingEmployment || saving"
|
||||
:aria-invalid="!!employmentErrors.job_title"
|
||||
@input="clearEmploymentFieldError('job_title')"
|
||||
/>
|
||||
<FieldError v-if="employmentErrors.job_title">
|
||||
{{ employmentErrors.job_title }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Jenis Kerja</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`employment-type-${editingEmploymentId ?? 'new'}`"
|
||||
class="w-full"
|
||||
:collection="employmentTypeCollection"
|
||||
:default-value="employmentTypeInitial"
|
||||
:disabled="savingEmployment || saving"
|
||||
@value-change="setEmploymentTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!employmentErrors.employment_type">
|
||||
<SelectValueText placeholder="Pilih jenis kerja" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jenis Kerja</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in employmentTypeCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="employmentErrors.employment_type">
|
||||
{{ employmentErrors.employment_type }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="employment-salary">Gaji (RM)</FieldLabel>
|
||||
<Input
|
||||
id="employment-salary"
|
||||
v-model="employmentForm.salary"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
:disabled="savingEmployment || saving"
|
||||
:aria-invalid="!!employmentErrors.salary"
|
||||
@input="clearEmploymentFieldError('salary')"
|
||||
/>
|
||||
<FieldError v-if="employmentErrors.salary">{{ employmentErrors.salary }}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="employment-start-date">Tarikh Mula</FieldLabel>
|
||||
<Input
|
||||
id="employment-start-date"
|
||||
v-model="employmentForm.start_date"
|
||||
type="date"
|
||||
:disabled="savingEmployment || saving"
|
||||
:aria-invalid="!!employmentErrors.start_date"
|
||||
@input="clearEmploymentFieldError('start_date')"
|
||||
/>
|
||||
<FieldError v-if="employmentErrors.start_date">
|
||||
{{ employmentErrors.start_date }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="employment-end-date">Tarikh Tamat</FieldLabel>
|
||||
<Input
|
||||
id="employment-end-date"
|
||||
v-model="employmentForm.end_date"
|
||||
type="date"
|
||||
:disabled="employmentForm.is_current || savingEmployment || saving"
|
||||
:aria-invalid="!!employmentErrors.end_date"
|
||||
@input="clearEmploymentFieldError('end_date')"
|
||||
/>
|
||||
<FieldError v-if="employmentErrors.end_date">
|
||||
{{ employmentErrors.end_date }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field class="md:col-span-2">
|
||||
<CheckboxRoot
|
||||
:checked="employmentForm.is_current"
|
||||
:disabled="savingEmployment || saving"
|
||||
@checked-change="({ checked }) => (employmentForm.is_current = checked === true)"
|
||||
>
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Pekerjaan semasa</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Lucide } from '@/components/ui/lucide'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { EMPLOYERS } from '@/constants/employers'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listRoles } from '@/modules/role/services/role.service'
|
||||
import type { RoleListItem } from '@/modules/role/types/role.types'
|
||||
@@ -48,6 +49,22 @@ const STATUS_FILTER_CHIPS: StatusFilterChip[] = [
|
||||
{ label: 'Menunggu', value: 'pending', variant: 'pending' },
|
||||
]
|
||||
|
||||
function getEmployerShortLabel(name: string): string {
|
||||
const match = name.match(/\(([^)]+)\)/)
|
||||
if (match?.[1]) return match[1]
|
||||
if (name.startsWith('An-Nisa')) return "An-Nisa'"
|
||||
if (name.startsWith('Kel Infra')) return 'Kel Infra'
|
||||
return name
|
||||
}
|
||||
|
||||
const UNIT_FILTER_CHIPS = [
|
||||
{ label: 'Semua', value: '' },
|
||||
...EMPLOYERS.map((employer) => ({
|
||||
label: getEmployerShortLabel(employer.name),
|
||||
value: employer.name,
|
||||
})),
|
||||
]
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
@@ -163,6 +180,14 @@ function setStatusFilter(value: string) {
|
||||
statusFilter.value = value
|
||||
}
|
||||
|
||||
function isUnitFilterActive(value: string) {
|
||||
return unitFilter.value === value
|
||||
}
|
||||
|
||||
function setUnitFilter(value: string) {
|
||||
unitFilter.value = value
|
||||
}
|
||||
|
||||
function formatDeletedAt(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
@@ -356,6 +381,7 @@ const {
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
unitFilter,
|
||||
joinDateFrom,
|
||||
joinDateTo,
|
||||
leaveDateFrom,
|
||||
@@ -540,6 +566,23 @@ onMounted(() => {
|
||||
{{ chip.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm opacity-70">Unit:</span>
|
||||
<Badge
|
||||
v-for="chip in UNIT_FILTER_CHIPS"
|
||||
:key="chip.value || 'all-units'"
|
||||
variant="ghost"
|
||||
:look="isUnitFilterActive(chip.value) ? 'filled' : 'outline'"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="chip.value || 'Semua unit'"
|
||||
:aria-pressed="isUnitFilterActive(chip.value)"
|
||||
@click="setUnitFilter(chip.value)"
|
||||
@keydown.enter="setUnitFilter(chip.value)"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { EmploymentApiResponse, EmploymentPayload } from '@/modules/profile/types/employment.types'
|
||||
|
||||
export async function createUserEmployment(
|
||||
userId: string,
|
||||
payload: EmploymentPayload,
|
||||
): Promise<EmploymentApiResponse> {
|
||||
const { data } = await api.post<EmploymentApiResponse>(`/v1/users/${userId}/employments`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateUserEmployment(
|
||||
userId: string,
|
||||
employmentId: string,
|
||||
payload: EmploymentPayload,
|
||||
): Promise<EmploymentApiResponse> {
|
||||
const { data } = await api.put<EmploymentApiResponse>(
|
||||
`/v1/users/${userId}/employments/${employmentId}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemas kini pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteUserEmployment(
|
||||
userId: string,
|
||||
employmentId: string,
|
||||
): Promise<EmploymentApiResponse> {
|
||||
const { data } = await api.delete<EmploymentApiResponse>(
|
||||
`/v1/users/${userId}/employments/${employmentId}`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export interface CreateUserPayload {
|
||||
member_number: number
|
||||
member_type: string
|
||||
join_date: string
|
||||
leave_date?: string | null
|
||||
birth_date: string
|
||||
birth_place: string
|
||||
}
|
||||
@@ -99,6 +100,7 @@ export interface ListUsersParams {
|
||||
join_date_to?: string
|
||||
leave_date_from?: string
|
||||
leave_date_to?: string
|
||||
company_name?: string
|
||||
}
|
||||
|
||||
export interface ListDeletedUsersParams {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { activityLayoutRoutes } from '@/modules/activity'
|
||||
import { dashboardLayoutRoutes } from '@/modules/dashboard'
|
||||
import { externalSystemLayoutRoutes } from '@/modules/external-system'
|
||||
import { activityLogLayoutRoutes } from '@/modules/activity-log'
|
||||
import { feedbackPublicRoutes, feedbackLayoutRoutes } from '@/modules/feedback'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@@ -30,15 +31,17 @@ const router = createRouter({
|
||||
...activityLayoutRoutes,
|
||||
...externalSystemLayoutRoutes,
|
||||
...membershipApplicationLayoutRoutes,
|
||||
...feedbackLayoutRoutes,
|
||||
],
|
||||
},
|
||||
...authPublicRoutes,
|
||||
...membershipApplicationPublicRoutes,
|
||||
...feedbackPublicRoutes,
|
||||
...profilePublicRoutes,
|
||||
],
|
||||
})
|
||||
|
||||
const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'verify-email', 'membership-application-apply'])
|
||||
const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'membership-application-apply', 'feedback-submit'])
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const authStore = useAuthStore(pinia)
|
||||
@@ -66,11 +69,11 @@ router.beforeEach(async (to) => {
|
||||
return true
|
||||
}
|
||||
|
||||
// Redirect authenticated users away from login/register/verify-email.
|
||||
if (
|
||||
authStore.isAuthenticated &&
|
||||
PUBLIC_ROUTE_NAMES.has(routeName) &&
|
||||
routeName !== 'membership-application-apply'
|
||||
routeName !== 'membership-application-apply' &&
|
||||
routeName !== 'feedback-submit'
|
||||
) {
|
||||
return resolvePostAuthRoute(authStore.user)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
isAuthenticated: (state) => state.user !== null,
|
||||
isAccountPending: (state) => state.user?.status === 'pending',
|
||||
isAccountActive: (state) => state.user?.status === 'active',
|
||||
isEmailVerified: (state) => Boolean(state.user?.email_verified_at),
|
||||
isPhoneVerified: (state) => Boolean(state.user?.phone_verified_at),
|
||||
roles: (state) => state.user?.roles ?? [],
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useBreadcrumb } from '@/composables/useBreadcrumb'
|
||||
import { useNotifications } from '@/modules/notification'
|
||||
@@ -14,6 +15,7 @@ import { useFilteredMenu } from '@/composables/useFilteredMenu'
|
||||
import { SideMenu } from '@/components/side-menu'
|
||||
import { AccountDropdown, AccountTrigger } from '@/components/account-dropdown'
|
||||
import { NotificationDropdown } from '@/components/notification-dropdown'
|
||||
import EmailVerificationBanner from '@/modules/auth/components/EmailVerificationBanner.vue'
|
||||
import {
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaViewport,
|
||||
@@ -33,6 +35,8 @@ const {
|
||||
} = useSideMenu()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const breadcrumbItems = useBreadcrumb([])
|
||||
const filteredMenu = useFilteredMenu(mainMenu)
|
||||
const { unreadCount, fetchNotifications } = useNotifications()
|
||||
@@ -89,6 +93,12 @@ onMounted(() => {
|
||||
authStore.fetchSession()
|
||||
fetchNotifications()
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
|
||||
const verified = route.query.verified
|
||||
if (typeof verified === 'string' && verified) {
|
||||
authStore.fetchSession()
|
||||
router.replace({ query: { ...route.query, verified: undefined } })
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -224,6 +234,10 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmailVerificationBanner
|
||||
v-if="authStore.isAuthenticated && !authStore.isEmailVerified"
|
||||
class="mb-4"
|
||||
/>
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Lucide, type Icon as LucideIcon } from '@/components/ui/lucide'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { HelpdeskFab } from '@/modules/feedback'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -58,16 +59,6 @@ const sideTabs: SideTab[] = [
|
||||
window.open('https://www.facebook.com/KoperasiPermodalanKelantanBerhad/', '_blank')
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: 'feedback-page',
|
||||
// label: 'Helpdesk',
|
||||
// iconType: 'lucide',
|
||||
// icon: 'MessageCircle',
|
||||
// offsetRem: 10.5,
|
||||
// onClick: () => {
|
||||
// window.open('https://docs.google.com/forms/d/e/1FAIpQLSd_0p-kKmsHq9z4fZ3e6X13V6O64s1-CpJw4_vR768Gg0_sA/viewform?usp=header', '_blank')
|
||||
// },
|
||||
// },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
@@ -106,6 +97,8 @@ onMounted(() => {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<HelpdeskFab :offset-rem="10.5" />
|
||||
|
||||
<Component />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user