DONE: phone number verification, admin can add user employment; WIP: wire with onewaysms
This commit is contained in:
@@ -18,6 +18,7 @@ export function useUserList() {
|
||||
const joinDateTo = ref('')
|
||||
const leaveDateFrom = ref('')
|
||||
const leaveDateTo = ref('')
|
||||
const unitFilter = ref('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
@@ -55,6 +56,7 @@ export function useUserList() {
|
||||
join_date_to: joinDateTo.value || undefined,
|
||||
leave_date_from: leaveDateFrom.value || undefined,
|
||||
leave_date_to: leaveDateTo.value || undefined,
|
||||
company_name: unitFilter.value.trim() || undefined,
|
||||
})
|
||||
|
||||
users.value = data.data
|
||||
@@ -79,6 +81,7 @@ export function useUserList() {
|
||||
join_date_to: joinDateTo.value || undefined,
|
||||
leave_date_from: leaveDateFrom.value || undefined,
|
||||
leave_date_to: leaveDateTo.value || undefined,
|
||||
company_name: unitFilter.value.trim() || undefined,
|
||||
})
|
||||
|
||||
stats.value = res.data
|
||||
@@ -109,6 +112,11 @@ export function useUserList() {
|
||||
fetchStats()
|
||||
})
|
||||
|
||||
watch(unitFilter, () => {
|
||||
fetchUsers(1)
|
||||
fetchStats()
|
||||
})
|
||||
|
||||
watch([joinDateFrom, joinDateTo, leaveDateFrom, leaveDateTo], () => {
|
||||
fetchUsers(1)
|
||||
fetchStats()
|
||||
@@ -144,6 +152,7 @@ export function useUserList() {
|
||||
joinDateTo,
|
||||
leaveDateFrom,
|
||||
leaveDateTo,
|
||||
unitFilter,
|
||||
hasJoinDateFilters,
|
||||
hasLeaveDateFilters,
|
||||
clearJoinDateFilters,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as select from '@zag-js/select'
|
||||
import dayjs from 'dayjs'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -102,6 +103,7 @@ const form = reactive({
|
||||
phone_number: '',
|
||||
member_number: '',
|
||||
join_date: '',
|
||||
leave_date: '',
|
||||
birth_date: '',
|
||||
birth_place: '',
|
||||
})
|
||||
@@ -154,6 +156,7 @@ async function handleSubmit() {
|
||||
member_number: Number(form.member_number),
|
||||
member_type: memberType!,
|
||||
join_date: form.join_date,
|
||||
leave_date: isInactiveStatus.value ? form.leave_date || null : null,
|
||||
birth_date: form.birth_date,
|
||||
birth_place: form.birth_place.trim(),
|
||||
})
|
||||
@@ -170,6 +173,17 @@ async function handleSubmit() {
|
||||
}
|
||||
|
||||
const formDisabled = computed(() => saving.value)
|
||||
|
||||
const selectedStatus = computed(
|
||||
() => labelToApiValue(STATUS_OPTIONS, statusValue.value[0]) ?? 'pending',
|
||||
)
|
||||
const isInactiveStatus = computed(() => selectedStatus.value === 'inactive')
|
||||
|
||||
watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
if (newStatus === 'inactive' && oldStatus !== 'inactive' && !form.leave_date) {
|
||||
form.leave_date = dayjs().format('YYYY-MM-DD')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -417,6 +431,17 @@ const formDisabled = computed(() => saving.value)
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field v-if="isInactiveStatus">
|
||||
<FieldLabel for="user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel>
|
||||
<Input
|
||||
id="user-leave-date"
|
||||
v-model="form.leave_date"
|
||||
class="w-full"
|
||||
type="date"
|
||||
:disabled="formDisabled"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
|
||||
<Input
|
||||
|
||||
@@ -3,11 +3,15 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import Swal from 'sweetalert2'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
@@ -19,8 +23,14 @@ import {
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import type { Employment, EmploymentPayload } from '@/modules/profile/types/employment.types'
|
||||
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
|
||||
import {
|
||||
createUserEmployment,
|
||||
deleteUserEmployment,
|
||||
updateUserEmployment,
|
||||
} from '../services/userEmployment.service'
|
||||
import { getUser, updateUser } from '../services/user.service'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
@@ -49,6 +59,62 @@ const MEMBER_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Pesara', value: 'Pesara' },
|
||||
]
|
||||
|
||||
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Tetap', value: 'Permanent' },
|
||||
{ label: 'Kontrak', value: 'Contract' },
|
||||
{ label: 'Latihan Industri', value: 'Internship' },
|
||||
{ label: 'Freelance', value: 'Freelance' },
|
||||
]
|
||||
|
||||
const EMPLOYERS = [
|
||||
{
|
||||
name: 'Infra Quest Sdn. Bhd. (IQSB)',
|
||||
address: 'Lot 1045, Jalan Dato’ Lundang, 15200 Kota Bharu, Kelantan',
|
||||
},
|
||||
{
|
||||
name: 'Permodalan Kelantan Berhad (PKB)',
|
||||
address:
|
||||
'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
|
||||
},
|
||||
{
|
||||
name: 'Koperasi Permodalan Kelantan Berhad (KOPKB)',
|
||||
address:
|
||||
'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
|
||||
},
|
||||
{
|
||||
name: "An-Nisa'",
|
||||
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
|
||||
},
|
||||
{
|
||||
name: 'Kel Infra Sdn. Bhd.',
|
||||
address: 'Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.',
|
||||
},
|
||||
] as const
|
||||
|
||||
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
|
||||
label: employer.name,
|
||||
value: employer.name,
|
||||
}))
|
||||
|
||||
type EmploymentFieldKey =
|
||||
| 'company_name'
|
||||
| 'job_title'
|
||||
| 'employment_type'
|
||||
| 'salary'
|
||||
| 'start_date'
|
||||
| 'end_date'
|
||||
| 'is_current'
|
||||
|
||||
const EMPLOYMENT_FIELD_KEYS: EmploymentFieldKey[] = [
|
||||
'company_name',
|
||||
'job_title',
|
||||
'employment_type',
|
||||
'salary',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'is_current',
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
@@ -71,6 +137,8 @@ const statusCollection = createSelectCollection(STATUS_OPTIONS)
|
||||
const genderCollection = createSelectCollection(GENDER_OPTIONS)
|
||||
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
|
||||
const memberTypeCollection = createSelectCollection(MEMBER_TYPE_OPTIONS)
|
||||
const employmentTypeCollection = createSelectCollection(EMPLOYMENT_TYPE_OPTIONS)
|
||||
const companyNameCollection = createSelectCollection(COMPANY_OPTIONS)
|
||||
|
||||
const statusValue = ref<string[]>([])
|
||||
const genderValue = ref<string[]>([])
|
||||
@@ -82,6 +150,31 @@ const genderInitial = ref<string[]>([])
|
||||
const marriageStatusInitial = ref<string[]>([])
|
||||
const memberTypeInitial = ref<string[]>([])
|
||||
|
||||
const employmentTypeValue = ref<string[]>([])
|
||||
const employmentTypeInitial = ref<string[]>([])
|
||||
const companyNameValue = ref<string[]>([])
|
||||
const companyNameInitial = ref<string[]>([])
|
||||
|
||||
const employments = ref<Employment[]>([])
|
||||
const savingEmployment = ref(false)
|
||||
const deletingEmploymentId = ref<string | null>(null)
|
||||
const editingEmploymentId = ref<string | null>(null)
|
||||
const employmentErrors = reactive<Partial<Record<EmploymentFieldKey, string>>>({})
|
||||
|
||||
function emptyEmploymentForm() {
|
||||
return {
|
||||
company_name: '',
|
||||
job_title: '',
|
||||
employment_type: '',
|
||||
salary: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
is_current: true,
|
||||
}
|
||||
}
|
||||
|
||||
const employmentForm = reactive(emptyEmploymentForm())
|
||||
|
||||
function setStatusValue(details: { value: string[] }) {
|
||||
statusValue.value = details.value
|
||||
}
|
||||
@@ -98,6 +191,59 @@ function setMemberTypeValue(details: { value: string[] }) {
|
||||
memberTypeValue.value = details.value
|
||||
}
|
||||
|
||||
function setEmploymentTypeValue(details: { value: string[] }) {
|
||||
employmentTypeValue.value = details.value
|
||||
clearEmploymentFieldError('employment_type')
|
||||
employmentForm.employment_type =
|
||||
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function setCompanyNameValue(details: { value: string[] }) {
|
||||
companyNameValue.value = details.value
|
||||
clearEmploymentFieldError('company_name')
|
||||
employmentForm.company_name = details.value[0] ?? ''
|
||||
}
|
||||
|
||||
function clearEmploymentFieldError(field: EmploymentFieldKey) {
|
||||
delete employmentErrors[field]
|
||||
}
|
||||
|
||||
function clearEmploymentErrors() {
|
||||
for (const field of EMPLOYMENT_FIELD_KEYS) {
|
||||
delete employmentErrors[field]
|
||||
}
|
||||
}
|
||||
|
||||
function setEmploymentErrorsFromApi(error: unknown): boolean {
|
||||
const apiErrors = getApiValidationErrors(error)
|
||||
if (!apiErrors) return false
|
||||
|
||||
for (const [field, messages] of Object.entries(apiErrors)) {
|
||||
if (EMPLOYMENT_FIELD_KEYS.includes(field as EmploymentFieldKey) && messages[0]) {
|
||||
employmentErrors[field as EmploymentFieldKey] = messages[0]
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(employmentErrors).length > 0
|
||||
}
|
||||
|
||||
function syncEmploymentSelectValues() {
|
||||
employmentTypeValue.value = apiValueToLabel(
|
||||
EMPLOYMENT_TYPE_OPTIONS,
|
||||
employmentForm.employment_type,
|
||||
)
|
||||
employmentTypeInitial.value = [...employmentTypeValue.value]
|
||||
companyNameValue.value = apiValueToLabel(COMPANY_OPTIONS, employmentForm.company_name)
|
||||
companyNameInitial.value = [...companyNameValue.value]
|
||||
}
|
||||
|
||||
function resetEmploymentForm() {
|
||||
Object.assign(employmentForm, emptyEmploymentForm())
|
||||
editingEmploymentId.value = null
|
||||
clearEmploymentErrors()
|
||||
syncEmploymentSelectValues()
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -154,6 +300,219 @@ function syncFormFromUser(user: Awaited<ReturnType<typeof getUser>>['data']) {
|
||||
genderInitial.value = [...genderValue.value]
|
||||
marriageStatusInitial.value = [...marriageStatusValue.value]
|
||||
memberTypeInitial.value = [...memberTypeValue.value]
|
||||
employments.value = user.employments ?? []
|
||||
}
|
||||
|
||||
const employmentTypeLabel = computed(() =>
|
||||
Object.fromEntries(EMPLOYMENT_TYPE_OPTIONS.map((option) => [option.value, option.label])),
|
||||
)
|
||||
|
||||
const isEditingEmployment = computed(() => editingEmploymentId.value !== null)
|
||||
|
||||
const canAddEmployment = computed(() => !loading.value && employments.value.length === 0)
|
||||
|
||||
const showEmploymentForm = computed(() => isEditingEmployment.value || canAddEmployment.value)
|
||||
|
||||
function formatSalary(value: number | string | null | undefined): string {
|
||||
const amount = Number(value)
|
||||
if (Number.isNaN(amount)) return '-'
|
||||
return new Intl.NumberFormat('ms-MY', {
|
||||
style: 'currency',
|
||||
currency: 'MYR',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
function formatDateLabel(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat('ms-MY', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function formatEmploymentPeriod(employment: Employment): string {
|
||||
const start = formatDateLabel(employment.start_date)
|
||||
if (employment.is_current) {
|
||||
return `${start} - Kini`
|
||||
}
|
||||
const end = formatDateLabel(employment.end_date)
|
||||
return end ? `${start} - ${end}` : start
|
||||
}
|
||||
|
||||
function validateEmploymentForm(): boolean {
|
||||
clearEmploymentErrors()
|
||||
|
||||
let valid = true
|
||||
|
||||
if (!companyNameValue.value[0]?.trim()) {
|
||||
employmentErrors.company_name = 'Nama syarikat diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!employmentForm.job_title.trim()) {
|
||||
employmentErrors.job_title = 'Jawatan diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (
|
||||
!employmentTypeValue.value[0] ||
|
||||
!labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, employmentTypeValue.value[0])
|
||||
) {
|
||||
employmentErrors.employment_type = 'Jenis kerja diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
const salary = Number(employmentForm.salary)
|
||||
if (!employmentForm.salary.toString().trim() || Number.isNaN(salary) || salary < 0) {
|
||||
employmentErrors.salary = 'Gaji diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!employmentForm.start_date) {
|
||||
employmentErrors.start_date = 'Tarikh mula diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!employmentForm.is_current && !employmentForm.end_date) {
|
||||
employmentErrors.end_date = 'Tarikh tamat diperlukan jika bukan pekerjaan semasa.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (
|
||||
!employmentForm.is_current &&
|
||||
employmentForm.start_date &&
|
||||
employmentForm.end_date &&
|
||||
employmentForm.end_date < employmentForm.start_date
|
||||
) {
|
||||
employmentErrors.end_date = 'Tarikh tamat mesti selepas tarikh mula.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
function buildEmploymentPayload(): EmploymentPayload {
|
||||
return {
|
||||
company_name: employmentForm.company_name.trim(),
|
||||
job_title: employmentForm.job_title.trim(),
|
||||
employment_type:
|
||||
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, employmentTypeValue.value[0]) ??
|
||||
employmentForm.employment_type,
|
||||
salary: Number(employmentForm.salary),
|
||||
start_date: employmentForm.start_date,
|
||||
end_date: employmentForm.is_current ? null : employmentForm.end_date || null,
|
||||
is_current: employmentForm.is_current,
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshEmployments() {
|
||||
const response = await getUser(userId.value)
|
||||
employments.value = response.data.employments ?? []
|
||||
}
|
||||
|
||||
function startEditEmployment(employment: Employment) {
|
||||
clearEmploymentErrors()
|
||||
editingEmploymentId.value = employment.id
|
||||
employmentForm.company_name = employment.company_name
|
||||
employmentForm.job_title = employment.job_title
|
||||
employmentForm.employment_type = employment.employment_type
|
||||
employmentForm.salary = String(employment.salary)
|
||||
employmentForm.start_date = toDateInputValue(employment.start_date)
|
||||
employmentForm.end_date = toDateInputValue(employment.end_date)
|
||||
employmentForm.is_current = employment.is_current
|
||||
syncEmploymentSelectValues()
|
||||
}
|
||||
|
||||
async function onSaveEmployment() {
|
||||
if (!validateEmploymentForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
savingEmployment.value = true
|
||||
const wasEditing = isEditingEmployment.value
|
||||
const payload = buildEmploymentPayload()
|
||||
|
||||
try {
|
||||
const res = wasEditing
|
||||
? await updateUserEmployment(userId.value, editingEmploymentId.value!, payload)
|
||||
: await createUserEmployment(userId.value, payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal menyimpan pekerjaan.')
|
||||
}
|
||||
|
||||
await refreshEmployments()
|
||||
resetEmploymentForm()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: wasEditing ? 'Pekerjaan berjaya dikemas kini.' : 'Pekerjaan berjaya ditambah.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (err) {
|
||||
if (!setEmploymentErrorsFromApi(err)) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(err, 'Gagal menyimpan pekerjaan.'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
savingEmployment.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteEmployment(employment: Employment) {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Padam pekerjaan?',
|
||||
text: 'Tindakan ini tidak boleh dibatalkan.',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Padam',
|
||||
cancelButtonText: 'Batal',
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
deletingEmploymentId.value = employment.id
|
||||
|
||||
try {
|
||||
const res = await deleteUserEmployment(userId.value, employment.id)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal memadam pekerjaan.')
|
||||
}
|
||||
|
||||
if (editingEmploymentId.value === employment.id) {
|
||||
resetEmploymentForm()
|
||||
}
|
||||
|
||||
await refreshEmployments()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: 'Pekerjaan berjaya dipadam.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (err) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(err, 'Gagal memadam pekerjaan.'),
|
||||
})
|
||||
} finally {
|
||||
deletingEmploymentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUser() {
|
||||
@@ -182,6 +541,16 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => employmentForm.is_current,
|
||||
(isCurrent) => {
|
||||
if (isCurrent) {
|
||||
employmentForm.end_date = ''
|
||||
clearEmploymentFieldError('end_date')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
@@ -218,6 +587,7 @@ async function handleSubmit() {
|
||||
const formDisabled = computed(() => loading.value || saving.value)
|
||||
|
||||
onMounted(() => {
|
||||
syncEmploymentSelectValues()
|
||||
fetchUser()
|
||||
})
|
||||
</script>
|
||||
@@ -405,5 +775,249 @@ onMounted(() => {
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Pekerjaan</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Urus maklumat pekerjaan pengguna.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-slate-500">Memuatkan pekerjaan...</div>
|
||||
|
||||
<div v-else-if="employments.length" class="space-y-3">
|
||||
<div
|
||||
v-for="employment in employments"
|
||||
:key="employment.id"
|
||||
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-slate-900">{{ employment.company_name }}</span>
|
||||
<Badge v-if="employment.is_current" class="bg-green-500 text-white">Semasa</Badge>
|
||||
<Badge look="outline">
|
||||
{{ employmentTypeLabel[employment.employment_type] ?? employment.employment_type }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-medium text-slate-700">{{ employment.job_title }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ formatEmploymentPeriod(employment) }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ formatSalary(employment.salary) }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="deletingEmploymentId === employment.id || saving"
|
||||
@click="startEditEmployment(employment)"
|
||||
>
|
||||
<Lucide class="mr-2 size-4" icon="Pencil" />
|
||||
Kemaskini
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none text-danger"
|
||||
:disabled="deletingEmploymentId === employment.id || saving"
|
||||
@click="onDeleteEmployment(employment)"
|
||||
>
|
||||
<Lucide
|
||||
class="mr-2 size-4"
|
||||
:icon="deletingEmploymentId === employment.id ? 'LoaderCircle' : 'Trash'"
|
||||
:class="{ 'animate-spin': deletingEmploymentId === employment.id }"
|
||||
/>
|
||||
Padam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
Tiada pekerjaan direkodkan.
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="showEmploymentForm"
|
||||
class="space-y-6 border-t border-foreground/10 pt-6"
|
||||
@submit.prevent="onSaveEmployment"
|
||||
>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-slate-900">
|
||||
{{ isEditingEmployment ? 'Kemaskini Pekerjaan' : 'Tambah Pekerjaan' }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{
|
||||
isEditingEmployment
|
||||
? 'Kemas kini maklumat pekerjaan yang dipilih.'
|
||||
: 'Tambah rekod pekerjaan baharu untuk pengguna ini.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="isEditingEmployment"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="savingEmployment"
|
||||
@click="resetEmploymentForm"
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="savingEmployment || saving">
|
||||
{{ savingEmployment ? 'Menyimpan...' : isEditingEmployment ? 'Kemaskini' : 'Tambah' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Nama Syarikat</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`company-name-${editingEmploymentId ?? 'new'}`"
|
||||
class="w-full"
|
||||
:collection="companyNameCollection"
|
||||
:default-value="companyNameInitial"
|
||||
:disabled="savingEmployment || saving"
|
||||
@value-change="setCompanyNameValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!employmentErrors.company_name">
|
||||
<SelectValueText placeholder="Pilih syarikat" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in companyNameCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="employmentErrors.company_name">
|
||||
{{ employmentErrors.company_name }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="employment-job-title">Jawatan</FieldLabel>
|
||||
<Input
|
||||
id="employment-job-title"
|
||||
v-model="employmentForm.job_title"
|
||||
type="text"
|
||||
placeholder="Jawatan"
|
||||
:disabled="savingEmployment || saving"
|
||||
:aria-invalid="!!employmentErrors.job_title"
|
||||
@input="clearEmploymentFieldError('job_title')"
|
||||
/>
|
||||
<FieldError v-if="employmentErrors.job_title">
|
||||
{{ employmentErrors.job_title }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Jenis Kerja</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`employment-type-${editingEmploymentId ?? 'new'}`"
|
||||
class="w-full"
|
||||
:collection="employmentTypeCollection"
|
||||
:default-value="employmentTypeInitial"
|
||||
:disabled="savingEmployment || saving"
|
||||
@value-change="setEmploymentTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!employmentErrors.employment_type">
|
||||
<SelectValueText placeholder="Pilih jenis kerja" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jenis Kerja</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in employmentTypeCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="employmentErrors.employment_type">
|
||||
{{ employmentErrors.employment_type }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="employment-salary">Gaji (RM)</FieldLabel>
|
||||
<Input
|
||||
id="employment-salary"
|
||||
v-model="employmentForm.salary"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
:disabled="savingEmployment || saving"
|
||||
:aria-invalid="!!employmentErrors.salary"
|
||||
@input="clearEmploymentFieldError('salary')"
|
||||
/>
|
||||
<FieldError v-if="employmentErrors.salary">{{ employmentErrors.salary }}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="employment-start-date">Tarikh Mula</FieldLabel>
|
||||
<Input
|
||||
id="employment-start-date"
|
||||
v-model="employmentForm.start_date"
|
||||
type="date"
|
||||
:disabled="savingEmployment || saving"
|
||||
:aria-invalid="!!employmentErrors.start_date"
|
||||
@input="clearEmploymentFieldError('start_date')"
|
||||
/>
|
||||
<FieldError v-if="employmentErrors.start_date">
|
||||
{{ employmentErrors.start_date }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="employment-end-date">Tarikh Tamat</FieldLabel>
|
||||
<Input
|
||||
id="employment-end-date"
|
||||
v-model="employmentForm.end_date"
|
||||
type="date"
|
||||
:disabled="employmentForm.is_current || savingEmployment || saving"
|
||||
:aria-invalid="!!employmentErrors.end_date"
|
||||
@input="clearEmploymentFieldError('end_date')"
|
||||
/>
|
||||
<FieldError v-if="employmentErrors.end_date">
|
||||
{{ employmentErrors.end_date }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field class="md:col-span-2">
|
||||
<CheckboxRoot
|
||||
:checked="employmentForm.is_current"
|
||||
:disabled="savingEmployment || saving"
|
||||
@checked-change="({ checked }) => (employmentForm.is_current = checked === true)"
|
||||
>
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Pekerjaan semasa</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Lucide } from '@/components/ui/lucide'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { EMPLOYERS } from '@/constants/employers'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listRoles } from '@/modules/role/services/role.service'
|
||||
import type { RoleListItem } from '@/modules/role/types/role.types'
|
||||
@@ -48,6 +49,22 @@ const STATUS_FILTER_CHIPS: StatusFilterChip[] = [
|
||||
{ label: 'Menunggu', value: 'pending', variant: 'pending' },
|
||||
]
|
||||
|
||||
function getEmployerShortLabel(name: string): string {
|
||||
const match = name.match(/\(([^)]+)\)/)
|
||||
if (match?.[1]) return match[1]
|
||||
if (name.startsWith('An-Nisa')) return "An-Nisa'"
|
||||
if (name.startsWith('Kel Infra')) return 'Kel Infra'
|
||||
return name
|
||||
}
|
||||
|
||||
const UNIT_FILTER_CHIPS = [
|
||||
{ label: 'Semua', value: '' },
|
||||
...EMPLOYERS.map((employer) => ({
|
||||
label: getEmployerShortLabel(employer.name),
|
||||
value: employer.name,
|
||||
})),
|
||||
]
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
@@ -163,6 +180,14 @@ function setStatusFilter(value: string) {
|
||||
statusFilter.value = value
|
||||
}
|
||||
|
||||
function isUnitFilterActive(value: string) {
|
||||
return unitFilter.value === value
|
||||
}
|
||||
|
||||
function setUnitFilter(value: string) {
|
||||
unitFilter.value = value
|
||||
}
|
||||
|
||||
function formatDeletedAt(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
@@ -356,6 +381,7 @@ const {
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
unitFilter,
|
||||
joinDateFrom,
|
||||
joinDateTo,
|
||||
leaveDateFrom,
|
||||
@@ -540,6 +566,23 @@ onMounted(() => {
|
||||
{{ chip.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm opacity-70">Unit:</span>
|
||||
<Badge
|
||||
v-for="chip in UNIT_FILTER_CHIPS"
|
||||
:key="chip.value || 'all-units'"
|
||||
variant="ghost"
|
||||
:look="isUnitFilterActive(chip.value) ? 'filled' : 'outline'"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="chip.value || 'Semua unit'"
|
||||
:aria-pressed="isUnitFilterActive(chip.value)"
|
||||
@click="setUnitFilter(chip.value)"
|
||||
@keydown.enter="setUnitFilter(chip.value)"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { EmploymentApiResponse, EmploymentPayload } from '@/modules/profile/types/employment.types'
|
||||
|
||||
export async function createUserEmployment(
|
||||
userId: string,
|
||||
payload: EmploymentPayload,
|
||||
): Promise<EmploymentApiResponse> {
|
||||
const { data } = await api.post<EmploymentApiResponse>(`/v1/users/${userId}/employments`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateUserEmployment(
|
||||
userId: string,
|
||||
employmentId: string,
|
||||
payload: EmploymentPayload,
|
||||
): Promise<EmploymentApiResponse> {
|
||||
const { data } = await api.put<EmploymentApiResponse>(
|
||||
`/v1/users/${userId}/employments/${employmentId}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemas kini pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteUserEmployment(
|
||||
userId: string,
|
||||
employmentId: string,
|
||||
): Promise<EmploymentApiResponse> {
|
||||
const { data } = await api.delete<EmploymentApiResponse>(
|
||||
`/v1/users/${userId}/employments/${employmentId}`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export interface CreateUserPayload {
|
||||
member_number: number
|
||||
member_type: string
|
||||
join_date: string
|
||||
leave_date?: string | null
|
||||
birth_date: string
|
||||
birth_place: string
|
||||
}
|
||||
@@ -99,6 +100,7 @@ export interface ListUsersParams {
|
||||
join_date_to?: string
|
||||
leave_date_from?: string
|
||||
leave_date_to?: string
|
||||
company_name?: string
|
||||
}
|
||||
|
||||
export interface ListDeletedUsersParams {
|
||||
|
||||
Reference in New Issue
Block a user