2 Commits

Author SHA1 Message Date
ismailmasseran d0f5e368e3 DONE: fix password bypass in production, use dropdown for admin to edit membership application (#13)
Build Docker Image / build-backend (push) Successful in 5m31s
Build Docker Image / build-frontend (push) Successful in 28s
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #13
2026-07-16 11:02:14 +08:00
ismailmasseran 59b571d944 DONE: fix naming, add transaction for completion of membership application (#12)
Build Docker Image / build-backend (push) Successful in 46s
Build Docker Image / build-frontend (push) Successful in 19s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #12
2026-07-15 11:35:45 +08:00
17 changed files with 194 additions and 272 deletions
+8 -3
View File
@@ -4,6 +4,7 @@ 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
@@ -91,11 +92,15 @@ 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
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7 AUTH_COOKIE_NAME=auth_token
FRONTEND_URL=https://mykopkb.koppkb.com AUTH_COOKIE_LIFETIME=360
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=7 PUBLIC_PROFILE_TOKEN_TTL_DAYS=3
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,4 +69,13 @@ 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,4 +6,5 @@ enum ReviewStage: string
{ {
case Management = 'MANAGEMENT'; case Management = 'MANAGEMENT';
case Board = 'BOARD'; case Board = 'BOARD';
case Completion = 'COMPLETION';
} }
@@ -254,12 +254,8 @@ class MembershipApplicationService
}); });
} }
public function complete( public function complete(MembershipApplication $application, User $processedBy, string $boardMeetingReference, string $boardMeetingDate,): MembershipApplication
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.'],
@@ -272,7 +268,7 @@ class MembershipApplicationService
]); ]);
} }
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) { return DB::transaction(function () use ($application, $processedBy, $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) {
@@ -286,6 +282,18 @@ 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(),
@@ -302,12 +310,8 @@ 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( public function completeBatch(array $applicationIds, User $processedBy, string $boardMeetingReference, string $boardMeetingDate): array
array $applicationIds, {
User $processedBy,
string $boardMeetingReference,
string $boardMeetingDate,
): array {
$succeeded = []; $succeeded = [];
$failed = []; $failed = [];
+23 -11
View File
@@ -94,19 +94,31 @@ 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.';
// Bypass password check in local or development environments
$shouldBypassPassword = config('app.env', 'local');
if ($user && ($shouldBypassPassword || Hash::check($request->password, $user->password))) {
if (! $user->canAuthenticate()) {
throw ValidationException::withMessages([
'email' => [$user->getLoginRestrictionMessage()],
]);
}
return $user; $failLogin = function () use ($invalidCredentialsMessage): never {
throw ValidationException::withMessages([
'email' => [$invalidCredentialsMessage],
]);
};
if (! $user) {
$failLogin();
} }
$shouldBypassPassword = config('app.env', 'local');
if (! $shouldBypassPassword && ! Hash::check($request->password, $user->password)) {
$failLogin();
}
if (! $user->canAuthenticate()) {
throw ValidationException::withMessages([
'email' => [$user->getLoginRestrictionMessage()],
]);
}
return $user;
}); });
} }
} }
+5 -2
View File
@@ -59,10 +59,13 @@ 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' => 'Data tidak sah.', 'message' => $firstMessage,
'errors' => $e->errors(), 'errors' => $errors,
], 422); ], 422);
} }
}); });
+12 -4
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,11 +14,19 @@ 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: "An-Nisa'", name: "Pusat Perubatan 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.', name: 'Kel Infra Sdn Bhd (KISB)',
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
@@ -30,6 +30,7 @@ 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
@@ -78,26 +79,6 @@ 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,
@@ -319,7 +300,7 @@ const stepTitle = computed(() => {
case 1: case 1:
return 'Maklumat Peribadi' return 'Maklumat Peribadi'
case 2: case 2:
return 'Hubungan & Alamat' return 'Hubungan & Alamat Rumah Semasa'
case 3: case 3:
return 'Maklumat Pekerjaan & Caruman' return 'Maklumat Pekerjaan & Caruman'
case 4: case 4:
@@ -940,7 +921,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'
? 'Luluskan Permohonan?' ? 'Semak 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'
? 'Luluskan?' ? 'Keputusan ALK?'
: 'Gagalkan?' : 'Keputusan ALK?'
} }
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 mesyuarat ALK.' ? 'Permohonan akan dihantar ke peringkat keputusan ALK.'
: 'Permohonan akan ditolak pada peringkat pentadbiran.' : 'Permohonan akan ditolak pada peringkat pentadbiran.'
} }
@@ -325,6 +325,7 @@ 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
} }
@@ -332,11 +333,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 'Diluluskan' if (decision === 'APPROVED') return 'Disemak'
if (decision === 'REJECTED') return 'Ditolak' if (decision === 'REJECTED') return 'Ditolak'
} }
if (stage === 'BOARD') { if (stage === 'BOARD' || stage === 'COMPLETION') {
if (decision === 'PASS') return 'Lulus' if (decision === 'PASS') return 'Lulus'
if (decision === 'FAIL') return 'Gagal' if (decision === 'FAIL') return 'Gagal'
} }
@@ -647,7 +648,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">
Luluskan atau tolak permohonan pada peringkat pentadbiran. Semak 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>
@@ -657,7 +658,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' })">
Luluskan Semak
</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' })">
@@ -667,9 +668,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">Semakan Lembaga</div> <div class="font-medium">Keputusan Ahli Lembaga Koperasi (ALK)</div>
<p class="mt-1 text-sm opacity-70"> <p class="mt-1 text-sm opacity-70">
Luluskan atau gagalkan permohonan pada peringkat lembaga. Luluskan atau gagalkan permohonan pada peringkat ALK.
</p> </p>
<Field class="mt-4"> <Field class="mt-4">
<FieldLabel for="board-remarks">Catatan</FieldLabel> <FieldLabel for="board-remarks">Catatan</FieldLabel>
@@ -1030,8 +1031,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" <Input id="detail-board-meeting-date" v-model="boardMeetingDate" type="date" :disabled="completeSubmitting"
:disabled="completeSubmitting" @input="boardMeetingDateError = null" /> @input="boardMeetingDateError = null" />
<FieldError v-if="boardMeetingDateError">{{ boardMeetingDateError }}</FieldError> <FieldError v-if="boardMeetingDateError">{{ boardMeetingDateError }}</FieldError>
</Field> </Field>
</div> </div>
@@ -58,6 +58,7 @@ 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,
@@ -67,6 +68,7 @@ import {
createEmptyFormState, createEmptyFormState,
createSelectCollection, createSelectCollection,
detailToFormState, detailToFormState,
getEmployerAddress,
labelToApiValue, labelToApiValue,
} from '../utils/membership-application-form.utils' } from '../utils/membership-application-form.utils'
@@ -112,6 +114,7 @@ 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(
() => () =>
@@ -123,6 +126,7 @@ 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,
@@ -260,6 +264,7 @@ 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
} }
@@ -271,7 +276,7 @@ function reviewDecisionLabel(decision: string | null, stage: string): string {
if (decision === 'REJECTED') return 'Ditolak' if (decision === 'REJECTED') return 'Ditolak'
} }
if (stage === 'BOARD') { if (stage === 'BOARD' || stage === 'COMPLETION') {
if (decision === 'PASS') return 'Lulus' if (decision === 'PASS') return 'Lulus'
if (decision === 'FAIL') return 'Gagal' if (decision === 'FAIL') return 'Gagal'
} }
@@ -308,6 +313,14 @@ 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
@@ -858,8 +871,24 @@ 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 for="employer_name">Nama Majikan</FieldLabel> <FieldLabel>Nama Majikan</FieldLabel>
<Input id="employer_name" v-model="form.applicant.employer_name" type="text" :disabled="!canEdit" /> <SelectRoot :key="`employer-${form.applicant.employer_name}`" class="w-full"
: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>
@@ -867,13 +896,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="!canEdit" /> <Textarea id="employer_address" v-model="form.applicant.employer_address" rows="3" disabled />
<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>
@@ -919,7 +948,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' stage: 'MANAGEMENT' | 'BOARD' | 'COMPLETION'
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') { if (stage === 'BOARD' || stage === 'COMPLETION') {
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' && (decision === 'PASS' || decision === 'FAIL')) ((stage === 'BOARD' || stage === 'COMPLETION') && (decision === 'PASS' || decision === 'FAIL'))
) { ) {
return undefined return undefined
} }
@@ -1,5 +1,6 @@
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,
@@ -35,6 +36,15 @@ 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',
+5 -30
View File
@@ -28,6 +28,7 @@ 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
@@ -65,36 +66,10 @@ 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: 'Latihan Industri', value: 'Internship' }, { label: 'Percubaan', value: 'Trial' },
{ label: 'Freelance', value: 'Freelance' }, { label: 'Sambilan', value: 'Part-time' },
] ]
// 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,
@@ -574,13 +549,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</FieldLabel> <FieldLabel for="employment-start-date">Tarikh Mula Perkhidmatan</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</FieldLabel> <FieldLabel for="employment-end-date">Tarikh Tamat Perkhidmatan</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')" />
+39 -141
View File
@@ -32,6 +32,7 @@ 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 }
@@ -62,35 +63,10 @@ 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: 'Latihan Industri', value: 'Internship' }, { label: 'Percubaan', value: 'Trial' },
{ label: 'Freelance', value: 'Freelance' }, { label: 'Sambilan', value: 'Part-time' },
] ]
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,
@@ -747,13 +723,7 @@ 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 <Input id="user-leave-date" v-model="form.leave_date" class="w-full" type="date" :disabled="formDisabled" />
id="user-leave-date"
v-model="form.leave_date"
class="w-full"
type="date"
:disabled="formDisabled"
/>
</Field> </Field>
<Field> <Field>
@@ -786,11 +756,8 @@ 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 <div v-for="employment in employments" :key="employment.id"
v-for="employment in employments" class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between">
: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>
@@ -804,46 +771,27 @@ 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 <Button type="button" variant="ghost" class="border border-foreground/15 shadow-none"
type="button" :disabled="deletingEmploymentId === employment.id || saving" @click="startEditEmployment(employment)">
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 <Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-danger"
type="button" :disabled="deletingEmploymentId === employment.id || saving" @click="onDeleteEmployment(employment)">
variant="ghost" <Lucide class="mr-2 size-4" :icon="deletingEmploymentId === employment.id ? 'LoaderCircle' : 'Trash'"
class="border border-foreground/15 shadow-none text-danger" :class="{ 'animate-spin': deletingEmploymentId === employment.id }" />
: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 <div v-else class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500">
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 <form v-if="showEmploymentForm" class="space-y-6 border-t border-foreground/10 pt-6"
v-if="showEmploymentForm" @submit.prevent="onSaveEmployment">
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">
@@ -858,14 +806,9 @@ onMounted(() => {
</p> </p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button v-if="isEditingEmployment" type="button" variant="ghost"
v-if="isEditingEmployment" class="border border-foreground/15 shadow-none" :disabled="savingEmployment"
type="button" @click="resetEmploymentForm">
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">
@@ -878,14 +821,9 @@ 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 <SelectRoot :key="`company-name-${editingEmploymentId ?? 'new'}`" class="w-full"
:key="`company-name-${editingEmploymentId ?? 'new'}`" :collection="companyNameCollection" :default-value="companyNameInitial"
class="w-full" :disabled="savingEmployment || saving" @value-change="setCompanyNameValue">
: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" />
@@ -894,11 +832,7 @@ onMounted(() => {
<SelectContent> <SelectContent>
<SelectItemGroup> <SelectItemGroup>
<SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel> <SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel>
<SelectItem <SelectItem v-for="item in companyNameCollection.items" :key="item.label" :item="item">
v-for="item in companyNameCollection.items"
:key="item.label"
:item="item"
>
<SelectItemText>{{ item.label }}</SelectItemText> <SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem> </SelectItem>
</SelectItemGroup> </SelectItemGroup>
@@ -911,15 +845,9 @@ onMounted(() => {
<Field> <Field>
<FieldLabel for="employment-job-title">Jawatan</FieldLabel> <FieldLabel for="employment-job-title">Jawatan</FieldLabel>
<Input <Input id="employment-job-title" v-model="employmentForm.job_title" type="text" placeholder="Jawatan"
id="employment-job-title" :disabled="savingEmployment || saving" :aria-invalid="!!employmentErrors.job_title"
v-model="employmentForm.job_title" @input="clearEmploymentFieldError('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>
@@ -927,14 +855,9 @@ onMounted(() => {
<Field> <Field>
<FieldLabel>Jenis Kerja</FieldLabel> <FieldLabel>Jenis Kerja</FieldLabel>
<SelectRoot <SelectRoot :key="`employment-type-${editingEmploymentId ?? 'new'}`" class="w-full"
:key="`employment-type-${editingEmploymentId ?? 'new'}`" :collection="employmentTypeCollection" :default-value="employmentTypeInitial"
class="w-full" :disabled="savingEmployment || saving" @value-change="setEmploymentTypeValue">
: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" />
@@ -943,11 +866,7 @@ onMounted(() => {
<SelectContent> <SelectContent>
<SelectItemGroup> <SelectItemGroup>
<SelectItemGroupLabel>Jenis Kerja</SelectItemGroupLabel> <SelectItemGroupLabel>Jenis Kerja</SelectItemGroupLabel>
<SelectItem <SelectItem v-for="item in employmentTypeCollection.items" :key="item.label" :item="item">
v-for="item in employmentTypeCollection.items"
:key="item.label"
:item="item"
>
<SelectItemText>{{ item.label }}</SelectItemText> <SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem> </SelectItem>
</SelectItemGroup> </SelectItemGroup>
@@ -960,30 +879,17 @@ onMounted(() => {
<Field> <Field>
<FieldLabel for="employment-salary">Gaji (RM)</FieldLabel> <FieldLabel for="employment-salary">Gaji (RM)</FieldLabel>
<Input <Input id="employment-salary" v-model="employmentForm.salary" type="number" min="0" step="0.01"
id="employment-salary" placeholder="0.00" :disabled="savingEmployment || saving" :aria-invalid="!!employmentErrors.salary"
v-model="employmentForm.salary" @input="clearEmploymentFieldError('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 <Input id="employment-start-date" v-model="employmentForm.start_date" type="date"
id="employment-start-date" :disabled="savingEmployment || saving" :aria-invalid="!!employmentErrors.start_date"
v-model="employmentForm.start_date" @input="clearEmploymentFieldError('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>
@@ -991,25 +897,17 @@ onMounted(() => {
<Field> <Field>
<FieldLabel for="employment-end-date">Tarikh Tamat</FieldLabel> <FieldLabel for="employment-end-date">Tarikh Tamat</FieldLabel>
<Input <Input id="employment-end-date" v-model="employmentForm.end_date" type="date"
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" :aria-invalid="!!employmentErrors.end_date" @input="clearEmploymentFieldError('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 <CheckboxRoot :checked="employmentForm.is_current" :disabled="savingEmployment || saving"
:checked="employmentForm.is_current" @checked-change="({ checked }) => (employmentForm.is_current = checked === true)">
: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',
Internship: 'Latihan Industri', Trial: 'Percubaan',
Freelance: 'Freelance', PartTime: 'Sambilan',
} }
function formatSalary(value: number | string | null | undefined): string { function formatSalary(value: number | string | null | undefined): string {
@@ -56,11 +56,8 @@ 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 <div v-for="employment in user.employments" :key="employment.id"
v-for="employment in user.employments" class="rounded-lg border border-foreground/10 p-4">
: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>
@@ -74,10 +71,7 @@ function formatEmploymentPeriod(employment: Employment): string {
</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">
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>
+5 -13
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('An-Nisa')) return "An-Nisa'" if (name.startsWith('Pusat Perubatan An-Nisa')) return "Pusat Perubatan An-Nisa'"
if (name.startsWith('Kel Infra')) return 'Kel Infra' if (name.startsWith('Kel Infra')) return 'Kel Infra'
return name return name
} }
@@ -568,18 +568,10 @@ 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 <Badge v-for="chip in UNIT_FILTER_CHIPS" :key="chip.value || 'all-units'" variant="ghost"
v-for="chip in UNIT_FILTER_CHIPS" :look="isUnitFilterActive(chip.value) ? 'filled' : 'outline'" role="button" tabindex="0"
:key="chip.value || 'all-units'" :title="chip.value || 'Semua unit'" :aria-pressed="isUnitFilterActive(chip.value)"
variant="ghost" @click="setUnitFilter(chip.value)" @keydown.enter="setUnitFilter(chip.value)">
: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>