Files
My-KOPKB/fe/src/modules/user/pages/UserEdit.vue
T
ismailmasseran 59b571d944
Build Docker Image / build-backend (push) Successful in 46s
Build Docker Image / build-frontend (push) Successful in 19s
DONE: fix naming, add transaction for completion of membership application (#12)
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #12
2026-07-15 11:35:45 +08:00

922 lines
33 KiB
Vue

<script lang="ts" setup>
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 { 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,
SelectTrigger,
SelectValueText,
SelectContent,
SelectItemGroup,
SelectItemGroupLabel,
SelectItem,
SelectItemText,
} from '@/components/ui/select'
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'
import { EMPLOYERS } from '@/constants/employers'
type SelectOption = { label: string; value: string }
const STATUS_OPTIONS: SelectOption[] = [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
{ label: 'Pending', value: 'pending' },
]
const GENDER_OPTIONS: SelectOption[] = [
{ label: 'Lelaki', value: 'Lelaki' },
{ label: 'Perempuan', value: 'Perempuan' },
]
const MARRIAGE_STATUS_OPTIONS: SelectOption[] = [
{ label: 'Belum Berkahwin', value: 'Belum Berkahwin' },
{ label: 'Berkahwin', value: 'Berkahwin' },
{ label: 'Bercerai', value: 'Bercerai' },
{ label: 'Balu', value: 'Balu' },
{ label: 'Duda', value: 'Duda' },
]
const MEMBER_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Anggota', value: 'Anggota' },
{ label: 'Pesara', value: 'Pesara' },
]
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Tetap', value: 'Permanent' },
{ label: 'Kontrak', value: 'Contract' },
{ label: 'Percubaan', value: 'Trial' },
{ label: 'Sambilan', value: 'Part-time' },
]
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,
itemToValue: (item) => item.label,
})
}
function labelToApiValue(options: SelectOption[], label: string | undefined): string | null {
if (!label) return null
return options.find((option) => option.label === label)?.value ?? null
}
function apiValueToLabel(options: SelectOption[], value: string | null | undefined): string[] {
if (!value) return []
const option = options.find((item) => item.value === value)
return option ? [option.label] : []
}
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[]>([])
const marriageStatusValue = ref<string[]>([])
const memberTypeValue = ref<string[]>([])
const statusInitial = ref<string[]>([])
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
}
function setGenderValue(details: { value: string[] }) {
genderValue.value = details.value
}
function setMarriageStatusValue(details: { value: string[] }) {
marriageStatusValue.value = details.value
}
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()
const userId = computed(() => String(route.params.id ?? ''))
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const successMessage = ref<string | null>(null)
const email = ref('')
const form = reactive({
name: '',
ic_number: '',
position: '',
phone_number: '',
member_number: '',
join_date: '',
leave_date: '',
birth_date: '',
birth_place: '',
})
function handleNameInput() {
form.name = sanitizeNameInput(form.name)
}
function handleIcNumberInput() {
form.ic_number = sanitizeIcNumberInput(form.ic_number)
}
function toDateInputValue(value: string | null | undefined): string {
if (!value) return ''
return value.slice(0, 10)
}
function syncFormFromUser(user: Awaited<ReturnType<typeof getUser>>['data']) {
email.value = user.email ?? ''
form.name = user.name ?? ''
form.ic_number = user.ic_number ?? ''
form.position = user.position ?? ''
form.phone_number = user.phone_number ?? ''
form.member_number = user.member_number != null ? String(user.member_number) : ''
form.join_date = toDateInputValue(user.join_date)
form.leave_date = toDateInputValue(user.leave_date)
form.birth_date = toDateInputValue(user.birth_date)
form.birth_place = user.birth_place ?? ''
statusValue.value = apiValueToLabel(STATUS_OPTIONS, user.status ?? 'active')
genderValue.value = apiValueToLabel(GENDER_OPTIONS, user.gender)
marriageStatusValue.value = apiValueToLabel(MARRIAGE_STATUS_OPTIONS, user.marriage_status)
memberTypeValue.value = apiValueToLabel(MEMBER_TYPE_OPTIONS, user.member_type)
statusInitial.value = [...statusValue.value]
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() {
loading.value = true
error.value = null
successMessage.value = null
try {
const response = await getUser(userId.value)
syncFormFromUser(response.data)
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan maklumat pengguna.')
} finally {
loading.value = false
}
}
const selectedStatus = computed(
() => labelToApiValue(STATUS_OPTIONS, statusValue.value[0]) ?? 'active',
)
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')
}
})
watch(
() => employmentForm.is_current,
(isCurrent) => {
if (isCurrent) {
employmentForm.end_date = ''
clearEmploymentFieldError('end_date')
}
},
)
async function handleSubmit() {
saving.value = true
error.value = null
successMessage.value = null
try {
await updateUser(userId.value, {
name: form.name.trim(),
ic_number: form.ic_number.trim(),
position: form.position.trim(),
phone_number: form.phone_number.trim() || null,
status: labelToApiValue(STATUS_OPTIONS, statusValue.value[0]) ?? 'active',
gender: labelToApiValue(GENDER_OPTIONS, genderValue.value[0]),
marriage_status: labelToApiValue(MARRIAGE_STATUS_OPTIONS, marriageStatusValue.value[0]),
member_number: form.member_number ? Number(form.member_number) : null,
member_type: labelToApiValue(MEMBER_TYPE_OPTIONS, memberTypeValue.value[0]),
join_date: form.join_date || null,
leave_date: isInactiveStatus.value ? form.leave_date || null : null,
birth_date: form.birth_date || null,
birth_place: form.birth_place.trim() || null,
})
successMessage.value = 'Pengguna berjaya dikemaskini.'
setTimeout(() => {
router.push({ name: 'list-users' })
}, 300)
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal mengemaskini pengguna.')
} finally {
saving.value = false
}
}
const formDisabled = computed(() => loading.value || saving.value)
onMounted(() => {
syncEmploymentSelectValues()
fetchUser()
})
</script>
<template>
<div class="w-full space-y-6">
<div class="flex flex-wrap items-center gap-3">
<h2 class="mr-auto text-lg font-medium">Kemaskini Pengguna</h2>
<Button look="outline" variant="secondary" type="button" :disabled="saving"
@click="router.push({ name: 'list-users' })">
Kembali
</Button>
</div>
<AlertRoot v-if="error" variant="danger">
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</AlertRoot>
<AlertRoot v-if="successMessage" variant="success">
<AlertTitle>Berjaya</AlertTitle>
<AlertDescription>{{ successMessage }}</AlertDescription>
</AlertRoot>
<Box>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-name">Nama</FieldLabel>
<Input id="user-name" v-model="form.name" class="w-full" type="text" placeholder="Nama penuh"
:disabled="formDisabled" required @input="handleNameInput" />
</Field>
<Field>
<FieldLabel for="user-email">Emel</FieldLabel>
<Input id="user-email" v-model="email" class="w-full" type="email" disabled />
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-ic">Nombor Kad Pengenalan</FieldLabel>
<Input id="user-ic" v-model="form.ic_number" class="w-full" type="text" inputmode="numeric" maxlength="15"
placeholder="Contoh: 900101011234" :disabled="formDisabled" required @input="handleIcNumberInput" />
</Field>
<Field>
<FieldLabel for="user-phone">Nombor Telefon</FieldLabel>
<Input id="user-phone" v-model="form.phone_number" class="w-full" type="text" placeholder="Nombor telefon"
:disabled="formDisabled" />
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-position">Jawatan</FieldLabel>
<Input id="user-position" v-model="form.position" class="w-full" type="text" placeholder="Jawatan"
:disabled="formDisabled" />
</Field>
<Field>
<FieldLabel>Status Pengguna</FieldLabel>
<SelectRoot v-if="!loading" class="w-full" :collection="statusCollection" :default-value="statusInitial"
:disabled="formDisabled" @value-change="setStatusValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih status" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel>Jantina</FieldLabel>
<SelectRoot v-if="!loading" class="w-full" :collection="genderCollection" :default-value="genderInitial"
:disabled="formDisabled" @value-change="setGenderValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih jantina" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Jantina</SelectItemGroupLabel>
<SelectItem v-for="item in genderCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</Field>
<Field>
<FieldLabel>Status Perkahwinan</FieldLabel>
<SelectRoot v-if="!loading" class="w-full" :collection="marriageStatusCollection"
:default-value="marriageStatusInitial" :disabled="formDisabled" @value-change="setMarriageStatusValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih status perkahwinan" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Status Perkahwinan</SelectItemGroupLabel>
<SelectItem v-for="item in marriageStatusCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-member-number">Nombor Anggota</FieldLabel>
<Input id="user-member-number" v-model="form.member_number" class="w-full" type="number" min="0"
placeholder="Nombor anggota" :disabled="formDisabled" />
</Field>
<Field>
<FieldLabel>Jenis Anggota</FieldLabel>
<SelectRoot v-if="!loading" class="w-full" :collection="memberTypeCollection"
:default-value="memberTypeInitial" :disabled="formDisabled" @value-change="setMemberTypeValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih jenis anggota" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Jenis Anggota</SelectItemGroupLabel>
<SelectItem v-for="item in memberTypeCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="user-join-date">Tarikh Sertai</FieldLabel>
<Input id="user-join-date" v-model="form.join_date" class="w-full" type="date" :disabled="formDisabled" />
</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 id="user-birth-date" v-model="form.birth_date" class="w-full" type="date" :disabled="formDisabled" />
</Field>
</div>
<Field>
<FieldLabel for="user-birth-place">Tempat Lahir</FieldLabel>
<Input id="user-birth-place" v-model="form.birth_place" class="w-full" type="text" placeholder="Tempat lahir"
:disabled="formDisabled" />
</Field>
<div class="flex items-center justify-end gap-2 pt-2">
<Button type="submit" variant="primary" look="outline" :disabled="formDisabled">
{{ saving ? 'Menyimpan...' : 'Simpan' }}
</Button>
</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>