DONE: update logo to original official logo, update letterhead and letterfooter layout (#8)
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local> Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -3,7 +3,7 @@ import type { ExternalSystem } from '../types/external-system.types'
|
||||
export const dummyExternalSystems: ExternalSystem[] = [
|
||||
{
|
||||
id: 'ext-001',
|
||||
code: 'KOPKB-PORTAL',
|
||||
code: 'portal-mykopkb',
|
||||
name: 'Portal Ahli KOPKB',
|
||||
description:
|
||||
'Sistem utama keahlian Koperasi Permodalan Kelantan Berhad untuk semakan dividen, penyata dan maklumat ahli.',
|
||||
@@ -13,15 +13,15 @@ export const dummyExternalSystems: ExternalSystem[] = [
|
||||
starts_at: '2026-01-01T00:00:00+08:00',
|
||||
ends_at: null,
|
||||
opens_in_new_tab: true,
|
||||
contact_email: 'sokongan@koppkb.com',
|
||||
contact_email: 'dev_kopkb@gmail.com',
|
||||
notes: 'Log masuk menggunakan e-mel berdaftar ahli KOPKB.',
|
||||
created_at: '2026-01-15T09:00:00+08:00',
|
||||
updated_at: '2026-06-01T14:30:00+08:00',
|
||||
},
|
||||
{
|
||||
id: 'ext-002',
|
||||
code: 'AGM-VOTE',
|
||||
name: 'Sistem Pengundian AGM',
|
||||
code: 'e-vote',
|
||||
name: 'Sistem Pengundian AGM KOPKB',
|
||||
description:
|
||||
'Platform pengundian dalam talian untuk Mesyuarat Agung Tahunan. Hanya tersedia semasa tempoh pengundian.',
|
||||
url: 'https://e-vote.erahn.com.my/login',
|
||||
@@ -30,7 +30,7 @@ export const dummyExternalSystems: ExternalSystem[] = [
|
||||
starts_at: '2026-05-01T08:00:00+08:00',
|
||||
ends_at: null,
|
||||
opens_in_new_tab: true,
|
||||
contact_email: 'agm@koppkb.com',
|
||||
contact_email: 'dev_kopkb@gmail.com',
|
||||
notes: 'Sila lengkapkan profil sebelum mengundi.',
|
||||
created_at: '2026-05-20T10:00:00+08:00',
|
||||
updated_at: '2026-06-28T11:15:00+08:00',
|
||||
|
||||
@@ -93,6 +93,8 @@ const previewUrl = ref<string | null>(null)
|
||||
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
const boardMeetingReference = ref('')
|
||||
const boardMeetingReferenceError = ref<string | null>(null)
|
||||
const boardMeetingDate = ref(dayjs().format('YYYY-MM-DD'))
|
||||
const boardMeetingDateError = ref<string | null>(null)
|
||||
const deleteConfirmDialogOpen = ref(false)
|
||||
const pendingDeleteDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
|
||||
@@ -372,6 +374,8 @@ function openConfirmAction(
|
||||
if (action.type === 'complete') {
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
boardMeetingDate.value = dayjs().format('YYYY-MM-DD')
|
||||
boardMeetingDateError.value = null
|
||||
}
|
||||
|
||||
pendingAction.value = action
|
||||
@@ -383,6 +387,8 @@ function closeConfirmDialog() {
|
||||
pendingAction.value = null
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
boardMeetingDate.value = dayjs().format('YYYY-MM-DD')
|
||||
boardMeetingDateError.value = null
|
||||
}
|
||||
|
||||
async function confirmPendingAction() {
|
||||
@@ -391,6 +397,7 @@ async function confirmPendingAction() {
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
boardMeetingReferenceError.value = null
|
||||
boardMeetingDateError.value = null
|
||||
|
||||
if (pendingAction.value.type === 'complete') {
|
||||
const reference = boardMeetingReference.value.trim()
|
||||
@@ -398,6 +405,10 @@ async function confirmPendingAction() {
|
||||
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
if (!boardMeetingDate.value) {
|
||||
boardMeetingDateError.value = 'Tarikh mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
completeSubmitting.value = true
|
||||
} else {
|
||||
reviewSubmitting.value = true
|
||||
@@ -421,6 +432,7 @@ async function confirmPendingAction() {
|
||||
} else {
|
||||
response = await completeMembershipApplication(applicationId.value, {
|
||||
board_meeting_reference: boardMeetingReference.value.trim(),
|
||||
board_meeting_date: boardMeetingDate.value,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -436,6 +448,9 @@ async function confirmPendingAction() {
|
||||
if (validationErrors?.board_meeting_reference?.[0]) {
|
||||
boardMeetingReferenceError.value = validationErrors.board_meeting_reference[0]
|
||||
}
|
||||
if (validationErrors?.board_meeting_date?.[0]) {
|
||||
boardMeetingDateError.value = validationErrors.board_meeting_date[0]
|
||||
}
|
||||
} finally {
|
||||
reviewSubmitting.value = false
|
||||
completeSubmitting.value = false
|
||||
@@ -1003,6 +1018,12 @@ onUnmounted(() => {
|
||||
@input="boardMeetingReferenceError = null" />
|
||||
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||
</Field>
|
||||
<Field v-if="pendingAction?.type === 'complete'" class="mt-4 text-left">
|
||||
<FieldLabel for="detail-board-meeting-date">Tarikh Mesyuarat Lembaga</FieldLabel>
|
||||
<Input id="detail-board-meeting-date" v-model="boardMeetingDate" type="date"
|
||||
:disabled="completeSubmitting" @input="boardMeetingDateError = null" />
|
||||
<FieldError v-if="boardMeetingDateError">{{ boardMeetingDateError }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-28" :disabled="reviewSubmitting || completeSubmitting">
|
||||
|
||||
@@ -110,6 +110,8 @@ const batchSuccessMessage = ref<string | null>(null)
|
||||
const batchFailedItems = ref<BatchCompleteFailedItem[]>([])
|
||||
const boardMeetingReference = ref('')
|
||||
const boardMeetingReferenceError = ref<string | null>(null)
|
||||
const boardMeetingDate = ref(dayjs().format('YYYY-MM-DD'))
|
||||
const boardMeetingDateError = ref<string | null>(null)
|
||||
const downloadingResultLetterId = ref<string | null>(null)
|
||||
|
||||
const selectableApplications = computed(() =>
|
||||
@@ -162,6 +164,8 @@ function openBatchConfirm() {
|
||||
if (!selectedIds.value.length) return
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
boardMeetingDate.value = dayjs().format('YYYY-MM-DD')
|
||||
boardMeetingDateError.value = null
|
||||
batchConfirmOpen.value = true
|
||||
}
|
||||
|
||||
@@ -173,15 +177,23 @@ async function confirmBatchComplete() {
|
||||
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
if (!boardMeetingDate.value) {
|
||||
boardMeetingDateError.value = 'Tarikh mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
|
||||
batchSubmitting.value = true
|
||||
batchSuccessMessage.value = null
|
||||
batchFailedItems.value = []
|
||||
boardMeetingReferenceError.value = null
|
||||
boardMeetingDateError.value = null
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await batchCompleteMembershipApplications(selectedIds.value, reference)
|
||||
const response = await batchCompleteMembershipApplications(selectedIds.value, {
|
||||
board_meeting_reference: reference,
|
||||
board_meeting_date: boardMeetingDate.value,
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
batchSuccessMessage.value = response.message
|
||||
@@ -200,6 +212,7 @@ async function confirmBatchComplete() {
|
||||
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
|
||||
boardMeetingDateError.value = validationErrors?.board_meeting_date?.[0] ?? null
|
||||
} else {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
|
||||
}
|
||||
@@ -245,30 +258,6 @@ function goToApplicationEdit(id: string) {
|
||||
router.push({ name: 'edit-membership-application', params: { id } })
|
||||
}
|
||||
|
||||
function canDownloadLetter(item: MembershipApplicationListItem): boolean {
|
||||
return !!item.has_result_letter && !!item.result_letter_document
|
||||
}
|
||||
|
||||
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 },
|
||||
@@ -450,12 +439,6 @@ const headers = computed<TableHeader[]>(() => {
|
||||
@click="goToApplicationEdit((item as MembershipApplicationListItem).id)">
|
||||
<Pencil 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>
|
||||
@@ -475,6 +458,12 @@ const headers = computed<TableHeader[]>(() => {
|
||||
@input="boardMeetingReferenceError = null" />
|
||||
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||
</Field>
|
||||
<Field class="mt-4 text-left">
|
||||
<FieldLabel for="batch-board-meeting-date">Tarikh Mesyuarat Lembaga</FieldLabel>
|
||||
<Input id="batch-board-meeting-date" v-model="boardMeetingDate" type="date" :disabled="batchSubmitting"
|
||||
@input="boardMeetingDateError = null" />
|
||||
<FieldError v-if="boardMeetingDateError">{{ boardMeetingDateError }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-32" :disabled="batchSubmitting">
|
||||
|
||||
@@ -225,13 +225,13 @@ export async function completeMembershipApplication(
|
||||
|
||||
export async function batchCompleteMembershipApplications(
|
||||
applicationIds: string[],
|
||||
boardMeetingReference: string,
|
||||
payload: GenerateResultLetterPayload,
|
||||
): Promise<BatchCompleteResponse> {
|
||||
const { data } = await api.post<BatchCompleteResponse>(
|
||||
'/v1/membership-applications/batch-complete',
|
||||
{
|
||||
application_ids: applicationIds,
|
||||
board_meeting_reference: boardMeetingReference,
|
||||
...payload,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ export type BatchCompleteResponse = {
|
||||
|
||||
export interface GenerateResultLetterPayload {
|
||||
board_meeting_reference: string
|
||||
board_meeting_date: string
|
||||
}
|
||||
|
||||
export type GenerateResultLetterResponse = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import QRCode from 'qrcode'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import logoUrl from '@/assets/images/logo-original.svg'
|
||||
import { displayCardValue } from '../utils/member-digital-card.utils'
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import logoUrl from '@/assets/images/logo-original.svg'
|
||||
import { displayCardValue } from '../utils/member-digital-card.utils'
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@@ -109,7 +109,7 @@ export function useImpersonate() {
|
||||
title: response.message,
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
timer: 2000,
|
||||
timer: 500,
|
||||
})
|
||||
|
||||
await router.push(resolvePostLoginRoute(response.redirect_path))
|
||||
@@ -143,7 +143,7 @@ export function useImpersonate() {
|
||||
title: response.message,
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
timer: 2000,
|
||||
timer: 500,
|
||||
})
|
||||
|
||||
await router.push(resolvePostLoginRoute(response.redirect_path))
|
||||
|
||||
@@ -321,7 +321,8 @@ onMounted(() => {
|
||||
<div class="flex w-full flex-col gap-3">
|
||||
<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"
|
||||
<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="Search name, email, IC, phone, role..."
|
||||
class="w-full pl-9" aria-label="Search users" />
|
||||
@@ -333,18 +334,10 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm opacity-70">Status:</span>
|
||||
<Badge
|
||||
v-for="chip in STATUS_FILTER_CHIPS"
|
||||
:key="chip.value || 'all'"
|
||||
:variant="chip.variant"
|
||||
:look="isStatusFilterActive(chip.value) ? 'filled' : 'outline'"
|
||||
class="capitalize"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-pressed="isStatusFilterActive(chip.value)"
|
||||
@click="setStatusFilter(chip.value)"
|
||||
@keydown.enter="setStatusFilter(chip.value)"
|
||||
>
|
||||
<Badge v-for="chip in STATUS_FILTER_CHIPS" :key="chip.value || 'all'" :variant="chip.variant"
|
||||
:look="isStatusFilterActive(chip.value) ? 'filled' : 'outline'" class="capitalize" role="button"
|
||||
tabindex="0" :aria-pressed="isStatusFilterActive(chip.value)" @click="setStatusFilter(chip.value)"
|
||||
@keydown.enter="setStatusFilter(chip.value)">
|
||||
{{ chip.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -385,28 +378,28 @@ onMounted(() => {
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="flex items-center">
|
||||
<Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="outline" size="sm"
|
||||
<Button v-if="hasPermission('lihat peranan')" type="button" variant="ghost" size="sm"
|
||||
class="bg-purple-600 text-white disabled:opacity-50" :disabled="savingRoles"
|
||||
:title="savingRoles ? 'Menyimpan perubahan...' : 'Tetapkan peranan'"
|
||||
@click="openAssignRolesDialog(item as UserListItem)"> Urus Peranan
|
||||
<Shield class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="hasPermission('lihat pengguna')" type="button" variant="outline" size="sm"
|
||||
<Button v-if="hasPermission('lihat pengguna')" type="button" variant="ghost" size="sm"
|
||||
class="bg-green-600 text-white disabled:opacity-50" :disabled="savingRoles"
|
||||
:title="savingRoles ? 'Menyimpan perubahan...' : 'Lihat profil'" @click="goToViewUser(item.id)">
|
||||
<Eye class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="outline" size="sm"
|
||||
<Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="ghost" size="sm"
|
||||
class="bg-yellow-600 text-white disabled:opacity-50" :disabled="savingRoles"
|
||||
:title="savingRoles ? 'Menyimpan perubahan...' : 'Kemaskini pengguna'" @click="goToEditUser(item.id)">
|
||||
<SquarePen class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="hasPermission('padam akaun pengguna')" type="button" variant="outline" size="sm"
|
||||
<Button v-if="hasPermission('padam akaun pengguna')" type="button" variant="ghost" size="sm"
|
||||
class="bg-red-600 text-white disabled:opacity-50" :disabled="deleting"
|
||||
@click="openDeleteConfirmation(item as UserListItem)">
|
||||
<Trash2 class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="showImpersonateButton(item as UserListItem)" type="button" variant="outline" size="sm"
|
||||
<Button v-if="showImpersonateButton(item as UserListItem)" type="button" variant="ghost" size="sm"
|
||||
class="bg-orange-600 text-white disabled:opacity-50"
|
||||
:disabled="!canImpersonateUser(item as UserListItem) || impersonateLoading || impersonating"
|
||||
:title="impersonateButtonTitle(item as UserListItem)" @click="impersonateUser(item as UserListItem)">
|
||||
@@ -417,6 +410,7 @@ onMounted(() => {
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<!-- Start of deleted users table -->
|
||||
<div class="space-y-4 border-t border-foreground/10 pt-8">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Pengguna Dipadam</h3>
|
||||
@@ -428,19 +422,10 @@ onMounted(() => {
|
||||
<AlertDescription>{{ deletedError }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<DataTable
|
||||
:headers="deletedHeaders"
|
||||
:items="deletedUsers"
|
||||
:loading="deletedLoading"
|
||||
:pagination="deletedPagination"
|
||||
:current-sort="deletedSortBy"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="deleted-users"
|
||||
v-model:page="deletedPage"
|
||||
v-model:items-per-page="deletedItemsPerPage"
|
||||
@update:sort-by="handleDeletedSortUpdate"
|
||||
>
|
||||
<DataTable :headers="deletedHeaders" :items="deletedUsers" :loading="deletedLoading"
|
||||
:pagination="deletedPagination" :current-sort="deletedSortBy" show-pagination exportable
|
||||
export-file-name="deleted-users" v-model:page="deletedPage" v-model:items-per-page="deletedItemsPerPage"
|
||||
@update:sort-by="handleDeletedSortUpdate">
|
||||
<template #item.name="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Profile Image" class="size-10 rounded-full" />
|
||||
@@ -475,16 +460,9 @@ onMounted(() => {
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
v-if="hasPermission('padam akaun pengguna')"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="bg-blue-600 text-white disabled:opacity-50"
|
||||
:disabled="restoring"
|
||||
title="Pulihkan pengguna"
|
||||
@click="openRestoreConfirmation(item as UserListItem)"
|
||||
>
|
||||
<Button v-if="hasPermission('padam akaun pengguna')" type="button" variant="ghost" size="sm"
|
||||
class="bg-blue-600 text-white disabled:opacity-50" :disabled="restoring" title="Pulihkan pengguna"
|
||||
@click="openRestoreConfirmation(item as UserListItem)">
|
||||
<RotateCcw class="size-4" aria-hidden="true" />
|
||||
Pulihkan
|
||||
</Button>
|
||||
@@ -493,6 +471,7 @@ onMounted(() => {
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Assign roles dialog -->
|
||||
<DialogRoot :open="assignRolesOpen" @openChange="(details) => (assignRolesOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5">
|
||||
@@ -537,6 +516,7 @@ onMounted(() => {
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<DialogRoot :open="deleteConfirmationOpen" @openChange="(details) => (deleteConfirmationOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
@@ -564,6 +544,7 @@ onMounted(() => {
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<!-- Restore confirmation dialog -->
|
||||
<DialogRoot :open="restoreConfirmationOpen" @openChange="(details) => (restoreConfirmationOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
@@ -581,14 +562,8 @@ onMounted(() => {
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="restoring">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="primary"
|
||||
look="outline"
|
||||
:disabled="restoring"
|
||||
@click="confirmRestore"
|
||||
>
|
||||
<Button class="w-24" type="button" variant="primary" look="outline" :disabled="restoring"
|
||||
@click="confirmRestore">
|
||||
{{ restoring ? 'Memulihkan...' : 'Pulihkan' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user