Feature/phone register (#11)

Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-07-14 12:03:22 +08:00
parent 1e50e3d19f
commit b05e074456
160 changed files with 6497 additions and 759 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 { externalSystemMenu } from '@/modules/external-system/menu'
import { activityLogMenu } from '@/modules/activity-log/menu'
import { feedbackMenu } from '@/modules/feedback'
export type { Menu }
@@ -17,6 +18,7 @@ const mainMenu: (string | Menu)[] = [
'Teknologi Maklumat',
...roleMenu,
...activityLogMenu,
...feedbackMenu,
'Pentadbiran',
...membershipApplicationMenu,
...userMenu,
@@ -0,0 +1,46 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { sendVerificationEmail } from '@/modules/auth'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
const dismissed = ref(false)
const loading = ref(false)
const feedbackMessage = ref('')
const errorMessage = ref('')
const handleSend = async () => {
loading.value = true
feedbackMessage.value = ''
errorMessage.value = ''
try {
const response = await sendVerificationEmail()
feedbackMessage.value = response.message
} catch (error) {
errorMessage.value = getApiErrorMessage(error, 'Gagal menghantar pautan pengesahan. Sila cuba lagi.')
} finally {
loading.value = false
}
}
</script>
<template>
<AlertRoot v-if="!dismissed" class="mb-4" variant="primary">
<AlertTitle>Pengesahan emel diperlukan.</AlertTitle>
<AlertDescription>
Sila sahkan emel anda dengan menekan butang di bawah.
<span v-if="feedbackMessage" class="mt-2 block">{{ feedbackMessage }}</span>
<span v-if="errorMessage" class="mt-2 block text-danger">{{ errorMessage }}</span>
</AlertDescription>
<div class="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="primary" look="outline" type="button" :disabled="loading" @click="handleSend">
{{ loading ? 'Menghantar...' : 'Hantar Pautan Pengesahan' }}
</Button>
<Button size="sm" type="button" @click="dismissed = true">
Abaikan
</Button>
</div>
</AlertRoot>
</template>
+14 -6
View File
@@ -4,28 +4,36 @@ export {
login,
logout,
register,
verifyEmail,
resendVerificationEmail,
sendPhoneVerificationOtp,
verifyPhoneVerificationOtp,
sendAuthenticatedPhoneVerificationOtp,
verifyAuthenticatedPhoneVerificationOtp,
sendVerificationEmail,
requestForgotPassword,
resetPassword,
fetchCurrentUser,
getAuthErrorMessage,
getRegisterErrorMessage,
getVerifyEmailErrorMessage,
getPhoneVerificationErrorMessage,
getForgotPasswordErrorMessage,
getResetPasswordErrorMessage,
resolvePostLoginRoute,
resolvePostAuthRoute,
isAccountPending,
isLoginVerificationRequired,
isEmailVerified,
isPhoneVerified,
} from './services/auth.service'
export type {
LoginCredentials,
LoginResponse,
RegisterCredentials,
RegisterResponse,
VerifyEmailPayload,
VerifyEmailResponse,
SendPhoneVerificationOtpPayload,
SendPhoneVerificationOtpResponse,
VerifyPhoneVerificationOtpPayload,
VerifyPhoneVerificationOtpResponse,
VerifyAuthenticatedPhoneVerificationOtpResponse,
ResendVerificationResponse,
ForgotPasswordPayload,
ForgotPasswordResponse,
ResetPasswordPayload,
+13 -6
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
@@ -10,6 +10,7 @@ import logoUrl from '@/assets/images/logo.svg'
import illustrationUrl from '@/assets/images/logo.svg'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const loggingOut = ref(false)
@@ -44,6 +45,12 @@ const handleLogout = async () => {
}
onMounted(() => {
const verified = route.query.verified
if (typeof verified === 'string' && verified) {
authStore.fetchSession()
router.replace({ query: { ...route.query, verified: undefined } })
}
statusPollInterval = setInterval(checkActivationStatus, 30000)
})
@@ -92,21 +99,21 @@ const appVersion = import.meta.env.VITE_APP_VERSION
</p>
<AlertRoot class="mt-8" variant="primary">
<AlertTitle>E-mel disahkan</AlertTitle>
<AlertTitle>Menunggu pengaktifan</AlertTitle>
<AlertDescription>
Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem. Anda akan dapat
mengakses sistem selepas akaun diaktifkan.
Pendaftaran anda berjaya. Akaun anda sedang menunggu pengaktifan daripada pentadbir
sistem. Anda akan dapat mengakses sistem selepas akaun diaktifkan.
</AlertDescription>
</AlertRoot>
<div class="mt-8 flex flex-col gap-4">
<Button class="box w-full px-4 py-5" variant="primary" type="button" :disabled="checkingStatus"
@click="checkActivationStatus">
{{ checkingStatus ? 'Checking...' : 'Semak Status' }}
{{ checkingStatus ? 'Menyemak...' : 'Semak Status' }}
</Button>
<Button class="box w-full px-4 py-5" look="outline" type="button" :disabled="loggingOut"
@click="handleLogout">
{{ loggingOut ? 'Logging out...' : 'Log Keluar' }}
{{ loggingOut ? 'Log keluar...' : 'Log Keluar' }}
</Button>
</div>
</Box>
+8 -12
View File
@@ -12,6 +12,7 @@ import {
login,
resolvePostAuthRoute,
} from '@/modules/auth'
import { HelpdeskFab } from '@/modules/feedback'
import { useAuthStore } from '@/stores/auth'
import illustrationUrl from '@/assets/images/logo.svg'
@@ -47,18 +48,12 @@ const handleLogin = async () => {
remember: remember.value,
})
const loginData = response.data
if ('requires_email_verification' in loginData) {
await router.push({
name: 'verify-email',
query: { email: loginData.email },
})
return
}
authStore.setSession(loginData.user, response.active_role ?? null, response.can_switch_role ?? false)
await router.push(resolvePostAuthRoute(loginData.user, response.redirect_path))
authStore.setSession(
response.data.user,
response.active_role ?? null,
response.can_switch_role ?? false,
)
await router.push(resolvePostAuthRoute(response.data.user, response.redirect_path))
} catch (error) {
errorMessage.value = getAuthErrorMessage(error)
} finally {
@@ -76,6 +71,7 @@ const appVersion = import.meta.env.VITE_APP_VERSION
'before:hidden before:xl:block before:content-[\'\'] before:w-[57%] before:mt-[-28%] before:mb-[-16%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:rotate-6 before:bg-primary/95 before:bg-noise before:rounded-[35%]',
'after:hidden after:xl:block after:content-[\'\'] after:w-[57%] after:mt-[-28%] after:mb-[-16%] after:ml-[-12%] after:absolute after:inset-y-0 after:left-0 after:transform after:rotate-6 after:border after:bg-accent after:bg-cover after:blur-xl after:rounded-[35%] after:border-primary',
]">
<HelpdeskFab />
<div :class="[
'p-3 sm:px-8 relative h-full',
'before:hidden before:xl:block before:w-[57%] before:mt-[-20%] before:mb-[-13%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:-rotate-6 before:bg-primary/40 before:bg-noise before:border before:border-primary/50 before:opacity-60 before:rounded-[20%]',
+334 -61
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
@@ -7,12 +7,33 @@ import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/ch
import { Input } from '@/components/ui/input'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { PasswordInput } from '@/components/ui/password-input'
import { getRegisterErrorMessage, register } from '@/modules/auth'
import {
getPhoneVerificationErrorMessage,
getRegisterErrorMessage,
register,
resolvePostAuthRoute,
sendPhoneVerificationOtp,
verifyPhoneVerificationOtp,
} from '@/modules/auth'
import { useAuthStore } from '@/stores/auth'
import { HelpdeskFab } from '@/modules/feedback'
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import illustrationUrl from '@/assets/images/logo.svg'
const router = useRouter()
const steps = [
{ id: 1, label: 'No. Telefon' },
{ id: 2, label: 'Sahkan OTP' },
{ id: 3, label: 'Maklumat Akaun' },
{ id: 4, label: 'Kata Laluan' },
] as const
const router = useRouter()
const authStore = useAuthStore()
const currentStep = ref(1)
const phoneNumber = ref('')
const otp = ref('')
const phoneVerificationToken = ref('')
const name = ref('')
const email = ref('')
const icNumber = ref('')
@@ -21,6 +42,61 @@ const passwordConfirmation = ref('')
const termsAccepted = ref(false)
const loading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const stepTitle = computed(() => {
switch (currentStep.value) {
case 1:
return 'Sahkan Nombor Telefon'
case 2:
return 'Masukkan Kod OTP'
case 3:
return 'Maklumat Akaun'
default:
return 'Tetapkan Kata Laluan'
}
})
const stepDescription = computed(() => {
switch (currentStep.value) {
case 1:
return 'Masukkan nombor telefon anda untuk menerima kod OTP melalui SMS.'
case 2:
return 'Masukkan kod OTP 6 digit yang telah dihantar ke nombor telefon anda.'
case 3:
return 'Lengkapkan maklumat asas akaun anda.'
default:
return 'Tetapkan kata laluan dan bersetuju dengan terma pendaftaran.'
}
})
const primaryActionLabel = computed(() => {
if (loading.value) {
switch (currentStep.value) {
case 1:
return 'Menghantar OTP...'
case 2:
return 'Mengesahkan OTP...'
case 3:
return 'Seterusnya'
default:
return 'Mendaftar...'
}
}
switch (currentStep.value) {
case 1:
return 'Hantar OTP'
case 2:
return 'Sahkan OTP'
case 3:
return 'Seterusnya'
default:
return 'Daftar'
}
})
const inputClass = 'box block min-w-full px-4 py-4 xl:min-w-md'
const handleNameInput = () => {
name.value = sanitizeNameInput(name.value)
@@ -30,13 +106,102 @@ const handleIcNumberInput = () => {
icNumber.value = sanitizeIcNumberInput(icNumber.value)
}
const handlePhoneInput = () => {
phoneNumber.value = phoneNumber.value.replace(/[^\d+]/g, '')
phoneVerificationToken.value = ''
}
const handleOtpInput = () => {
otp.value = otp.value.replace(/\D/g, '').slice(0, 6)
}
const clearMessages = () => {
errorMessage.value = ''
successMessage.value = ''
}
const handleSendOtp = async () => {
clearMessages()
loading.value = true
try {
const response = await sendPhoneVerificationOtp({
phone_number: phoneNumber.value,
})
successMessage.value = response.message
currentStep.value = 2
} catch (error) {
errorMessage.value = getPhoneVerificationErrorMessage(error)
} finally {
loading.value = false
}
}
const handleVerifyOtp = async () => {
clearMessages()
loading.value = true
try {
const response = await verifyPhoneVerificationOtp({
phone_number: phoneNumber.value,
otp: otp.value,
})
phoneNumber.value = response.data.phone_number
phoneVerificationToken.value = response.data.verification_token
successMessage.value = response.message
currentStep.value = 3
} catch (error) {
errorMessage.value = getPhoneVerificationErrorMessage(error)
} finally {
loading.value = false
}
}
const validateAccountStep = (): boolean => {
clearMessages()
if (!name.value.trim()) {
errorMessage.value = 'Nama penuh diperlukan.'
return false
}
if (!email.value.trim()) {
errorMessage.value = 'Emel diperlukan.'
return false
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
errorMessage.value = 'Emel tidak sah.'
return false
}
if (!icNumber.value.trim()) {
errorMessage.value = 'No. kad pengenalan diperlukan.'
return false
}
return true
}
const handleRegister = async () => {
if (!password.value || !passwordConfirmation.value) {
errorMessage.value = 'Kata laluan diperlukan.'
return
}
if (password.value !== passwordConfirmation.value) {
errorMessage.value = 'Pengesahan kata laluan tidak sepadan.'
return
}
if (!termsAccepted.value) {
errorMessage.value = 'Sila bersetuju dengan Dasar Privasi dan Terma dan Syarat.'
return
}
errorMessage.value = ''
clearMessages()
loading.value = true
try {
@@ -44,34 +209,88 @@ const handleRegister = async () => {
name: name.value,
email: email.value,
ic_number: icNumber.value,
phone_number: phoneNumber.value,
phone_verification_token: phoneVerificationToken.value,
password: password.value,
password_confirmation: passwordConfirmation.value,
})
await router.push({
name: 'verify-email',
query: { email: response.data.email },
})
authStore.setSession(
response.data.user,
response.active_role ?? null,
response.can_switch_role ?? false,
)
await router.push(resolvePostAuthRoute(response.data.user, response.redirect_path))
} catch (error) {
errorMessage.value = getRegisterErrorMessage(error)
} finally {
loading.value = false
}
}
}
const handlePrimaryAction = async () => {
if (currentStep.value === 1) {
await handleSendOtp()
return
}
if (currentStep.value === 2) {
await handleVerifyOtp()
return
}
if (currentStep.value === 3) {
if (!validateAccountStep()) {
return
}
currentStep.value = 4
return
}
await handleRegister()
}
const goPrevious = () => {
clearMessages()
if (currentStep.value > 1) {
currentStep.value -= 1
}
}
const stepButtonClass = (stepId: number) => {
if (stepId === currentStep.value) {
return 'size-10 rounded-full shadow-none'
}
if (stepId < currentStep.value) {
return 'size-10 rounded-full shadow-none bg-primary text-primary-foreground'
}
return 'bg-background border border-foreground/15 shadow-none size-10 rounded-full'
}
const stepLabelClass = (stepId: number) => {
return stepId === currentStep.value
? 'mt-2 text-xs font-medium text-primary'
: 'mt-2 text-xs opacity-70'
}
</script>
<template>
<div :class="[
'relative h-screen lg:overflow-hidden bg-primary bg-noise xl:bg-background xl:bg-none',
'relative min-h-dvh bg-primary bg-noise xl:h-screen xl:overflow-hidden xl:bg-background xl:bg-none',
'before:hidden before:xl:block before:content-[\'\'] before:w-[57%] before:mt-[-28%] before:mb-[-16%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:rotate-6 before:bg-primary/95 before:bg-noise before:rounded-[35%]',
'after:hidden after:xl:block after:content-[\'\'] after:w-[57%] after:mt-[-28%] after:mb-[-16%] after:ml-[-12%] after:absolute after:inset-y-0 after:left-0 after:transform after:rotate-6 after:border after:bg-accent after:bg-cover after:blur-xl after:rounded-[35%] after:border-primary',
]">
<HelpdeskFab />
<div :class="[
'p-3 sm:px-8 relative h-full',
'relative p-3 sm:px-8',
'before:hidden before:xl:block before:w-[57%] before:mt-[-20%] before:mb-[-13%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:-rotate-6 before:bg-primary/40 before:bg-noise before:border before:border-primary/50 before:opacity-60 before:rounded-[20%]',
]">
<div class="container relative z-10 mx-auto sm:px-20">
<div class="block grid-cols-2 gap-4 xl:grid">
<div class="block xl:grid xl:grid-cols-2 xl:gap-4">
<div class="hidden min-h-screen flex-col xl:flex">
<div class="my-auto">
<img class="-mt-16 w-1/2" :src="illustrationUrl" alt="logo-RAJD" />
@@ -84,61 +303,115 @@ const handleRegister = async () => {
</div>
</div>
<div class="my-10 flex h-screen py-5 xl:my-0 xl:h-auto xl:py-0">
<div class="my-6 py-4 xl:my-0 xl:flex xl:min-h-screen xl:items-center xl:py-0">
<Box raised="double"
class="mx-auto my-auto w-full px-5 py-8 sm:w-3/4 sm:px-8 lg:w-2/4 xl:ml-24 xl:w-auto xl:p-0 xl:before:hidden xl:after:hidden xl:shadow-none xl:border-none xl:bg-none">
class="mx-auto w-full px-5 py-6 sm:w-3/4 sm:px-8 lg:w-2/4 xl:ml-24 xl:w-auto xl:p-0 xl:before:hidden xl:after:hidden xl:shadow-none xl:border-none xl:bg-none">
<h2 class="text-center text-2xl font-semibold xl:text-left xl:text-3xl">Daftar Akaun</h2>
<div class="mt-2 text-center opacity-70 xl:hidden">
Daftar Akaun
<div
class="before:bg-foreground/10 relative mt-5 flex flex-row justify-between gap-1 px-1 before:absolute before:bottom-[calc(50%-0.75rem)] before:left-[12%] before:right-[12%] before:h-0.5 before:w-auto">
<div v-for="step in steps" :key="step.id" class="z-10 flex flex-1 flex-col items-center text-center">
<Button :class="stepButtonClass(step.id)" :variant="step.id === currentStep ? 'primary' : 'ghost'"
type="button">
{{ step.id }}
</Button>
<div :class="stepLabelClass(step.id)">
{{ step.label }}
</div>
</div>
</div>
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
<AlertTitle>Daftar gagal</AlertTitle>
<AlertDescription>{{ errorMessage }}</AlertDescription>
</AlertRoot>
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleRegister">
<Input v-model="name" class="box block min-w-full px-5 py-6 xl:min-w-md" type="text"
placeholder="Nama Penuh" autocomplete="name" required @input="handleNameInput" />
<Input v-model="email" class="box block min-w-full px-5 py-6 xl:min-w-md" type="email"
placeholder="Email" autocomplete="email" required />
<Input v-model="icNumber" class="box block min-w-full px-5 py-6 xl:min-w-md" type="text"
inputmode="numeric" maxlength="15" placeholder="Contoh: 900101011234" required
@input="handleIcNumberInput" />
<PasswordInput v-model="password" class="box block min-w-full px-5 py-6 xl:min-w-md" type="password"
placeholder="Kata Laluan" autocomplete="new-password" minlength="8" required />
<PasswordInput v-model="passwordConfirmation" class="box block min-w-full px-5 py-6 xl:min-w-md"
placeholder="Sahkan Kata Laluan" autocomplete="new-password" minlength="8" required />
<div class="flex text-xs sm:text-sm">
<CheckboxRoot :checked="termsAccepted"
@checked-change="({ checked }) => (termsAccepted = checked === true)">
<CheckboxControl />
<CheckboxLabel>
Dengan mendaftar, anda bersetuju dengan
<RouterLink class="text-primary ml-1" to="/privacy-policy">
Dasar Privasi
</RouterLink>
&amp;
<RouterLink class="text-primary ml-1" to="/terms">
Terma dan Syarat
</RouterLink>
.
</CheckboxLabel>
</CheckboxRoot>
<div class="mt-5 border-t border-foreground/10 pt-5">
<div class="text-center xl:text-left">
<div class="text-base font-medium">{{ stepTitle }}</div>
<div class="mt-1 text-sm opacity-70">{{ stepDescription }}</div>
</div>
<div class="mt-5 text-center xl:mt-10 xl:text-left">
<Button class="box w-full px-4 py-5" variant="primary" type="submit"
:disabled="loading || !termsAccepted">
{{ loading ? 'Mendaftar...' : 'Daftar' }}
</Button>
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
@click="router.push({ name: 'login' })">
Log masuk
</Button>
</div>
</form>
<AlertRoot v-if="errorMessage" class="mt-4 py-3" variant="danger">
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ errorMessage }}</AlertDescription>
</AlertRoot>
<AlertRoot v-if="successMessage" class="mt-4 py-3" variant="success">
<AlertTitle>Berjaya</AlertTitle>
<AlertDescription>{{ successMessage }}</AlertDescription>
</AlertRoot>
<form class="mt-4 flex flex-col gap-5" @submit.prevent="handlePrimaryAction">
<div class="flex flex-col gap-3">
<template v-if="currentStep === 1">
<Input v-model="phoneNumber" :class="inputClass" type="tel" inputmode="tel"
placeholder="No. Telefon, contoh: 0123456790" autocomplete="tel" required
@input="handlePhoneInput" />
</template>
<template v-else-if="currentStep === 2">
<Input v-model="otp" :class="`${inputClass} text-center tracking-[0.4em]`" type="text"
inputmode="numeric" maxlength="6" placeholder="000000" autocomplete="one-time-code" required
@input="handleOtpInput" />
<Button class="box w-full px-4 py-4" look="outline" type="button" :disabled="loading"
@click="handleSendOtp">
Hantar Semula OTP
</Button>
</template>
<template v-else-if="currentStep === 3">
<div class="rounded-lg border border-foreground/10 bg-foreground/5 px-4 py-3 text-sm">
<span class="opacity-70">Telefon disahkan:</span>
<span class="ml-1 font-medium">{{ phoneNumber }}</span>
</div>
<Input v-model="name" :class="inputClass" type="text" placeholder="Nama Penuh" autocomplete="name"
required @input="handleNameInput" />
<Input v-model="email" :class="inputClass" type="email" placeholder="Email" autocomplete="email"
required />
<Input v-model="icNumber" :class="inputClass" type="text" inputmode="numeric" maxlength="15"
placeholder="Contoh: 900101011234" required @input="handleIcNumberInput" />
</template>
<template v-else>
<PasswordInput v-model="password" :class="inputClass" placeholder="Kata Laluan"
autocomplete="new-password" minlength="8" required />
<PasswordInput v-model="passwordConfirmation" :class="inputClass" placeholder="Sahkan Kata Laluan"
autocomplete="new-password" minlength="8" required />
<div class="flex text-xs sm:text-sm">
<CheckboxRoot :checked="termsAccepted"
@checked-change="({ checked }) => (termsAccepted = checked === true)">
<CheckboxControl />
<CheckboxLabel>
Dengan mendaftar, anda bersetuju dengan
<RouterLink class="text-primary ml-1" to="/privacy-policy">
Dasar Privasi
</RouterLink>
&amp;
<RouterLink class="text-primary ml-1" to="/terms">
Terma dan Syarat
</RouterLink>
.
</CheckboxLabel>
</CheckboxRoot>
</div>
</template>
</div>
<div class="space-y-3">
<div class="flex gap-3">
<Button v-if="currentStep > 1" class="box w-full px-4 py-4" look="outline" type="button"
:disabled="loading" @click="goPrevious">
Sebelum
</Button>
<Button class="box w-full px-4 py-4" variant="primary" type="submit"
:disabled="loading || (currentStep === 4 && !termsAccepted)">
{{ primaryActionLabel }}
</Button>
</div>
<button type="button" class="w-full text-sm opacity-70 hover:opacity-100"
@click="router.push({ name: 'login' })">
Sudah mempunyai akaun? Log masuk
</button>
</div>
</form>
</div>
</Box>
</div>
</div>
-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'),
meta: { module: 'auth' },
},
{
path: '/verify-email',
name: 'verify-email',
component: () => import('./pages/VerifyEmail.vue'),
meta: { module: 'auth' },
},
{
path: '/forgot-password',
name: 'forgot-password',
+55 -17
View File
@@ -5,16 +5,18 @@ import type {
ForgotPasswordResponse,
LoginCredentials,
LoginResponse,
LoginVerificationRequiredData,
RegisterCredentials,
RegisterResponse,
ResendVerificationResponse,
ResetPasswordPayload,
ResetPasswordResponse,
ResendVerificationResponse,
SessionResponse,
SendPhoneVerificationOtpPayload,
SendPhoneVerificationOtpResponse,
SwitchRoleResponse,
VerifyEmailPayload,
VerifyEmailResponse,
VerifyPhoneVerificationOtpPayload,
VerifyPhoneVerificationOtpResponse,
VerifyAuthenticatedPhoneVerificationOtpResponse,
} from '../types/auth.types'
export async function login(credentials: LoginCredentials): Promise<LoginResponse> {
@@ -27,13 +29,48 @@ export async function register(credentials: RegisterCredentials): Promise<Regist
return data
}
export async function verifyEmail(payload: VerifyEmailPayload): Promise<VerifyEmailResponse> {
const { data } = await api.post<VerifyEmailResponse>('/verify-email', payload)
export async function sendPhoneVerificationOtp(
payload: SendPhoneVerificationOtpPayload,
): Promise<SendPhoneVerificationOtpResponse> {
const { data } = await api.post<SendPhoneVerificationOtpResponse>(
'/phone-verification/send',
payload,
)
return data
}
export async function resendVerificationEmail(email: string): Promise<ResendVerificationResponse> {
const { data } = await api.post<ResendVerificationResponse>('/verify-email/resend', { email })
export async function verifyPhoneVerificationOtp(
payload: VerifyPhoneVerificationOtpPayload,
): Promise<VerifyPhoneVerificationOtpResponse> {
const { data } = await api.post<VerifyPhoneVerificationOtpResponse>(
'/phone-verification/verify',
payload,
)
return data
}
export async function sendAuthenticatedPhoneVerificationOtp(
payload: SendPhoneVerificationOtpPayload,
): Promise<SendPhoneVerificationOtpResponse> {
const { data } = await api.post<SendPhoneVerificationOtpResponse>(
'/v1/phone-verification/send',
payload,
)
return data
}
export async function verifyAuthenticatedPhoneVerificationOtp(
payload: VerifyPhoneVerificationOtpPayload,
): Promise<VerifyAuthenticatedPhoneVerificationOtpResponse> {
const { data } = await api.post<VerifyAuthenticatedPhoneVerificationOtpResponse>(
'/v1/phone-verification/verify',
payload,
)
return data
}
export async function sendVerificationEmail(): Promise<ResendVerificationResponse> {
const { data } = await api.post<ResendVerificationResponse>('/v1/email/verification-notification')
return data
}
@@ -73,8 +110,8 @@ export function getRegisterErrorMessage(error: unknown): string {
return getApiErrorMessage(error, 'Registration failed. Please try again.')
}
export function getVerifyEmailErrorMessage(error: unknown): string {
return getApiErrorMessage(error, 'Email verification failed. Please try again.')
export function getPhoneVerificationErrorMessage(error: unknown): string {
return getApiErrorMessage(error, 'Gagal mengesahkan nombor telefon. Sila cuba lagi.')
}
export function getForgotPasswordErrorMessage(error: unknown): string {
@@ -85,15 +122,16 @@ export function getResetPasswordErrorMessage(error: unknown): string {
return getApiErrorMessage(error, 'Gagal menetapkan semula kata laluan. Sila cuba lagi.')
}
export function isAccountPending(user: { status: string } | null | undefined): boolean {
return user?.status === 'pending'
export function isEmailVerified(user: { email_verified_at?: string | null } | null | undefined): boolean {
return Boolean(user?.email_verified_at)
}
export function isLoginVerificationRequired(
response: LoginResponse,
): response is LoginResponse & { data: LoginVerificationRequiredData } {
return 'requires_email_verification' in response.data
&& response.data.requires_email_verification === true
export function isPhoneVerified(user: { phone_verified_at?: string | null } | null | undefined): boolean {
return Boolean(user?.phone_verified_at)
}
export function isAccountPending(user: { status: string } | null | undefined): boolean {
return user?.status === 'pending'
}
export function resolvePostAuthRoute(
+33 -17
View File
@@ -34,6 +34,7 @@ export interface AuthUser {
ic_number: string | null
position: string | null
phone_number: string | null
phone_verified_at: string | null
image_url: string | null
member_number: number | null
member_type: string | null
@@ -45,6 +46,7 @@ export interface AuthUser {
birth_date: string | null
birth_place: string | null
onboarding_completed_at: string | null
email_verified_at: string | null
roles?: Array<AuthRole & { permissions?: AuthPermission[] }>
}
@@ -54,20 +56,17 @@ export interface LoginSessionData {
expires_at: string
}
export interface LoginVerificationRequiredData {
email: string
requires_email_verification: true
}
export interface LoginResponse {
export interface AuthSessionResponse {
success: boolean
message: string
data: LoginSessionData | LoginVerificationRequiredData
data: LoginSessionData
active_role?: AuthRole | null
can_switch_role?: boolean
redirect_path?: string
}
export type LoginResponse = AuthSessionResponse
export interface SwitchRoleResponse extends SessionResponse {
message: string
}
@@ -76,25 +75,42 @@ export interface RegisterCredentials {
name: string
email: string
ic_number: string
phone_number: string
phone_verification_token: string
password: string
password_confirmation: string
}
export interface RegisterResponse {
success: boolean
message: string
data: {
email: string
requires_email_verification: boolean
}
export type RegisterResponse = AuthSessionResponse
export interface SendPhoneVerificationOtpPayload {
phone_number: string
}
export interface VerifyEmailPayload {
email: string
export interface SendPhoneVerificationOtpResponse {
success: boolean
message: string
}
export interface VerifyPhoneVerificationOtpPayload {
phone_number: string
otp: string
}
export type VerifyEmailResponse = LoginResponse
export interface VerifyPhoneVerificationOtpResponse {
success: boolean
message: string
data: {
phone_number: string
verification_token: string
}
}
export interface VerifyAuthenticatedPhoneVerificationOtpResponse {
success: boolean
message: string
data: AuthUser
}
export interface ResendVerificationResponse {
success: boolean
@@ -1,32 +1,52 @@
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useExternalSystemList } from './useExternalSystemList'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { getExternalSystem } from '../services/external-system.service'
import {
getExternalSystemStatus,
isExternalSystemAccessible,
} from '../utils/external-system.utils'
import type { ExternalSystem } from '../types/external-system.types'
export function useExternalSystemDetail() {
const route = useRoute()
const { getSystemById } = useExternalSystemList()
const loading = ref(false)
const error = ref<string | null>(null)
const system = ref<ExternalSystem | null>(null)
const systemId = computed(() => String(route.params.id ?? ''))
const system = computed(() => getSystemById(systemId.value) ?? null)
const status = computed(() => (system.value ? getExternalSystemStatus(system.value) : null))
const isAccessible = computed(() =>
system.value ? isExternalSystemAccessible(system.value) : false,
)
async function fetchSystem(id: string) {
loading.value = true
error.value = null
try {
system.value = await getExternalSystem(id)
} catch (err) {
system.value = null
error.value = getApiErrorMessage(err, 'Sistem luaran tidak dijumpai.')
} finally {
loading.value = false
}
}
watch(
systemId,
() => {
error.value = system.value ? null : 'Sistem luaran tidak dijumpai.'
(id) => {
if (!id) {
system.value = null
error.value = 'Sistem luaran tidak dijumpai.'
return
}
fetchSystem(id)
},
{ immediate: true },
)
@@ -0,0 +1,49 @@
import { ref } from 'vue'
import Swal from 'sweetalert2'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { launchExternalSystemSso } from '../services/external-system.service'
import { isExternalSystemAccessible } from '../utils/external-system.utils'
import type { ExternalSystem } from '../types/external-system.types'
export function useExternalSystemLaunch() {
const launching = ref(false)
async function launchExternalSystem(system: ExternalSystem) {
if (!isExternalSystemAccessible(system) || launching.value) {
return
}
launching.value = true
try {
if (system.sso_enabled) {
const response = await launchExternalSystemSso(system.code)
window.open(
response.data.launch_url,
system.opens_in_new_tab ? '_blank' : '_self',
'noopener,noreferrer',
)
return
}
window.open(
system.url,
system.opens_in_new_tab ? '_blank' : '_self',
'noopener,noreferrer',
)
} catch (error) {
await Swal.fire({
icon: 'error',
title: 'Gagal membuka sistem',
text: getApiErrorMessage(error, 'Tidak dapat membuka sistem luaran.'),
})
} finally {
launching.value = false
}
}
return {
launching,
launchExternalSystem,
}
}
@@ -1,5 +1,6 @@
import { computed, ref } from 'vue'
import { dummyExternalSystems } from '../data/dummy-external-systems'
import { computed, onMounted, ref } from 'vue'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { listExternalSystems } from '../services/external-system.service'
import {
externalSystemStatusLabel,
getExternalSystemStatus,
@@ -9,14 +10,16 @@ import type { ExternalSystem } from '../types/external-system.types'
export function useExternalSystemList() {
const search = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
const allSystems = ref<ExternalSystem[]>([])
const systems = computed(() => {
const query = search.value.trim().toLowerCase()
if (!query) {
return dummyExternalSystems
return allSystems.value
}
return dummyExternalSystems.filter((system) => {
return allSystems.value.filter((system) => {
const haystack = [
system.name,
system.code,
@@ -35,14 +38,34 @@ export function useExternalSystemList() {
)
function getSystemById(id: string): ExternalSystem | undefined {
return dummyExternalSystems.find((system) => system.id === id)
return allSystems.value.find((system) => system.id === id)
}
async function fetchSystems() {
loading.value = true
error.value = null
try {
allSystems.value = await listExternalSystems()
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai sistem luaran.')
allSystems.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
fetchSystems()
})
return {
systems,
search,
loading,
error,
availableCount,
getSystemById,
fetchSystems,
}
}
@@ -1,38 +0,0 @@
import type { ExternalSystem } from '../types/external-system.types'
export const dummyExternalSystems: ExternalSystem[] = [
{
id: 'ext-001',
code: 'portal-mykopkb',
name: 'Portal Ahli KOPKB',
description:
'Sistem utama keahlian Koperasi Permodalan Kelantan Berhad untuk semakan dividen, penyata dan maklumat ahli.',
url: 'https://mykopkb.koppkb.com',
icon: 'Users',
is_active: true,
starts_at: '2026-01-01T00:00:00+08:00',
ends_at: null,
opens_in_new_tab: true,
contact_email: 'dev_kopkb@gmail.com',
notes: 'Log masuk menggunakan e-mel berdaftar ahli KOPKB.',
created_at: '2026-01-15T09:00:00+08:00',
updated_at: '2026-06-01T14:30:00+08:00',
},
{
id: 'ext-002',
code: 'e-vote',
name: 'Sistem Pengundian AGM KOPKB',
description:
'Platform pengundian dalam talian untuk Mesyuarat Agung Tahunan. Hanya tersedia semasa tempoh pengundian.',
url: 'https://e-vote.erahn.com.my/login',
icon: 'Vote',
is_active: true,
starts_at: '2026-05-01T08:00:00+08:00',
ends_at: null,
opens_in_new_tab: true,
contact_email: 'dev_kopkb@gmail.com',
notes: 'Sila lengkapkan profil sebelum mengundi.',
created_at: '2026-05-20T10:00:00+08:00',
updated_at: '2026-06-28T11:15:00+08:00',
},
]
@@ -6,15 +6,16 @@ import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { Lucide } from '@/components/ui/lucide'
import { useExternalSystemDetail } from '../composables/useExternalSystemDetail'
import { useExternalSystemLaunch } from '../composables/useExternalSystemLaunch'
import {
externalSystemStatusLabel,
externalSystemStatusVariant,
formatExternalSystemDateTime,
openExternalSystem,
} from '../utils/external-system.utils'
const router = useRouter()
const { system, error, status, isAccessible } = useExternalSystemDetail()
const { launching, launchExternalSystem } = useExternalSystemLaunch()
function goBack() {
router.push({ name: 'list-external-systems' })
@@ -22,7 +23,7 @@ function goBack() {
function handleOpen() {
if (!system.value) return
openExternalSystem(system.value)
launchExternalSystem(system.value)
}
</script>
@@ -93,7 +94,7 @@ function handleOpen() {
<Button
look="outline"
variant="primary"
:disabled="!isAccessible"
:disabled="!isAccessible || launching"
@click="handleOpen"
>
Buka Sistem
@@ -7,24 +7,25 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import { useExternalSystemList } from '../composables/useExternalSystemList'
import { useExternalSystemLaunch } from '../composables/useExternalSystemLaunch'
import {
externalSystemStatusLabel,
externalSystemStatusVariant,
formatExternalSystemDateTime,
getExternalSystemStatus,
openExternalSystem,
} from '../utils/external-system.utils'
import type { ExternalSystem } from '../types/external-system.types'
const router = useRouter()
const { systems, search, loading, availableCount } = useExternalSystemList()
const { systems, search, loading, error, availableCount } = useExternalSystemList()
const { launching, launchExternalSystem } = useExternalSystemLaunch()
function goToDetail(system: ExternalSystem) {
router.push({ name: 'view-external-system', params: { id: system.id } })
}
function handleOpen(system: ExternalSystem) {
openExternalSystem(system)
launchExternalSystem(system)
}
</script>
@@ -39,6 +40,11 @@ function handleOpen(system: ExternalSystem) {
</Badge>
</div>
<AlertRoot v-if="error" class="mb-6" variant="danger">
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</AlertRoot>
<div class="mt-5 grid grid-cols-12 gap-x-6 gap-y-8">
<div class="col-span-12 mt-2 flex flex-wrap items-center sm:flex-nowrap">
<div class="w-full sm:w-auto">
@@ -82,7 +88,8 @@ function handleOpen(system: ExternalSystem) {
<div class="mt-5 flex flex-col gap-2 sm:flex-row">
<Button class="w-full sm:flex-1" look="outline" variant="primary"
:disabled="getExternalSystemStatus(system) !== 'available'" @click="handleOpen(system)">
:disabled="getExternalSystemStatus(system) !== 'available' || launching"
@click="handleOpen(system)">
Buka Sistem
<Lucide icon="ExternalLink" class="size-4" />
</Button>
@@ -94,7 +101,7 @@ function handleOpen(system: ExternalSystem) {
</Box>
</template>
<Box v-else class="col-span-12 p-8 text-center">
<Box v-else-if="!loading" class="col-span-12 p-8 text-center">
<Lucide icon="SearchX" class="mx-auto size-8 opacity-40" />
<div class="mt-3 text-base font-medium">Tiada sistem dijumpai</div>
<p class="mt-1 text-sm opacity-70">Cuba istilah carian yang berbeza.</p>
@@ -0,0 +1,41 @@
import { api } from '@/core/services/api'
import type {
ExternalSystem,
ExternalSystemListResponse,
ExternalSystemResponse,
ExternalSystemSsoLaunchResponse,
} from '../types/external-system.types'
export async function listExternalSystems(): Promise<ExternalSystem[]> {
const { data } = await api.get<ExternalSystemListResponse>('/v1/external-systems')
if (!data.success) {
throw new Error(data.message ?? 'Gagal memuatkan senarai sistem luaran.')
}
return data.data
}
export async function getExternalSystem(id: string): Promise<ExternalSystem> {
const { data } = await api.get<ExternalSystemResponse>(`/v1/external-systems/${id}`)
if (!data.success) {
throw new Error(data.message ?? 'Sistem luaran tidak dijumpai.')
}
return data.data
}
export async function launchExternalSystemSso(
systemCode: string,
): Promise<ExternalSystemSsoLaunchResponse> {
const { data } = await api.post<ExternalSystemSsoLaunchResponse>(
`/v1/external-systems/${systemCode}/sso/launch`,
)
if (!data.success) {
throw new Error(data.message ?? 'Gagal membuka sistem.')
}
return data
}
@@ -13,8 +13,30 @@ export type ExternalSystem = {
starts_at: string | null
ends_at: string | null
opens_in_new_tab: boolean
sso_enabled: boolean
contact_email: string | null
notes: string | null
created_at: string
updated_at: string
}
export type ExternalSystemSsoLaunchResponse = {
success: boolean
message?: string
data: {
launch_url: string
expires_at: string
}
}
export type ExternalSystemListResponse = {
success: boolean
message?: string
data: ExternalSystem[]
}
export type ExternalSystemResponse = {
success: boolean
message?: string
data: ExternalSystem
}
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { Lucide } from '@/components/ui/lucide'
import { useRouter } from 'vue-router'
const props = withDefaults(
defineProps<{
/** Vertical offset from vertical center, in rem (matches Layout side tabs). */
offsetRem?: number
/** Open feedback form in a new tab instead of navigating in place. */
openInNewTab?: boolean
}>(),
{
offsetRem: 3.5,
openInNewTab: true,
},
)
const router = useRouter()
function openHelpdesk(event: MouseEvent) {
event.preventDefault()
const { href } = router.resolve({ name: 'feedback-submit' })
if (props.openInNewTab) {
window.open(href, '_blank', 'noopener,noreferrer')
return
}
router.push({ name: 'feedback-submit' })
}
</script>
<template>
<button type="button" aria-label="Helpdesk" :style="{
top: `calc(50% + ${offsetRem}rem)`,
['--color' as string]: 'var(--color-primary)',
}" :class="[
'group fixed right-0 z-50 flex h-12 cursor-pointer items-center overflow-hidden rounded-l-full border border-(--color)/50 bg-background/80 shadow-lg transition-all',
'w-14 hover:w-44',
'before:absolute before:inset-0 before:bg-(--color)/20',
]" @click="openHelpdesk">
<span class="relative z-10 flex items-center gap-2 px-5">
<Lucide icon="MessageCircle" />
<span :class="[
'whitespace-nowrap text-sm',
'max-w-0 overflow-hidden transition-[max-width] duration-200 ease-out',
'group-hover:max-w-40',
]">
Maklum Balas
</span>
</span>
</button>
</template>
@@ -0,0 +1,104 @@
import { onMounted, ref, watch } from 'vue'
import debounce from 'lodash/debounce'
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
import { useApiPagination } from '@/composables/useApiPagination'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { listFeedback } from '../services/feedback.service'
import type {
FeedbackListItem,
FeedbackPriority,
FeedbackStatus,
FeedbackType,
} from '../types/feedback.types'
export function useFeedbackList() {
const items = ref<FeedbackListItem[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const search = ref('')
const typeFilter = ref<FeedbackType | ''>('')
const statusFilter = ref<FeedbackStatus | ''>('')
const priorityFilter = ref<FeedbackPriority | ''>('')
const sortBy = ref<SortConfig[]>([{ key: 'created_at', order: 'desc' }])
const page = ref(1)
const itemsPerPage = ref(10)
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
async function fetchItems(requestPage = page.value) {
loading.value = true
error.value = null
try {
const activeSort = sortBy.value[0]
const data = await listFeedback({
page: requestPage,
per_page: itemsPerPage.value,
sort_by: activeSort?.key ?? 'created_at',
sort_order: activeSort?.order ?? 'desc',
search: search.value.trim() || undefined,
type: typeFilter.value || undefined,
status: statusFilter.value || undefined,
priority: priorityFilter.value || undefined,
})
items.value = data.data
applyPagination(data.pagination)
page.value = data.pagination.current_page
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai maklum balas.')
items.value = []
} finally {
loading.value = false
}
}
function handleSortUpdate(value: SortConfig[]) {
sortBy.value = value
fetchItems(1)
}
const debouncedSearch = debounce(() => {
fetchItems(1)
}, 400)
watch(search, () => {
debouncedSearch()
})
watch([typeFilter, statusFilter, priorityFilter], () => {
fetchItems(1)
})
watch(page, (nextPage, previousPage) => {
if (nextPage !== previousPage) {
fetchItems(nextPage)
}
})
watch(itemsPerPage, (nextValue, previousValue) => {
if (nextValue !== previousValue) {
fetchItems(1)
}
})
onMounted(() => {
fetchItems(1)
})
return {
items,
loading,
error,
search,
typeFilter,
statusFilter,
priorityFilter,
sortBy,
page,
itemsPerPage,
pagination,
handleSortUpdate,
fetchItems,
}
}
+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>
@@ -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 { Lucide } from '@/components/ui/lucide'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import { HelpdeskFab } from '@/modules/feedback'
import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import type {
@@ -567,6 +568,7 @@ function stepLabelClass(stepId: number) {
<template>
<div class="min-h-screen bg-background">
<HelpdeskFab />
<div class="border-b border-foreground/10 bg-primary/5">
<div class="container mx-auto flex items-center justify-between px-5 py-4 sm:px-8">
<div class="flex items-center gap-4">
@@ -0,0 +1,184 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
getPhoneVerificationErrorMessage,
sendAuthenticatedPhoneVerificationOtp,
verifyAuthenticatedPhoneVerificationOtp,
} from '@/modules/auth'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const changingPhone = ref(false)
const otpSent = ref(false)
const phoneNumber = ref(authStore.user?.phone_number ?? '')
const otp = ref('')
const feedbackMessage = ref('')
const errorMessage = ref('')
const isVerified = computed(() => authStore.isPhoneVerified)
const sectionDescription = computed(() => {
if (changingPhone.value) {
return 'Masukkan nombor telefon baharu dan sahkan dengan kod OTP SMS.'
}
if (isVerified.value) {
return 'Nombor telefon anda telah disahkan.'
}
return 'Sahkan nombor telefon anda melalui kod OTP SMS.'
})
watch(
() => authStore.user?.phone_number,
(value) => {
if (!otpSent.value && !changingPhone.value) {
phoneNumber.value = value ?? ''
}
},
)
const resetOtpState = () => {
otp.value = ''
otpSent.value = false
clearMessages()
}
const clearMessages = () => {
feedbackMessage.value = ''
errorMessage.value = ''
}
const handlePhoneInput = () => {
phoneNumber.value = phoneNumber.value.replace(/[^\d+]/g, '')
otp.value = ''
otpSent.value = false
}
const handleOtpInput = () => {
otp.value = otp.value.replace(/\D/g, '').slice(0, 6)
}
const startChangePhone = () => {
changingPhone.value = true
phoneNumber.value = ''
resetOtpState()
}
const cancelChangePhone = () => {
changingPhone.value = false
phoneNumber.value = authStore.user?.phone_number ?? ''
resetOtpState()
}
const handleSendOtp = async () => {
loading.value = true
clearMessages()
try {
const response = await sendAuthenticatedPhoneVerificationOtp({
phone_number: phoneNumber.value,
})
feedbackMessage.value = response.message
otpSent.value = true
} catch (error) {
errorMessage.value = getPhoneVerificationErrorMessage(error)
} finally {
loading.value = false
}
}
const handleVerifyOtp = async () => {
loading.value = true
clearMessages()
try {
const response = await verifyAuthenticatedPhoneVerificationOtp({
phone_number: phoneNumber.value,
otp: otp.value,
})
authStore.setUserProfile(response.data)
phoneNumber.value = response.data.phone_number ?? phoneNumber.value
changingPhone.value = false
otp.value = ''
otpSent.value = false
feedbackMessage.value = response.message
} catch (error) {
errorMessage.value = getPhoneVerificationErrorMessage(error)
} finally {
loading.value = false
}
}
</script>
<template>
<Box raised="single" class="p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-slate-900">Nombor Telefon</h3>
<p class="mt-1 text-sm text-slate-500">
{{ sectionDescription }}
</p>
</div>
<div v-if="isVerified && !changingPhone" class="space-y-4">
<div class="flex flex-wrap items-center gap-3">
<Input id="profile-phone-verified" :model-value="authStore.user?.phone_number ?? '-'" type="tel" disabled
class="max-w-sm" />
<Badge variant="success">Disahkan</Badge>
</div>
<p v-if="feedbackMessage" class="text-sm text-slate-600">{{ feedbackMessage }}</p>
<Button type="button" look="outline" :disabled="loading" @click="startChangePhone">
Tukar Nombor Telefon
</Button>
</div>
<div v-else class="space-y-4">
<FieldGroup>
<Field class="max-w-sm">
<FieldLabel for="profile-phone-verify">No. Telefon</FieldLabel>
<Input id="profile-phone-verify" v-model="phoneNumber" type="tel" inputmode="tel"
placeholder="No. Telefon, contoh: 0123456790" autocomplete="tel" :disabled="loading || otpSent"
@input="handlePhoneInput" />
</Field>
<Field v-if="otpSent" class="max-w-sm">
<FieldLabel for="profile-phone-otp">Kod OTP</FieldLabel>
<Input id="profile-phone-otp" v-model="otp" type="text" inputmode="numeric" maxlength="6" placeholder="000000"
autocomplete="one-time-code" class="text-center tracking-[0.4em]" :disabled="loading"
@input="handleOtpInput" />
</Field>
</FieldGroup>
<p v-if="feedbackMessage" class="text-sm text-slate-600">{{ feedbackMessage }}</p>
<p v-if="errorMessage" class="text-sm text-danger">{{ errorMessage }}</p>
<div class="flex flex-wrap gap-2">
<Button v-if="!otpSent" type="button" variant="primary" :disabled="loading || !phoneNumber"
@click="handleSendOtp">
{{ loading ? 'Menghantar...' : 'Hantar Kod OTP' }}
</Button>
<template v-else>
<Button type="button" variant="primary" :disabled="loading || otp.length !== 6" @click="handleVerifyOtp">
{{ loading ? 'Mengesahkan...' : 'Sahkan OTP' }}
</Button>
<Button type="button" look="outline" :disabled="loading" @click="handleSendOtp">
Hantar Semula OTP
</Button>
</template>
<Button v-if="changingPhone" type="button" look="outline" :disabled="loading" @click="cancelChangePhone">
Batal
</Button>
</div>
</div>
</Box>
</template>
@@ -72,7 +72,7 @@ const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
// TODO: replace with API lookup
const EMPLOYERS = [
{
name: 'Infra Quest Sdn Bhd (IQSB)',
name: 'Infra Quest Sdn. Bhd. (IQSB)',
address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan',
},
{
@@ -89,6 +89,10 @@ const EMPLOYERS = [
name: "An-Nisa'",
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
},
{
name: "Kel Infra Sdn. Bhd.",
address: "Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.",
}
] as const
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
+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 { useAuthStore } from '@/stores/auth'
import HeirTab from './HeirTab.vue'
import PhoneVerificationSection from '../components/PhoneVerificationSection.vue'
defineProps<{
embedded?: boolean
@@ -44,7 +45,6 @@ const form = reactive({
name: '',
ic_number: '',
position: '',
phone_number: '',
birth_date: '',
birth_place: '',
})
@@ -350,7 +350,6 @@ function syncFormFromUser() {
form.name = user.name ?? ''
form.ic_number = user.ic_number ?? ''
form.position = user.position ?? ''
form.phone_number = user.phone_number ?? ''
form.birth_date = toDateInputValue(user.birth_date)
form.birth_place = user.birth_place ?? ''
syncProfileSelectValues()
@@ -368,7 +367,6 @@ async function onSaveProfile() {
name: form.name.trim(),
ic_number: form.ic_number.trim(),
position: form.position.trim(),
phone_number: form.phone_number.trim(),
gender: gender ?? undefined,
marriage_status: marriageStatus ?? undefined,
birth_date: form.birth_date || undefined,
@@ -591,11 +589,6 @@ onMounted(async () => {
<Input id="profile-ic" v-model="form.ic_number" type="text" inputmode="numeric" maxlength="15"
placeholder="Contoh: 900101011234" @input="handleIcNumberInput" />
</Field>
<Field>
<FieldLabel for="profile-phone">No. Telefon</FieldLabel>
<Input id="profile-phone" v-model="form.phone_number" type="tel" pattern="[0-9]*"
placeholder="0123456789" />
</Field>
<Field class="md:col-span-2">
<FieldLabel for="profile-position">Jawatan</FieldLabel>
<Input id="profile-position" v-model="form.position" type="text" placeholder="Jawatan" />
@@ -677,6 +670,8 @@ onMounted(async () => {
</form>
</Box>
<PhoneVerificationSection />
<Box raised="single" class="p-6">
<div class="space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -18,6 +18,7 @@ export function useUserList() {
const joinDateTo = ref('')
const leaveDateFrom = ref('')
const leaveDateTo = ref('')
const unitFilter = ref('')
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
const page = ref(1)
const itemsPerPage = ref(10)
@@ -55,6 +56,7 @@ export function useUserList() {
join_date_to: joinDateTo.value || undefined,
leave_date_from: leaveDateFrom.value || undefined,
leave_date_to: leaveDateTo.value || undefined,
company_name: unitFilter.value.trim() || undefined,
})
users.value = data.data
@@ -79,6 +81,7 @@ export function useUserList() {
join_date_to: joinDateTo.value || undefined,
leave_date_from: leaveDateFrom.value || undefined,
leave_date_to: leaveDateTo.value || undefined,
company_name: unitFilter.value.trim() || undefined,
})
stats.value = res.data
@@ -109,6 +112,11 @@ export function useUserList() {
fetchStats()
})
watch(unitFilter, () => {
fetchUsers(1)
fetchStats()
})
watch([joinDateFrom, joinDateTo, leaveDateFrom, leaveDateTo], () => {
fetchUsers(1)
fetchStats()
@@ -144,6 +152,7 @@ export function useUserList() {
joinDateTo,
leaveDateFrom,
leaveDateTo,
unitFilter,
hasJoinDateFilters,
hasLeaveDateFilters,
clearJoinDateFilters,
+26 -1
View File
@@ -1,7 +1,8 @@
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import * as select from '@zag-js/select'
import dayjs from 'dayjs'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
@@ -102,6 +103,7 @@ const form = reactive({
phone_number: '',
member_number: '',
join_date: '',
leave_date: '',
birth_date: '',
birth_place: '',
})
@@ -154,6 +156,7 @@ async function handleSubmit() {
member_number: Number(form.member_number),
member_type: memberType!,
join_date: form.join_date,
leave_date: isInactiveStatus.value ? form.leave_date || null : null,
birth_date: form.birth_date,
birth_place: form.birth_place.trim(),
})
@@ -170,6 +173,17 @@ async function handleSubmit() {
}
const formDisabled = computed(() => saving.value)
const selectedStatus = computed(
() => labelToApiValue(STATUS_OPTIONS, statusValue.value[0]) ?? 'pending',
)
const isInactiveStatus = computed(() => selectedStatus.value === 'inactive')
watch(selectedStatus, (newStatus, oldStatus) => {
if (newStatus === 'inactive' && oldStatus !== 'inactive' && !form.leave_date) {
form.leave_date = dayjs().format('YYYY-MM-DD')
}
})
</script>
<template>
@@ -417,6 +431,17 @@ const formDisabled = computed(() => saving.value)
/>
</Field>
<Field v-if="isInactiveStatus">
<FieldLabel for="user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel>
<Input
id="user-leave-date"
v-model="form.leave_date"
class="w-full"
type="date"
:disabled="formDisabled"
/>
</Field>
<Field>
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
<Input
+616 -2
View File
@@ -3,11 +3,15 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import * as select from '@zag-js/select'
import Swal from 'sweetalert2'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import {
SelectRoot,
SelectControl,
@@ -19,8 +23,14 @@ import {
SelectItem,
SelectItemText,
} from '@/components/ui/select'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import type { Employment, EmploymentPayload } from '@/modules/profile/types/employment.types'
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import {
createUserEmployment,
deleteUserEmployment,
updateUserEmployment,
} from '../services/userEmployment.service'
import { getUser, updateUser } from '../services/user.service'
type SelectOption = { label: string; value: string }
@@ -49,6 +59,62 @@ const MEMBER_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Pesara', value: 'Pesara' },
]
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Tetap', value: 'Permanent' },
{ label: 'Kontrak', value: 'Contract' },
{ label: 'Latihan Industri', value: 'Internship' },
{ label: 'Freelance', value: 'Freelance' },
]
const EMPLOYERS = [
{
name: 'Infra Quest Sdn. Bhd. (IQSB)',
address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan',
},
{
name: 'Permodalan Kelantan Berhad (PKB)',
address:
'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
},
{
name: 'Koperasi Permodalan Kelantan Berhad (KOPKB)',
address:
'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
},
{
name: "An-Nisa'",
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
},
{
name: 'Kel Infra Sdn. Bhd.',
address: 'Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.',
},
] as const
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
label: employer.name,
value: employer.name,
}))
type EmploymentFieldKey =
| 'company_name'
| 'job_title'
| 'employment_type'
| 'salary'
| 'start_date'
| 'end_date'
| 'is_current'
const EMPLOYMENT_FIELD_KEYS: EmploymentFieldKey[] = [
'company_name',
'job_title',
'employment_type',
'salary',
'start_date',
'end_date',
'is_current',
]
function createSelectCollection(options: SelectOption[]) {
return select.collection({
items: options,
@@ -71,6 +137,8 @@ const statusCollection = createSelectCollection(STATUS_OPTIONS)
const genderCollection = createSelectCollection(GENDER_OPTIONS)
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
const memberTypeCollection = createSelectCollection(MEMBER_TYPE_OPTIONS)
const employmentTypeCollection = createSelectCollection(EMPLOYMENT_TYPE_OPTIONS)
const companyNameCollection = createSelectCollection(COMPANY_OPTIONS)
const statusValue = ref<string[]>([])
const genderValue = ref<string[]>([])
@@ -82,6 +150,31 @@ const genderInitial = ref<string[]>([])
const marriageStatusInitial = ref<string[]>([])
const memberTypeInitial = ref<string[]>([])
const employmentTypeValue = ref<string[]>([])
const employmentTypeInitial = ref<string[]>([])
const companyNameValue = ref<string[]>([])
const companyNameInitial = ref<string[]>([])
const employments = ref<Employment[]>([])
const savingEmployment = ref(false)
const deletingEmploymentId = ref<string | null>(null)
const editingEmploymentId = ref<string | null>(null)
const employmentErrors = reactive<Partial<Record<EmploymentFieldKey, string>>>({})
function emptyEmploymentForm() {
return {
company_name: '',
job_title: '',
employment_type: '',
salary: '',
start_date: '',
end_date: '',
is_current: true,
}
}
const employmentForm = reactive(emptyEmploymentForm())
function setStatusValue(details: { value: string[] }) {
statusValue.value = details.value
}
@@ -98,6 +191,59 @@ function setMemberTypeValue(details: { value: string[] }) {
memberTypeValue.value = details.value
}
function setEmploymentTypeValue(details: { value: string[] }) {
employmentTypeValue.value = details.value
clearEmploymentFieldError('employment_type')
employmentForm.employment_type =
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, details.value[0]) ?? ''
}
function setCompanyNameValue(details: { value: string[] }) {
companyNameValue.value = details.value
clearEmploymentFieldError('company_name')
employmentForm.company_name = details.value[0] ?? ''
}
function clearEmploymentFieldError(field: EmploymentFieldKey) {
delete employmentErrors[field]
}
function clearEmploymentErrors() {
for (const field of EMPLOYMENT_FIELD_KEYS) {
delete employmentErrors[field]
}
}
function setEmploymentErrorsFromApi(error: unknown): boolean {
const apiErrors = getApiValidationErrors(error)
if (!apiErrors) return false
for (const [field, messages] of Object.entries(apiErrors)) {
if (EMPLOYMENT_FIELD_KEYS.includes(field as EmploymentFieldKey) && messages[0]) {
employmentErrors[field as EmploymentFieldKey] = messages[0]
}
}
return Object.keys(employmentErrors).length > 0
}
function syncEmploymentSelectValues() {
employmentTypeValue.value = apiValueToLabel(
EMPLOYMENT_TYPE_OPTIONS,
employmentForm.employment_type,
)
employmentTypeInitial.value = [...employmentTypeValue.value]
companyNameValue.value = apiValueToLabel(COMPANY_OPTIONS, employmentForm.company_name)
companyNameInitial.value = [...companyNameValue.value]
}
function resetEmploymentForm() {
Object.assign(employmentForm, emptyEmploymentForm())
editingEmploymentId.value = null
clearEmploymentErrors()
syncEmploymentSelectValues()
}
const router = useRouter()
const route = useRoute()
@@ -154,6 +300,219 @@ function syncFormFromUser(user: Awaited<ReturnType<typeof getUser>>['data']) {
genderInitial.value = [...genderValue.value]
marriageStatusInitial.value = [...marriageStatusValue.value]
memberTypeInitial.value = [...memberTypeValue.value]
employments.value = user.employments ?? []
}
const employmentTypeLabel = computed(() =>
Object.fromEntries(EMPLOYMENT_TYPE_OPTIONS.map((option) => [option.value, option.label])),
)
const isEditingEmployment = computed(() => editingEmploymentId.value !== null)
const canAddEmployment = computed(() => !loading.value && employments.value.length === 0)
const showEmploymentForm = computed(() => isEditingEmployment.value || canAddEmployment.value)
function formatSalary(value: number | string | null | undefined): string {
const amount = Number(value)
if (Number.isNaN(amount)) return '-'
return new Intl.NumberFormat('ms-MY', {
style: 'currency',
currency: 'MYR',
minimumFractionDigits: 2,
}).format(amount)
}
function formatDateLabel(value: string | null | undefined): string {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat('ms-MY', {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}
function formatEmploymentPeriod(employment: Employment): string {
const start = formatDateLabel(employment.start_date)
if (employment.is_current) {
return `${start} - Kini`
}
const end = formatDateLabel(employment.end_date)
return end ? `${start} - ${end}` : start
}
function validateEmploymentForm(): boolean {
clearEmploymentErrors()
let valid = true
if (!companyNameValue.value[0]?.trim()) {
employmentErrors.company_name = 'Nama syarikat diperlukan.'
valid = false
}
if (!employmentForm.job_title.trim()) {
employmentErrors.job_title = 'Jawatan diperlukan.'
valid = false
}
if (
!employmentTypeValue.value[0] ||
!labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, employmentTypeValue.value[0])
) {
employmentErrors.employment_type = 'Jenis kerja diperlukan.'
valid = false
}
const salary = Number(employmentForm.salary)
if (!employmentForm.salary.toString().trim() || Number.isNaN(salary) || salary < 0) {
employmentErrors.salary = 'Gaji diperlukan.'
valid = false
}
if (!employmentForm.start_date) {
employmentErrors.start_date = 'Tarikh mula diperlukan.'
valid = false
}
if (!employmentForm.is_current && !employmentForm.end_date) {
employmentErrors.end_date = 'Tarikh tamat diperlukan jika bukan pekerjaan semasa.'
valid = false
}
if (
!employmentForm.is_current &&
employmentForm.start_date &&
employmentForm.end_date &&
employmentForm.end_date < employmentForm.start_date
) {
employmentErrors.end_date = 'Tarikh tamat mesti selepas tarikh mula.'
valid = false
}
return valid
}
function buildEmploymentPayload(): EmploymentPayload {
return {
company_name: employmentForm.company_name.trim(),
job_title: employmentForm.job_title.trim(),
employment_type:
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, employmentTypeValue.value[0]) ??
employmentForm.employment_type,
salary: Number(employmentForm.salary),
start_date: employmentForm.start_date,
end_date: employmentForm.is_current ? null : employmentForm.end_date || null,
is_current: employmentForm.is_current,
}
}
async function refreshEmployments() {
const response = await getUser(userId.value)
employments.value = response.data.employments ?? []
}
function startEditEmployment(employment: Employment) {
clearEmploymentErrors()
editingEmploymentId.value = employment.id
employmentForm.company_name = employment.company_name
employmentForm.job_title = employment.job_title
employmentForm.employment_type = employment.employment_type
employmentForm.salary = String(employment.salary)
employmentForm.start_date = toDateInputValue(employment.start_date)
employmentForm.end_date = toDateInputValue(employment.end_date)
employmentForm.is_current = employment.is_current
syncEmploymentSelectValues()
}
async function onSaveEmployment() {
if (!validateEmploymentForm()) {
return
}
savingEmployment.value = true
const wasEditing = isEditingEmployment.value
const payload = buildEmploymentPayload()
try {
const res = wasEditing
? await updateUserEmployment(userId.value, editingEmploymentId.value!, payload)
: await createUserEmployment(userId.value, payload)
if (!res.success) {
throw new Error(res.message ?? 'Gagal menyimpan pekerjaan.')
}
await refreshEmployments()
resetEmploymentForm()
await Swal.fire({
toast: true,
position: 'top-end',
icon: 'success',
title: wasEditing ? 'Pekerjaan berjaya dikemas kini.' : 'Pekerjaan berjaya ditambah.',
showConfirmButton: false,
timer: 3000,
})
} catch (err) {
if (!setEmploymentErrorsFromApi(err)) {
await Swal.fire({
icon: 'error',
title: 'Ralat',
text: getApiErrorMessage(err, 'Gagal menyimpan pekerjaan.'),
})
}
} finally {
savingEmployment.value = false
}
}
async function onDeleteEmployment(employment: Employment) {
const result = await Swal.fire({
icon: 'warning',
title: 'Padam pekerjaan?',
text: 'Tindakan ini tidak boleh dibatalkan.',
showCancelButton: true,
confirmButtonText: 'Padam',
cancelButtonText: 'Batal',
})
if (!result.isConfirmed) return
deletingEmploymentId.value = employment.id
try {
const res = await deleteUserEmployment(userId.value, employment.id)
if (!res.success) {
throw new Error(res.message ?? 'Gagal memadam pekerjaan.')
}
if (editingEmploymentId.value === employment.id) {
resetEmploymentForm()
}
await refreshEmployments()
await Swal.fire({
toast: true,
position: 'top-end',
icon: 'success',
title: 'Pekerjaan berjaya dipadam.',
showConfirmButton: false,
timer: 3000,
})
} catch (err) {
await Swal.fire({
icon: 'error',
title: 'Ralat',
text: getApiErrorMessage(err, 'Gagal memadam pekerjaan.'),
})
} finally {
deletingEmploymentId.value = null
}
}
async function fetchUser() {
@@ -182,6 +541,16 @@ watch(selectedStatus, (newStatus, oldStatus) => {
}
})
watch(
() => employmentForm.is_current,
(isCurrent) => {
if (isCurrent) {
employmentForm.end_date = ''
clearEmploymentFieldError('end_date')
}
},
)
async function handleSubmit() {
saving.value = true
error.value = null
@@ -218,6 +587,7 @@ async function handleSubmit() {
const formDisabled = computed(() => loading.value || saving.value)
onMounted(() => {
syncEmploymentSelectValues()
fetchUser()
})
</script>
@@ -405,5 +775,249 @@ onMounted(() => {
</div>
</form>
</Box>
<Box>
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold text-slate-900">Pekerjaan</h3>
<p class="mt-1 text-sm text-slate-500">Urus maklumat pekerjaan pengguna.</p>
</div>
<div v-if="loading" class="text-sm text-slate-500">Memuatkan pekerjaan...</div>
<div v-else-if="employments.length" class="space-y-3">
<div
v-for="employment in employments"
:key="employment.id"
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-slate-900">{{ employment.company_name }}</span>
<Badge v-if="employment.is_current" class="bg-green-500 text-white">Semasa</Badge>
<Badge look="outline">
{{ employmentTypeLabel[employment.employment_type] ?? employment.employment_type }}
</Badge>
</div>
<p class="mt-1 text-sm font-medium text-slate-700">{{ employment.job_title }}</p>
<p class="mt-1 text-sm text-slate-500">{{ formatEmploymentPeriod(employment) }}</p>
<p class="mt-1 text-sm text-slate-500">{{ formatSalary(employment.salary) }}</p>
</div>
<div class="flex shrink-0 gap-2">
<Button
type="button"
variant="ghost"
class="border border-foreground/15 shadow-none"
:disabled="deletingEmploymentId === employment.id || saving"
@click="startEditEmployment(employment)"
>
<Lucide class="mr-2 size-4" icon="Pencil" />
Kemaskini
</Button>
<Button
type="button"
variant="ghost"
class="border border-foreground/15 shadow-none text-danger"
:disabled="deletingEmploymentId === employment.id || saving"
@click="onDeleteEmployment(employment)"
>
<Lucide
class="mr-2 size-4"
:icon="deletingEmploymentId === employment.id ? 'LoaderCircle' : 'Trash'"
:class="{ 'animate-spin': deletingEmploymentId === employment.id }"
/>
Padam
</Button>
</div>
</div>
</div>
<div
v-else
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
>
Tiada pekerjaan direkodkan.
</div>
<form
v-if="showEmploymentForm"
class="space-y-6 border-t border-foreground/10 pt-6"
@submit.prevent="onSaveEmployment"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 class="text-base font-semibold text-slate-900">
{{ isEditingEmployment ? 'Kemaskini Pekerjaan' : 'Tambah Pekerjaan' }}
</h4>
<p class="mt-1 text-sm text-slate-500">
{{
isEditingEmployment
? 'Kemas kini maklumat pekerjaan yang dipilih.'
: 'Tambah rekod pekerjaan baharu untuk pengguna ini.'
}}
</p>
</div>
<div class="flex gap-2">
<Button
v-if="isEditingEmployment"
type="button"
variant="ghost"
class="border border-foreground/15 shadow-none"
:disabled="savingEmployment"
@click="resetEmploymentForm"
>
Batal
</Button>
<Button type="submit" variant="primary" :disabled="savingEmployment || saving">
{{ savingEmployment ? 'Menyimpan...' : isEditingEmployment ? 'Kemaskini' : 'Tambah' }}
</Button>
</div>
</div>
<FieldGroup>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel>Nama Syarikat</FieldLabel>
<SelectRoot
:key="`company-name-${editingEmploymentId ?? 'new'}`"
class="w-full"
:collection="companyNameCollection"
:default-value="companyNameInitial"
:disabled="savingEmployment || saving"
@value-change="setCompanyNameValue"
>
<SelectControl>
<SelectTrigger :aria-invalid="!!employmentErrors.company_name">
<SelectValueText placeholder="Pilih syarikat" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel>
<SelectItem
v-for="item in companyNameCollection.items"
:key="item.label"
:item="item"
>
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="employmentErrors.company_name">
{{ employmentErrors.company_name }}
</FieldError>
</Field>
<Field>
<FieldLabel for="employment-job-title">Jawatan</FieldLabel>
<Input
id="employment-job-title"
v-model="employmentForm.job_title"
type="text"
placeholder="Jawatan"
:disabled="savingEmployment || saving"
:aria-invalid="!!employmentErrors.job_title"
@input="clearEmploymentFieldError('job_title')"
/>
<FieldError v-if="employmentErrors.job_title">
{{ employmentErrors.job_title }}
</FieldError>
</Field>
<Field>
<FieldLabel>Jenis Kerja</FieldLabel>
<SelectRoot
:key="`employment-type-${editingEmploymentId ?? 'new'}`"
class="w-full"
:collection="employmentTypeCollection"
:default-value="employmentTypeInitial"
:disabled="savingEmployment || saving"
@value-change="setEmploymentTypeValue"
>
<SelectControl>
<SelectTrigger :aria-invalid="!!employmentErrors.employment_type">
<SelectValueText placeholder="Pilih jenis kerja" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Jenis Kerja</SelectItemGroupLabel>
<SelectItem
v-for="item in employmentTypeCollection.items"
:key="item.label"
:item="item"
>
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="employmentErrors.employment_type">
{{ employmentErrors.employment_type }}
</FieldError>
</Field>
<Field>
<FieldLabel for="employment-salary">Gaji (RM)</FieldLabel>
<Input
id="employment-salary"
v-model="employmentForm.salary"
type="number"
min="0"
step="0.01"
placeholder="0.00"
:disabled="savingEmployment || saving"
:aria-invalid="!!employmentErrors.salary"
@input="clearEmploymentFieldError('salary')"
/>
<FieldError v-if="employmentErrors.salary">{{ employmentErrors.salary }}</FieldError>
</Field>
<Field>
<FieldLabel for="employment-start-date">Tarikh Mula</FieldLabel>
<Input
id="employment-start-date"
v-model="employmentForm.start_date"
type="date"
:disabled="savingEmployment || saving"
:aria-invalid="!!employmentErrors.start_date"
@input="clearEmploymentFieldError('start_date')"
/>
<FieldError v-if="employmentErrors.start_date">
{{ employmentErrors.start_date }}
</FieldError>
</Field>
<Field>
<FieldLabel for="employment-end-date">Tarikh Tamat</FieldLabel>
<Input
id="employment-end-date"
v-model="employmentForm.end_date"
type="date"
:disabled="employmentForm.is_current || savingEmployment || saving"
:aria-invalid="!!employmentErrors.end_date"
@input="clearEmploymentFieldError('end_date')"
/>
<FieldError v-if="employmentErrors.end_date">
{{ employmentErrors.end_date }}
</FieldError>
</Field>
<Field class="md:col-span-2">
<CheckboxRoot
:checked="employmentForm.is_current"
:disabled="savingEmployment || saving"
@checked-change="({ checked }) => (employmentForm.is_current = checked === true)"
>
<CheckboxControl />
<CheckboxLabel>Pekerjaan semasa</CheckboxLabel>
</CheckboxRoot>
</Field>
</div>
</FieldGroup>
</form>
</div>
</Box>
</div>
</template>
+43
View File
@@ -20,6 +20,7 @@ import { Lucide } from '@/components/ui/lucide'
import DataTable from '@/components/ui/usage/DataTable.vue'
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
import { usePermissions } from '@/composables/usePermissions'
import { EMPLOYERS } from '@/constants/employers'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { listRoles } from '@/modules/role/services/role.service'
import type { RoleListItem } from '@/modules/role/types/role.types'
@@ -48,6 +49,22 @@ const STATUS_FILTER_CHIPS: StatusFilterChip[] = [
{ label: 'Menunggu', value: 'pending', variant: 'pending' },
]
function getEmployerShortLabel(name: string): string {
const match = name.match(/\(([^)]+)\)/)
if (match?.[1]) return match[1]
if (name.startsWith('An-Nisa')) return "An-Nisa'"
if (name.startsWith('Kel Infra')) return 'Kel Infra'
return name
}
const UNIT_FILTER_CHIPS = [
{ label: 'Semua', value: '' },
...EMPLOYERS.map((employer) => ({
label: getEmployerShortLabel(employer.name),
value: employer.name,
})),
]
const router = useRouter()
const { hasPermission } = usePermissions()
@@ -163,6 +180,14 @@ function setStatusFilter(value: string) {
statusFilter.value = value
}
function isUnitFilterActive(value: string) {
return unitFilter.value === value
}
function setUnitFilter(value: string) {
unitFilter.value = value
}
function formatDeletedAt(value: string | null | undefined): string {
if (!value) return '-'
return dayjs(value).format('DD MMM YYYY, HH:mm')
@@ -356,6 +381,7 @@ const {
error,
search,
statusFilter,
unitFilter,
joinDateFrom,
joinDateTo,
leaveDateFrom,
@@ -540,6 +566,23 @@ onMounted(() => {
{{ chip.label }}
</Badge>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm opacity-70">Unit:</span>
<Badge
v-for="chip in UNIT_FILTER_CHIPS"
:key="chip.value || 'all-units'"
variant="ghost"
:look="isUnitFilterActive(chip.value) ? 'filled' : 'outline'"
role="button"
tabindex="0"
:title="chip.value || 'Semua unit'"
:aria-pressed="isUnitFilterActive(chip.value)"
@click="setUnitFilter(chip.value)"
@keydown.enter="setUnitFilter(chip.value)"
>
{{ chip.label }}
</Badge>
</div>
</div>
</template>
@@ -0,0 +1,47 @@
import { api } from '@/core/services/api'
import type { EmploymentApiResponse, EmploymentPayload } from '@/modules/profile/types/employment.types'
export async function createUserEmployment(
userId: string,
payload: EmploymentPayload,
): Promise<EmploymentApiResponse> {
const { data } = await api.post<EmploymentApiResponse>(`/v1/users/${userId}/employments`, payload)
if (!data.success) {
throw new Error(data.message ?? 'Gagal menambah pekerjaan.')
}
return data
}
export async function updateUserEmployment(
userId: string,
employmentId: string,
payload: EmploymentPayload,
): Promise<EmploymentApiResponse> {
const { data } = await api.put<EmploymentApiResponse>(
`/v1/users/${userId}/employments/${employmentId}`,
payload,
)
if (!data.success) {
throw new Error(data.message ?? 'Gagal mengemas kini pekerjaan.')
}
return data
}
export async function deleteUserEmployment(
userId: string,
employmentId: string,
): Promise<EmploymentApiResponse> {
const { data } = await api.delete<EmploymentApiResponse>(
`/v1/users/${userId}/employments/${employmentId}`,
)
if (!data.success) {
throw new Error(data.message ?? 'Gagal memadam pekerjaan.')
}
return data
}
+2
View File
@@ -84,6 +84,7 @@ export interface CreateUserPayload {
member_number: number
member_type: string
join_date: string
leave_date?: string | null
birth_date: string
birth_place: string
}
@@ -99,6 +100,7 @@ export interface ListUsersParams {
join_date_to?: string
leave_date_from?: string
leave_date_to?: string
company_name?: string
}
export interface ListDeletedUsersParams {
+6 -3
View File
@@ -13,6 +13,7 @@ import { activityLayoutRoutes } from '@/modules/activity'
import { dashboardLayoutRoutes } from '@/modules/dashboard'
import { externalSystemLayoutRoutes } from '@/modules/external-system'
import { activityLogLayoutRoutes } from '@/modules/activity-log'
import { feedbackPublicRoutes, feedbackLayoutRoutes } from '@/modules/feedback'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -30,15 +31,17 @@ const router = createRouter({
...activityLayoutRoutes,
...externalSystemLayoutRoutes,
...membershipApplicationLayoutRoutes,
...feedbackLayoutRoutes,
],
},
...authPublicRoutes,
...membershipApplicationPublicRoutes,
...feedbackPublicRoutes,
...profilePublicRoutes,
],
})
const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'verify-email', 'membership-application-apply'])
const PUBLIC_ROUTE_NAMES = new Set(['login', 'register', 'membership-application-apply', 'feedback-submit'])
router.beforeEach(async (to) => {
const authStore = useAuthStore(pinia)
@@ -66,11 +69,11 @@ router.beforeEach(async (to) => {
return true
}
// Redirect authenticated users away from login/register/verify-email.
if (
authStore.isAuthenticated &&
PUBLIC_ROUTE_NAMES.has(routeName) &&
routeName !== 'membership-application-apply'
routeName !== 'membership-application-apply' &&
routeName !== 'feedback-submit'
) {
return resolvePostAuthRoute(authStore.user)
}
+2
View File
@@ -36,6 +36,8 @@ export const useAuthStore = defineStore('auth', {
isAuthenticated: (state) => state.user !== null,
isAccountPending: (state) => state.user?.status === 'pending',
isAccountActive: (state) => state.user?.status === 'active',
isEmailVerified: (state) => Boolean(state.user?.email_verified_at),
isPhoneVerified: (state) => Boolean(state.user?.phone_verified_at),
roles: (state) => state.user?.roles ?? [],
},
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useBreadcrumb } from '@/composables/useBreadcrumb'
import { useNotifications } from '@/modules/notification'
@@ -14,6 +15,7 @@ import { useFilteredMenu } from '@/composables/useFilteredMenu'
import { SideMenu } from '@/components/side-menu'
import { AccountDropdown, AccountTrigger } from '@/components/account-dropdown'
import { NotificationDropdown } from '@/components/notification-dropdown'
import EmailVerificationBanner from '@/modules/auth/components/EmailVerificationBanner.vue'
import {
ScrollAreaRoot,
ScrollAreaViewport,
@@ -33,6 +35,8 @@ const {
} = useSideMenu()
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const breadcrumbItems = useBreadcrumb([])
const filteredMenu = useFilteredMenu(mainMenu)
const { unreadCount, fetchNotifications } = useNotifications()
@@ -89,6 +93,12 @@ onMounted(() => {
authStore.fetchSession()
fetchNotifications()
document.addEventListener('click', handleDocumentClick)
const verified = route.query.verified
if (typeof verified === 'string' && verified) {
authStore.fetchSession()
router.replace({ query: { ...route.query, verified: undefined } })
}
})
onUnmounted(() => {
@@ -224,6 +234,10 @@ onUnmounted(() => {
</div>
</div>
</div>
<EmailVerificationBanner
v-if="authStore.isAuthenticated && !authStore.isEmailVerified"
class="mb-4"
/>
<RouterView />
</div>
</div>
+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 { onMounted, computed } from 'vue'
import { Icon } from '@iconify/vue'
import { HelpdeskFab } from '@/modules/feedback'
const route = useRoute()
const router = useRouter()
@@ -58,16 +59,6 @@ const sideTabs: SideTab[] = [
window.open('https://www.facebook.com/KoperasiPermodalanKelantanBerhad/', '_blank')
},
},
// {
// id: 'feedback-page',
// label: 'Helpdesk',
// iconType: 'lucide',
// icon: 'MessageCircle',
// offsetRem: 10.5,
// onClick: () => {
// window.open('https://docs.google.com/forms/d/e/1FAIpQLSd_0p-kKmsHq9z4fZ3e6X13V6O64s1-CpJw4_vR768Gg0_sA/viewform?usp=header', '_blank')
// },
// },
]
onMounted(() => {
@@ -106,6 +97,8 @@ onMounted(() => {
</span>
</button>
<HelpdeskFab :offset-rem="10.5" />
<Component />
</div>
</template>