4 Commits

Author SHA1 Message Date
ISMAIL MASSERAN 52ffd3393a DONE: feedback module, use permission name for notification 2026-07-14 12:02:37 +08:00
ISMAIL MASSERAN 324b7facf1 DONE: sso into e-vote; WIP: feedback modules 2026-07-13 12:15:44 +08:00
ISMAIL MASSERAN a11b75729a DONE: phone number verification, admin can add user employment; WIP: wire with onewaysms 2026-07-12 12:36:46 +08:00
ISMAIL MASSERAN 431bbee17c DONE: replace email verification by link instead of otp 2026-07-12 10:03:31 +08:00
15 changed files with 257 additions and 126 deletions
+3 -8
View File
@@ -4,7 +4,6 @@ APP_KEY=base64:XdMAy5IbQMHsTrp1N7Tn/LIJC4SKenN8CfhYLt1s3tk=
APP_DEBUG=false
APP_TIMEZONE=Asia/Kuala_Lumpur
APP_URL=https://api.koppkb.com
FRONTEND_URL=https://mykopkb.koppkb.com
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -92,15 +91,11 @@ ENABLE_API_KEY_AUTH=true
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
API_LOG_KEY_USAGE=true
AUTH_COOKIE_NAME=auth_token
AUTH_COOKIE_LIFETIME=360
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAME_SITE=lax
AUTH_COOKIE_EXPOSE_TOKEN=false
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
FRONTEND_URL=https://mykopkb.koppkb.com
BROWSERSHOT_NO_SANDBOX=true
PUBLIC_PROFILE_TOKEN_TTL_DAYS=3
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
EMAIL_VERIFICATION_EXPIRE_MINUTES=60
EMAIL_VERIFICATION_REDIRECT_PATH=/profile-overview-2
@@ -69,13 +69,4 @@ class MembershipApplicationReview extends Model
return BoardDecision::tryFrom($this->decision);
}
public function completionDecision(): ?BoardDecision
{
if ($this->stage !== ReviewStage::Completion) {
return null;
}
return BoardDecision::tryFrom($this->decision);
}
}
@@ -6,5 +6,4 @@ enum ReviewStage: string
{
case Management = 'MANAGEMENT';
case Board = 'BOARD';
case Completion = 'COMPLETION';
}
@@ -254,8 +254,12 @@ class MembershipApplicationService
});
}
public function complete(MembershipApplication $application, User $processedBy, string $boardMeetingReference, string $boardMeetingDate,): MembershipApplication
{
public function complete(
MembershipApplication $application,
User $processedBy,
string $boardMeetingReference,
string $boardMeetingDate,
): MembershipApplication {
if ($application->status !== ApplicationStatus::PendingNotification) {
throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'],
@@ -268,7 +272,7 @@ class MembershipApplicationService
]);
}
return DB::transaction(function () use ($application, $processedBy, $boardMeetingReference, $boardMeetingDate) {
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
$plainPassword = null;
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
@@ -282,18 +286,6 @@ class MembershipApplicationService
$this->sendResultNotification($application, $resultLetter, $plainPassword);
$application->reviews()->create([
'stage' => ReviewStage::Completion,
'decision' => $application->board_result,
'remarks' => sprintf(
'Rujukan mesyuarat lembaga: %s, Tarikh mesyuarat: %s',
$boardMeetingReference,
$boardMeetingDate,
),
'reviewer_id' => $processedBy->id,
'reviewed_at' => now(),
]);
$application->update([
'status' => ApplicationStatus::Completed,
'completed_at' => now()->toDateTimeString(),
@@ -310,8 +302,12 @@ class MembershipApplicationService
* failed: list<array{id: string, application_number: string|null, message: string}>
* }
*/
public function completeBatch(array $applicationIds, User $processedBy, string $boardMeetingReference, string $boardMeetingDate): array
{
public function completeBatch(
array $applicationIds,
User $processedBy,
string $boardMeetingReference,
string $boardMeetingDate,
): array {
$succeeded = [];
$failed = [];
+4 -12
View File
@@ -1,7 +1,7 @@
export const EMPLOYERS = [
{
name: 'Infra Quest Sdn Bhd (IQSB)',
address: "Lot 1045, Jalan Dato' Lundang, 15200 Kota Bharu, Kelantan",
name: 'Infra Quest Sdn. Bhd. (IQSB)',
address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan',
},
{
name: 'Permodalan Kelantan Berhad (PKB)',
@@ -14,19 +14,11 @@ export const EMPLOYERS = [
'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
},
{
name: "Pusat Perubatan An-Nisa'",
name: "An-Nisa'",
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
},
{
name: 'Kel Infra Sdn Bhd (KISB)',
name: 'Kel Infra Sdn. Bhd.',
address: 'Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.',
},
{
name: 'Xtra Biz Sdn Bhd (XBSB)',
address: "Lot 241, Jalan Dato' Cik Arif, Taman Mutiara 1, 15000 Kota Bharu, Kelantan.",
},
{
name: 'Pesara',
address: '-'
}
] as const
+1 -1
View File
@@ -365,7 +365,7 @@ const stepLabelClass = (stepId: number) => {
<Input v-model="email" :class="inputClass" type="email" placeholder="Email" autocomplete="email"
required />
<Input v-model="icNumber" :class="inputClass" type="text" inputmode="numeric" maxlength="15"
placeholder="No. Kad Pengenalan, contoh: 900101011234" required @input="handleIcNumberInput" />
placeholder="Contoh: 900101011234" required @input="handleIcNumberInput" />
</template>
<template v-else>
@@ -30,7 +30,6 @@ import type {
MembershipApplicationReferenceForm,
} from '../types/membership-application.types'
import illustrationUrl from '@/assets/images/logo.svg'
import { EMPLOYERS } from '@/constants/employers'
const MAX_FILE_SIZE_MB = 10
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
@@ -79,6 +78,26 @@ const RELATIONSHIP_OPTIONS: SelectOption[] = [
{ label: 'Lain-lain', value: 'Lain-lain' },
]
// TODO: replace with API lookup
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.',
},
] as const
const EMPLOYER_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
label: employer.name,
value: employer.name,
@@ -300,7 +319,7 @@ const stepTitle = computed(() => {
case 1:
return 'Maklumat Peribadi'
case 2:
return 'Hubungan & Alamat Rumah Semasa'
return 'Hubungan & Alamat'
case 3:
return 'Maklumat Pekerjaan & Caruman'
case 4:
@@ -921,7 +940,7 @@ function stepLabelClass(stepId: number) {
<Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png"
@change="handleFileChange('employer_letter', $event)" />
<FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter']
}}</FieldError>
}}</FieldError>
</Field>
<div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4">
@@ -168,14 +168,14 @@ const confirmDialogTitle = computed(() => {
if (pendingAction.value.type === 'management') {
return pendingAction.value.decision === 'APPROVED'
? 'Semak Permohonan?'
? 'Luluskan Permohonan?'
: 'Tolak Permohonan?'
}
if (pendingAction.value.type === 'board') {
return pendingAction.value.decision === 'PASS'
? 'Keputusan ALK?'
: 'Keputusan ALK?'
? 'Luluskan?'
: 'Gagalkan?'
}
return 'Selesaikan Permohonan?'
@@ -186,7 +186,7 @@ const confirmDialogDescription = computed(() => {
if (pendingAction.value.type === 'management') {
return pendingAction.value.decision === 'APPROVED'
? 'Permohonan akan dihantar ke peringkat keputusan ALK.'
? 'Permohonan akan dihantar ke mesyuarat ALK.'
: 'Permohonan akan ditolak pada peringkat pentadbiran.'
}
@@ -325,7 +325,6 @@ function getReference(type: 'PROPOSER' | 'SUPPORTER'): MembershipApplicationRefe
function reviewStageLabel(stage: string): string {
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
if (stage === 'COMPLETION') return 'Penyelesaian & Makluman Keputusan'
return stage
}
@@ -333,11 +332,11 @@ function reviewDecisionLabel(decision: string | null, stage: string): string {
if (!decision) return '-'
if (stage === 'MANAGEMENT') {
if (decision === 'APPROVED') return 'Disemak'
if (decision === 'APPROVED') return 'Diluluskan'
if (decision === 'REJECTED') return 'Ditolak'
}
if (stage === 'BOARD' || stage === 'COMPLETION') {
if (stage === 'BOARD') {
if (decision === 'PASS') return 'Lulus'
if (decision === 'FAIL') return 'Gagal'
}
@@ -648,7 +647,7 @@ onUnmounted(() => {
<Box v-if="showManagementReview" class="p-5 sm:p-6">
<div class="font-medium">Semakan Pentadbiran</div>
<p class="mt-1 text-sm opacity-70">
Semak atau tolak permohonan pada peringkat pentadbiran.
Luluskan atau tolak permohonan pada peringkat pentadbiran.
</p>
<Field class="mt-4">
<FieldLabel for="management-remarks">Catatan</FieldLabel>
@@ -658,7 +657,7 @@ onUnmounted(() => {
<div class="mt-4 flex flex-wrap gap-2">
<Button type="button" variant="success" :disabled="reviewSubmitting || completeSubmitting"
@click="openConfirmAction({ type: 'management', decision: 'APPROVED' })">
Semak
Luluskan
</Button>
<Button type="button" variant="danger" look="outline" :disabled="reviewSubmitting || completeSubmitting"
@click="openConfirmAction({ type: 'management', decision: 'REJECTED' })">
@@ -668,9 +667,9 @@ onUnmounted(() => {
</Box>
<Box v-if="showBoardReview" class="p-5 sm:p-6">
<div class="font-medium">Keputusan Ahli Lembaga Koperasi (ALK)</div>
<div class="font-medium">Semakan Lembaga</div>
<p class="mt-1 text-sm opacity-70">
Luluskan atau gagalkan permohonan pada peringkat ALK.
Luluskan atau gagalkan permohonan pada peringkat lembaga.
</p>
<Field class="mt-4">
<FieldLabel for="board-remarks">Catatan</FieldLabel>
@@ -1031,8 +1030,8 @@ onUnmounted(() => {
</Field>
<Field v-if="pendingAction?.type === 'complete'" class="mt-4 text-left">
<FieldLabel for="detail-board-meeting-date">Tarikh Mesyuarat Lembaga</FieldLabel>
<Input id="detail-board-meeting-date" v-model="boardMeetingDate" type="date" :disabled="completeSubmitting"
@input="boardMeetingDateError = null" />
<Input id="detail-board-meeting-date" v-model="boardMeetingDate" type="date"
:disabled="completeSubmitting" @input="boardMeetingDateError = null" />
<FieldError v-if="boardMeetingDateError">{{ boardMeetingDateError }}</FieldError>
</Field>
</div>
@@ -260,7 +260,6 @@ const isPreviewPdf = computed(() => (previewDocument.value ? isPdfDocument(previ
function reviewStageLabel(stage: string): string {
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
if (stage === 'COMPLETION') return 'Penyelesaian & Makluman Keputusan'
return stage
}
@@ -272,7 +271,7 @@ function reviewDecisionLabel(decision: string | null, stage: string): string {
if (decision === 'REJECTED') return 'Ditolak'
}
if (stage === 'BOARD' || stage === 'COMPLETION') {
if (stage === 'BOARD') {
if (decision === 'PASS') return 'Lulus'
if (decision === 'FAIL') return 'Gagal'
}
@@ -868,13 +867,13 @@ onUnmounted(() => {
<FieldLabel for="current_position">Jawatan Semasa</FieldLabel>
<Input id="current_position" v-model="form.applicant.current_position" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.current_position']">{{ fieldErrors['applicant.current_position']
}}</FieldError>
}}</FieldError>
</Field>
<Field class="col-span-12">
<FieldLabel for="employer_address">Alamat Majikan</FieldLabel>
<Textarea id="employer_address" v-model="form.applicant.employer_address" rows="3" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.employer_address']">{{ fieldErrors['applicant.employer_address']
}}</FieldError>
}}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel>
@@ -920,7 +919,7 @@ onUnmounted(() => {
<FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel>
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">{{ fieldErrors[`heirs.${index}.ic_number`]
}}</FieldError>
}}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Hubungan</FieldLabel>
@@ -164,7 +164,7 @@ export interface MembershipApplicationDocumentDetail {
export interface MembershipApplicationReviewDetail {
id: string
stage: 'MANAGEMENT' | 'BOARD' | 'COMPLETION'
stage: 'MANAGEMENT' | 'BOARD'
decision: string | null
remarks: string | null
reviewer: { id: string; name: string } | null
@@ -45,7 +45,7 @@ export function reviewDecisionBadgeVariant(
if (decision === 'REJECTED') return 'danger'
}
if (stage === 'BOARD' || stage === 'COMPLETION') {
if (stage === 'BOARD') {
if (decision === 'PASS') return 'success'
if (decision === 'FAIL') return 'danger'
}
@@ -59,7 +59,7 @@ export function reviewDecisionBadgeLook(
): BadgeVariants['look'] {
if (
(stage === 'MANAGEMENT' && (decision === 'APPROVED' || decision === 'REJECTED')) ||
((stage === 'BOARD' || stage === 'COMPLETION') && (decision === 'PASS' || decision === 'FAIL'))
(stage === 'BOARD' && (decision === 'PASS' || decision === 'FAIL'))
) {
return undefined
}
+30 -5
View File
@@ -28,7 +28,6 @@ import {
updateEmployment,
} from '@/modules/profile/services/employment.service'
import type { Employment, EmploymentPayload } from '@/modules/profile/types/employment.types'
import { EMPLOYERS } from '@/constants/employers'
defineProps<{
embedded?: boolean
@@ -66,10 +65,36 @@ type SelectOption = { label: string; value: string }
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Tetap', value: 'Permanent' },
{ label: 'Kontrak', value: 'Contract' },
{ label: 'Percubaan', value: 'Trial' },
{ label: 'Sambilan', value: 'Part-time' },
{ label: 'Latihan Industri', value: 'Internship' },
{ label: 'Freelance', value: 'Freelance' },
]
// TODO: replace with API lookup
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,
@@ -549,13 +574,13 @@ onMounted(async () => {
<FieldError v-if="employmentErrors.salary">{{ employmentErrors.salary }}</FieldError>
</Field>
<Field>
<FieldLabel for="employment-start-date">Tarikh Mula Perkhidmatan</FieldLabel>
<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 Perkhidmatan</FieldLabel>
<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')" />
+141 -39
View File
@@ -32,7 +32,6 @@ import {
updateUserEmployment,
} from '../services/userEmployment.service'
import { getUser, updateUser } from '../services/user.service'
import { EMPLOYERS } from '@/constants/employers'
type SelectOption = { label: string; value: string }
@@ -63,10 +62,35 @@ const MEMBER_TYPE_OPTIONS: SelectOption[] = [
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Tetap', value: 'Permanent' },
{ label: 'Kontrak', value: 'Contract' },
{ label: 'Percubaan', value: 'Trial' },
{ label: 'Sambilan', value: 'Part-time' },
{ 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,
@@ -723,7 +747,13 @@ onMounted(() => {
<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" />
<Input
id="user-leave-date"
v-model="form.leave_date"
class="w-full"
type="date"
:disabled="formDisabled"
/>
</Field>
<Field>
@@ -756,8 +786,11 @@ onMounted(() => {
<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
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>
@@ -771,27 +804,46 @@ onMounted(() => {
<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)">
<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 }" />
<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">
<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">
<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">
@@ -806,9 +858,14 @@ onMounted(() => {
</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">
<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">
@@ -821,9 +878,14 @@ onMounted(() => {
<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">
<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" />
@@ -832,7 +894,11 @@ onMounted(() => {
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel>
<SelectItem v-for="item in companyNameCollection.items" :key="item.label" :item="item">
<SelectItem
v-for="item in companyNameCollection.items"
:key="item.label"
:item="item"
>
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
@@ -845,9 +911,15 @@ onMounted(() => {
<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')" />
<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>
@@ -855,9 +927,14 @@ onMounted(() => {
<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">
<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" />
@@ -866,7 +943,11 @@ onMounted(() => {
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Jenis Kerja</SelectItemGroupLabel>
<SelectItem v-for="item in employmentTypeCollection.items" :key="item.label" :item="item">
<SelectItem
v-for="item in employmentTypeCollection.items"
:key="item.label"
:item="item"
>
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
@@ -879,17 +960,30 @@ onMounted(() => {
<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')" />
<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')" />
<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>
@@ -897,17 +991,25 @@ onMounted(() => {
<Field>
<FieldLabel for="employment-end-date">Tarikh Tamat</FieldLabel>
<Input id="employment-end-date" v-model="employmentForm.end_date" type="date"
<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')" />
: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)">
<CheckboxRoot
:checked="employmentForm.is_current"
:disabled="savingEmployment || saving"
@checked-change="({ checked }) => (employmentForm.is_current = checked === true)"
>
<CheckboxControl />
<CheckboxLabel>Pekerjaan semasa</CheckboxLabel>
</CheckboxRoot>
@@ -12,8 +12,8 @@ defineProps<{
const EMPLOYMENT_TYPE_LABEL: Record<string, string> = {
Permanent: 'Tetap',
Contract: 'Kontrak',
Trial: 'Percubaan',
PartTime: 'Sambilan',
Internship: 'Latihan Industri',
Freelance: 'Freelance',
}
function formatSalary(value: number | string | null | undefined): string {
@@ -56,8 +56,11 @@ function formatEmploymentPeriod(employment: Employment): string {
</div>
<div v-if="user.employments?.length" class="space-y-3">
<div v-for="employment in user.employments" :key="employment.id"
class="rounded-lg border border-foreground/10 p-4">
<div
v-for="employment in user.employments"
:key="employment.id"
class="rounded-lg border border-foreground/10 p-4"
>
<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>
@@ -71,7 +74,10 @@ function formatEmploymentPeriod(employment: Employment): string {
</div>
</div>
<div v-else class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500">
<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>
</Box>
+13 -5
View File
@@ -52,7 +52,7 @@ const STATUS_FILTER_CHIPS: StatusFilterChip[] = [
function getEmployerShortLabel(name: string): string {
const match = name.match(/\(([^)]+)\)/)
if (match?.[1]) return match[1]
if (name.startsWith('Pusat Perubatan An-Nisa')) return "Pusat Perubatan An-Nisa'"
if (name.startsWith('An-Nisa')) return "An-Nisa'"
if (name.startsWith('Kel Infra')) return 'Kel Infra'
return name
}
@@ -568,10 +568,18 @@ onMounted(() => {
</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)">
<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>