Feature/phone register (#11)

Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: KoPKB/My-KOPKB#11
This commit is contained in:
2026-07-14 12:03:22 +08:00
parent 252eedb1d0
commit c88709a32b
43 changed files with 3368 additions and 371 deletions
+24
View File
@@ -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
+2
View File
@@ -6,6 +6,7 @@ import { activityMenu } from '@/modules/activity'
import { dashboardMenu } from '@/modules/dashboard/menu' import { dashboardMenu } from '@/modules/dashboard/menu'
import { externalSystemMenu } from '@/modules/external-system/menu' import { externalSystemMenu } from '@/modules/external-system/menu'
import { activityLogMenu } from '@/modules/activity-log/menu' import { activityLogMenu } from '@/modules/activity-log/menu'
import { feedbackMenu } from '@/modules/feedback'
export type { Menu } export type { Menu }
@@ -17,6 +18,7 @@ const mainMenu: (string | Menu)[] = [
'Teknologi Maklumat', 'Teknologi Maklumat',
...roleMenu, ...roleMenu,
...activityLogMenu, ...activityLogMenu,
...feedbackMenu,
'Pentadbiran', 'Pentadbiran',
...membershipApplicationMenu, ...membershipApplicationMenu,
...userMenu, ...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>
+14 -6
View File
@@ -4,28 +4,36 @@ export {
login, login,
logout, logout,
register, register,
verifyEmail, sendPhoneVerificationOtp,
resendVerificationEmail, verifyPhoneVerificationOtp,
sendAuthenticatedPhoneVerificationOtp,
verifyAuthenticatedPhoneVerificationOtp,
sendVerificationEmail,
requestForgotPassword, requestForgotPassword,
resetPassword, resetPassword,
fetchCurrentUser, fetchCurrentUser,
getAuthErrorMessage, getAuthErrorMessage,
getRegisterErrorMessage, getRegisterErrorMessage,
getVerifyEmailErrorMessage, getPhoneVerificationErrorMessage,
getForgotPasswordErrorMessage, getForgotPasswordErrorMessage,
getResetPasswordErrorMessage, getResetPasswordErrorMessage,
resolvePostLoginRoute, resolvePostLoginRoute,
resolvePostAuthRoute, resolvePostAuthRoute,
isAccountPending, isAccountPending,
isLoginVerificationRequired, isEmailVerified,
isPhoneVerified,
} from './services/auth.service' } from './services/auth.service'
export type { export type {
LoginCredentials, LoginCredentials,
LoginResponse, LoginResponse,
RegisterCredentials, RegisterCredentials,
RegisterResponse, RegisterResponse,
VerifyEmailPayload, SendPhoneVerificationOtpPayload,
VerifyEmailResponse, SendPhoneVerificationOtpResponse,
VerifyPhoneVerificationOtpPayload,
VerifyPhoneVerificationOtpResponse,
VerifyAuthenticatedPhoneVerificationOtpResponse,
ResendVerificationResponse,
ForgotPasswordPayload, ForgotPasswordPayload,
ForgotPasswordResponse, ForgotPasswordResponse,
ResetPasswordPayload, ResetPasswordPayload,
+13 -6
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Box } from '@/components/ui/box' import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert' 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' import illustrationUrl from '@/assets/images/logo.svg'
const router = useRouter() const router = useRouter()
const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const loggingOut = ref(false) const loggingOut = ref(false)
@@ -44,6 +45,12 @@ const handleLogout = async () => {
} }
onMounted(() => { onMounted(() => {
const verified = route.query.verified
if (typeof verified === 'string' && verified) {
authStore.fetchSession()
router.replace({ query: { ...route.query, verified: undefined } })
}
statusPollInterval = setInterval(checkActivationStatus, 30000) statusPollInterval = setInterval(checkActivationStatus, 30000)
}) })
@@ -92,21 +99,21 @@ const appVersion = import.meta.env.VITE_APP_VERSION
</p> </p>
<AlertRoot class="mt-8" variant="primary"> <AlertRoot class="mt-8" variant="primary">
<AlertTitle>E-mel disahkan</AlertTitle> <AlertTitle>Menunggu pengaktifan</AlertTitle>
<AlertDescription> <AlertDescription>
Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem. Anda akan dapat Pendaftaran anda berjaya. Akaun anda sedang menunggu pengaktifan daripada pentadbir
mengakses sistem selepas akaun diaktifkan. sistem. Anda akan dapat mengakses sistem selepas akaun diaktifkan.
</AlertDescription> </AlertDescription>
</AlertRoot> </AlertRoot>
<div class="mt-8 flex flex-col gap-4"> <div class="mt-8 flex flex-col gap-4">
<Button class="box w-full px-4 py-5" variant="primary" type="button" :disabled="checkingStatus" <Button class="box w-full px-4 py-5" variant="primary" type="button" :disabled="checkingStatus"
@click="checkActivationStatus"> @click="checkActivationStatus">
{{ checkingStatus ? 'Checking...' : 'Semak Status' }} {{ checkingStatus ? 'Menyemak...' : 'Semak Status' }}
</Button> </Button>
<Button class="box w-full px-4 py-5" look="outline" type="button" :disabled="loggingOut" <Button class="box w-full px-4 py-5" look="outline" type="button" :disabled="loggingOut"
@click="handleLogout"> @click="handleLogout">
{{ loggingOut ? 'Logging out...' : 'Log Keluar' }} {{ loggingOut ? 'Log keluar...' : 'Log Keluar' }}
</Button> </Button>
</div> </div>
</Box> </Box>
+8 -12
View File
@@ -12,6 +12,7 @@ import {
login, login,
resolvePostAuthRoute, resolvePostAuthRoute,
} from '@/modules/auth' } from '@/modules/auth'
import { HelpdeskFab } from '@/modules/feedback'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import illustrationUrl from '@/assets/images/logo.svg' import illustrationUrl from '@/assets/images/logo.svg'
@@ -47,18 +48,12 @@ const handleLogin = async () => {
remember: remember.value, remember: remember.value,
}) })
const loginData = response.data authStore.setSession(
response.data.user,
if ('requires_email_verification' in loginData) { response.active_role ?? null,
await router.push({ response.can_switch_role ?? false,
name: 'verify-email', )
query: { email: loginData.email }, await router.push(resolvePostAuthRoute(response.data.user, response.redirect_path))
})
return
}
authStore.setSession(loginData.user, response.active_role ?? null, response.can_switch_role ?? false)
await router.push(resolvePostAuthRoute(loginData.user, response.redirect_path))
} catch (error) { } catch (error) {
errorMessage.value = getAuthErrorMessage(error) errorMessage.value = getAuthErrorMessage(error)
} finally { } 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%]', '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', '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="[ <div :class="[
'p-3 sm:px-8 relative h-full', '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%]', '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%]',
+309 -36
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue' import { computed, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import { Box } from '@/components/ui/box' import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button' 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 { Input } from '@/components/ui/input'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert' import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { PasswordInput } from '@/components/ui/password-input' 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 { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import illustrationUrl from '@/assets/images/logo.svg' 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 name = ref('')
const email = ref('') const email = ref('')
const icNumber = ref('') const icNumber = ref('')
@@ -21,6 +42,61 @@ const passwordConfirmation = ref('')
const termsAccepted = ref(false) const termsAccepted = ref(false)
const loading = ref(false) const loading = ref(false)
const errorMessage = ref('') 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 = () => { const handleNameInput = () => {
name.value = sanitizeNameInput(name.value) name.value = sanitizeNameInput(name.value)
@@ -30,13 +106,102 @@ const handleIcNumberInput = () => {
icNumber.value = sanitizeIcNumberInput(icNumber.value) 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 () => { 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) { if (!termsAccepted.value) {
errorMessage.value = 'Sila bersetuju dengan Dasar Privasi dan Terma dan Syarat.' errorMessage.value = 'Sila bersetuju dengan Dasar Privasi dan Terma dan Syarat.'
return return
} }
errorMessage.value = '' clearMessages()
loading.value = true loading.value = true
try { try {
@@ -44,34 +209,88 @@ const handleRegister = async () => {
name: name.value, name: name.value,
email: email.value, email: email.value,
ic_number: icNumber.value, ic_number: icNumber.value,
phone_number: phoneNumber.value,
phone_verification_token: phoneVerificationToken.value,
password: password.value, password: password.value,
password_confirmation: passwordConfirmation.value, password_confirmation: passwordConfirmation.value,
}) })
await router.push({ authStore.setSession(
name: 'verify-email', response.data.user,
query: { email: response.data.email }, response.active_role ?? null,
}) response.can_switch_role ?? false,
)
await router.push(resolvePostAuthRoute(response.data.user, response.redirect_path))
} catch (error) { } catch (error) {
errorMessage.value = getRegisterErrorMessage(error) errorMessage.value = getRegisterErrorMessage(error)
} finally { } finally {
loading.value = false 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> </script>
<template> <template>
<div :class="[ <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%]', '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', '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="[ <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%]', '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="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="hidden min-h-screen flex-col xl:flex">
<div class="my-auto"> <div class="my-auto">
<img class="-mt-16 w-1/2" :src="illustrationUrl" alt="logo-RAJD" /> <img class="-mt-16 w-1/2" :src="illustrationUrl" alt="logo-RAJD" />
@@ -84,31 +303,76 @@ const handleRegister = async () => {
</div> </div>
</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" <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> <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> </div>
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger"> <div class="mt-5 border-t border-foreground/10 pt-5">
<AlertTitle>Daftar gagal</AlertTitle> <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>
<AlertRoot v-if="errorMessage" class="mt-4 py-3" variant="danger">
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ errorMessage }}</AlertDescription> <AlertDescription>{{ errorMessage }}</AlertDescription>
</AlertRoot> </AlertRoot>
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleRegister"> <AlertRoot v-if="successMessage" class="mt-4 py-3" variant="success">
<Input v-model="name" class="box block min-w-full px-5 py-6 xl:min-w-md" type="text" <AlertTitle>Berjaya</AlertTitle>
placeholder="Nama Penuh" autocomplete="name" required @input="handleNameInput" /> <AlertDescription>{{ successMessage }}</AlertDescription>
<Input v-model="email" class="box block min-w-full px-5 py-6 xl:min-w-md" type="email" </AlertRoot>
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" <form class="mt-4 flex flex-col gap-5" @submit.prevent="handlePrimaryAction">
inputmode="numeric" maxlength="15" placeholder="Contoh: 900101011234" required <div class="flex flex-col gap-3">
@input="handleIcNumberInput" /> <template v-if="currentStep === 1">
<PasswordInput v-model="password" class="box block min-w-full px-5 py-6 xl:min-w-md" type="password" <Input v-model="phoneNumber" :class="inputClass" type="tel" inputmode="tel"
placeholder="Kata Laluan" autocomplete="new-password" minlength="8" required /> placeholder="No. Telefon, contoh: 0123456790" autocomplete="tel" required
<PasswordInput v-model="passwordConfirmation" class="box block min-w-full px-5 py-6 xl:min-w-md" @input="handlePhoneInput" />
placeholder="Sahkan Kata Laluan" autocomplete="new-password" minlength="8" required /> </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"> <div class="flex text-xs sm:text-sm">
<CheckboxRoot :checked="termsAccepted" <CheckboxRoot :checked="termsAccepted"
@@ -127,18 +391,27 @@ const handleRegister = async () => {
</CheckboxLabel> </CheckboxLabel>
</CheckboxRoot> </CheckboxRoot>
</div> </div>
</template>
</div>
<div class="mt-5 text-center xl:mt-10 xl:text-left"> <div class="space-y-3">
<Button class="box w-full px-4 py-5" variant="primary" type="submit" <div class="flex gap-3">
:disabled="loading || !termsAccepted"> <Button v-if="currentStep > 1" class="box w-full px-4 py-4" look="outline" type="button"
{{ loading ? 'Mendaftar...' : 'Daftar' }} :disabled="loading" @click="goPrevious">
Sebelum
</Button> </Button>
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button" <Button class="box w-full px-4 py-4" variant="primary" type="submit"
@click="router.push({ name: 'login' })"> :disabled="loading || (currentStep === 4 && !termsAccepted)">
Log masuk {{ primaryActionLabel }}
</Button> </Button>
</div> </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> </form>
</div>
</Box> </Box>
</div> </div>
</div> </div>
-164
View File
@@ -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>
-6
View File
@@ -13,12 +13,6 @@ export const authPublicRoutes: RouteRecordRaw[] = [
component: () => import('./pages/Register.vue'), component: () => import('./pages/Register.vue'),
meta: { module: 'auth' }, meta: { module: 'auth' },
}, },
{
path: '/verify-email',
name: 'verify-email',
component: () => import('./pages/VerifyEmail.vue'),
meta: { module: 'auth' },
},
{ {
path: '/forgot-password', path: '/forgot-password',
name: 'forgot-password', name: 'forgot-password',
+55 -17
View File
@@ -5,16 +5,18 @@ import type {
ForgotPasswordResponse, ForgotPasswordResponse,
LoginCredentials, LoginCredentials,
LoginResponse, LoginResponse,
LoginVerificationRequiredData,
RegisterCredentials, RegisterCredentials,
RegisterResponse, RegisterResponse,
ResendVerificationResponse,
ResetPasswordPayload, ResetPasswordPayload,
ResetPasswordResponse, ResetPasswordResponse,
ResendVerificationResponse,
SessionResponse, SessionResponse,
SendPhoneVerificationOtpPayload,
SendPhoneVerificationOtpResponse,
SwitchRoleResponse, SwitchRoleResponse,
VerifyEmailPayload, VerifyPhoneVerificationOtpPayload,
VerifyEmailResponse, VerifyPhoneVerificationOtpResponse,
VerifyAuthenticatedPhoneVerificationOtpResponse,
} from '../types/auth.types' } from '../types/auth.types'
export async function login(credentials: LoginCredentials): Promise<LoginResponse> { export async function login(credentials: LoginCredentials): Promise<LoginResponse> {
@@ -27,13 +29,48 @@ export async function register(credentials: RegisterCredentials): Promise<Regist
return data return data
} }
export async function verifyEmail(payload: VerifyEmailPayload): Promise<VerifyEmailResponse> { export async function sendPhoneVerificationOtp(
const { data } = await api.post<VerifyEmailResponse>('/verify-email', payload) payload: SendPhoneVerificationOtpPayload,
): Promise<SendPhoneVerificationOtpResponse> {
const { data } = await api.post<SendPhoneVerificationOtpResponse>(
'/phone-verification/send',
payload,
)
return data return data
} }
export async function resendVerificationEmail(email: string): Promise<ResendVerificationResponse> { export async function verifyPhoneVerificationOtp(
const { data } = await api.post<ResendVerificationResponse>('/verify-email/resend', { email }) 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 return data
} }
@@ -73,8 +110,8 @@ export function getRegisterErrorMessage(error: unknown): string {
return getApiErrorMessage(error, 'Registration failed. Please try again.') return getApiErrorMessage(error, 'Registration failed. Please try again.')
} }
export function getVerifyEmailErrorMessage(error: unknown): string { export function getPhoneVerificationErrorMessage(error: unknown): string {
return getApiErrorMessage(error, 'Email verification failed. Please try again.') return getApiErrorMessage(error, 'Gagal mengesahkan nombor telefon. Sila cuba lagi.')
} }
export function getForgotPasswordErrorMessage(error: unknown): string { 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.') return getApiErrorMessage(error, 'Gagal menetapkan semula kata laluan. Sila cuba lagi.')
} }
export function isAccountPending(user: { status: string } | null | undefined): boolean { export function isEmailVerified(user: { email_verified_at?: string | null } | null | undefined): boolean {
return user?.status === 'pending' return Boolean(user?.email_verified_at)
} }
export function isLoginVerificationRequired( export function isPhoneVerified(user: { phone_verified_at?: string | null } | null | undefined): boolean {
response: LoginResponse, return Boolean(user?.phone_verified_at)
): response is LoginResponse & { data: LoginVerificationRequiredData } { }
return 'requires_email_verification' in response.data
&& response.data.requires_email_verification === true export function isAccountPending(user: { status: string } | null | undefined): boolean {
return user?.status === 'pending'
} }
export function resolvePostAuthRoute( export function resolvePostAuthRoute(
+33 -17
View File
@@ -34,6 +34,7 @@ export interface AuthUser {
ic_number: string | null ic_number: string | null
position: string | null position: string | null
phone_number: string | null phone_number: string | null
phone_verified_at: string | null
image_url: string | null image_url: string | null
member_number: number | null member_number: number | null
member_type: string | null member_type: string | null
@@ -45,6 +46,7 @@ export interface AuthUser {
birth_date: string | null birth_date: string | null
birth_place: string | null birth_place: string | null
onboarding_completed_at: string | null onboarding_completed_at: string | null
email_verified_at: string | null
roles?: Array<AuthRole & { permissions?: AuthPermission[] }> roles?: Array<AuthRole & { permissions?: AuthPermission[] }>
} }
@@ -54,20 +56,17 @@ export interface LoginSessionData {
expires_at: string expires_at: string
} }
export interface LoginVerificationRequiredData { export interface AuthSessionResponse {
email: string
requires_email_verification: true
}
export interface LoginResponse {
success: boolean success: boolean
message: string message: string
data: LoginSessionData | LoginVerificationRequiredData data: LoginSessionData
active_role?: AuthRole | null active_role?: AuthRole | null
can_switch_role?: boolean can_switch_role?: boolean
redirect_path?: string redirect_path?: string
} }
export type LoginResponse = AuthSessionResponse
export interface SwitchRoleResponse extends SessionResponse { export interface SwitchRoleResponse extends SessionResponse {
message: string message: string
} }
@@ -76,25 +75,42 @@ export interface RegisterCredentials {
name: string name: string
email: string email: string
ic_number: string ic_number: string
phone_number: string
phone_verification_token: string
password: string password: string
password_confirmation: string password_confirmation: string
} }
export interface RegisterResponse { export type RegisterResponse = AuthSessionResponse
success: boolean
message: string export interface SendPhoneVerificationOtpPayload {
data: { phone_number: string
email: string
requires_email_verification: boolean
}
} }
export interface VerifyEmailPayload { export interface SendPhoneVerificationOtpResponse {
email: string success: boolean
message: string
}
export interface VerifyPhoneVerificationOtpPayload {
phone_number: string
otp: 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 { export interface ResendVerificationResponse {
success: boolean success: boolean
@@ -1,32 +1,52 @@
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useExternalSystemList } from './useExternalSystemList' import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { getExternalSystem } from '../services/external-system.service'
import { import {
getExternalSystemStatus, getExternalSystemStatus,
isExternalSystemAccessible, isExternalSystemAccessible,
} from '../utils/external-system.utils' } from '../utils/external-system.utils'
import type { ExternalSystem } from '../types/external-system.types'
export function useExternalSystemDetail() { export function useExternalSystemDetail() {
const route = useRoute() const route = useRoute()
const { getSystemById } = useExternalSystemList()
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const system = ref<ExternalSystem | null>(null)
const systemId = computed(() => String(route.params.id ?? '')) const systemId = computed(() => String(route.params.id ?? ''))
const system = computed(() => getSystemById(systemId.value) ?? null)
const status = computed(() => (system.value ? getExternalSystemStatus(system.value) : null)) const status = computed(() => (system.value ? getExternalSystemStatus(system.value) : null))
const isAccessible = computed(() => const isAccessible = computed(() =>
system.value ? isExternalSystemAccessible(system.value) : false, 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( watch(
systemId, systemId,
() => { (id) => {
error.value = system.value ? null : 'Sistem luaran tidak dijumpai.' if (!id) {
system.value = null
error.value = 'Sistem luaran tidak dijumpai.'
return
}
fetchSystem(id)
}, },
{ immediate: true }, { 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 { computed, onMounted, ref } from 'vue'
import { dummyExternalSystems } from '../data/dummy-external-systems' import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { listExternalSystems } from '../services/external-system.service'
import { import {
externalSystemStatusLabel, externalSystemStatusLabel,
getExternalSystemStatus, getExternalSystemStatus,
@@ -9,14 +10,16 @@ import type { ExternalSystem } from '../types/external-system.types'
export function useExternalSystemList() { export function useExternalSystemList() {
const search = ref('') const search = ref('')
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null)
const allSystems = ref<ExternalSystem[]>([])
const systems = computed(() => { const systems = computed(() => {
const query = search.value.trim().toLowerCase() const query = search.value.trim().toLowerCase()
if (!query) { if (!query) {
return dummyExternalSystems return allSystems.value
} }
return dummyExternalSystems.filter((system) => { return allSystems.value.filter((system) => {
const haystack = [ const haystack = [
system.name, system.name,
system.code, system.code,
@@ -35,14 +38,34 @@ export function useExternalSystemList() {
) )
function getSystemById(id: string): ExternalSystem | undefined { 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 { return {
systems, systems,
search, search,
loading, loading,
error,
availableCount, availableCount,
getSystemById, 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 { Button } from '@/components/ui/button'
import { Lucide } from '@/components/ui/lucide' import { Lucide } from '@/components/ui/lucide'
import { useExternalSystemDetail } from '../composables/useExternalSystemDetail' import { useExternalSystemDetail } from '../composables/useExternalSystemDetail'
import { useExternalSystemLaunch } from '../composables/useExternalSystemLaunch'
import { import {
externalSystemStatusLabel, externalSystemStatusLabel,
externalSystemStatusVariant, externalSystemStatusVariant,
formatExternalSystemDateTime, formatExternalSystemDateTime,
openExternalSystem,
} from '../utils/external-system.utils' } from '../utils/external-system.utils'
const router = useRouter() const router = useRouter()
const { system, error, status, isAccessible } = useExternalSystemDetail() const { system, error, status, isAccessible } = useExternalSystemDetail()
const { launching, launchExternalSystem } = useExternalSystemLaunch()
function goBack() { function goBack() {
router.push({ name: 'list-external-systems' }) router.push({ name: 'list-external-systems' })
@@ -22,7 +23,7 @@ function goBack() {
function handleOpen() { function handleOpen() {
if (!system.value) return if (!system.value) return
openExternalSystem(system.value) launchExternalSystem(system.value)
} }
</script> </script>
@@ -93,7 +94,7 @@ function handleOpen() {
<Button <Button
look="outline" look="outline"
variant="primary" variant="primary"
:disabled="!isAccessible" :disabled="!isAccessible || launching"
@click="handleOpen" @click="handleOpen"
> >
Buka Sistem Buka Sistem
@@ -7,24 +7,25 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide' import { Lucide } from '@/components/ui/lucide'
import { useExternalSystemList } from '../composables/useExternalSystemList' import { useExternalSystemList } from '../composables/useExternalSystemList'
import { useExternalSystemLaunch } from '../composables/useExternalSystemLaunch'
import { import {
externalSystemStatusLabel, externalSystemStatusLabel,
externalSystemStatusVariant, externalSystemStatusVariant,
formatExternalSystemDateTime, formatExternalSystemDateTime,
getExternalSystemStatus, getExternalSystemStatus,
openExternalSystem,
} from '../utils/external-system.utils' } from '../utils/external-system.utils'
import type { ExternalSystem } from '../types/external-system.types' import type { ExternalSystem } from '../types/external-system.types'
const router = useRouter() const router = useRouter()
const { systems, search, loading, availableCount } = useExternalSystemList() const { systems, search, loading, error, availableCount } = useExternalSystemList()
const { launching, launchExternalSystem } = useExternalSystemLaunch()
function goToDetail(system: ExternalSystem) { function goToDetail(system: ExternalSystem) {
router.push({ name: 'view-external-system', params: { id: system.id } }) router.push({ name: 'view-external-system', params: { id: system.id } })
} }
function handleOpen(system: ExternalSystem) { function handleOpen(system: ExternalSystem) {
openExternalSystem(system) launchExternalSystem(system)
} }
</script> </script>
@@ -39,6 +40,11 @@ function handleOpen(system: ExternalSystem) {
</Badge> </Badge>
</div> </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="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="col-span-12 mt-2 flex flex-wrap items-center sm:flex-nowrap">
<div class="w-full sm:w-auto"> <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"> <div class="mt-5 flex flex-col gap-2 sm:flex-row">
<Button class="w-full sm:flex-1" look="outline" variant="primary" <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 Buka Sistem
<Lucide icon="ExternalLink" class="size-4" /> <Lucide icon="ExternalLink" class="size-4" />
</Button> </Button>
@@ -94,7 +101,7 @@ function handleOpen(system: ExternalSystem) {
</Box> </Box>
</template> </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" /> <Lucide icon="SearchX" class="mx-auto size-8 opacity-40" />
<div class="mt-3 text-base font-medium">Tiada sistem dijumpai</div> <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> <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 starts_at: string | null
ends_at: string | null ends_at: string | null
opens_in_new_tab: boolean opens_in_new_tab: boolean
sso_enabled: boolean
contact_email: string | null contact_email: string | null
notes: string | null notes: string | null
created_at: string created_at: string
updated_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,
}
}
+22
View File
@@ -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'
+10
View File
@@ -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>
+289
View File
@@ -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>
+33
View File
@@ -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 { AlertRoot, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Lucide } from '@/components/ui/lucide' import { Lucide } from '@/components/ui/lucide'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage' import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import { HelpdeskFab } from '@/modules/feedback'
import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service' import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils' import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import type { import type {
@@ -567,6 +568,7 @@ function stepLabelClass(stepId: number) {
<template> <template>
<div class="min-h-screen bg-background"> <div class="min-h-screen bg-background">
<HelpdeskFab />
<div class="border-b border-foreground/10 bg-primary/5"> <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="container mx-auto flex items-center justify-between px-5 py-4 sm:px-8">
<div class="flex items-center gap-4"> <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>
+5 -1
View File
@@ -72,7 +72,7 @@ const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
// TODO: replace with API lookup // TODO: replace with API lookup
const EMPLOYERS = [ const EMPLOYERS = [
{ {
name: 'Infra Quest Sdn Bhd (IQSB)', name: 'Infra Quest Sdn. Bhd. (IQSB)',
address: 'Lot 1045, Jalan Dato’ Lundang, 15200 Kota Bharu, Kelantan', address: 'Lot 1045, Jalan Dato’ Lundang, 15200 Kota Bharu, Kelantan',
}, },
{ {
@@ -89,6 +89,10 @@ const EMPLOYERS = [
name: "An-Nisa'", name: "An-Nisa'",
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.', 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 ] as const
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({ const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
+3 -8
View File
@@ -31,6 +31,7 @@ import { updateProfile } from '@/modules/profile/services/profile.service'
import type { Address, AddressPayload } from '@/modules/profile/types/address.types' import type { Address, AddressPayload } from '@/modules/profile/types/address.types'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import HeirTab from './HeirTab.vue' import HeirTab from './HeirTab.vue'
import PhoneVerificationSection from '../components/PhoneVerificationSection.vue'
defineProps<{ defineProps<{
embedded?: boolean embedded?: boolean
@@ -44,7 +45,6 @@ const form = reactive({
name: '', name: '',
ic_number: '', ic_number: '',
position: '', position: '',
phone_number: '',
birth_date: '', birth_date: '',
birth_place: '', birth_place: '',
}) })
@@ -350,7 +350,6 @@ function syncFormFromUser() {
form.name = user.name ?? '' form.name = user.name ?? ''
form.ic_number = user.ic_number ?? '' form.ic_number = user.ic_number ?? ''
form.position = user.position ?? '' form.position = user.position ?? ''
form.phone_number = user.phone_number ?? ''
form.birth_date = toDateInputValue(user.birth_date) form.birth_date = toDateInputValue(user.birth_date)
form.birth_place = user.birth_place ?? '' form.birth_place = user.birth_place ?? ''
syncProfileSelectValues() syncProfileSelectValues()
@@ -368,7 +367,6 @@ async function onSaveProfile() {
name: form.name.trim(), name: form.name.trim(),
ic_number: form.ic_number.trim(), ic_number: form.ic_number.trim(),
position: form.position.trim(), position: form.position.trim(),
phone_number: form.phone_number.trim(),
gender: gender ?? undefined, gender: gender ?? undefined,
marriage_status: marriageStatus ?? undefined, marriage_status: marriageStatus ?? undefined,
birth_date: form.birth_date || 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" <Input id="profile-ic" v-model="form.ic_number" type="text" inputmode="numeric" maxlength="15"
placeholder="Contoh: 900101011234" @input="handleIcNumberInput" /> placeholder="Contoh: 900101011234" @input="handleIcNumberInput" />
</Field> </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"> <Field class="md:col-span-2">
<FieldLabel for="profile-position">Jawatan</FieldLabel> <FieldLabel for="profile-position">Jawatan</FieldLabel>
<Input id="profile-position" v-model="form.position" type="text" placeholder="Jawatan" /> <Input id="profile-position" v-model="form.position" type="text" placeholder="Jawatan" />
@@ -677,6 +670,8 @@ onMounted(async () => {
</form> </form>
</Box> </Box>
<PhoneVerificationSection />
<Box raised="single" class="p-6"> <Box raised="single" class="p-6">
<div class="space-y-6"> <div class="space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <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 joinDateTo = ref('')
const leaveDateFrom = ref('') const leaveDateFrom = ref('')
const leaveDateTo = ref('') const leaveDateTo = ref('')
const unitFilter = ref('')
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }]) const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
const page = ref(1) const page = ref(1)
const itemsPerPage = ref(10) const itemsPerPage = ref(10)
@@ -55,6 +56,7 @@ export function useUserList() {
join_date_to: joinDateTo.value || undefined, join_date_to: joinDateTo.value || undefined,
leave_date_from: leaveDateFrom.value || undefined, leave_date_from: leaveDateFrom.value || undefined,
leave_date_to: leaveDateTo.value || undefined, leave_date_to: leaveDateTo.value || undefined,
company_name: unitFilter.value.trim() || undefined,
}) })
users.value = data.data users.value = data.data
@@ -79,6 +81,7 @@ export function useUserList() {
join_date_to: joinDateTo.value || undefined, join_date_to: joinDateTo.value || undefined,
leave_date_from: leaveDateFrom.value || undefined, leave_date_from: leaveDateFrom.value || undefined,
leave_date_to: leaveDateTo.value || undefined, leave_date_to: leaveDateTo.value || undefined,
company_name: unitFilter.value.trim() || undefined,
}) })
stats.value = res.data stats.value = res.data
@@ -109,6 +112,11 @@ export function useUserList() {
fetchStats() fetchStats()
}) })
watch(unitFilter, () => {
fetchUsers(1)
fetchStats()
})
watch([joinDateFrom, joinDateTo, leaveDateFrom, leaveDateTo], () => { watch([joinDateFrom, joinDateTo, leaveDateFrom, leaveDateTo], () => {
fetchUsers(1) fetchUsers(1)
fetchStats() fetchStats()
@@ -144,6 +152,7 @@ export function useUserList() {
joinDateTo, joinDateTo,
leaveDateFrom, leaveDateFrom,
leaveDateTo, leaveDateTo,
unitFilter,
hasJoinDateFilters, hasJoinDateFilters,
hasLeaveDateFilters, hasLeaveDateFilters,
clearJoinDateFilters, clearJoinDateFilters,
+26 -1
View File
@@ -1,7 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import * as select from '@zag-js/select' import * as select from '@zag-js/select'
import dayjs from 'dayjs'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert' import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { Box } from '@/components/ui/box' import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -102,6 +103,7 @@ const form = reactive({
phone_number: '', phone_number: '',
member_number: '', member_number: '',
join_date: '', join_date: '',
leave_date: '',
birth_date: '', birth_date: '',
birth_place: '', birth_place: '',
}) })
@@ -154,6 +156,7 @@ async function handleSubmit() {
member_number: Number(form.member_number), member_number: Number(form.member_number),
member_type: memberType!, member_type: memberType!,
join_date: form.join_date, join_date: form.join_date,
leave_date: isInactiveStatus.value ? form.leave_date || null : null,
birth_date: form.birth_date, birth_date: form.birth_date,
birth_place: form.birth_place.trim(), birth_place: form.birth_place.trim(),
}) })
@@ -170,6 +173,17 @@ async function handleSubmit() {
} }
const formDisabled = computed(() => saving.value) 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> </script>
<template> <template>
@@ -417,6 +431,17 @@ const formDisabled = computed(() => saving.value)
/> />
</Field> </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> <Field>
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel> <FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
<Input <Input
+616 -2
View File
@@ -3,11 +3,15 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import * as select from '@zag-js/select' import * as select from '@zag-js/select'
import Swal from 'sweetalert2'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert' import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box' import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button' 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 { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import { import {
SelectRoot, SelectRoot,
SelectControl, SelectControl,
@@ -19,8 +23,14 @@ import {
SelectItem, SelectItem,
SelectItemText, SelectItemText,
} from '@/components/ui/select' } 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 { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import {
createUserEmployment,
deleteUserEmployment,
updateUserEmployment,
} from '../services/userEmployment.service'
import { getUser, updateUser } from '../services/user.service' import { getUser, updateUser } from '../services/user.service'
type SelectOption = { label: string; value: string } type SelectOption = { label: string; value: string }
@@ -49,6 +59,62 @@ const MEMBER_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Pesara', value: 'Pesara' }, { 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[]) { function createSelectCollection(options: SelectOption[]) {
return select.collection({ return select.collection({
items: options, items: options,
@@ -71,6 +137,8 @@ const statusCollection = createSelectCollection(STATUS_OPTIONS)
const genderCollection = createSelectCollection(GENDER_OPTIONS) const genderCollection = createSelectCollection(GENDER_OPTIONS)
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS) const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
const memberTypeCollection = createSelectCollection(MEMBER_TYPE_OPTIONS) const memberTypeCollection = createSelectCollection(MEMBER_TYPE_OPTIONS)
const employmentTypeCollection = createSelectCollection(EMPLOYMENT_TYPE_OPTIONS)
const companyNameCollection = createSelectCollection(COMPANY_OPTIONS)
const statusValue = ref<string[]>([]) const statusValue = ref<string[]>([])
const genderValue = ref<string[]>([]) const genderValue = ref<string[]>([])
@@ -82,6 +150,31 @@ const genderInitial = ref<string[]>([])
const marriageStatusInitial = ref<string[]>([]) const marriageStatusInitial = ref<string[]>([])
const memberTypeInitial = 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[] }) { function setStatusValue(details: { value: string[] }) {
statusValue.value = details.value statusValue.value = details.value
} }
@@ -98,6 +191,59 @@ function setMemberTypeValue(details: { value: string[] }) {
memberTypeValue.value = details.value 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 router = useRouter()
const route = useRoute() const route = useRoute()
@@ -154,6 +300,219 @@ function syncFormFromUser(user: Awaited<ReturnType<typeof getUser>>['data']) {
genderInitial.value = [...genderValue.value] genderInitial.value = [...genderValue.value]
marriageStatusInitial.value = [...marriageStatusValue.value] marriageStatusInitial.value = [...marriageStatusValue.value]
memberTypeInitial.value = [...memberTypeValue.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() { 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() { async function handleSubmit() {
saving.value = true saving.value = true
error.value = null error.value = null
@@ -218,6 +587,7 @@ async function handleSubmit() {
const formDisabled = computed(() => loading.value || saving.value) const formDisabled = computed(() => loading.value || saving.value)
onMounted(() => { onMounted(() => {
syncEmploymentSelectValues()
fetchUser() fetchUser()
}) })
</script> </script>
@@ -405,5 +775,249 @@ onMounted(() => {
</div> </div>
</form> </form>
</Box> </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> </div>
</template> </template>
+43
View File
@@ -20,6 +20,7 @@ import { Lucide } from '@/components/ui/lucide'
import DataTable from '@/components/ui/usage/DataTable.vue' import DataTable from '@/components/ui/usage/DataTable.vue'
import type { TableHeader } from '@/components/ui/usage/DataTable.vue' import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
import { usePermissions } from '@/composables/usePermissions' import { usePermissions } from '@/composables/usePermissions'
import { EMPLOYERS } from '@/constants/employers'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage' import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { listRoles } from '@/modules/role/services/role.service' import { listRoles } from '@/modules/role/services/role.service'
import type { RoleListItem } from '@/modules/role/types/role.types' import type { RoleListItem } from '@/modules/role/types/role.types'
@@ -48,6 +49,22 @@ const STATUS_FILTER_CHIPS: StatusFilterChip[] = [
{ label: 'Menunggu', value: 'pending', variant: 'pending' }, { 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 router = useRouter()
const { hasPermission } = usePermissions() const { hasPermission } = usePermissions()
@@ -163,6 +180,14 @@ function setStatusFilter(value: string) {
statusFilter.value = value 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 { function formatDeletedAt(value: string | null | undefined): string {
if (!value) return '-' if (!value) return '-'
return dayjs(value).format('DD MMM YYYY, HH:mm') return dayjs(value).format('DD MMM YYYY, HH:mm')
@@ -356,6 +381,7 @@ const {
error, error,
search, search,
statusFilter, statusFilter,
unitFilter,
joinDateFrom, joinDateFrom,
joinDateTo, joinDateTo,
leaveDateFrom, leaveDateFrom,
@@ -540,6 +566,23 @@ onMounted(() => {
{{ chip.label }} {{ chip.label }}
</Badge> </Badge>
</div> </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> </div>
</template> </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
}
+2
View File
@@ -84,6 +84,7 @@ export interface CreateUserPayload {
member_number: number member_number: number
member_type: string member_type: string
join_date: string join_date: string
leave_date?: string | null
birth_date: string birth_date: string
birth_place: string birth_place: string
} }
@@ -99,6 +100,7 @@ export interface ListUsersParams {
join_date_to?: string join_date_to?: string
leave_date_from?: string leave_date_from?: string
leave_date_to?: string leave_date_to?: string
company_name?: string
} }
export interface ListDeletedUsersParams { export interface ListDeletedUsersParams {
+6 -3
View File
@@ -13,6 +13,7 @@ import { activityLayoutRoutes } from '@/modules/activity'
import { dashboardLayoutRoutes } from '@/modules/dashboard' import { dashboardLayoutRoutes } from '@/modules/dashboard'
import { externalSystemLayoutRoutes } from '@/modules/external-system' import { externalSystemLayoutRoutes } from '@/modules/external-system'
import { activityLogLayoutRoutes } from '@/modules/activity-log' import { activityLogLayoutRoutes } from '@/modules/activity-log'
import { feedbackPublicRoutes, feedbackLayoutRoutes } from '@/modules/feedback'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@@ -30,15 +31,17 @@ const router = createRouter({
...activityLayoutRoutes, ...activityLayoutRoutes,
...externalSystemLayoutRoutes, ...externalSystemLayoutRoutes,
...membershipApplicationLayoutRoutes, ...membershipApplicationLayoutRoutes,
...feedbackLayoutRoutes,
], ],
}, },
...authPublicRoutes, ...authPublicRoutes,
...membershipApplicationPublicRoutes, ...membershipApplicationPublicRoutes,
...feedbackPublicRoutes,
...profilePublicRoutes, ...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) => { router.beforeEach(async (to) => {
const authStore = useAuthStore(pinia) const authStore = useAuthStore(pinia)
@@ -66,11 +69,11 @@ router.beforeEach(async (to) => {
return true return true
} }
// Redirect authenticated users away from login/register/verify-email.
if ( if (
authStore.isAuthenticated && authStore.isAuthenticated &&
PUBLIC_ROUTE_NAMES.has(routeName) && PUBLIC_ROUTE_NAMES.has(routeName) &&
routeName !== 'membership-application-apply' routeName !== 'membership-application-apply' &&
routeName !== 'feedback-submit'
) { ) {
return resolvePostAuthRoute(authStore.user) return resolvePostAuthRoute(authStore.user)
} }
+2
View File
@@ -36,6 +36,8 @@ export const useAuthStore = defineStore('auth', {
isAuthenticated: (state) => state.user !== null, isAuthenticated: (state) => state.user !== null,
isAccountPending: (state) => state.user?.status === 'pending', isAccountPending: (state) => state.user?.status === 'pending',
isAccountActive: (state) => state.user?.status === 'active', 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 ?? [], roles: (state) => state.user?.roles ?? [],
}, },
+14
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { onMounted, onUnmounted, ref, watch } from 'vue' import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useBreadcrumb } from '@/composables/useBreadcrumb' import { useBreadcrumb } from '@/composables/useBreadcrumb'
import { useNotifications } from '@/modules/notification' import { useNotifications } from '@/modules/notification'
@@ -14,6 +15,7 @@ import { useFilteredMenu } from '@/composables/useFilteredMenu'
import { SideMenu } from '@/components/side-menu' import { SideMenu } from '@/components/side-menu'
import { AccountDropdown, AccountTrigger } from '@/components/account-dropdown' import { AccountDropdown, AccountTrigger } from '@/components/account-dropdown'
import { NotificationDropdown } from '@/components/notification-dropdown' import { NotificationDropdown } from '@/components/notification-dropdown'
import EmailVerificationBanner from '@/modules/auth/components/EmailVerificationBanner.vue'
import { import {
ScrollAreaRoot, ScrollAreaRoot,
ScrollAreaViewport, ScrollAreaViewport,
@@ -33,6 +35,8 @@ const {
} = useSideMenu() } = useSideMenu()
const authStore = useAuthStore() const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const breadcrumbItems = useBreadcrumb([]) const breadcrumbItems = useBreadcrumb([])
const filteredMenu = useFilteredMenu(mainMenu) const filteredMenu = useFilteredMenu(mainMenu)
const { unreadCount, fetchNotifications } = useNotifications() const { unreadCount, fetchNotifications } = useNotifications()
@@ -89,6 +93,12 @@ onMounted(() => {
authStore.fetchSession() authStore.fetchSession()
fetchNotifications() fetchNotifications()
document.addEventListener('click', handleDocumentClick) document.addEventListener('click', handleDocumentClick)
const verified = route.query.verified
if (typeof verified === 'string' && verified) {
authStore.fetchSession()
router.replace({ query: { ...route.query, verified: undefined } })
}
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -224,6 +234,10 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</div> </div>
<EmailVerificationBanner
v-if="authStore.isAuthenticated && !authStore.isEmailVerified"
class="mb-4"
/>
<RouterView /> <RouterView />
</div> </div>
</div> </div>
+3 -10
View File
@@ -4,6 +4,7 @@ import { Lucide, type Icon as LucideIcon } from '@/components/ui/lucide'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { onMounted, computed } from 'vue' import { onMounted, computed } from 'vue'
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { HelpdeskFab } from '@/modules/feedback'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -58,16 +59,6 @@ const sideTabs: SideTab[] = [
window.open('https://www.facebook.com/KoperasiPermodalanKelantanBerhad/', '_blank') 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(() => { onMounted(() => {
@@ -106,6 +97,8 @@ onMounted(() => {
</span> </span>
</button> </button>
<HelpdeskFab :offset-rem="10.5" />
<Component /> <Component />
</div> </div>
</template> </template>