DONE: phone number verification, admin can add user employment; WIP: wire with onewaysms

This commit is contained in:
ISMAIL MASSERAN
2026-07-12 12:36:46 +08:00
parent 431bbee17c
commit a11b75729a
37 changed files with 2214 additions and 73 deletions
+11
View File
@@ -4,24 +4,35 @@ export {
login,
logout,
register,
sendPhoneVerificationOtp,
verifyPhoneVerificationOtp,
sendAuthenticatedPhoneVerificationOtp,
verifyAuthenticatedPhoneVerificationOtp,
sendVerificationEmail,
requestForgotPassword,
resetPassword,
fetchCurrentUser,
getAuthErrorMessage,
getRegisterErrorMessage,
getPhoneVerificationErrorMessage,
getForgotPasswordErrorMessage,
getResetPasswordErrorMessage,
resolvePostLoginRoute,
resolvePostAuthRoute,
isAccountPending,
isEmailVerified,
isPhoneVerified,
} from './services/auth.service'
export type {
LoginCredentials,
LoginResponse,
RegisterCredentials,
RegisterResponse,
SendPhoneVerificationOtpPayload,
SendPhoneVerificationOtpResponse,
VerifyPhoneVerificationOtpPayload,
VerifyPhoneVerificationOtpResponse,
VerifyAuthenticatedPhoneVerificationOtpResponse,
ResendVerificationResponse,
ForgotPasswordPayload,
ForgotPasswordResponse,
+323 -56
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,14 +7,32 @@ 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, resolvePostAuthRoute } from '@/modules/auth'
import {
getPhoneVerificationErrorMessage,
getRegisterErrorMessage,
register,
resolvePostAuthRoute,
sendPhoneVerificationOtp,
verifyPhoneVerificationOtp,
} from '@/modules/auth'
import { useAuthStore } from '@/stores/auth'
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
import illustrationUrl from '@/assets/images/logo.svg'
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('')
@@ -23,6 +41,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)
@@ -32,13 +105,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 {
@@ -46,6 +208,8 @@ 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,
})
@@ -61,21 +225,70 @@ const handleRegister = async () => {
} 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',
]">
<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" />
@@ -88,61 +301,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>
@@ -11,7 +11,12 @@ import type {
ResetPasswordResponse,
ResendVerificationResponse,
SessionResponse,
SendPhoneVerificationOtpPayload,
SendPhoneVerificationOtpResponse,
SwitchRoleResponse,
VerifyPhoneVerificationOtpPayload,
VerifyPhoneVerificationOtpResponse,
VerifyAuthenticatedPhoneVerificationOtpResponse,
} from '../types/auth.types'
export async function login(credentials: LoginCredentials): Promise<LoginResponse> {
@@ -24,6 +29,46 @@ export async function register(credentials: RegisterCredentials): Promise<Regist
return data
}
export async function sendPhoneVerificationOtp(
payload: SendPhoneVerificationOtpPayload,
): Promise<SendPhoneVerificationOtpResponse> {
const { data } = await api.post<SendPhoneVerificationOtpResponse>(
'/phone-verification/send',
payload,
)
return data
}
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
@@ -65,6 +110,10 @@ export function getRegisterErrorMessage(error: unknown): string {
return getApiErrorMessage(error, 'Registration 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 {
return getApiErrorMessage(error, 'Gagal menghantar kod OTP. Sila cuba lagi.')
}
@@ -77,6 +126,10 @@ export function isEmailVerified(user: { email_verified_at?: string | null } | nu
return Boolean(user?.email_verified_at)
}
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'
}
+32
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
@@ -74,12 +75,43 @@ export interface RegisterCredentials {
name: string
email: string
ic_number: string
phone_number: string
phone_verification_token: string
password: string
password_confirmation: string
}
export type RegisterResponse = AuthSessionResponse
export interface SendPhoneVerificationOtpPayload {
phone_number: string
}
export interface SendPhoneVerificationOtpResponse {
success: boolean
message: string
}
export interface VerifyPhoneVerificationOtpPayload {
phone_number: string
otp: string
}
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
message: string
@@ -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 {