diff --git a/src/modules/profile/components/PhoneVerificationSection.vue b/src/modules/profile/components/PhoneVerificationSection.vue
new file mode 100644
index 0000000..cdf7a6a
--- /dev/null
+++ b/src/modules/profile/components/PhoneVerificationSection.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+
Nombor Telefon
+
+ {{ sectionDescription }}
+
+
+
+
+
+
+ Disahkan
+
+
+
{{ feedbackMessage }}
+
+
+
+
+
+
+
+ No. Telefon
+
+
+
+ Kod OTP
+
+
+
+
+
{{ feedbackMessage }}
+
{{ errorMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/profile/pages/EmploymentTab.vue b/src/modules/profile/pages/EmploymentTab.vue
index d5adcb2..5400aec 100644
--- a/src/modules/profile/pages/EmploymentTab.vue
+++ b/src/modules/profile/pages/EmploymentTab.vue
@@ -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) => ({
diff --git a/src/modules/profile/pages/ProfileTab.vue b/src/modules/profile/pages/ProfileTab.vue
index f4fef6a..7106781 100644
--- a/src/modules/profile/pages/ProfileTab.vue
+++ b/src/modules/profile/pages/ProfileTab.vue
@@ -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 () => {
-
- No. Telefon
-
-
Jawatan
@@ -677,6 +670,8 @@ onMounted(async () => {
+
+
diff --git a/src/modules/user/composables/useUserList.ts b/src/modules/user/composables/useUserList.ts
index c7082d9..2c4393c 100644
--- a/src/modules/user/composables/useUserList.ts
+++ b/src/modules/user/composables/useUserList.ts
@@ -18,6 +18,7 @@ export function useUserList() {
const joinDateTo = ref('')
const leaveDateFrom = ref('')
const leaveDateTo = ref('')
+ const unitFilter = ref('')
const sortBy = ref
([{ 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,
diff --git a/src/modules/user/pages/UserCreate.vue b/src/modules/user/pages/UserCreate.vue
index f8f4c80..09e684b 100644
--- a/src/modules/user/pages/UserCreate.vue
+++ b/src/modules/user/pages/UserCreate.vue
@@ -1,7 +1,8 @@
@@ -417,6 +431,17 @@ const formDisabled = computed(() => saving.value)
/>
+
+ Tarikh Berhenti Menjadi Anggota
+
+
+
Tarikh Lahir
({
+ 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([])
const genderValue = ref([])
@@ -82,6 +150,31 @@ const genderInitial = ref([])
const marriageStatusInitial = ref([])
const memberTypeInitial = ref([])
+const employmentTypeValue = ref([])
+const employmentTypeInitial = ref([])
+const companyNameValue = ref([])
+const companyNameInitial = ref([])
+
+const employments = ref([])
+const savingEmployment = ref(false)
+const deletingEmploymentId = ref(null)
+const editingEmploymentId = ref(null)
+const employmentErrors = reactive>>({})
+
+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>['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()
})
@@ -405,5 +775,249 @@ onMounted(() => {
+
+
+
+
+
Pekerjaan
+
Urus maklumat pekerjaan pengguna.
+
+
+
Memuatkan pekerjaan...
+
+
+
+
+
+ {{ employment.company_name }}
+ Semasa
+
+ {{ employmentTypeLabel[employment.employment_type] ?? employment.employment_type }}
+
+
+
{{ employment.job_title }}
+
{{ formatEmploymentPeriod(employment) }}
+
{{ formatSalary(employment.salary) }}
+
+
+
+
+
+
+
+
+
+ Tiada pekerjaan direkodkan.
+
+
+
+
+
diff --git a/src/modules/user/pages/UserList.vue b/src/modules/user/pages/UserList.vue
index a00a2f8..32e64d2 100644
--- a/src/modules/user/pages/UserList.vue
+++ b/src/modules/user/pages/UserList.vue
@@ -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 }}