542 lines
18 KiB
Vue
542 lines
18 KiB
Vue
<script lang="ts" setup>
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
import * as select from '@zag-js/select'
|
|
import Swal from 'sweetalert2'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Box } from '@/components/ui/box'
|
|
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 {
|
|
createEmployment,
|
|
deleteEmployment,
|
|
listEmployments,
|
|
updateEmployment,
|
|
} from '@/modules/profile/services/employment.service'
|
|
import type { Employment, EmploymentPayload } from '@/modules/profile/types/employment.types'
|
|
|
|
defineProps<{
|
|
embedded?: boolean
|
|
}>()
|
|
|
|
const employments = ref<Employment[]>([])
|
|
const loadingEmployments = ref(false)
|
|
const savingEmployment = ref(false)
|
|
const deletingEmploymentId = ref<string | null>(null)
|
|
const editingEmploymentId = ref<string | null>(null)
|
|
|
|
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',
|
|
]
|
|
|
|
const employmentErrors = reactive<Partial<Record<EmploymentFieldKey, string>>>({})
|
|
|
|
type SelectOption = { label: string; value: string }
|
|
|
|
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
|
|
{ label: 'Tetap', value: 'Permanent' },
|
|
{ label: 'Kontrak', value: 'Contract' },
|
|
{ label: 'Latihan Industri', value: 'Internship' },
|
|
{ label: 'Freelance', value: 'Freelance' },
|
|
]
|
|
|
|
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] : [value]
|
|
}
|
|
|
|
const employmentTypeCollection = createSelectCollection(EMPLOYMENT_TYPE_OPTIONS)
|
|
|
|
const employmentTypeValue = ref<string[]>([])
|
|
const employmentTypeInitial = ref<string[]>([])
|
|
|
|
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 toDateInputValue(value: string | null | undefined): string {
|
|
if (!value) return ''
|
|
return value.slice(0, 10)
|
|
}
|
|
|
|
function emptyEmploymentForm() {
|
|
return {
|
|
company_name: '',
|
|
job_title: '',
|
|
employment_type: '',
|
|
salary: '',
|
|
start_date: '',
|
|
end_date: '',
|
|
is_current: true,
|
|
}
|
|
}
|
|
|
|
const employmentForm = reactive(emptyEmploymentForm())
|
|
|
|
const employmentTypeLabel = computed(() =>
|
|
Object.fromEntries(EMPLOYMENT_TYPE_OPTIONS.map((option) => [option.value, option.label])),
|
|
)
|
|
|
|
const isEditingEmployment = computed(() => editingEmploymentId.value !== null)
|
|
|
|
function setEmploymentTypeValue(details: { value: string[] }) {
|
|
employmentTypeValue.value = details.value
|
|
clearEmploymentFieldError('employment_type')
|
|
employmentForm.employment_type =
|
|
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, details.value[0]) ?? ''
|
|
}
|
|
|
|
function syncEmploymentSelectValues() {
|
|
employmentTypeValue.value = apiValueToLabel(EMPLOYMENT_TYPE_OPTIONS, employmentForm.employment_type)
|
|
employmentTypeInitial.value = [...employmentTypeValue.value]
|
|
}
|
|
|
|
function resetEmploymentForm() {
|
|
Object.assign(employmentForm, emptyEmploymentForm())
|
|
editingEmploymentId.value = null
|
|
clearEmploymentErrors()
|
|
syncEmploymentSelectValues()
|
|
}
|
|
|
|
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 (!employmentForm.company_name.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 fetchEmployments() {
|
|
loadingEmployments.value = true
|
|
|
|
try {
|
|
const res = await listEmployments()
|
|
employments.value = res.data
|
|
} catch (error) {
|
|
await Swal.fire({
|
|
icon: 'error',
|
|
title: 'Ralat',
|
|
text: getApiErrorMessage(error, 'Gagal memuatkan pekerjaan.'),
|
|
})
|
|
} finally {
|
|
loadingEmployments.value = false
|
|
}
|
|
}
|
|
|
|
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 updateEmployment(editingEmploymentId.value!, payload)
|
|
: await createEmployment(payload)
|
|
|
|
if (!res.success) {
|
|
throw new Error(res.message ?? 'Gagal menyimpan pekerjaan.')
|
|
}
|
|
|
|
await fetchEmployments()
|
|
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 (error) {
|
|
if (!setEmploymentErrorsFromApi(error)) {
|
|
await Swal.fire({
|
|
icon: 'error',
|
|
title: 'Ralat',
|
|
text: getApiErrorMessage(error, '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 deleteEmployment(employment.id)
|
|
|
|
if (!res.success) {
|
|
throw new Error(res.message ?? 'Gagal memadam pekerjaan.')
|
|
}
|
|
|
|
if (editingEmploymentId.value === employment.id) {
|
|
resetEmploymentForm()
|
|
}
|
|
|
|
await fetchEmployments()
|
|
|
|
await Swal.fire({
|
|
toast: true,
|
|
position: 'top-end',
|
|
icon: 'success',
|
|
title: 'Pekerjaan berjaya dipadam.',
|
|
showConfirmButton: false,
|
|
timer: 3000,
|
|
})
|
|
} catch (error) {
|
|
await Swal.fire({
|
|
icon: 'error',
|
|
title: 'Ralat',
|
|
text: getApiErrorMessage(error, 'Gagal memadam pekerjaan.'),
|
|
})
|
|
} finally {
|
|
deletingEmploymentId.value = null
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => employmentForm.is_current,
|
|
(isCurrent) => {
|
|
if (isCurrent) {
|
|
employmentForm.end_date = ''
|
|
clearEmploymentFieldError('end_date')
|
|
}
|
|
},
|
|
)
|
|
|
|
onMounted(async () => {
|
|
syncEmploymentSelectValues()
|
|
await fetchEmployments()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div :class="embedded ? '' : 'mt-5'">
|
|
<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">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-slate-900">Pekerjaan</h3>
|
|
<p class="mt-1 text-sm text-slate-500">
|
|
Urus sejarah dan maklumat pekerjaan anda.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loadingEmployments" 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 class="bg-green-500 text-white" v-if="employment.is_current">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" @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" @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 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 ke profil anda.'
|
|
}}
|
|
</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">
|
|
{{ savingEmployment ? 'Menyimpan...' : isEditingEmployment ? 'Kemaskini' : 'Tambah' }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<FieldGroup>
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<Field>
|
|
<FieldLabel for="employment-company">Nama Syarikat</FieldLabel>
|
|
<Input id="employment-company" v-model="employmentForm.company_name" type="text"
|
|
placeholder="Nama syarikat" :aria-invalid="!!employmentErrors.company_name"
|
|
@input="clearEmploymentFieldError('company_name')" />
|
|
<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"
|
|
: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" @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" :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"
|
|
: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" :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"
|
|
@checked-change="({ checked }) => (employmentForm.is_current = checked === true)">
|
|
<CheckboxControl />
|
|
<CheckboxLabel>Pekerjaan semasa</CheckboxLabel>
|
|
</CheckboxRoot>
|
|
</Field>
|
|
</div>
|
|
</FieldGroup>
|
|
</form>
|
|
</div>
|
|
</Box>
|
|
</div>
|
|
</template>
|