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
@@ -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>