Dev/v1.1 (#2)
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local> Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -513,7 +513,7 @@ function stepLabelClass(stepId: number) {
|
||||
<div
|
||||
class="before:bg-foreground/10 relative flex flex-col justify-center px-5 before:absolute before:bottom-0 before:top-0 before:mt-6 before:hidden before:h-0.5 before:w-[69%] sm:px-10 lg:flex-row before:lg:block">
|
||||
<div v-for="step in steps" :key="step.id" class="z-10 flex flex-1 items-center lg:block lg:text-center">
|
||||
<Button :class="stepButtonClass(step.id)" :variant="step.id === currentStep ? 'default' : 'ghost'">
|
||||
<Button :class="stepButtonClass(step.id)" :variant="step.id === currentStep ? 'primary' : 'ghost'">
|
||||
{{ step.id }}
|
||||
</Button>
|
||||
<div :class="stepLabelClass(step.id)">
|
||||
@@ -810,7 +810,7 @@ function stepLabelClass(stepId: number) {
|
||||
<Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png"
|
||||
@change="handleFileChange('employer_letter', $event)" />
|
||||
<FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter']
|
||||
}}</FieldError>
|
||||
}}</FieldError>
|
||||
</Field>
|
||||
|
||||
<div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { CircleAlert, CircleCheck, Download, Eye, Pencil } from '@lucide/vue'
|
||||
import { CircleAlert, CircleCheck, Download, Eye, FileText, Pencil } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
@@ -13,7 +13,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
@@ -23,10 +23,17 @@ import {
|
||||
completeMembershipApplication,
|
||||
downloadMembershipApplicationDocument,
|
||||
fetchMembershipApplicationDocument,
|
||||
generateMembershipApplicationResultLetter,
|
||||
getMembershipApplication,
|
||||
submitBoardReview,
|
||||
submitManagementReview,
|
||||
} from '../services/membership-application.service'
|
||||
import {
|
||||
reviewDecisionBadgeLook,
|
||||
reviewDecisionBadgeVariant,
|
||||
statusBadgeLook,
|
||||
statusBadgeVariant,
|
||||
} from '../utils/membership-application-badge.utils'
|
||||
import type {
|
||||
BoardReviewDecision,
|
||||
ManagementReviewDecision,
|
||||
@@ -36,6 +43,7 @@ import type {
|
||||
MembershipApplicationReviewDetail,
|
||||
MembershipApplicationStatus,
|
||||
} from '../types/membership-application.types'
|
||||
import { RESULT_LETTER_DOCUMENT_TYPE } from '../types/membership-application.types'
|
||||
|
||||
const WORKFLOW_STEPS = [
|
||||
{ id: 1, label: 'Dihantar' },
|
||||
@@ -50,6 +58,7 @@ const DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
||||
photo: 'Gambar Passport',
|
||||
salary_slip: 'Slip Gaji',
|
||||
employer_letter: 'Surat Pengesahan Majikan',
|
||||
[RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan',
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
@@ -77,6 +86,10 @@ const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
const generateDialogOpen = ref(false)
|
||||
const generateSubmitting = ref(false)
|
||||
const boardMeetingReference = ref('')
|
||||
const boardMeetingReferenceError = ref<string | null>(null)
|
||||
|
||||
function statusLabel(status: MembershipApplicationStatus): string {
|
||||
const labels: Record<MembershipApplicationStatus, string> = {
|
||||
@@ -90,13 +103,6 @@ function statusLabel(status: MembershipApplicationStatus): string {
|
||||
return labels[status] ?? status
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: MembershipApplicationStatus) {
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
if (status === 'MANAGEMENT_REJECTED') return 'danger'
|
||||
if (status === 'PENDING_BOARD' || status === 'PENDING_NOTIFICATION') return 'pending'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function getWorkflowProgress(status: MembershipApplicationStatus) {
|
||||
switch (status) {
|
||||
case 'SUBMITTED':
|
||||
@@ -136,6 +142,20 @@ const showCompleteAction = computed(
|
||||
application.value?.status === 'PENDING_NOTIFICATION',
|
||||
)
|
||||
|
||||
const resultLetterDocument = computed(() =>
|
||||
application.value?.documents.find((document) => document.type === RESULT_LETTER_DOCUMENT_TYPE) ?? null,
|
||||
)
|
||||
|
||||
const showGenerateResultLetter = computed(
|
||||
() =>
|
||||
hasPermission('jana surat keputusan keahlian') &&
|
||||
application.value?.status === 'COMPLETED' &&
|
||||
!resultLetterDocument.value &&
|
||||
(application.value.board_result === 'PASS' || application.value.board_result === 'FAIL'),
|
||||
)
|
||||
|
||||
const applicant = computed(() => application.value?.applicant ?? null)
|
||||
|
||||
const confirmDialogTitle = computed(() => {
|
||||
if (!pendingAction.value) return 'Sahkan Tindakan'
|
||||
|
||||
@@ -208,8 +228,6 @@ function workflowStepLabelClass(stepId: number) {
|
||||
: 'ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32'
|
||||
}
|
||||
|
||||
const applicant = computed(() => application.value?.applicant ?? null)
|
||||
|
||||
const sortedReviews = computed(() => {
|
||||
if (!application.value?.reviews.length) return []
|
||||
|
||||
@@ -220,6 +238,10 @@ const sortedReviews = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const uploadedDocuments = computed(() =>
|
||||
application.value?.documents.filter((document) => document.type !== RESULT_LETTER_DOCUMENT_TYPE) ?? [],
|
||||
)
|
||||
|
||||
function displayValue(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value)
|
||||
@@ -307,20 +329,6 @@ function reviewDecisionLabel(decision: string | null, stage: string): string {
|
||||
return decision
|
||||
}
|
||||
|
||||
function reviewDecisionBadgeVariant(decision: string | null, stage: string) {
|
||||
if (stage === 'MANAGEMENT') {
|
||||
if (decision === 'APPROVED') return 'success'
|
||||
if (decision === 'REJECTED') return 'danger'
|
||||
}
|
||||
|
||||
if (stage === 'BOARD') {
|
||||
if (decision === 'PASS') return 'success'
|
||||
if (decision === 'FAIL') return 'danger'
|
||||
}
|
||||
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function reviewTimelineDotClass(review: MembershipApplicationReviewDetail): string {
|
||||
const variant = reviewDecisionBadgeVariant(review.decision, review.stage)
|
||||
|
||||
@@ -405,6 +413,49 @@ async function confirmPendingAction() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openGenerateDialog() {
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
generateDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeGenerateDialog() {
|
||||
if (generateSubmitting.value) return
|
||||
generateDialogOpen.value = false
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
}
|
||||
|
||||
async function confirmGenerateLetter() {
|
||||
if (!application.value || generateSubmitting.value) return
|
||||
|
||||
const reference = boardMeetingReference.value.trim()
|
||||
if (!reference) {
|
||||
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
|
||||
generateSubmitting.value = true
|
||||
boardMeetingReferenceError.value = null
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await generateMembershipApplicationResultLetter(application.value.id, {
|
||||
board_meeting_reference: reference,
|
||||
})
|
||||
|
||||
application.value = response.data.application
|
||||
successMessage.value = response.message
|
||||
closeGenerateDialog()
|
||||
} catch (err) {
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
|
||||
error.value = getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
|
||||
} finally {
|
||||
generateSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadDocument(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value || downloadingDocumentId.value) return
|
||||
|
||||
@@ -500,7 +551,8 @@ onUnmounted(() => {
|
||||
<div class="text-xl font-semibold">{{ application.application_number }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge :variant="statusBadgeVariant(application.status)" class="whitespace-nowrap">
|
||||
<Badge :variant="statusBadgeVariant(application.status)" :look="statusBadgeLook(application.status)"
|
||||
class="whitespace-nowrap">
|
||||
{{ statusLabel(application.status) }}
|
||||
</Badge>
|
||||
<Badge v-if="application.board_result"
|
||||
@@ -539,7 +591,7 @@ onUnmounted(() => {
|
||||
<div v-for="step in WORKFLOW_STEPS" :key="step.id"
|
||||
class="z-10 flex flex-1 items-center lg:block lg:text-center">
|
||||
<Button type="button" :class="workflowStepButtonClass(step.id)"
|
||||
:variant="step.id === workflowProgress?.currentStep && application.status !== 'COMPLETED' && !workflowProgress?.failed ? 'default' : 'ghost'"
|
||||
:variant="step.id === workflowProgress?.currentStep && application.status !== 'COMPLETED' && !workflowProgress?.failed ? 'primary' : 'ghost'"
|
||||
disabled>
|
||||
{{ step.id }}
|
||||
</Button>
|
||||
@@ -608,6 +660,46 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="showGenerateResultLetter" class="p-5 sm:p-6">
|
||||
<div class="font-medium">Surat Keputusan</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Jana surat keputusan lembaga untuk permohonan ini. Surat hanya boleh dijana sekali.
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button type="button" variant="primary" @click="openGenerateDialog">
|
||||
<FileText class="mr-2 size-4" />
|
||||
Jana Surat Keputusan
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-else-if="resultLetterDocument" class="p-5 sm:p-6">
|
||||
<div class="font-medium">Surat Keputusan</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
{{ resultLetterDocument.name }} · {{ formatFileSize(resultLetterDocument.file_size) }}
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
:disabled="previewLoading && previewDocument?.id === resultLetterDocument.id"
|
||||
@click="handleViewDocument(resultLetterDocument)"
|
||||
>
|
||||
<Eye class="mr-2 size-4" />
|
||||
Lihat
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
:disabled="downloadingDocumentId === resultLetterDocument.id"
|
||||
@click="handleDownloadDocument(resultLetterDocument)"
|
||||
>
|
||||
<Download class="mr-2 size-4" />
|
||||
{{ downloadingDocumentId === resultLetterDocument.id ? 'Memuat turun...' : 'Muat Turun' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<TabsRoot defaultValue="personal" class="w-full">
|
||||
<Box raised="single" class="w-full p-0">
|
||||
<div class="w-full px-5 py-4">
|
||||
@@ -797,9 +889,9 @@ onUnmounted(() => {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documents" class="mt-6">
|
||||
<div v-if="!application.documents.length" class="opacity-70">Tiada dokumen dimuat naik.</div>
|
||||
<div v-if="!uploadedDocuments.length" class="opacity-70">Tiada dokumen dimuat naik.</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="document in application.documents" :key="document.id"
|
||||
<div v-for="document in uploadedDocuments" :key="document.id"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-foreground/10 p-4">
|
||||
<div>
|
||||
<div class="font-medium">{{ documentLabel(document.type, document.name) }}</div>
|
||||
@@ -840,7 +932,11 @@ onUnmounted(() => {
|
||||
{{ formatDateTime(review.reviewed_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge :variant="reviewDecisionBadgeVariant(review.decision, review.stage)" class="whitespace-nowrap">
|
||||
<Badge
|
||||
:variant="reviewDecisionBadgeVariant(review.decision, review.stage)"
|
||||
:look="reviewDecisionBadgeLook(review.decision, review.stage)"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
{{ reviewDecisionLabel(review.decision, review.stage) }}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -881,6 +977,43 @@ onUnmounted(() => {
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<DialogRoot :open="generateDialogOpen" @openChange="(details) => { if (!details.open) closeGenerateDialog() }">
|
||||
<DialogContent>
|
||||
<div class="p-5">
|
||||
<div class="text-2xl font-medium">Jana Surat Keputusan</div>
|
||||
<p v-if="application" class="mt-2 text-sm opacity-70">
|
||||
{{ application.application_number }} · {{ application.applicant?.name ?? '-' }}
|
||||
</p>
|
||||
<Field class="mt-5">
|
||||
<FieldLabel for="detail-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
|
||||
<Input
|
||||
id="detail-board-meeting-reference"
|
||||
v-model="boardMeetingReference"
|
||||
type="text"
|
||||
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026"
|
||||
:disabled="generateSubmitting"
|
||||
@input="boardMeetingReferenceError = null"
|
||||
/>
|
||||
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-32" :disabled="generateSubmitting" @click="closeGenerateDialog">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-32"
|
||||
type="button"
|
||||
variant="primary"
|
||||
:disabled="generateSubmitting"
|
||||
@click="confirmGenerateLetter"
|
||||
>
|
||||
{{ generateSubmitting ? 'Menjana...' : 'Jana Surat' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="previewOpen" class="fixed inset-0 z-70 flex items-center justify-center p-4 sm:p-6" role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -37,6 +37,12 @@ import {
|
||||
updateMembershipApplication,
|
||||
uploadMembershipApplicationDocument,
|
||||
} from '../services/membership-application.service'
|
||||
import {
|
||||
reviewDecisionBadgeLook,
|
||||
reviewDecisionBadgeVariant,
|
||||
statusBadgeLook,
|
||||
statusBadgeVariant,
|
||||
} from '../utils/membership-application-badge.utils'
|
||||
import type {
|
||||
DocumentUploadType,
|
||||
MembershipApplicationDetail,
|
||||
@@ -166,13 +172,6 @@ function statusLabel(status: MembershipApplicationStatus): string {
|
||||
return labels[status] ?? status
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: MembershipApplicationStatus) {
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
if (status === 'MANAGEMENT_REJECTED') return 'danger'
|
||||
if (status === 'PENDING_BOARD' || status === 'PENDING_NOTIFICATION') return 'pending'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function workflowStepButtonClass(stepId: number) {
|
||||
const progress = workflowProgress.value
|
||||
if (!progress) return 'mx-2 size-12 rounded-full shadow-none bg-background border border-foreground/15'
|
||||
@@ -268,20 +267,6 @@ function reviewDecisionLabel(decision: string | null, stage: string): string {
|
||||
return decision
|
||||
}
|
||||
|
||||
function reviewDecisionBadgeVariant(decision: string | null, stage: string) {
|
||||
if (stage === 'MANAGEMENT') {
|
||||
if (decision === 'APPROVED') return 'success'
|
||||
if (decision === 'REJECTED') return 'danger'
|
||||
}
|
||||
|
||||
if (stage === 'BOARD') {
|
||||
if (decision === 'PASS') return 'success'
|
||||
if (decision === 'FAIL') return 'danger'
|
||||
}
|
||||
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function reviewTimelineDotClass(review: MembershipApplicationReviewDetail): string {
|
||||
const variant = reviewDecisionBadgeVariant(review.decision, review.stage)
|
||||
|
||||
@@ -543,12 +528,8 @@ onUnmounted(() => {
|
||||
{{ application.application_number }} · {{ application.applicant?.name ?? '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
look="outline"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
@click="router.push({ name: 'view-membership-application', params: { id: applicationId } })"
|
||||
>
|
||||
<Button look="outline" variant="secondary" type="button"
|
||||
@click="router.push({ name: 'view-membership-application', params: { id: applicationId } })">
|
||||
Lihat
|
||||
</Button>
|
||||
<Button look="outline" variant="secondary" type="button"
|
||||
@@ -581,7 +562,8 @@ onUnmounted(() => {
|
||||
<div class="text-xl font-semibold">{{ application.application_number }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge :variant="statusBadgeVariant(application.status)" class="whitespace-nowrap">
|
||||
<Badge :variant="statusBadgeVariant(application.status)" :look="statusBadgeLook(application.status)"
|
||||
class="whitespace-nowrap">
|
||||
{{ statusLabel(application.status) }}
|
||||
</Badge>
|
||||
<Badge v-if="application.board_result"
|
||||
@@ -620,7 +602,7 @@ onUnmounted(() => {
|
||||
<div v-for="step in WORKFLOW_STEPS" :key="step.id"
|
||||
class="z-10 flex flex-1 items-center lg:block lg:text-center">
|
||||
<Button type="button" :class="workflowStepButtonClass(step.id)"
|
||||
:variant="step.id === workflowProgress?.currentStep && application.status !== 'COMPLETED' && !workflowProgress?.failed ? 'default' : 'ghost'"
|
||||
:variant="step.id === workflowProgress?.currentStep && application.status !== 'COMPLETED' && !workflowProgress?.failed ? 'primary' : 'ghost'"
|
||||
disabled>
|
||||
{{ step.id }}
|
||||
</Button>
|
||||
@@ -635,29 +617,39 @@ onUnmounted(() => {
|
||||
<Box raised="single" class="w-full p-0">
|
||||
<div class="w-full px-5 py-4">
|
||||
<TabsList class="mb-0 flex w-full">
|
||||
<TabsTrigger class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm" value="personal">
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="personal">
|
||||
Maklumat Peribadi
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm" value="contact">
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="contact">
|
||||
Hubungan & Alamat
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm" value="employment">
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="employment">
|
||||
Pekerjaan & Caruman
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm" value="heirs">
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="heirs">
|
||||
Waris
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm" value="references">
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="references">
|
||||
Pencadang & Penyokong
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm" value="documents">
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="documents">
|
||||
Dokumen
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
v-if="application.reviews.length"
|
||||
<TabsTrigger v-if="application.reviews.length"
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="reviews"
|
||||
>
|
||||
value="reviews">
|
||||
Sejarah Semakan
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -679,28 +671,25 @@ onUnmounted(() => {
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="ic_number">No. Kad Pengenalan</FieldLabel>
|
||||
<Input id="ic_number" v-model="form.applicant.ic_number" type="text" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.ic_number']">{{ fieldErrors['applicant.ic_number'] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors['applicant.ic_number']">{{ fieldErrors['applicant.ic_number'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="birth_date">Tarikh Lahir</FieldLabel>
|
||||
<Input id="birth_date" v-model="form.applicant.birth_date" type="date" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.birth_date']">{{ fieldErrors['applicant.birth_date'] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors['applicant.birth_date']">{{ fieldErrors['applicant.birth_date'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="birth_place">Tempat Lahir</FieldLabel>
|
||||
<Input id="birth_place" v-model="form.applicant.birth_place" type="text" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.birth_place']">{{ fieldErrors['applicant.birth_place'] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors['applicant.birth_place']">{{ fieldErrors['applicant.birth_place'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Jantina</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`gender-${form.applicant.gender}`"
|
||||
class="w-full"
|
||||
:collection="genderCollection"
|
||||
:default-value="genderInitial"
|
||||
:disabled="!canEdit"
|
||||
@value-change="setGenderValue"
|
||||
>
|
||||
<SelectRoot :key="`gender-${form.applicant.gender}`" class="w-full" :collection="genderCollection"
|
||||
:default-value="genderInitial" :disabled="!canEdit" @value-change="setGenderValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.gender']">
|
||||
<SelectValueText placeholder="Pilih jantina" />
|
||||
@@ -719,14 +708,9 @@ onUnmounted(() => {
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Status Perkahwinan</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`marriage-${form.applicant.marriage_status}`"
|
||||
class="w-full"
|
||||
:collection="marriageStatusCollection"
|
||||
:default-value="marriageStatusInitial"
|
||||
:disabled="!canEdit"
|
||||
@value-change="setMarriageStatusValue"
|
||||
>
|
||||
<SelectRoot :key="`marriage-${form.applicant.marriage_status}`" class="w-full"
|
||||
:collection="marriageStatusCollection" :default-value="marriageStatusInitial" :disabled="!canEdit"
|
||||
@value-change="setMarriageStatusValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.marriage_status']">
|
||||
<SelectValueText placeholder="Pilih status" />
|
||||
@@ -741,7 +725,8 @@ onUnmounted(() => {
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="fieldErrors['applicant.marriage_status']">{{ fieldErrors['applicant.marriage_status'] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors['applicant.marriage_status']">{{ fieldErrors['applicant.marriage_status'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -756,12 +741,14 @@ onUnmounted(() => {
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="phone_number">No. Telefon</FieldLabel>
|
||||
<Input id="phone_number" v-model="form.applicant.phone_number" type="text" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.phone_number']">{{ fieldErrors['applicant.phone_number'] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors['applicant.phone_number']">{{ fieldErrors['applicant.phone_number'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="office_number">No. Pejabat</FieldLabel>
|
||||
<Input id="office_number" v-model="form.applicant.office_number" type="text" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.office_number']">{{ fieldErrors['applicant.office_number'] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors['applicant.office_number']">{{ fieldErrors['applicant.office_number'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="postcode">Poskod</FieldLabel>
|
||||
@@ -776,51 +763,51 @@ onUnmounted(() => {
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="employer_name">Nama Majikan</FieldLabel>
|
||||
<Input id="employer_name" v-model="form.applicant.employer_name" type="text" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.employer_name']">{{ fieldErrors['applicant.employer_name'] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors['applicant.employer_name']">{{ fieldErrors['applicant.employer_name'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="current_position">Jawatan Semasa</FieldLabel>
|
||||
<Input id="current_position" v-model="form.applicant.current_position" type="text" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.current_position']">{{ fieldErrors['applicant.current_position'] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors['applicant.current_position']">{{ fieldErrors['applicant.current_position']
|
||||
}}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12">
|
||||
<FieldLabel for="employer_address">Alamat Majikan</FieldLabel>
|
||||
<Textarea id="employer_address" v-model="form.applicant.employer_address" rows="3" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.employer_address']">{{ fieldErrors['applicant.employer_address'] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors['applicant.employer_address']">{{ fieldErrors['applicant.employer_address']
|
||||
}}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel>
|
||||
<Input id="start_work_date" v-model="form.applicant.start_work_date" type="date" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.start_work_date']">{{ fieldErrors['applicant.start_work_date'] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors['applicant.start_work_date']">{{ fieldErrors['applicant.start_work_date'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="stock_monthly_contribution">Caruman Saham (RM)</FieldLabel>
|
||||
<Input id="stock_monthly_contribution" v-model="form.applicant.stock_monthly_contribution" type="number"
|
||||
min="0" step="0.01" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.stock_monthly_contribution']">{{ fieldErrors['applicant.stock_monthly_contribution'] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors['applicant.stock_monthly_contribution']">{{
|
||||
fieldErrors['applicant.stock_monthly_contribution'] }}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="fee_monthly_contribution">Caruman Yuran (RM)</FieldLabel>
|
||||
<Input id="fee_monthly_contribution" v-model="form.applicant.fee_monthly_contribution" type="number"
|
||||
min="0" step="0.01" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.fee_monthly_contribution']">{{ fieldErrors['applicant.fee_monthly_contribution'] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors['applicant.fee_monthly_contribution']">{{
|
||||
fieldErrors['applicant.fee_monthly_contribution'] }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="heirs" class="mt-6">
|
||||
<div class="space-y-4">
|
||||
<div v-for="(heir, index) in form.heirs" :key="index"
|
||||
class="rounded-lg border border-foreground/10 p-4">
|
||||
<div v-for="(heir, index) in form.heirs" :key="index" class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="font-medium">Waris {{ index + 1 }}</div>
|
||||
<Button
|
||||
v-if="canEdit && form.heirs.length > 1"
|
||||
type="button"
|
||||
look="outline"
|
||||
size="sm"
|
||||
@click="removeHeir(index)"
|
||||
>
|
||||
<Button v-if="canEdit && form.heirs.length > 1" type="button" look="outline" size="sm"
|
||||
@click="removeHeir(index)">
|
||||
Buang
|
||||
</Button>
|
||||
</div>
|
||||
@@ -828,23 +815,21 @@ onUnmounted(() => {
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel :for="`heir-name-${index}`">Nama</FieldLabel>
|
||||
<Input :id="`heir-name-${index}`" v-model="heir.name" type="text" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.name`]">{{ fieldErrors[`heirs.${index}.name`] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.name`]">{{ fieldErrors[`heirs.${index}.name`] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel>
|
||||
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">{{ fieldErrors[`heirs.${index}.ic_number`] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">{{ fieldErrors[`heirs.${index}.ic_number`]
|
||||
}}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Hubungan</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`heir-relationship-${index}-${heir.relationship}`"
|
||||
class="w-full"
|
||||
<SelectRoot :key="`heir-relationship-${index}-${heir.relationship}`" class="w-full"
|
||||
:collection="relationshipCollection"
|
||||
:default-value="apiValueToLabel(RELATIONSHIP_OPTIONS, heir.relationship)"
|
||||
:disabled="!canEdit"
|
||||
@value-change="(details) => setHeirRelationshipValue(index, details)"
|
||||
>
|
||||
:default-value="apiValueToLabel(RELATIONSHIP_OPTIONS, heir.relationship)" :disabled="!canEdit"
|
||||
@value-change="(details) => setHeirRelationshipValue(index, details)">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!fieldErrors[`heirs.${index}.relationship`]">
|
||||
<SelectValueText placeholder="Pilih hubungan" />
|
||||
@@ -859,12 +844,14 @@ onUnmounted(() => {
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.relationship`]">{{ fieldErrors[`heirs.${index}.relationship`] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.relationship`]">{{
|
||||
fieldErrors[`heirs.${index}.relationship`] }}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel :for="`heir-phone-${index}`">No. Telefon</FieldLabel>
|
||||
<Input :id="`heir-phone-${index}`" v-model="heir.phone_number" type="text" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.phone_number`]">{{ fieldErrors[`heirs.${index}.phone_number`] }}</FieldError>
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.phone_number`]">{{
|
||||
fieldErrors[`heirs.${index}.phone_number`] }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -882,15 +869,9 @@ onUnmounted(() => {
|
||||
<div class="grid grid-cols-12 gap-4 gap-y-5">
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="proposer_ic_number">No. KP Pencadang</FieldLabel>
|
||||
<Input
|
||||
id="proposer_ic_number"
|
||||
v-model="form.references.proposer.ic_number"
|
||||
type="text"
|
||||
placeholder="Contoh: 900101011234"
|
||||
:disabled="!canEdit || referenceLookupLoading.proposer"
|
||||
@input="handleReferenceIcInput('proposer')"
|
||||
@blur="lookupReference('proposer')"
|
||||
/>
|
||||
<Input id="proposer_ic_number" v-model="form.references.proposer.ic_number" type="text"
|
||||
placeholder="Contoh: 900101011234" :disabled="!canEdit || referenceLookupLoading.proposer"
|
||||
@input="handleReferenceIcInput('proposer')" @blur="lookupReference('proposer')" />
|
||||
<p v-if="form.references.proposer.name" class="mt-1 text-sm text-success">
|
||||
{{ form.references.proposer.name }}
|
||||
</p>
|
||||
@@ -900,15 +881,9 @@ onUnmounted(() => {
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="supporter_ic_number">No. KP Penyokong</FieldLabel>
|
||||
<Input
|
||||
id="supporter_ic_number"
|
||||
v-model="form.references.supporter.ic_number"
|
||||
type="text"
|
||||
placeholder="Contoh: 850505055678"
|
||||
:disabled="!canEdit || referenceLookupLoading.supporter"
|
||||
@input="handleReferenceIcInput('supporter')"
|
||||
@blur="lookupReference('supporter')"
|
||||
/>
|
||||
<Input id="supporter_ic_number" v-model="form.references.supporter.ic_number" type="text"
|
||||
placeholder="Contoh: 850505055678" :disabled="!canEdit || referenceLookupLoading.supporter"
|
||||
@input="handleReferenceIcInput('supporter')" @blur="lookupReference('supporter')" />
|
||||
<p v-if="form.references.supporter.name" class="mt-1 text-sm text-success">
|
||||
{{ form.references.supporter.name }}
|
||||
</p>
|
||||
@@ -925,11 +900,7 @@ onUnmounted(() => {
|
||||
Muat naik fail baharu untuk menggantikan dokumen sedia ada.
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="type in DOCUMENT_UPLOAD_TYPES"
|
||||
:key="type"
|
||||
class="rounded-lg border border-foreground/10 p-4"
|
||||
>
|
||||
<div v-for="type in DOCUMENT_UPLOAD_TYPES" :key="type" class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">{{ DOCUMENT_TYPE_LABELS[type] }}</div>
|
||||
@@ -939,23 +910,15 @@ onUnmounted(() => {
|
||||
<div v-else class="mt-1 text-sm opacity-70">Tiada dokumen dimuat naik.</div>
|
||||
</div>
|
||||
<div v-if="documentsByType[type]" class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
size="sm"
|
||||
<Button type="button" look="outline" size="sm"
|
||||
:disabled="previewLoading && previewDocument?.id === documentsByType[type]?.id"
|
||||
@click="documentsByType[type] && handleViewDocument(documentsByType[type]!)"
|
||||
>
|
||||
@click="documentsByType[type] && handleViewDocument(documentsByType[type]!)">
|
||||
<Eye class="mr-2 size-4" />
|
||||
Lihat
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
size="sm"
|
||||
<Button type="button" look="outline" size="sm"
|
||||
:disabled="downloadingDocumentId === documentsByType[type]?.id"
|
||||
@click="documentsByType[type] && handleDownloadDocument(documentsByType[type]!)"
|
||||
>
|
||||
@click="documentsByType[type] && handleDownloadDocument(documentsByType[type]!)">
|
||||
<Download class="mr-2 size-4" />
|
||||
Muat Turun
|
||||
</Button>
|
||||
@@ -965,13 +928,8 @@ onUnmounted(() => {
|
||||
<FieldLabel :for="`document-${type}`">
|
||||
{{ documentsByType[type] ? 'Ganti Dokumen' : 'Muat Naik Dokumen' }}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
:id="`document-${type}`"
|
||||
type="file"
|
||||
:accept="documentAccept(type)"
|
||||
:disabled="uploadingDocumentType === type"
|
||||
@change="handleDocumentUpload(type, $event)"
|
||||
/>
|
||||
<Input :id="`document-${type}`" type="file" :accept="documentAccept(type)"
|
||||
:disabled="uploadingDocumentType === type" @change="handleDocumentUpload(type, $event)" />
|
||||
<FieldError v-if="fieldErrors[`documents.${type}`]">{{ fieldErrors[`documents.${type}`] }}</FieldError>
|
||||
<p v-if="uploadingDocumentType === type" class="mt-1 text-sm opacity-70">Memuat naik...</p>
|
||||
</Field>
|
||||
@@ -981,19 +939,11 @@ onUnmounted(() => {
|
||||
|
||||
<TabsContent v-if="application.reviews.length" value="reviews" class="mt-6">
|
||||
<div class="relative ms-3 ps-8">
|
||||
<div
|
||||
v-for="(review, index) in sortedReviews"
|
||||
:key="review.id"
|
||||
class="relative pb-8 last:pb-0"
|
||||
>
|
||||
<span
|
||||
class="absolute -start-8 top-1.5 flex size-3.5 rounded-full border-2 ring-4 ring-background"
|
||||
:class="reviewTimelineDotClass(review)"
|
||||
/>
|
||||
<span
|
||||
v-if="index < sortedReviews.length - 1"
|
||||
class="absolute -start-[1.375rem] top-5 h-[calc(100%-0.25rem)] w-px bg-foreground/15"
|
||||
/>
|
||||
<div v-for="(review, index) in sortedReviews" :key="review.id" class="relative pb-8 last:pb-0">
|
||||
<span class="absolute -inset-s-8 top-1.5 flex size-3.5 rounded-full border-2 ring-4 ring-background"
|
||||
:class="reviewTimelineDotClass(review)" />
|
||||
<span v-if="index < sortedReviews.length - 1"
|
||||
class="absolute -inset-s-5.5 top-5 h-[calc(100%-0.25rem)] w-px bg-foreground/15" />
|
||||
|
||||
<div class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
@@ -1003,10 +953,8 @@ onUnmounted(() => {
|
||||
{{ formatDateTime(review.reviewed_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="reviewDecisionBadgeVariant(review.decision, review.stage)"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
<Badge :variant="reviewDecisionBadgeVariant(review.decision, review.stage)"
|
||||
:look="reviewDecisionBadgeLook(review.decision, review.stage)" class="whitespace-nowrap">
|
||||
{{ reviewDecisionLabel(review.decision, review.stage) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CircleAlert, CircleCheck, Search, Eye, Pencil } from '@lucide/vue'
|
||||
import { CircleAlert, CircleCheck, Search, Eye, Pencil, FileText, Download } from '@lucide/vue'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import {
|
||||
@@ -26,16 +26,27 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import axios from 'axios'
|
||||
import { useMembershipApplicationList } from '../composables/useMembershipApplicationList'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { batchCompleteMembershipApplications } from '../services/membership-application.service'
|
||||
import {
|
||||
batchCompleteMembershipApplications,
|
||||
downloadMembershipApplicationDocument,
|
||||
generateMembershipApplicationResultLetter,
|
||||
} from '../services/membership-application.service'
|
||||
import {
|
||||
boardResultBadgeVariant,
|
||||
statusBadgeLook,
|
||||
statusBadgeVariant,
|
||||
} from '../utils/membership-application-badge.utils'
|
||||
import type {
|
||||
BatchCompleteFailedItem,
|
||||
BatchCompleteResponse,
|
||||
GenerateResultLetterResponse,
|
||||
MembershipApplicationBoardResult,
|
||||
MembershipApplicationListItem,
|
||||
MembershipApplicationStatus,
|
||||
@@ -94,11 +105,19 @@ const {
|
||||
} = useMembershipApplicationList()
|
||||
|
||||
const canBatchComplete = computed(() => hasPermission('selesaikan permohonan keahlian'))
|
||||
const canGenerateResultLetter = computed(() => hasPermission('jana surat keputusan keahlian'))
|
||||
const selectedIds = ref<string[]>([])
|
||||
const batchSubmitting = ref(false)
|
||||
const batchConfirmOpen = ref(false)
|
||||
const batchSuccessMessage = ref<string | null>(null)
|
||||
const letterSuccessMessage = ref<string | null>(null)
|
||||
const batchFailedItems = ref<BatchCompleteFailedItem[]>([])
|
||||
const generateDialogOpen = ref(false)
|
||||
const generateSubmitting = ref(false)
|
||||
const boardMeetingReference = ref('')
|
||||
const boardMeetingReferenceError = ref<string | null>(null)
|
||||
const generateTarget = ref<MembershipApplicationListItem | null>(null)
|
||||
const downloadingResultLetterId = ref<string | null>(null)
|
||||
|
||||
const selectableApplications = computed(() =>
|
||||
applications.value.filter((item) => item.status === 'PENDING_NOTIFICATION'),
|
||||
@@ -203,25 +222,12 @@ function statusLabel(status: MembershipApplicationStatus): string {
|
||||
return labels[status] ?? status
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: MembershipApplicationStatus) {
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
if (status === 'MANAGEMENT_REJECTED') return 'danger'
|
||||
if (status === 'PENDING_BOARD' || status === 'PENDING_NOTIFICATION') return 'pending'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function boardResultLabel(result: MembershipApplicationBoardResult | null): string {
|
||||
if (result === 'PASS') return 'Lulus'
|
||||
if (result === 'FAIL') return 'Gagal'
|
||||
return '-'
|
||||
}
|
||||
|
||||
function boardResultBadgeVariant(result: MembershipApplicationBoardResult | null) {
|
||||
if (result === 'PASS') return 'success'
|
||||
if (result === 'FAIL') return 'danger'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function formatSubmittedAt(value: string | null): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD/MM/YYYY HH:mm')
|
||||
@@ -235,47 +241,131 @@ function goToApplicationEdit(id: string) {
|
||||
router.push({ name: 'edit-membership-application', params: { id } })
|
||||
}
|
||||
|
||||
function canGenerateLetter(item: MembershipApplicationListItem): boolean {
|
||||
return (
|
||||
canGenerateResultLetter.value &&
|
||||
item.status === 'COMPLETED' &&
|
||||
!item.has_result_letter &&
|
||||
(item.board_result === 'PASS' || item.board_result === 'FAIL')
|
||||
)
|
||||
}
|
||||
|
||||
function canDownloadLetter(item: MembershipApplicationListItem): boolean {
|
||||
return !!item.has_result_letter && !!item.result_letter_document
|
||||
}
|
||||
|
||||
function openGenerateDialog(item: MembershipApplicationListItem) {
|
||||
generateTarget.value = item
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
generateDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeGenerateDialog() {
|
||||
if (generateSubmitting.value) return
|
||||
generateDialogOpen.value = false
|
||||
generateTarget.value = null
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
}
|
||||
|
||||
async function confirmGenerateLetter() {
|
||||
if (!generateTarget.value || generateSubmitting.value) return
|
||||
|
||||
const reference = boardMeetingReference.value.trim()
|
||||
if (!reference) {
|
||||
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
|
||||
generateSubmitting.value = true
|
||||
boardMeetingReferenceError.value = null
|
||||
error.value = null
|
||||
letterSuccessMessage.value = null
|
||||
|
||||
try {
|
||||
const response = await generateMembershipApplicationResultLetter(generateTarget.value.id, {
|
||||
board_meeting_reference: reference,
|
||||
})
|
||||
|
||||
letterSuccessMessage.value = response.message
|
||||
closeGenerateDialog()
|
||||
await fetchApplications(page.value)
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response?.data) {
|
||||
const responseData = err.response.data as GenerateResultLetterResponse
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
|
||||
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
|
||||
} else {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
|
||||
}
|
||||
} finally {
|
||||
generateSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadResultLetter(item: MembershipApplicationListItem) {
|
||||
const document = item.result_letter_document
|
||||
if (!document) return
|
||||
|
||||
downloadingResultLetterId.value = item.id
|
||||
|
||||
try {
|
||||
await downloadMembershipApplicationDocument(
|
||||
item.id,
|
||||
document.id,
|
||||
document.name,
|
||||
document.mime_type,
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat turun surat keputusan.')
|
||||
} finally {
|
||||
downloadingResultLetterId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const headers = computed<TableHeader[]>(() => {
|
||||
const base: TableHeader[] = [
|
||||
{ title: 'Bil.', key: '#', sortable: false },
|
||||
{ title: 'No. Permohonan', key: 'application_number', sortable: true },
|
||||
{
|
||||
title: 'Nama Pemohon',
|
||||
key: 'applicant_name',
|
||||
sortable: false,
|
||||
exportValue: (item) => item.applicant?.name ?? '',
|
||||
},
|
||||
{
|
||||
title: 'Emel',
|
||||
key: 'applicant_email',
|
||||
sortable: false,
|
||||
exportValue: (item) => item.applicant?.email ?? '',
|
||||
},
|
||||
{
|
||||
title: 'No. IC',
|
||||
key: 'applicant_ic_number',
|
||||
sortable: false,
|
||||
exportValue: (item) => item.applicant?.ic_number ?? '',
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
key: 'status',
|
||||
sortable: true,
|
||||
exportValue: (item) => statusLabel(item.status),
|
||||
},
|
||||
{
|
||||
title: 'Keputusan Lembaga',
|
||||
key: 'board_result',
|
||||
sortable: false,
|
||||
exportValue: (item) => boardResultLabel(item.board_result),
|
||||
},
|
||||
{
|
||||
title: 'Tarikh Hantar',
|
||||
key: 'submitted_at',
|
||||
sortable: true,
|
||||
exportValue: (item) => formatSubmittedAt(item.submitted_at),
|
||||
},
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
{ title: 'No. Permohonan', key: 'application_number', sortable: true },
|
||||
{
|
||||
title: 'Nama Pemohon',
|
||||
key: 'applicant_name',
|
||||
sortable: false,
|
||||
exportValue: (item) => item.applicant?.name ?? '',
|
||||
},
|
||||
{
|
||||
title: 'Emel',
|
||||
key: 'applicant_email',
|
||||
sortable: false,
|
||||
exportValue: (item) => item.applicant?.email ?? '',
|
||||
},
|
||||
{
|
||||
title: 'No. IC',
|
||||
key: 'applicant_ic_number',
|
||||
sortable: false,
|
||||
exportValue: (item) => item.applicant?.ic_number ?? '',
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
key: 'status',
|
||||
sortable: true,
|
||||
exportValue: (item) => statusLabel(item.status),
|
||||
},
|
||||
{
|
||||
title: 'Keputusan Lembaga',
|
||||
key: 'board_result',
|
||||
sortable: false,
|
||||
exportValue: (item) => boardResultLabel(item.board_result),
|
||||
},
|
||||
{
|
||||
title: 'Tarikh Hantar',
|
||||
key: 'submitted_at',
|
||||
sortable: true,
|
||||
exportValue: (item) => formatSubmittedAt(item.submitted_at),
|
||||
},
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
if (canBatchComplete.value) {
|
||||
@@ -294,6 +384,13 @@ const headers = computed<TableHeader[]>(() => {
|
||||
<p class="mt-1 text-sm opacity-70">Urus dan semak permohonan keahlian koperasi.</p>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="letterSuccessMessage" variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ letterSuccessMessage }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="letterSuccessMessage = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="batchSuccessMessage" variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
@@ -321,41 +418,20 @@ const headers = computed<TableHeader[]>(() => {
|
||||
<AlertCloseTrigger @click="error = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<DataTable
|
||||
:headers="headers"
|
||||
:items="applications"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:current-sort="sortBy"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="permohonan-keahlian"
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate"
|
||||
>
|
||||
<DataTable :headers="headers" :items="applications" :loading="loading" :pagination="pagination"
|
||||
:current-sort="sortBy" show-pagination exportable export-file-name="permohonan-keahlian" v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage" @update:sort-by="handleSortUpdate">
|
||||
<template #toolbar>
|
||||
<div class="flex w-full flex-wrap items-center gap-3">
|
||||
<div class="relative w-full max-w-md flex-1">
|
||||
<Search
|
||||
class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
v-model="search"
|
||||
type="search"
|
||||
placeholder="Cari no. permohonan, nama, emel, IC..."
|
||||
class="w-full pl-9"
|
||||
aria-label="Cari permohonan keahlian"
|
||||
/>
|
||||
<Search class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
|
||||
aria-hidden="true" />
|
||||
<Input v-model="search" type="search" placeholder="Cari no. permohonan, nama, emel, IC..."
|
||||
class="w-full pl-9" aria-label="Cari permohonan keahlian" />
|
||||
</div>
|
||||
|
||||
<SelectRoot
|
||||
class="w-full sm:w-56"
|
||||
:collection="statusFilterCollection"
|
||||
:default-value="statusFilterInitial"
|
||||
@value-change="setStatusFilterValue"
|
||||
>
|
||||
<SelectRoot class="w-full sm:w-56" :collection="statusFilterCollection" :default-value="statusFilterInitial"
|
||||
@value-change="setStatusFilterValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis status">
|
||||
<SelectValueText placeholder="Semua Status" />
|
||||
@@ -364,11 +440,7 @@ const headers = computed<TableHeader[]>(() => {
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in statusFilterCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItem v-for="item in statusFilterCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
@@ -376,21 +448,13 @@ const headers = computed<TableHeader[]>(() => {
|
||||
</SelectRoot>
|
||||
|
||||
<template v-if="canBatchComplete">
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
variant="secondary"
|
||||
<Button type="button" look="outline" variant="secondary"
|
||||
:disabled="!selectableApplications.length || loading"
|
||||
@click="toggleSelectAllOnPage(!allSelectableSelected)"
|
||||
>
|
||||
@click="toggleSelectAllOnPage(!allSelectableSelected)">
|
||||
{{ allSelectableSelected ? 'Nyahpilih Halaman' : 'Pilih Halaman' }}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
:disabled="!selectedIds.length || loading || batchSubmitting"
|
||||
@click="openBatchConfirm"
|
||||
>
|
||||
<Button type="button" variant="primary" :disabled="!selectedIds.length || loading || batchSubmitting"
|
||||
@click="openBatchConfirm">
|
||||
Selesaikan Terpilih ({{ selectedIds.length }})
|
||||
</Button>
|
||||
</template>
|
||||
@@ -398,11 +462,9 @@ const headers = computed<TableHeader[]>(() => {
|
||||
</template>
|
||||
|
||||
<template v-if="canBatchComplete" #item.select="{ item }">
|
||||
<CheckboxRoot
|
||||
v-if="isSelectable(item as MembershipApplicationListItem)"
|
||||
<CheckboxRoot v-if="isSelectable(item as MembershipApplicationListItem)"
|
||||
:checked="isSelected((item as MembershipApplicationListItem).id)"
|
||||
@checked-change="({ checked }) => toggleSelection((item as MembershipApplicationListItem).id)"
|
||||
>
|
||||
@checked-change="({ checked }) => toggleSelection((item as MembershipApplicationListItem).id)">
|
||||
<CheckboxControl />
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
@@ -422,6 +484,7 @@ const headers = computed<TableHeader[]>(() => {
|
||||
<template #item.status="{ item }">
|
||||
<Badge
|
||||
:variant="statusBadgeVariant((item as MembershipApplicationListItem).status)"
|
||||
:look="statusBadgeLook((item as MembershipApplicationListItem).status)"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
{{ statusLabel((item as MembershipApplicationListItem).status) }}
|
||||
@@ -429,11 +492,9 @@ const headers = computed<TableHeader[]>(() => {
|
||||
</template>
|
||||
|
||||
<template #item.board_result="{ item }">
|
||||
<Badge
|
||||
v-if="(item as MembershipApplicationListItem).board_result"
|
||||
<Badge v-if="(item as MembershipApplicationListItem).board_result"
|
||||
:variant="boardResultBadgeVariant((item as MembershipApplicationListItem).board_result)"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
class="whitespace-nowrap">
|
||||
{{ boardResultLabel((item as MembershipApplicationListItem).board_result) }}
|
||||
</Badge>
|
||||
<span v-else class="opacity-50">-</span>
|
||||
@@ -445,36 +506,44 @@ const headers = computed<TableHeader[]>(() => {
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="hasPermission('lihat permohonan keahlian')"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="bg-green-600 text-white"
|
||||
title="Lihat butiran permohonan"
|
||||
@click="goToApplicationDetail((item as MembershipApplicationListItem).id)"
|
||||
>
|
||||
<Button v-if="hasPermission('lihat permohonan keahlian')" type="button" variant="ghost" size="sm"
|
||||
class="bg-green-600 text-white" title="Lihat butiran permohonan"
|
||||
@click="goToApplicationDetail((item as MembershipApplicationListItem).id)">
|
||||
<Eye class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasPermission('kemaskini permohonan keahlian')"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="bg-blue-600 text-white"
|
||||
title="Kemaskini permohonan"
|
||||
@click="goToApplicationEdit((item as MembershipApplicationListItem).id)"
|
||||
>
|
||||
<Button v-if="hasPermission('kemaskini permohonan keahlian')" type="button" variant="ghost" size="sm"
|
||||
class="bg-blue-600 text-white" title="Kemaskini permohonan"
|
||||
@click="goToApplicationEdit((item as MembershipApplicationListItem).id)">
|
||||
<Pencil class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canGenerateLetter(item as MembershipApplicationListItem)"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-amber-600 text-white"
|
||||
title="Jana surat keputusan"
|
||||
@click="openGenerateDialog(item as MembershipApplicationListItem)"
|
||||
>
|
||||
<FileText class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDownloadLetter(item as MembershipApplicationListItem)"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-purple-600 text-white"
|
||||
title="Muat turun surat keputusan"
|
||||
:disabled="downloadingResultLetterId === (item as MembershipApplicationListItem).id"
|
||||
@click="handleDownloadResultLetter(item as MembershipApplicationListItem)"
|
||||
>
|
||||
<Download class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<DialogRoot
|
||||
:open="batchConfirmOpen"
|
||||
@openChange="(details) => { batchConfirmOpen = details.open }"
|
||||
>
|
||||
<DialogRoot :open="batchConfirmOpen" @openChange="(details) => { batchConfirmOpen = details.open }">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-2 text-2xl font-medium">Selesaikan Permohonan Terpilih?</div>
|
||||
@@ -486,14 +555,49 @@ const headers = computed<TableHeader[]>(() => {
|
||||
<DialogCloseTrigger class="mr-2 w-32" :disabled="batchSubmitting">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button class="w-32" type="button" variant="primary" :disabled="batchSubmitting"
|
||||
@click="confirmBatchComplete">
|
||||
{{ batchSubmitting ? 'Memproses...' : 'Sahkan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<DialogRoot :open="generateDialogOpen" @openChange="(details) => { if (!details.open) closeGenerateDialog() }">
|
||||
<DialogContent>
|
||||
<div class="p-5">
|
||||
<div class="text-2xl font-medium">Jana Surat Keputusan</div>
|
||||
<p v-if="generateTarget" class="mt-2 text-sm opacity-70">
|
||||
{{ generateTarget.application_number }} · {{ generateTarget.applicant?.name ?? '-' }}
|
||||
</p>
|
||||
<Field class="mt-5">
|
||||
<FieldLabel for="board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
|
||||
<Input
|
||||
id="board-meeting-reference"
|
||||
v-model="boardMeetingReference"
|
||||
type="text"
|
||||
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026"
|
||||
:disabled="generateSubmitting"
|
||||
@input="boardMeetingReferenceError = null"
|
||||
/>
|
||||
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||
</Field>
|
||||
<p class="mt-3 text-sm opacity-70">
|
||||
Surat hanya boleh dijana sekali dan akan disimpan sebagai dokumen permohonan.
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-32" :disabled="generateSubmitting" @click="closeGenerateDialog">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-32"
|
||||
type="button"
|
||||
variant="primary"
|
||||
:disabled="batchSubmitting"
|
||||
@click="confirmBatchComplete"
|
||||
:disabled="generateSubmitting"
|
||||
@click="confirmGenerateLetter"
|
||||
>
|
||||
{{ batchSubmitting ? 'Memproses...' : 'Sahkan' }}
|
||||
{{ generateSubmitting ? 'Menjana...' : 'Jana Surat' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type {
|
||||
BatchCompleteResponse,
|
||||
DocumentUploadType,
|
||||
GenerateResultLetterPayload,
|
||||
GenerateResultLetterResponse,
|
||||
ListMembershipApplicationsParams,
|
||||
MembershipApplicationApiResponse,
|
||||
MembershipApplicationFormState,
|
||||
@@ -9,7 +12,6 @@ import type {
|
||||
MembershipApplicationReviewPayload,
|
||||
MembershipApplicationReviewResponse,
|
||||
MembershipApplicationSubmitResponse,
|
||||
BatchCompleteResponse,
|
||||
MembershipApplicationUpdateResponse,
|
||||
MemberLookupResponse,
|
||||
UpdateMembershipApplicationPayload,
|
||||
@@ -215,6 +217,22 @@ export async function batchCompleteMembershipApplications(
|
||||
return data
|
||||
}
|
||||
|
||||
export async function generateMembershipApplicationResultLetter(
|
||||
id: string,
|
||||
payload: GenerateResultLetterPayload,
|
||||
): Promise<GenerateResultLetterResponse> {
|
||||
const { data } = await api.post<GenerateResultLetterResponse>(
|
||||
`/v1/membership-applications/${id}/result-letter`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to generate result letter')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Fetch membership application document/ view in modal
|
||||
export async function fetchMembershipApplicationDocument(
|
||||
applicationId: string,
|
||||
|
||||
@@ -80,6 +80,8 @@ export type MembershipApplicationStatus =
|
||||
|
||||
export type MembershipApplicationBoardResult = 'PASS' | 'FAIL'
|
||||
|
||||
export const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter' as const
|
||||
|
||||
export interface MembershipApplicationApplicantSummary {
|
||||
name: string
|
||||
email: string
|
||||
@@ -92,6 +94,8 @@ export interface MembershipApplicationListItem {
|
||||
status: MembershipApplicationStatus
|
||||
board_result: MembershipApplicationBoardResult | null
|
||||
submitted_at: string | null
|
||||
has_result_letter: boolean
|
||||
result_letter_document: MembershipApplicationDocumentDetail | null
|
||||
applicant: MembershipApplicationApplicantSummary | null
|
||||
created_at: string | null
|
||||
}
|
||||
@@ -232,3 +236,16 @@ export type BatchCompleteResponse = {
|
||||
failed: BatchCompleteFailedItem[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenerateResultLetterPayload {
|
||||
board_meeting_reference: string
|
||||
}
|
||||
|
||||
export type GenerateResultLetterResponse = {
|
||||
success: boolean
|
||||
message: string
|
||||
data: {
|
||||
application: MembershipApplicationDetail
|
||||
document: MembershipApplicationDocumentDetail | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
|
||||
import type {
|
||||
MembershipApplicationBoardResult,
|
||||
MembershipApplicationStatus,
|
||||
} from '../types/membership-application.types'
|
||||
|
||||
export function statusBadgeVariant(
|
||||
status: MembershipApplicationStatus,
|
||||
): NonNullable<BadgeVariants['variant']> {
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
if (status === 'MANAGEMENT_REJECTED') return 'danger'
|
||||
if (status === 'PENDING_BOARD' || status === 'PENDING_NOTIFICATION') return 'pending'
|
||||
return 'ghost'
|
||||
}
|
||||
|
||||
export function statusBadgeLook(
|
||||
status: MembershipApplicationStatus,
|
||||
): BadgeVariants['look'] {
|
||||
if (
|
||||
status === 'COMPLETED' ||
|
||||
status === 'MANAGEMENT_REJECTED' ||
|
||||
status === 'PENDING_BOARD' ||
|
||||
status === 'PENDING_NOTIFICATION'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
export function boardResultBadgeVariant(
|
||||
result: MembershipApplicationBoardResult | null,
|
||||
): NonNullable<BadgeVariants['variant']> {
|
||||
if (result === 'PASS') return 'success'
|
||||
if (result === 'FAIL') return 'danger'
|
||||
return 'ghost'
|
||||
}
|
||||
|
||||
export function reviewDecisionBadgeVariant(
|
||||
decision: string | null,
|
||||
stage: string,
|
||||
): NonNullable<BadgeVariants['variant']> {
|
||||
if (stage === 'MANAGEMENT') {
|
||||
if (decision === 'APPROVED') return 'success'
|
||||
if (decision === 'REJECTED') return 'danger'
|
||||
}
|
||||
|
||||
if (stage === 'BOARD') {
|
||||
if (decision === 'PASS') return 'success'
|
||||
if (decision === 'FAIL') return 'danger'
|
||||
}
|
||||
|
||||
return 'ghost'
|
||||
}
|
||||
|
||||
export function reviewDecisionBadgeLook(
|
||||
decision: string | null,
|
||||
stage: string,
|
||||
): BadgeVariants['look'] {
|
||||
if (
|
||||
(stage === 'MANAGEMENT' && (decision === 'APPROVED' || decision === 'REJECTED')) ||
|
||||
(stage === 'BOARD' && (decision === 'PASS' || decision === 'FAIL'))
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return 'outline'
|
||||
}
|
||||
@@ -156,9 +156,8 @@ onMounted(async () => {
|
||||
<div
|
||||
class="mt-6 flex flex-1 items-center justify-center border-t border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-0 lg:pt-0">
|
||||
<div
|
||||
class="relative aspect-[1.75/1] w-full max-w-[17rem] overflow-hidden rounded-2xl bg-gradient-to-br from-primary via-primary/95 to-primary/75 p-4 text-primary-foreground shadow-lg ring-1 ring-white/20 sm:max-w-xs sm:p-5"
|
||||
role="img"
|
||||
aria-label="Kad digital anggota">
|
||||
class="relative aspect-1.75/1 w-full max-w-68 overflow-hidden rounded-2xl bg-linear-to-br from-primary via-primary/95 to-primary/75 p-4 text-primary-foreground shadow-lg ring-1 ring-white/20 sm:max-w-xs sm:p-5"
|
||||
role="img" aria-label="Kad digital anggota">
|
||||
<div class="pointer-events-none absolute inset-0 bg-noise opacity-30" />
|
||||
<div class="pointer-events-none absolute -right-10 -top-10 size-36 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute -bottom-12 -left-8 size-40 rounded-full bg-white/5" />
|
||||
@@ -212,9 +211,6 @@ onMounted(async () => {
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="2">
|
||||
<Lucide class="mr-2 size-4" icon="Lock" /> Kata Laluan
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="4">
|
||||
<Lucide class="mr-2 size-4" icon="MoreHorizontal" /> Lain-lain
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</Box>
|
||||
@@ -238,357 +234,6 @@ onMounted(async () => {
|
||||
<TabsContent value="6" class="mt-8">
|
||||
<HeirTab embedded />
|
||||
</TabsContent>
|
||||
<!-- Perkhidmatan -->
|
||||
<TabsContent value="4" class="mt-8">
|
||||
<div class="grid grid-cols-12 gap-x-6 gap-y-8">
|
||||
<!-- BEGIN: Latest Uploads -->
|
||||
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
|
||||
<div class="flex items-center border-b border-foreground/15 px-5 py-5 sm:py-3">
|
||||
<h2 class="mr-auto text-base font-medium">Latest Uploads</h2>
|
||||
<MenuRoot class="w-auto ml-auto sm:hidden">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="all">All Files</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
<Button variant="ghost" class="shadow-none border border-foreground/15 hidden sm:flex">All Files</Button>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<FileIcon class="w-12" variant="directory" />
|
||||
<div class="ml-4">
|
||||
<a class="font-medium" href="">Documentation</a>
|
||||
<div class="mt-0.5 text-xs opacity-70">40 KB</div>
|
||||
</div>
|
||||
<MenuRoot class="w-auto ml-auto">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="share">
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Share File
|
||||
</MenuItem>
|
||||
<MenuItem value="delete">
|
||||
<Lucide class="mr-2 size-4" icon="Trash" /> Delete
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
<div class="mt-5 flex items-center">
|
||||
<FileIcon class="w-12 text-xs" variant="file" type="MP3" />
|
||||
<div class="ml-4">
|
||||
<a class="font-medium" href="">Celine Dion - Ashes</a>
|
||||
<div class="mt-0.5 text-xs opacity-70">40 KB</div>
|
||||
</div>
|
||||
<MenuRoot class="w-auto ml-auto">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="share">
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Share File
|
||||
</MenuItem>
|
||||
<MenuItem value="delete">
|
||||
<Lucide class="mr-2 size-4" icon="Trash" /> Delete
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
<div class="mt-5 flex items-center">
|
||||
<FileIcon class="w-12" variant="empty-directory" />
|
||||
<div class="ml-4">
|
||||
<a class="font-medium" href="">Resources</a>
|
||||
<div class="mt-0.5 text-xs opacity-70">0 KB</div>
|
||||
</div>
|
||||
<MenuRoot class="w-auto ml-auto">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="share">
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Share File
|
||||
</MenuItem>
|
||||
<MenuItem value="delete">
|
||||
<Lucide class="mr-2 size-4" icon="Trash" /> Delete
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<!-- END: Latest Uploads -->
|
||||
<!-- BEGIN: Work In Progress -->
|
||||
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
|
||||
<TabsRoot defaultValue="wip-0">
|
||||
<div class="relative flex items-center border-b border-foreground/15 p-5">
|
||||
<h2 class="mr-auto text-base font-medium">Work In Progress</h2>
|
||||
<MenuRoot class="w-auto ml-auto sm:hidden">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="new">New</MenuItem>
|
||||
<MenuItem value="last-week">Last Week</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
<TabsList class="absolute inset-y-0 h-[2.8rem] right-5 my-auto hidden w-auto sm:flex">
|
||||
<TabsTrigger value="wip-0">New</TabsTrigger>
|
||||
<TabsTrigger value="wip-1">Last Week</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="wip-0" class="p-5">
|
||||
<div>
|
||||
<div class="flex">
|
||||
<div class="mr-auto">Pending Tasks</div>
|
||||
<div>20%</div>
|
||||
</div>
|
||||
<ProgressRoot :defaultValue="50" class="mt-2">
|
||||
<ProgressTrack>
|
||||
<ProgressRange />
|
||||
</ProgressTrack>
|
||||
</ProgressRoot>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="flex">
|
||||
<div class="mr-auto">Completed Tasks</div>
|
||||
<div>2 / 20</div>
|
||||
</div>
|
||||
<ProgressRoot :defaultValue="25" class="mt-2">
|
||||
<ProgressTrack>
|
||||
<ProgressRange />
|
||||
</ProgressTrack>
|
||||
</ProgressRoot>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="flex">
|
||||
<div class="mr-auto">Tasks In Progress</div>
|
||||
<div>42</div>
|
||||
</div>
|
||||
<ProgressRoot :defaultValue="75" class="mt-2">
|
||||
<ProgressTrack>
|
||||
<ProgressRange />
|
||||
</ProgressTrack>
|
||||
</ProgressRoot>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<Button variant="ghost" class="border border-foreground/15 shadow-none mx-auto mt-5 inline-block"
|
||||
as="a" href="">
|
||||
View More Details
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</Box>
|
||||
<!-- END: Work In Progress -->
|
||||
<!-- BEGIN: Daily Sales -->
|
||||
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
|
||||
<div class="flex items-center border-b border-foreground/15 px-5 py-5 sm:py-3">
|
||||
<h2 class="mr-auto text-base font-medium">Daily Sales</h2>
|
||||
<MenuRoot class="w-auto ml-auto sm:hidden">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="download">
|
||||
<Lucide class="mr-2 size-4" icon="File" /> Download Excel
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
<Button variant="ghost" class="shadow-none border border-foreground/15 hidden sm:flex">
|
||||
<Lucide class="mr-2 size-4" icon="File" /> Download Excel
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div v-for="(faker, index) in fakers.slice(0, 3)" :key="index" :class="{ 'mt-5': index > 0 }"
|
||||
class="relative flex items-center">
|
||||
<AvatarRoot class="size-12 bg-background rounded-full">
|
||||
<AvatarFallback>IM</AvatarFallback>
|
||||
<AvatarImage :src="faker['photos'][0]" />
|
||||
</AvatarRoot>
|
||||
<div class="ml-4 mr-auto">
|
||||
<a class="font-medium" href="">{{ faker['users'][0]!['name'] }}</a>
|
||||
<div class="mr-5 opacity-70 sm:mr-5">
|
||||
{{
|
||||
index === 0
|
||||
? 'Bootstrap 4 HTML Admin Template'
|
||||
: index === 1
|
||||
? 'Tailwind Admin Dashboard Template'
|
||||
: 'Vuejs HTML Admin Template'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-medium">
|
||||
{{ index === 0 ? '+$19' : index === 1 ? '+$25' : '+$21' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<!-- END: Daily Sales -->
|
||||
<!-- BEGIN: Latest Tasks -->
|
||||
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
|
||||
<TabsRoot defaultValue="lt-0">
|
||||
<div class="relative flex items-center border-b border-foreground/15 p-5">
|
||||
<h2 class="mr-auto text-base font-medium">Latest Tasks</h2>
|
||||
<MenuRoot class="w-auto ml-auto sm:hidden">
|
||||
<MenuTrigger as-child>
|
||||
<a class="block size-5" href="#">
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</a>
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-40">
|
||||
<MenuItem value="new">New</MenuItem>
|
||||
<MenuItem value="last-week">Last Week</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
<TabsList class="absolute inset-y-0 h-[2.8rem] right-5 my-auto hidden w-auto sm:flex">
|
||||
<TabsTrigger value="lt-0">New</TabsTrigger>
|
||||
<TabsTrigger value="lt-1">Last Week</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<TabsContent value="lt-0">
|
||||
<div class="flex items-center">
|
||||
<div class="border-l-4 border-primary/20 pl-4">
|
||||
<a class="font-medium" href="">Create New Campaign</a>
|
||||
<div class="opacity-70">10:00 AM</div>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<SwitchRoot>
|
||||
<SwitchControl />
|
||||
</SwitchRoot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex items-center">
|
||||
<div class="border-l-4 border-primary/20 pl-4">
|
||||
<a class="font-medium" href="">Meeting With Client</a>
|
||||
<div class="opacity-70">02:00 PM</div>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<SwitchRoot>
|
||||
<SwitchControl />
|
||||
</SwitchRoot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex items-center">
|
||||
<div class="border-l-4 border-primary/20 pl-4">
|
||||
<a class="font-medium" href="">Create New Repository</a>
|
||||
<div class="opacity-70">04:00 PM</div>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<SwitchRoot>
|
||||
<SwitchControl />
|
||||
</SwitchRoot>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</Box>
|
||||
<!-- END: Latest Tasks -->
|
||||
<Box raised="single" class="col-span-12 p-0">
|
||||
<CarouselRoot :default-page="0" :slide-count="fakers.slice(0, 5).length">
|
||||
<div class="flex items-center border-b border-foreground/15 px-5 py-3">
|
||||
<h2 class="mr-auto text-base font-medium">New Products</h2>
|
||||
<CarouselPrevTrigger as-child>
|
||||
<Button variant="ghost" class="shadow-none border border-foreground/15 mr-2">
|
||||
<Lucide class="size-4" icon="ChevronLeft" />
|
||||
</Button>
|
||||
</CarouselPrevTrigger>
|
||||
<CarouselNextTrigger as-child>
|
||||
<Button variant="ghost" class="shadow-none border border-foreground/15">
|
||||
<Lucide class="size-4" icon="ChevronRight" />
|
||||
</Button>
|
||||
</CarouselNextTrigger>
|
||||
</div>
|
||||
<div class="px-5">
|
||||
<CarouselItemGroup class="py-5">
|
||||
<CarouselItem v-for="(faker, index) in fakers.slice(0, 5)" :key="index" :index="index" class="px-5"
|
||||
as-child>
|
||||
<div>
|
||||
<div class="flex flex-col items-center pb-5 lg:flex-row">
|
||||
<div class="flex flex-col items-center pr-5 sm:flex-row lg:border-r border-foreground/15">
|
||||
<div class="sm:mr-5">
|
||||
<AvatarRoot class="size-20 bg-background rounded-full">
|
||||
<AvatarFallback>IM</AvatarFallback>
|
||||
<AvatarImage :src="faker['images'][0]" />
|
||||
</AvatarRoot>
|
||||
</div>
|
||||
<div class="mr-auto mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<a class="text-lg font-medium" href="">
|
||||
{{ faker['products'][0]!['name'] }}
|
||||
</a>
|
||||
<div class="mt-1 opacity-70 sm:mt-0">
|
||||
{{ faker['news'][0]!['shortContent'] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-6 flex w-full flex-1 items-center justify-center border-t border-foreground/15 px-5 pt-4 lg:mt-0 lg:w-auto lg:border-t-0 lg:pt-0">
|
||||
<div class="w-20 rounded-md py-3 text-center">
|
||||
<div class="text-xl font-medium">{{ faker['totals'][0] }}</div>
|
||||
<div class="opacity-70">Orders</div>
|
||||
</div>
|
||||
<div class="w-20 rounded-md py-3 text-center">
|
||||
<div class="text-xl font-medium">{{ faker['totals'][1] }}k</div>
|
||||
<div class="opacity-70">Purchases</div>
|
||||
</div>
|
||||
<div class="w-20 rounded-md py-3 text-center">
|
||||
<div class="text-xl font-medium">{{ faker['totals'][0] }}</div>
|
||||
<div class="opacity-70">Reviews</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center border-t border-foreground/15 pt-5 sm:flex-row">
|
||||
<div
|
||||
class="flex w-full items-center justify-center border-b border-foreground/15 pb-5 sm:w-auto sm:justify-start sm:border-b-0 sm:pb-0">
|
||||
<Badge look="outline" class="mr-3 px-3 py-2">{{
|
||||
faker['dates'][0]
|
||||
}}</Badge>
|
||||
<div class="opacity-70">Date of Release</div>
|
||||
</div>
|
||||
<div class="mt-5 flex sm:ml-auto sm:mt-0">
|
||||
<Button variant="ghost"
|
||||
class="border border-foreground/15 shadow-none ml-auto">Preview</Button>
|
||||
<Button variant="ghost" class="border border-foreground/15 shadow-none ml-2">Details</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselItemGroup>
|
||||
</div>
|
||||
</CarouselRoot>
|
||||
</Box>
|
||||
<!-- END: New Products -->
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user