Dev/v1.1 (#2)
Build Docker Image / build-backend (push) Successful in 46s
Build Docker Image / build-frontend (push) Failing after 11s

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:
2026-07-02 10:09:08 +08:00
parent 2e2d94f0d6
commit 421775d3ff
41 changed files with 1210 additions and 971 deletions
@@ -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"