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
18 changed files with 272 additions and 194 deletions
+3 -8
View File
@@ -4,7 +4,6 @@ APP_KEY=base64:XdMAy5IbQMHsTrp1N7Tn/LIJC4SKenN8CfhYLt1s3tk=
APP_DEBUG=false APP_DEBUG=false
APP_TIMEZONE=Asia/Kuala_Lumpur APP_TIMEZONE=Asia/Kuala_Lumpur
APP_URL=https://api.koppkb.com APP_URL=https://api.koppkb.com
FRONTEND_URL=https://mykopkb.koppkb.com
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -92,15 +91,11 @@ ENABLE_API_KEY_AUTH=true
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
API_LOG_KEY_USAGE=true API_LOG_KEY_USAGE=true
AUTH_COOKIE_NAME=auth_token PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
AUTH_COOKIE_LIFETIME=360 FRONTEND_URL=https://mykopkb.koppkb.com
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAME_SITE=lax
AUTH_COOKIE_EXPOSE_TOKEN=false
BROWSERSHOT_NO_SANDBOX=true BROWSERSHOT_NO_SANDBOX=true
PUBLIC_PROFILE_TOKEN_TTL_DAYS=3 PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
EMAIL_VERIFICATION_EXPIRE_MINUTES=60 EMAIL_VERIFICATION_EXPIRE_MINUTES=60
EMAIL_VERIFICATION_REDIRECT_PATH=/profile-overview-2 EMAIL_VERIFICATION_REDIRECT_PATH=/profile-overview-2
@@ -69,13 +69,4 @@ class MembershipApplicationReview extends Model
return BoardDecision::tryFrom($this->decision); 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 Management = 'MANAGEMENT';
case Board = 'BOARD'; 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) { if ($application->status !== ApplicationStatus::PendingNotification) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'], '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; $plainPassword = null;
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) { if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
@@ -282,18 +286,6 @@ class MembershipApplicationService
$this->sendResultNotification($application, $resultLetter, $plainPassword); $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([ $application->update([
'status' => ApplicationStatus::Completed, 'status' => ApplicationStatus::Completed,
'completed_at' => now()->toDateTimeString(), 'completed_at' => now()->toDateTimeString(),
@@ -310,8 +302,12 @@ class MembershipApplicationService
* failed: list<array{id: string, application_number: string|null, message: string}> * 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 = []; $succeeded = [];
$failed = []; $failed = [];
+8 -20
View File
@@ -94,31 +94,19 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::authenticateUsing(function (Request $request) { Fortify::authenticateUsing(function (Request $request) {
$user = User::where('email', $request->email)->first(); $user = User::where('email', $request->email)->first();
$invalidCredentialsMessage = 'Emel atau kata laluan tidak sah.';
$failLogin = function () use ($invalidCredentialsMessage): never {
throw ValidationException::withMessages([
'email' => [$invalidCredentialsMessage],
]);
};
if (! $user) {
$failLogin();
}
// Bypass password check in local or development environments
$shouldBypassPassword = config('app.env', 'local'); $shouldBypassPassword = config('app.env', 'local');
if (! $shouldBypassPassword && ! Hash::check($request->password, $user->password)) { if ($user && ($shouldBypassPassword || Hash::check($request->password, $user->password))) {
$failLogin(); if (! $user->canAuthenticate()) {
} throw ValidationException::withMessages([
'email' => [$user->getLoginRestrictionMessage()],
]);
}
if (! $user->canAuthenticate()) { return $user;
throw ValidationException::withMessages([
'email' => [$user->getLoginRestrictionMessage()],
]);
} }
return $user;
}); });
} }
} }
+2 -5
View File
@@ -59,13 +59,10 @@ return Application::configure(basePath: dirname(__DIR__))
// Handle Validation exceptions (422 errors) // Handle Validation exceptions (422 errors)
$exceptions->render(function (Illuminate\Validation\ValidationException $e, $request) { $exceptions->render(function (Illuminate\Validation\ValidationException $e, $request) {
if ($request->expectsJson()) { if ($request->expectsJson()) {
$errors = $e->errors();
$firstMessage = collect($errors)->flatten()->first() ?? 'Data tidak sah.';
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => $firstMessage, 'message' => 'Data tidak sah.',
'errors' => $errors, 'errors' => $e->errors(),
], 422); ], 422);
} }
}); });
+4 -12
View File
@@ -1,7 +1,7 @@
export const EMPLOYERS = [ export const EMPLOYERS = [
{ {
name: 'Infra Quest Sdn Bhd (IQSB)', name: 'Infra Quest Sdn. Bhd. (IQSB)',
address: "Lot 1045, Jalan Dato' Lundang, 15200 Kota Bharu, Kelantan", address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan',
}, },
{ {
name: 'Permodalan Kelantan Berhad (PKB)', 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.', '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.', 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.', address: 'Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.',
}, },
{
name: 'Xtra Biz Sdn Bhd (XBSB)',
address: "-",
},
{
name: 'Pesara',
address: '-'
}
] as const ] 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" <Input v-model="email" :class="inputClass" type="email" placeholder="Email" autocomplete="email"
required /> required />
<Input v-model="icNumber" :class="inputClass" type="text" inputmode="numeric" maxlength="15" <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>
<template v-else> <template v-else>
@@ -30,7 +30,6 @@ import type {
MembershipApplicationReferenceForm, MembershipApplicationReferenceForm,
} from '../types/membership-application.types' } from '../types/membership-application.types'
import illustrationUrl from '@/assets/images/logo.svg' import illustrationUrl from '@/assets/images/logo.svg'
import { EMPLOYERS } from '@/constants/employers'
const MAX_FILE_SIZE_MB = 10 const MAX_FILE_SIZE_MB = 10
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 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' }, { 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) => ({ const EMPLOYER_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
label: employer.name, label: employer.name,
value: employer.name, value: employer.name,
@@ -300,7 +319,7 @@ const stepTitle = computed(() => {
case 1: case 1:
return 'Maklumat Peribadi' return 'Maklumat Peribadi'
case 2: case 2:
return 'Hubungan & Alamat Rumah Semasa' return 'Hubungan & Alamat'
case 3: case 3:
return 'Maklumat Pekerjaan & Caruman' return 'Maklumat Pekerjaan & Caruman'
case 4: case 4:
@@ -921,7 +940,7 @@ function stepLabelClass(stepId: number) {
<Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png" <Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png"
@change="handleFileChange('employer_letter', $event)" /> @change="handleFileChange('employer_letter', $event)" />
<FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter'] <FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter']
}}</FieldError> }}</FieldError>
</Field> </Field>
<div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4"> <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') { if (pendingAction.value.type === 'management') {
return pendingAction.value.decision === 'APPROVED' return pendingAction.value.decision === 'APPROVED'
? 'Semak Permohonan?' ? 'Luluskan Permohonan?'
: 'Tolak Permohonan?' : 'Tolak Permohonan?'
} }
if (pendingAction.value.type === 'board') { if (pendingAction.value.type === 'board') {
return pendingAction.value.decision === 'PASS' return pendingAction.value.decision === 'PASS'
? 'Keputusan ALK?' ? 'Luluskan?'
: 'Keputusan ALK?' : 'Gagalkan?'
} }
return 'Selesaikan Permohonan?' return 'Selesaikan Permohonan?'
@@ -186,7 +186,7 @@ const confirmDialogDescription = computed(() => {
if (pendingAction.value.type === 'management') { if (pendingAction.value.type === 'management') {
return pendingAction.value.decision === 'APPROVED' return pendingAction.value.decision === 'APPROVED'
? 'Permohonan akan dihantar ke peringkat keputusan ALK.' ? 'Permohonan akan dihantar ke mesyuarat ALK.'
: 'Permohonan akan ditolak pada peringkat pentadbiran.' : 'Permohonan akan ditolak pada peringkat pentadbiran.'
} }
@@ -325,7 +325,6 @@ function getReference(type: 'PROPOSER' | 'SUPPORTER'): MembershipApplicationRefe
function reviewStageLabel(stage: string): string { function reviewStageLabel(stage: string): string {
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran' if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)' if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
if (stage === 'COMPLETION') return 'Penyelesaian & Makluman Keputusan'
return stage return stage
} }
@@ -333,11 +332,11 @@ function reviewDecisionLabel(decision: string | null, stage: string): string {
if (!decision) return '-' if (!decision) return '-'
if (stage === 'MANAGEMENT') { if (stage === 'MANAGEMENT') {
if (decision === 'APPROVED') return 'Disemak' if (decision === 'APPROVED') return 'Diluluskan'
if (decision === 'REJECTED') return 'Ditolak' if (decision === 'REJECTED') return 'Ditolak'
} }
if (stage === 'BOARD' || stage === 'COMPLETION') { if (stage === 'BOARD') {
if (decision === 'PASS') return 'Lulus' if (decision === 'PASS') return 'Lulus'
if (decision === 'FAIL') return 'Gagal' if (decision === 'FAIL') return 'Gagal'
} }
@@ -648,7 +647,7 @@ onUnmounted(() => {
<Box v-if="showManagementReview" class="p-5 sm:p-6"> <Box v-if="showManagementReview" class="p-5 sm:p-6">
<div class="font-medium">Semakan Pentadbiran</div> <div class="font-medium">Semakan Pentadbiran</div>
<p class="mt-1 text-sm opacity-70"> <p class="mt-1 text-sm opacity-70">
Semak atau tolak permohonan pada peringkat pentadbiran. Luluskan atau tolak permohonan pada peringkat pentadbiran.
</p> </p>
<Field class="mt-4"> <Field class="mt-4">
<FieldLabel for="management-remarks">Catatan</FieldLabel> <FieldLabel for="management-remarks">Catatan</FieldLabel>
@@ -658,7 +657,7 @@ onUnmounted(() => {
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<Button type="button" variant="success" :disabled="reviewSubmitting || completeSubmitting" <Button type="button" variant="success" :disabled="reviewSubmitting || completeSubmitting"
@click="openConfirmAction({ type: 'management', decision: 'APPROVED' })"> @click="openConfirmAction({ type: 'management', decision: 'APPROVED' })">
Semak Luluskan
</Button> </Button>
<Button type="button" variant="danger" look="outline" :disabled="reviewSubmitting || completeSubmitting" <Button type="button" variant="danger" look="outline" :disabled="reviewSubmitting || completeSubmitting"
@click="openConfirmAction({ type: 'management', decision: 'REJECTED' })"> @click="openConfirmAction({ type: 'management', decision: 'REJECTED' })">
@@ -668,9 +667,9 @@ onUnmounted(() => {
</Box> </Box>
<Box v-if="showBoardReview" class="p-5 sm:p-6"> <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"> <p class="mt-1 text-sm opacity-70">
Luluskan atau gagalkan permohonan pada peringkat ALK. Luluskan atau gagalkan permohonan pada peringkat lembaga.
</p> </p>
<Field class="mt-4"> <Field class="mt-4">
<FieldLabel for="board-remarks">Catatan</FieldLabel> <FieldLabel for="board-remarks">Catatan</FieldLabel>
@@ -1031,8 +1030,8 @@ onUnmounted(() => {
</Field> </Field>
<Field v-if="pendingAction?.type === 'complete'" class="mt-4 text-left"> <Field v-if="pendingAction?.type === 'complete'" class="mt-4 text-left">
<FieldLabel for="detail-board-meeting-date">Tarikh Mesyuarat Lembaga</FieldLabel> <FieldLabel for="detail-board-meeting-date">Tarikh Mesyuarat Lembaga</FieldLabel>
<Input id="detail-board-meeting-date" v-model="boardMeetingDate" type="date" :disabled="completeSubmitting" <Input id="detail-board-meeting-date" v-model="boardMeetingDate" type="date"
@input="boardMeetingDateError = null" /> :disabled="completeSubmitting" @input="boardMeetingDateError = null" />
<FieldError v-if="boardMeetingDateError">{{ boardMeetingDateError }}</FieldError> <FieldError v-if="boardMeetingDateError">{{ boardMeetingDateError }}</FieldError>
</Field> </Field>
</div> </div>
@@ -58,7 +58,6 @@ import { ADMIN_ATTACHMENT_DOCUMENT_TYPE } from '../types/membership-application.
import { import {
APPLICANT_DOCUMENT_UPLOAD_TYPES, APPLICANT_DOCUMENT_UPLOAD_TYPES,
DOCUMENT_TYPE_LABELS, DOCUMENT_TYPE_LABELS,
EMPLOYER_OPTIONS,
GENDER_OPTIONS, GENDER_OPTIONS,
MARRIAGE_STATUS_OPTIONS, MARRIAGE_STATUS_OPTIONS,
RELATIONSHIP_OPTIONS, RELATIONSHIP_OPTIONS,
@@ -68,7 +67,6 @@ import {
createEmptyFormState, createEmptyFormState,
createSelectCollection, createSelectCollection,
detailToFormState, detailToFormState,
getEmployerAddress,
labelToApiValue, labelToApiValue,
} from '../utils/membership-application-form.utils' } from '../utils/membership-application-form.utils'
@@ -114,7 +112,6 @@ const referenceLookupLoading = reactive({
const genderCollection = createSelectCollection(GENDER_OPTIONS) const genderCollection = createSelectCollection(GENDER_OPTIONS)
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS) const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS) const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
const employerCollection = createSelectCollection(EMPLOYER_OPTIONS)
const canEdit = computed( const canEdit = computed(
() => () =>
@@ -126,7 +123,6 @@ const genderInitial = computed(() => apiValueToLabel(GENDER_OPTIONS, form.applic
const marriageStatusInitial = computed(() => const marriageStatusInitial = computed(() =>
apiValueToLabel(MARRIAGE_STATUS_OPTIONS, form.applicant.marriage_status), apiValueToLabel(MARRIAGE_STATUS_OPTIONS, form.applicant.marriage_status),
) )
const employerInitial = computed(() => apiValueToLabel(EMPLOYER_OPTIONS, form.applicant.employer_name))
const workflowProgress = computed(() => const workflowProgress = computed(() =>
application.value ? getWorkflowProgress(application.value.status) : null, application.value ? getWorkflowProgress(application.value.status) : null,
@@ -264,7 +260,6 @@ const isPreviewPdf = computed(() => (previewDocument.value ? isPdfDocument(previ
function reviewStageLabel(stage: string): string { function reviewStageLabel(stage: string): string {
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran' if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)' if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
if (stage === 'COMPLETION') return 'Penyelesaian & Makluman Keputusan'
return stage return stage
} }
@@ -276,7 +271,7 @@ function reviewDecisionLabel(decision: string | null, stage: string): string {
if (decision === 'REJECTED') return 'Ditolak' if (decision === 'REJECTED') return 'Ditolak'
} }
if (stage === 'BOARD' || stage === 'COMPLETION') { if (stage === 'BOARD') {
if (decision === 'PASS') return 'Lulus' if (decision === 'PASS') return 'Lulus'
if (decision === 'FAIL') return 'Gagal' if (decision === 'FAIL') return 'Gagal'
} }
@@ -313,14 +308,6 @@ function setMarriageStatusValue(details: { value: string[] }) {
delete fieldErrors['applicant.marriage_status'] delete fieldErrors['applicant.marriage_status']
} }
function setEmployerValue(details: { value: string[] }) {
const employerName = labelToApiValue(EMPLOYER_OPTIONS, details.value[0])
form.applicant.employer_name = employerName
form.applicant.employer_address = getEmployerAddress(employerName)
delete fieldErrors['applicant.employer_name']
delete fieldErrors['applicant.employer_address']
}
function setHeirRelationshipValue(index: number, details: { value: string[] }) { function setHeirRelationshipValue(index: number, details: { value: string[] }) {
const heir = form.heirs[index] const heir = form.heirs[index]
if (!heir) return if (!heir) return
@@ -871,24 +858,8 @@ onUnmounted(() => {
<TabsContent value="employment" class="mt-6"> <TabsContent value="employment" class="mt-6">
<div class="grid grid-cols-12 gap-4 gap-y-5"> <div class="grid grid-cols-12 gap-4 gap-y-5">
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel>Nama Majikan</FieldLabel> <FieldLabel for="employer_name">Nama Majikan</FieldLabel>
<SelectRoot :key="`employer-${form.applicant.employer_name}`" class="w-full" <Input id="employer_name" v-model="form.applicant.employer_name" type="text" :disabled="!canEdit" />
:collection="employerCollection" :default-value="employerInitial" :disabled="!canEdit"
@value-change="setEmployerValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.employer_name']">
<SelectValueText placeholder="Pilih majikan" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Nama Majikan</SelectItemGroupLabel>
<SelectItem v-for="item in employerCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="fieldErrors['applicant.employer_name']">{{ fieldErrors['applicant.employer_name'] }} <FieldError v-if="fieldErrors['applicant.employer_name']">{{ fieldErrors['applicant.employer_name'] }}
</FieldError> </FieldError>
</Field> </Field>
@@ -896,13 +867,13 @@ onUnmounted(() => {
<FieldLabel for="current_position">Jawatan Semasa</FieldLabel> <FieldLabel for="current_position">Jawatan Semasa</FieldLabel>
<Input id="current_position" v-model="form.applicant.current_position" type="text" :disabled="!canEdit" /> <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 v-if="fieldErrors['applicant.current_position']">{{ fieldErrors['applicant.current_position']
}}</FieldError> }}</FieldError>
</Field> </Field>
<Field class="col-span-12"> <Field class="col-span-12">
<FieldLabel for="employer_address">Alamat Majikan</FieldLabel> <FieldLabel for="employer_address">Alamat Majikan</FieldLabel>
<Textarea id="employer_address" v-model="form.applicant.employer_address" rows="3" disabled /> <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 v-if="fieldErrors['applicant.employer_address']">{{ fieldErrors['applicant.employer_address']
}}</FieldError> }}</FieldError>
</Field> </Field>
<Field class="col-span-12 sm:col-span-4"> <Field class="col-span-12 sm:col-span-4">
<FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel> <FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel>
@@ -948,7 +919,7 @@ onUnmounted(() => {
<FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel> <FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel>
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" :disabled="!canEdit" /> <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 v-if="fieldErrors[`heirs.${index}.ic_number`]">{{ fieldErrors[`heirs.${index}.ic_number`]
}}</FieldError> }}</FieldError>
</Field> </Field>
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel>Hubungan</FieldLabel> <FieldLabel>Hubungan</FieldLabel>
@@ -164,7 +164,7 @@ export interface MembershipApplicationDocumentDetail {
export interface MembershipApplicationReviewDetail { export interface MembershipApplicationReviewDetail {
id: string id: string
stage: 'MANAGEMENT' | 'BOARD' | 'COMPLETION' stage: 'MANAGEMENT' | 'BOARD'
decision: string | null decision: string | null
remarks: string | null remarks: string | null
reviewer: { id: string; name: string } | null reviewer: { id: string; name: string } | null
@@ -45,7 +45,7 @@ export function reviewDecisionBadgeVariant(
if (decision === 'REJECTED') return 'danger' if (decision === 'REJECTED') return 'danger'
} }
if (stage === 'BOARD' || stage === 'COMPLETION') { if (stage === 'BOARD') {
if (decision === 'PASS') return 'success' if (decision === 'PASS') return 'success'
if (decision === 'FAIL') return 'danger' if (decision === 'FAIL') return 'danger'
} }
@@ -59,7 +59,7 @@ export function reviewDecisionBadgeLook(
): BadgeVariants['look'] { ): BadgeVariants['look'] {
if ( if (
(stage === 'MANAGEMENT' && (decision === 'APPROVED' || decision === 'REJECTED')) || (stage === 'MANAGEMENT' && (decision === 'APPROVED' || decision === 'REJECTED')) ||
((stage === 'BOARD' || stage === 'COMPLETION') && (decision === 'PASS' || decision === 'FAIL')) (stage === 'BOARD' && (decision === 'PASS' || decision === 'FAIL'))
) { ) {
return undefined return undefined
} }
@@ -1,6 +1,5 @@
import dayjs from 'dayjs' import dayjs from 'dayjs'
import * as select from '@zag-js/select' import * as select from '@zag-js/select'
import { EMPLOYERS } from '@/constants/employers'
import type { import type {
MembershipApplicationDetail, MembershipApplicationDetail,
MembershipApplicationDocumentsForm, MembershipApplicationDocumentsForm,
@@ -36,15 +35,6 @@ export const RELATIONSHIP_OPTIONS: SelectOption[] = [
{ label: 'Lain-lain', value: 'Lain-lain' }, { label: 'Lain-lain', value: 'Lain-lain' },
] ]
export const EMPLOYER_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
label: employer.name,
value: employer.name,
}))
export function getEmployerAddress(name: string): string {
return EMPLOYERS.find((employer) => employer.name === name)?.address ?? ''
}
export const DOCUMENT_TYPE_LABELS: Record<string, string> = { export const DOCUMENT_TYPE_LABELS: Record<string, string> = {
ic_copy: 'Salinan Kad Pengenalan', ic_copy: 'Salinan Kad Pengenalan',
photo: 'Gambar Passport', photo: 'Gambar Passport',
+30 -5
View File
@@ -28,7 +28,6 @@ import {
updateEmployment, updateEmployment,
} from '@/modules/profile/services/employment.service' } from '@/modules/profile/services/employment.service'
import type { Employment, EmploymentPayload } from '@/modules/profile/types/employment.types' import type { Employment, EmploymentPayload } from '@/modules/profile/types/employment.types'
import { EMPLOYERS } from '@/constants/employers'
defineProps<{ defineProps<{
embedded?: boolean embedded?: boolean
@@ -66,10 +65,36 @@ type SelectOption = { label: string; value: string }
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [ const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Tetap', value: 'Permanent' }, { label: 'Tetap', value: 'Permanent' },
{ label: 'Kontrak', value: 'Contract' }, { label: 'Kontrak', value: 'Contract' },
{ label: 'Percubaan', value: 'Trial' }, { label: 'Latihan Industri', value: 'Internship' },
{ label: 'Sambilan', value: 'Part-time' }, { 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) => ({ const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
label: employer.name, label: employer.name,
value: employer.name, value: employer.name,
@@ -549,13 +574,13 @@ onMounted(async () => {
<FieldError v-if="employmentErrors.salary">{{ employmentErrors.salary }}</FieldError> <FieldError v-if="employmentErrors.salary">{{ employmentErrors.salary }}</FieldError>
</Field> </Field>
<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" <Input id="employment-start-date" v-model="employmentForm.start_date" type="date"
:aria-invalid="!!employmentErrors.start_date" @input="clearEmploymentFieldError('start_date')" /> :aria-invalid="!!employmentErrors.start_date" @input="clearEmploymentFieldError('start_date')" />
<FieldError v-if="employmentErrors.start_date">{{ employmentErrors.start_date }}</FieldError> <FieldError v-if="employmentErrors.start_date">{{ employmentErrors.start_date }}</FieldError>
</Field> </Field>
<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" <Input id="employment-end-date" v-model="employmentForm.end_date" type="date"
:disabled="employmentForm.is_current" :aria-invalid="!!employmentErrors.end_date" :disabled="employmentForm.is_current" :aria-invalid="!!employmentErrors.end_date"
@input="clearEmploymentFieldError('end_date')" /> @input="clearEmploymentFieldError('end_date')" />
+141 -39
View File
@@ -32,7 +32,6 @@ import {
updateUserEmployment, updateUserEmployment,
} from '../services/userEmployment.service' } from '../services/userEmployment.service'
import { getUser, updateUser } from '../services/user.service' import { getUser, updateUser } from '../services/user.service'
import { EMPLOYERS } from '@/constants/employers'
type SelectOption = { label: string; value: string } type SelectOption = { label: string; value: string }
@@ -63,10 +62,35 @@ const MEMBER_TYPE_OPTIONS: SelectOption[] = [
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [ const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Tetap', value: 'Permanent' }, { label: 'Tetap', value: 'Permanent' },
{ label: 'Kontrak', value: 'Contract' }, { label: 'Kontrak', value: 'Contract' },
{ label: 'Percubaan', value: 'Trial' }, { label: 'Latihan Industri', value: 'Internship' },
{ label: 'Sambilan', value: 'Part-time' }, { 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) => ({ const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
label: employer.name, label: employer.name,
value: employer.name, value: employer.name,
@@ -723,7 +747,13 @@ onMounted(() => {
<Field v-if="isInactiveStatus"> <Field v-if="isInactiveStatus">
<FieldLabel for="user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel> <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>
<Field> <Field>
@@ -756,8 +786,11 @@ onMounted(() => {
<div v-if="loading" class="text-sm text-slate-500">Memuatkan pekerjaan...</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-else-if="employments.length" class="space-y-3">
<div v-for="employment in employments" :key="employment.id" <div
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between"> 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="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-slate-900">{{ employment.company_name }}</span> <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> <p class="mt-1 text-sm text-slate-500">{{ formatSalary(employment.salary) }}</p>
</div> </div>
<div class="flex shrink-0 gap-2"> <div class="flex shrink-0 gap-2">
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none" <Button
:disabled="deletingEmploymentId === employment.id || saving" @click="startEditEmployment(employment)"> 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" /> <Lucide class="mr-2 size-4" icon="Pencil" />
Kemaskini Kemaskini
</Button> </Button>
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-danger" <Button
:disabled="deletingEmploymentId === employment.id || saving" @click="onDeleteEmployment(employment)"> type="button"
<Lucide class="mr-2 size-4" :icon="deletingEmploymentId === employment.id ? 'LoaderCircle' : 'Trash'" variant="ghost"
:class="{ 'animate-spin': deletingEmploymentId === employment.id }" /> 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 Padam
</Button> </Button>
</div> </div>
</div> </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. Tiada pekerjaan direkodkan.
</div> </div>
<form v-if="showEmploymentForm" class="space-y-6 border-t border-foreground/10 pt-6" <form
@submit.prevent="onSaveEmployment"> 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 class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h4 class="text-base font-semibold text-slate-900"> <h4 class="text-base font-semibold text-slate-900">
@@ -806,9 +858,14 @@ onMounted(() => {
</p> </p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button v-if="isEditingEmployment" type="button" variant="ghost" <Button
class="border border-foreground/15 shadow-none" :disabled="savingEmployment" v-if="isEditingEmployment"
@click="resetEmploymentForm"> type="button"
variant="ghost"
class="border border-foreground/15 shadow-none"
:disabled="savingEmployment"
@click="resetEmploymentForm"
>
Batal Batal
</Button> </Button>
<Button type="submit" variant="primary" :disabled="savingEmployment || saving"> <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"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field> <Field>
<FieldLabel>Nama Syarikat</FieldLabel> <FieldLabel>Nama Syarikat</FieldLabel>
<SelectRoot :key="`company-name-${editingEmploymentId ?? 'new'}`" class="w-full" <SelectRoot
:collection="companyNameCollection" :default-value="companyNameInitial" :key="`company-name-${editingEmploymentId ?? 'new'}`"
:disabled="savingEmployment || saving" @value-change="setCompanyNameValue"> class="w-full"
:collection="companyNameCollection"
:default-value="companyNameInitial"
:disabled="savingEmployment || saving"
@value-change="setCompanyNameValue"
>
<SelectControl> <SelectControl>
<SelectTrigger :aria-invalid="!!employmentErrors.company_name"> <SelectTrigger :aria-invalid="!!employmentErrors.company_name">
<SelectValueText placeholder="Pilih syarikat" /> <SelectValueText placeholder="Pilih syarikat" />
@@ -832,7 +894,11 @@ onMounted(() => {
<SelectContent> <SelectContent>
<SelectItemGroup> <SelectItemGroup>
<SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel> <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> <SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem> </SelectItem>
</SelectItemGroup> </SelectItemGroup>
@@ -845,9 +911,15 @@ onMounted(() => {
<Field> <Field>
<FieldLabel for="employment-job-title">Jawatan</FieldLabel> <FieldLabel for="employment-job-title">Jawatan</FieldLabel>
<Input id="employment-job-title" v-model="employmentForm.job_title" type="text" placeholder="Jawatan" <Input
:disabled="savingEmployment || saving" :aria-invalid="!!employmentErrors.job_title" id="employment-job-title"
@input="clearEmploymentFieldError('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"> <FieldError v-if="employmentErrors.job_title">
{{ employmentErrors.job_title }} {{ employmentErrors.job_title }}
</FieldError> </FieldError>
@@ -855,9 +927,14 @@ onMounted(() => {
<Field> <Field>
<FieldLabel>Jenis Kerja</FieldLabel> <FieldLabel>Jenis Kerja</FieldLabel>
<SelectRoot :key="`employment-type-${editingEmploymentId ?? 'new'}`" class="w-full" <SelectRoot
:collection="employmentTypeCollection" :default-value="employmentTypeInitial" :key="`employment-type-${editingEmploymentId ?? 'new'}`"
:disabled="savingEmployment || saving" @value-change="setEmploymentTypeValue"> class="w-full"
:collection="employmentTypeCollection"
:default-value="employmentTypeInitial"
:disabled="savingEmployment || saving"
@value-change="setEmploymentTypeValue"
>
<SelectControl> <SelectControl>
<SelectTrigger :aria-invalid="!!employmentErrors.employment_type"> <SelectTrigger :aria-invalid="!!employmentErrors.employment_type">
<SelectValueText placeholder="Pilih jenis kerja" /> <SelectValueText placeholder="Pilih jenis kerja" />
@@ -866,7 +943,11 @@ onMounted(() => {
<SelectContent> <SelectContent>
<SelectItemGroup> <SelectItemGroup>
<SelectItemGroupLabel>Jenis Kerja</SelectItemGroupLabel> <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> <SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem> </SelectItem>
</SelectItemGroup> </SelectItemGroup>
@@ -879,17 +960,30 @@ onMounted(() => {
<Field> <Field>
<FieldLabel for="employment-salary">Gaji (RM)</FieldLabel> <FieldLabel for="employment-salary">Gaji (RM)</FieldLabel>
<Input id="employment-salary" v-model="employmentForm.salary" type="number" min="0" step="0.01" <Input
placeholder="0.00" :disabled="savingEmployment || saving" :aria-invalid="!!employmentErrors.salary" id="employment-salary"
@input="clearEmploymentFieldError('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> <FieldError v-if="employmentErrors.salary">{{ employmentErrors.salary }}</FieldError>
</Field> </Field>
<Field> <Field>
<FieldLabel for="employment-start-date">Tarikh Mula</FieldLabel> <FieldLabel for="employment-start-date">Tarikh Mula</FieldLabel>
<Input id="employment-start-date" v-model="employmentForm.start_date" type="date" <Input
:disabled="savingEmployment || saving" :aria-invalid="!!employmentErrors.start_date" id="employment-start-date"
@input="clearEmploymentFieldError('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"> <FieldError v-if="employmentErrors.start_date">
{{ employmentErrors.start_date }} {{ employmentErrors.start_date }}
</FieldError> </FieldError>
@@ -897,17 +991,25 @@ onMounted(() => {
<Field> <Field>
<FieldLabel for="employment-end-date">Tarikh Tamat</FieldLabel> <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" :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"> <FieldError v-if="employmentErrors.end_date">
{{ employmentErrors.end_date }} {{ employmentErrors.end_date }}
</FieldError> </FieldError>
</Field> </Field>
<Field class="md:col-span-2"> <Field class="md:col-span-2">
<CheckboxRoot :checked="employmentForm.is_current" :disabled="savingEmployment || saving" <CheckboxRoot
@checked-change="({ checked }) => (employmentForm.is_current = checked === true)"> :checked="employmentForm.is_current"
:disabled="savingEmployment || saving"
@checked-change="({ checked }) => (employmentForm.is_current = checked === true)"
>
<CheckboxControl /> <CheckboxControl />
<CheckboxLabel>Pekerjaan semasa</CheckboxLabel> <CheckboxLabel>Pekerjaan semasa</CheckboxLabel>
</CheckboxRoot> </CheckboxRoot>
@@ -12,8 +12,8 @@ defineProps<{
const EMPLOYMENT_TYPE_LABEL: Record<string, string> = { const EMPLOYMENT_TYPE_LABEL: Record<string, string> = {
Permanent: 'Tetap', Permanent: 'Tetap',
Contract: 'Kontrak', Contract: 'Kontrak',
Trial: 'Percubaan', Internship: 'Latihan Industri',
PartTime: 'Sambilan', Freelance: 'Freelance',
} }
function formatSalary(value: number | string | null | undefined): string { function formatSalary(value: number | string | null | undefined): string {
@@ -56,8 +56,11 @@ function formatEmploymentPeriod(employment: Employment): string {
</div> </div>
<div v-if="user.employments?.length" class="space-y-3"> <div v-if="user.employments?.length" class="space-y-3">
<div v-for="employment in user.employments" :key="employment.id" <div
class="rounded-lg border border-foreground/10 p-4"> 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"> <div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-slate-900">{{ employment.company_name }}</span> <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 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> </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. Tiada pekerjaan direkodkan.
</div> </div>
</Box> </Box>
+13 -5
View File
@@ -52,7 +52,7 @@ const STATUS_FILTER_CHIPS: StatusFilterChip[] = [
function getEmployerShortLabel(name: string): string { function getEmployerShortLabel(name: string): string {
const match = name.match(/\(([^)]+)\)/) const match = name.match(/\(([^)]+)\)/)
if (match?.[1]) return match[1] 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' if (name.startsWith('Kel Infra')) return 'Kel Infra'
return name return name
} }
@@ -568,10 +568,18 @@ onMounted(() => {
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="text-sm opacity-70">Unit:</span> <span class="text-sm opacity-70">Unit:</span>
<Badge v-for="chip in UNIT_FILTER_CHIPS" :key="chip.value || 'all-units'" variant="ghost" <Badge
:look="isUnitFilterActive(chip.value) ? 'filled' : 'outline'" role="button" tabindex="0" v-for="chip in UNIT_FILTER_CHIPS"
:title="chip.value || 'Semua unit'" :aria-pressed="isUnitFilterActive(chip.value)" :key="chip.value || 'all-units'"
@click="setUnitFilter(chip.value)" @keydown.enter="setUnitFilter(chip.value)"> 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 }} {{ chip.label }}
</Badge> </Badge>
</div> </div>