diff --git a/src/modules/membership-application/pages/MembershipApplicationDetail.vue b/src/modules/membership-application/pages/MembershipApplicationDetail.vue
index 67ea0ea..fe4262c 100644
--- a/src/modules/membership-application/pages/MembershipApplicationDetail.vue
+++ b/src/modules/membership-application/pages/MembershipApplicationDetail.vue
@@ -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
= {
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(null)
const previewDocument = ref(null)
+const generateDialogOpen = ref(false)
+const generateSubmitting = ref(false)
+const boardMeetingReference = ref('')
+const boardMeetingReferenceError = ref(null)
function statusLabel(status: MembershipApplicationStatus): string {
const labels: Record = {
@@ -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(() => {
{{ application.application_number }}