Dev/v1.2 (#4)
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local> Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listActivityLogs } from '../services/activity-log.service'
|
||||
import type { ActivityLogItem } from '../types/activity-log.types'
|
||||
|
||||
export function useActivityLogList() {
|
||||
const activityLogs = ref<ActivityLogItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchActivityLogs(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await listActivityLogs({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
search: search.value.trim() || undefined,
|
||||
})
|
||||
|
||||
activityLogs.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan log aktiviti.')
|
||||
activityLogs.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchActivityLogs(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchActivityLogs(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchActivityLogs(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchActivityLogs(1)
|
||||
})
|
||||
|
||||
return {
|
||||
activityLogs,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
fetchActivityLogs,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { activityLogLayoutRoutes } from './routes'
|
||||
export { activityLogMenu } from './menu'
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const activityLogMenu: Menu[] = [
|
||||
{
|
||||
icon: 'ScrollText',
|
||||
route_name: 'list-activity-logs',
|
||||
title: 'Log Aktiviti',
|
||||
permission: 'lihat log aktiviti',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts" setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { Search } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useActivityLogList } from '../composables/useActivityLogList'
|
||||
import { formatModelType } from '../utils/activity-log.utils'
|
||||
|
||||
const {
|
||||
activityLogs,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
} = useActivityLogList()
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function formatCauserName(item: { causer?: { name?: string } | null }): string {
|
||||
return item.causer?.name ?? '-'
|
||||
}
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Bil.', key: '#', sortable: false },
|
||||
{
|
||||
title: 'Tarikh',
|
||||
key: 'created_at',
|
||||
sortable: false,
|
||||
exportValue: (item) => formatDateTime(item.created_at),
|
||||
},
|
||||
{
|
||||
title: 'Pengguna',
|
||||
key: 'causer.name',
|
||||
sortable: false,
|
||||
exportValue: (item) => formatCauserName(item),
|
||||
},
|
||||
{
|
||||
title: 'Emel',
|
||||
key: 'causer.email',
|
||||
sortable: false,
|
||||
exportValue: (item) => item.causer?.email ?? '-',
|
||||
},
|
||||
{ title: 'Keterangan', key: 'description', sortable: false },
|
||||
{
|
||||
title: 'Subjek',
|
||||
key: 'subject_type',
|
||||
sortable: false,
|
||||
exportValue: (item) => formatModelType(item.subject_type),
|
||||
},
|
||||
{ title: 'Peristiwa', key: 'event', sortable: false },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium">Log Aktiviti</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Semak rekod aktiviti pengguna dalam sistem.</p>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<DataTable :headers="headers" :items="activityLogs" :loading="loading" :pagination="pagination" show-pagination
|
||||
exportable export-file-name="activity-logs" v-model:page="page" v-model:items-per-page="itemsPerPage">
|
||||
<template #toolbar>
|
||||
<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 keterangan, subjek, peristiwa, pengguna..."
|
||||
class="w-full pl-9" aria-label="Cari log aktiviti" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.created_at="{ item }">
|
||||
{{ formatDateTime(item.created_at) }}
|
||||
</template>
|
||||
|
||||
<template #item.causer.name="{ item }">
|
||||
{{ formatCauserName(item) }}
|
||||
</template>
|
||||
|
||||
<template #item.causer.email="{ item }">
|
||||
<span class="lowercase">{{ item.causer?.email ?? '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.subject_type="{ item }">
|
||||
{{ formatModelType(item.subject_type) }}
|
||||
</template>
|
||||
|
||||
<template #item.event="{ item }">
|
||||
{{ item.event ?? '-' }}
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const activityLogLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'activity-logs',
|
||||
name: 'list-activity-logs',
|
||||
component: () => import('./pages/ActivityLogList.vue'),
|
||||
meta: {
|
||||
title: 'Log Aktiviti',
|
||||
module: 'activity-log',
|
||||
permission: 'lihat log aktiviti',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type { ActivityLogItem, ListActivityLogsParams } from '../types/activity-log.types'
|
||||
|
||||
export async function listActivityLogs(
|
||||
params: ListActivityLogsParams,
|
||||
): Promise<PaginatedApiResponse<ActivityLogItem>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<ActivityLogItem>>('/v1/activitylogs', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load activity logs')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type ActivityLogCauser = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export type ActivityLogItem = {
|
||||
id: number
|
||||
log_name: string | null
|
||||
description: string
|
||||
subject_type: string | null
|
||||
subject_id: string | null
|
||||
event: string | null
|
||||
causer_type: string | null
|
||||
causer_id: string | null
|
||||
properties: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
causer: ActivityLogCauser | null
|
||||
}
|
||||
|
||||
export type ListActivityLogsParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function formatModelType(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
|
||||
const segments = value.split('\\')
|
||||
return segments[segments.length - 1] || value
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { Button } from '@/components/ui/button'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { logout, resolvePostAuthRoute } from '@/modules/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import logoUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { getForgotPasswordErrorMessage, requestForgotPassword } from '@/modules/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
resolvePostAuthRoute,
|
||||
} from '@/modules/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -125,11 +125,8 @@ const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
<CheckboxLabel>Ingat saya</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="opacity-70 hover:opacity-100"
|
||||
@click="router.push({ name: 'forgot-password' })"
|
||||
>
|
||||
<button type="button" class="opacity-70 hover:opacity-100"
|
||||
@click="router.push({ name: 'forgot-password' })">
|
||||
Lupa Password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import illustrationUrl from '@/assets/images/illustration.svg'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
@@ -12,7 +10,7 @@ const sections = [
|
||||
{
|
||||
id: 'pengenalan',
|
||||
title: '1. Pengenalan',
|
||||
placeholder: 'Selamat datang ke SUTERA 3.0. Kami amat menghargai kepercayaan yang anda berikan untuk mengendalikan maklumat peribadi anda. Dasar Privasi ini digubal untuk membantu anda memahami bagaimana kami mengumpul, menggunakan, mendedahkan, dan melindungi data peribadi anda selaras dengan Akta Perlindungan Data Peribadi 2010 (PDPA) dan piawaian keselamatan global.\n\nDasar ini terpakai kepada semua TDM, dan mana-mana pihak yang mengakses atau menggunakan perkhidmatan, laman web, dan aplikasi kami.'
|
||||
placeholder: 'Selamat datang ke MyKOPKB. Kami amat menghargai kepercayaan yang anda berikan untuk mengendalikan maklumat peribadi anda. Dasar Privasi ini digubal untuk membantu anda memahami bagaimana kami mengumpul, menggunakan, mendedahkan, dan melindungi data peribadi anda selaras dengan Akta Perlindungan Data Peribadi 2010 (PDPA) dan piawaian keselamatan global.\n\nDasar ini terpakai kepada semua Koperasi Permodalan Kelantan Berhad (KOPKB), dan mana-mana pihak yang mengakses atau menggunakan perkhidmatan, laman web, dan aplikasi kami.'
|
||||
},
|
||||
{
|
||||
id: 'data-dikumpul',
|
||||
@@ -60,7 +58,7 @@ const sections = [
|
||||
id: 'hubungi',
|
||||
title: '10. Hubungi Kami',
|
||||
placeholder:
|
||||
'Jika anda ada pertanyaan, komen atau permintaan tentang dasar ini, hubungi kami di\n- KAPT MOHAMMAD EFANDY BIN JAFFARI (effandy.jaffari@army.mil.my)'
|
||||
'Jika anda ada pertanyaan, komen atau permintaan tentang dasar ini, hubungi kami di\n- ISMAIL BIN MASSERAN (ismail@koppkb.com)'
|
||||
|
||||
}
|
||||
];
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { PasswordInput } from '@/components/ui/password-input'
|
||||
import { getRegisterErrorMessage, register } from '@/modules/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
requestForgotPassword,
|
||||
resetPassword,
|
||||
} from '@/modules/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -133,61 +133,28 @@ const onOtpInput = (event: Event) => {
|
||||
</AlertRoot>
|
||||
|
||||
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleReset">
|
||||
<Input
|
||||
v-model="email"
|
||||
class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
:model-value="otp"
|
||||
class="box block min-w-full px-5 py-6 xl:min-w-md text-center tracking-[0.5em] text-lg"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="6"
|
||||
placeholder="000000"
|
||||
autocomplete="one-time-code"
|
||||
required
|
||||
@input="onOtpInput"
|
||||
/>
|
||||
<PasswordInput
|
||||
v-model="password"
|
||||
class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
placeholder="Kata laluan baharu"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<PasswordInput
|
||||
v-model="passwordConfirmation"
|
||||
class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
placeholder="Sahkan kata laluan baharu"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<Input v-model="email" class="box block min-w-full px-5 py-6 xl:min-w-md" type="email"
|
||||
placeholder="Email" autocomplete="email" required />
|
||||
<Input :model-value="otp"
|
||||
class="box block min-w-full px-5 py-6 xl:min-w-md text-center tracking-[0.5em] text-lg" type="text"
|
||||
inputmode="numeric" pattern="[0-9]*" maxlength="6" placeholder="000000" autocomplete="one-time-code"
|
||||
required @input="onOtpInput" />
|
||||
<PasswordInput v-model="password" class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
placeholder="Kata laluan baharu" autocomplete="new-password" required />
|
||||
<PasswordInput v-model="passwordConfirmation" class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
placeholder="Sahkan kata laluan baharu" autocomplete="new-password" required />
|
||||
|
||||
<div class="mt-5 text-center xl:mt-10 xl:text-left">
|
||||
<Button class="box w-full px-4 py-5" variant="primary" type="submit"
|
||||
:disabled="loading || !canSubmit">
|
||||
{{ loading ? 'Menyimpan...' : 'Tetapkan Semula Kata Laluan' }}
|
||||
</Button>
|
||||
<Button
|
||||
class="box mt-4 w-full px-4 py-5"
|
||||
look="outline"
|
||||
type="button"
|
||||
:disabled="resendLoading || !email"
|
||||
@click="handleResend"
|
||||
>
|
||||
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
|
||||
:disabled="resendLoading || !email" @click="handleResend">
|
||||
{{ resendLoading ? 'Menghantar...' : 'Hantar Semula Kod OTP' }}
|
||||
</Button>
|
||||
<Button
|
||||
class="box mt-4 w-full px-4 py-5"
|
||||
look="outline"
|
||||
type="button"
|
||||
@click="router.push({ name: 'login' })"
|
||||
>
|
||||
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
|
||||
@click="router.push({ name: 'login' })">
|
||||
Kembali ke Log Masuk
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
@@ -12,14 +11,14 @@ const sections = [
|
||||
id: 'pengenalan',
|
||||
title: '1. Pengenalan',
|
||||
placeholder: `
|
||||
Terma dan Syarat ini mengawal penggunaan platform, laman web, dan perkhidmatan yang disediakan oleh Kementerian Pertahanan Malaysia (MINDEF). Dengan mengakses atau menggunakan perkhidmatan kami, anda dianggap telah membaca, memahami, dan bersetuju untuk terikat dengan terma ini. Jika anda tidak bersetuju dengan mana-mana bahagian terma ini, sila hentikan penggunaan perkhidmatan kami.
|
||||
Terma dan Syarat ini mengawal penggunaan platform, laman web, dan perkhidmatan yang disediakan oleh Koperasi Permodalan Kelantan Berhad (KOPKB). Dengan mengakses atau menggunakan perkhidmatan kami, anda dianggap telah membaca, memahami, dan bersetuju untuk terikat dengan terma ini. Jika anda tidak bersetuju dengan mana-mana bahagian terma ini, sila hentikan penggunaan perkhidmatan kami.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'akaun',
|
||||
title: '2. Akaun & Kelayakan',
|
||||
placeholder: `
|
||||
Anda mesti berumur sekurang-kurangnya 18 tahun dan merupakan tentera. Anda bertanggungjawab memastikan maklumat akaun sentiasa tepat dan terkini. Anda juga bertanggungjawab menjaga kerahsiaan kata laluan serta segala aktiviti yang berlaku di bawah akaun anda.
|
||||
Anda mesti berumur sekurang-kurangnya 18 tahun dan merupakan ahli KOPKB. Anda bertanggungjawab memastikan maklumat akaun sentiasa tepat dan terkini. Anda juga bertanggungjawab menjaga kerahsiaan kata laluan serta segala aktiviti yang berlaku di bawah akaun anda.
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -40,7 +39,7 @@ Sesetengah perkhidmatan mungkin tertakluk kepada bayaran yang dinyatakan semasa
|
||||
id: 'kandungan',
|
||||
title: '5. Kandungan & Hak Milik',
|
||||
placeholder: `
|
||||
Semua kandungan, reka bentuk, logo, teks, grafik, dan bahan lain yang terdapat pada platform ini adalah hak milik Kementerian Pertahanan Malaysia (MINDEF) atau pemberi lesennya. Anda diberikan lesen terhad untuk menggunakan kandungan tersebut bagi tujuan penggunaan peribadi dan bukan komersial sahaja. Sebarang penyalinan, pengubahsuaian, atau pengedaran tanpa kebenaran bertulis adalah dilarang.
|
||||
Semua kandungan, reka bentuk, logo, teks, grafik, dan bahan lain yang terdapat pada platform ini adalah hak milik Koperasi Permodalan Kelantan Berhad (KOPKB) atau pemberi lesennya. Anda diberikan lesen terhad untuk menggunakan kandungan tersebut bagi tujuan penggunaan peribadi dan bukan komersial sahaja. Sebarang penyalinan, pengubahsuaian, atau pengedaran tanpa kebenaran bertulis adalah dilarang.
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -70,8 +69,8 @@ Kami boleh mengemas kini atau meminda Terma dan Syarat ini dari semasa ke semasa
|
||||
placeholder: `
|
||||
Sekiranya anda mempunyai sebarang pertanyaan berkaitan Terma dan Syarat ini, sila hubungi:
|
||||
|
||||
Nama: KAPT MOHAMMAD EFANDY BIN JAFFARI
|
||||
E-mel: effandy.jaffari@army.mil.my
|
||||
Nama: Koperasi Permodalan Kelantan Berhad (KOPKB)
|
||||
E-mel: admin@koppkb.com
|
||||
`,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
verifyEmail,
|
||||
} from '@/modules/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import logoUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -37,12 +37,14 @@ export interface AuthUser {
|
||||
image_url: string | null
|
||||
member_number: number | null
|
||||
member_type: string | null
|
||||
public_profile_token: string | null
|
||||
status: string
|
||||
gender: string | null
|
||||
marriage_status: string | null
|
||||
join_date: string | null
|
||||
birth_date: string | null
|
||||
birth_place: string | null
|
||||
onboarding_completed_at: string | null
|
||||
roles?: Array<AuthRole & { permissions?: AuthPermission[] }>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { dashboardLayoutRoutes } from './routes'
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const dashboardMenu: Menu[] = [
|
||||
{
|
||||
icon: 'CircleGauge',
|
||||
route_name: 'dashboard-overview',
|
||||
title: 'Dashboard',
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,552 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { today, getLocalTimeZone } from '@internationalized/date'
|
||||
import phoneIllustration from '@/assets/images/phone-illustration.svg'
|
||||
import womanIllustration from '@/assets/images/woman-illustration.svg'
|
||||
import { completeOnboarding } from '@/modules/profile/services/profile.service'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import {
|
||||
CarouselRoot,
|
||||
CarouselPrevTrigger,
|
||||
CarouselNextTrigger,
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import {
|
||||
DatePickerRoot,
|
||||
DatePickerControl,
|
||||
DatePickerInput,
|
||||
DatePickerTrigger,
|
||||
DatePickerPositioner,
|
||||
DatePickerContent,
|
||||
DatePickerYearSelect,
|
||||
DatePickerMonthSelect,
|
||||
DatePickerView,
|
||||
DatePickerViewControl,
|
||||
DatePickerPrevTrigger,
|
||||
DatePickerNextTrigger,
|
||||
DatePickerRangeText,
|
||||
DatePickerTable,
|
||||
DatePickerTableHead,
|
||||
DatePickerTableRow,
|
||||
DatePickerTableHeader,
|
||||
DatePickerTableBody,
|
||||
DatePickerTableCell,
|
||||
DatePickerTableCellTrigger,
|
||||
DatePickerContext,
|
||||
} from '@/components/ui/datepicker'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel, FieldGroup } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import {
|
||||
MenuRoot,
|
||||
MenuTrigger,
|
||||
MenuPositioner,
|
||||
MenuContent,
|
||||
MenuCheckboxItem,
|
||||
} from '@/components/ui/menu'
|
||||
import { Line1, Line2, Pie1, Donut1, Donut2 } from '@/components/chart-presets'
|
||||
import { Transactions } from '@/components/transactions'
|
||||
import { RecentActivities } from '@/components/recent-activities'
|
||||
import { DailyNotes } from '@/components/daily-notes'
|
||||
import { Schedules } from '@/components/schedules'
|
||||
import { OfficialStores } from '@/components/official-stores'
|
||||
import { WeeklyBestSellers } from '@/components/weekly-best-sellers'
|
||||
import { WeeklyTopProducts } from '@/components/weekly-top-products'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const pc = ref(false)
|
||||
const electronic = ref(false)
|
||||
const smartphone = ref(false)
|
||||
const photography = ref(false)
|
||||
const sport = ref(false)
|
||||
const onboardingDialog = ref(false)
|
||||
const salesReportDate = ref<any[]>([
|
||||
today(getLocalTimeZone()).subtract({ days: 7 }),
|
||||
today(getLocalTimeZone()),
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
onboardingDialog.value = !authStore.user?.onboarding_completed_at
|
||||
})
|
||||
|
||||
async function markOnboardingComplete() {
|
||||
if (authStore.user?.onboarding_completed_at) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await completeOnboarding()
|
||||
if (res.success) {
|
||||
authStore.setUserProfile(res.data)
|
||||
}
|
||||
} catch {
|
||||
// Will retry on next visit if the request failed
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnboardingOpenChange(details: { open: boolean }) {
|
||||
onboardingDialog.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
void markOnboardingComplete()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="col-span-12 2xl:col-span-9">
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<!-- BEGIN: General Report -->
|
||||
<div class="col-span-12">
|
||||
<div class="flex h-10 items-center">
|
||||
<h2 class="me-5 truncate text-lg font-medium">General Report</h2>
|
||||
<a class="text-primary ms-auto flex items-center gap-3" href="">
|
||||
<Lucide icon="RefreshCcw" /> Refresh
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-5 grid grid-cols-12 gap-6">
|
||||
<div class="col-span-12 sm:col-span-6 xl:col-span-3">
|
||||
<Box raised="single">
|
||||
<div class="flex">
|
||||
<Lucide class="h-7 w-7 stroke-1 drop-shadow [--color:var(--color-primary)]" icon="CircleGauge" />
|
||||
<div class="ms-auto">
|
||||
<Badge variant="success" look="outline" content="12% Higher than last month">
|
||||
12%
|
||||
<Lucide class="ms-0.5" icon="ChevronUp" />
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-2xl font-medium leading-8">$724,091.47</div>
|
||||
<div class="mt-1.5 text-xs uppercase opacity-70">Item Sales</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 xl:col-span-3">
|
||||
<Box raised="single">
|
||||
<div class="flex">
|
||||
<Lucide class="h-7 w-7 stroke-1 [--color:var(--color-pending)]" icon="PanelBottomClose" />
|
||||
<div class="ms-auto">
|
||||
<Badge variant="success" look="outline" content="9% Higher than last month">
|
||||
9%
|
||||
<Lucide class="ms-0.5" icon="ChevronUp" />
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-2xl font-medium leading-8">21,546</div>
|
||||
<div class="mt-1.5 text-xs uppercase opacity-70">New Orders</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 xl:col-span-3">
|
||||
<Box raised="single">
|
||||
<div class="flex">
|
||||
<Lucide class="h-7 w-7 stroke-1 [--color:var(--color-warning)]" icon="Disc3" />
|
||||
<div class="ms-auto">
|
||||
<Badge variant="danger" look="outline" content="7% Lower than last month">
|
||||
7%
|
||||
<Lucide class="ms-0.5" icon="ChevronDown" />
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-2xl font-medium leading-8">1,524,091</div>
|
||||
<div class="mt-1.5 text-xs uppercase opacity-70">Total Products</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 xl:col-span-3">
|
||||
<Box raised="single">
|
||||
<div class="flex">
|
||||
<Lucide class="h-7 w-7 stroke-1 [--color:var(--color-danger)]" icon="Album" />
|
||||
<div class="ms-auto">
|
||||
<Badge variant="success" look="outline" content="41% Higher than last month">
|
||||
41%
|
||||
<Lucide class="ms-0.5" icon="ChevronUp" />
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-2xl font-medium leading-8">42,924,091</div>
|
||||
<div class="mt-1.5 text-xs uppercase opacity-70">Unique Visitors</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END: General Report -->
|
||||
<!-- BEGIN: Sales Report -->
|
||||
<div class="col-span-12 mt-5 lg:col-span-6">
|
||||
<div class="block h-10 items-center sm:flex">
|
||||
<h2 class="me-5 truncate text-lg font-medium">Sales Report</h2>
|
||||
<div class="relative mt-3 sm:ms-auto sm:mt-0">
|
||||
<DatePickerRoot selection-mode="range" :num-of-months="2" :value="salesReportDate"
|
||||
@value-change="(details) => (salesReportDate = details.value)">
|
||||
<DatePickerControl>
|
||||
<DatePickerInput :index="0" />
|
||||
<DatePickerTrigger />
|
||||
</DatePickerControl>
|
||||
<DatePickerPositioner>
|
||||
<DatePickerContent>
|
||||
<DatePickerYearSelect />
|
||||
<DatePickerMonthSelect />
|
||||
<DatePickerViewControl>
|
||||
<DatePickerPrevTrigger />
|
||||
<DatePickerRangeText />
|
||||
<DatePickerNextTrigger />
|
||||
</DatePickerViewControl>
|
||||
<DatePickerView view="day" class="flex-row">
|
||||
<DatePickerContext v-slot="{ datePicker }">
|
||||
<DatePickerTable>
|
||||
<DatePickerTableHead>
|
||||
<DatePickerTableRow>
|
||||
<DatePickerTableHeader v-for="(weekDay, id) in datePicker?.weekDays" :key="id">
|
||||
{{ weekDay.short }}
|
||||
</DatePickerTableHeader>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableHead>
|
||||
<DatePickerTableBody>
|
||||
<DatePickerTableRow v-for="(week, id) in datePicker?.weeks" :key="id">
|
||||
<DatePickerTableCell v-for="(day, id) in week" :key="id" :value="day">
|
||||
<DatePickerTableCellTrigger>
|
||||
{{ day.day }}
|
||||
</DatePickerTableCellTrigger>
|
||||
</DatePickerTableCell>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableBody>
|
||||
</DatePickerTable>
|
||||
</DatePickerContext>
|
||||
<DatePickerContext v-slot="{ datePicker }">
|
||||
<DatePickerTable>
|
||||
<DatePickerTableHead>
|
||||
<DatePickerTableRow>
|
||||
<DatePickerTableHeader v-for="(weekDay, id) in datePicker?.weekDays" :key="id">
|
||||
{{ weekDay.short }}
|
||||
</DatePickerTableHeader>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableHead>
|
||||
<DatePickerTableBody>
|
||||
<DatePickerTableRow v-for="(week, id) in datePicker?.getOffset({
|
||||
months: 1,
|
||||
}).weeks" :key="id">
|
||||
<DatePickerTableCell v-for="(day, id) in week" :key="id" :value="day"
|
||||
:visible-range="datePicker?.getOffset({ months: 1 }).visibleRange">
|
||||
<DatePickerTableCellTrigger>
|
||||
{{ day.day }}
|
||||
</DatePickerTableCellTrigger>
|
||||
</DatePickerTableCell>
|
||||
</DatePickerTableRow>
|
||||
</DatePickerTableBody>
|
||||
</DatePickerTable>
|
||||
</DatePickerContext>
|
||||
</DatePickerView>
|
||||
</DatePickerContent>
|
||||
</DatePickerPositioner>
|
||||
</DatePickerRoot>
|
||||
</div>
|
||||
</div>
|
||||
<Box class="mt-12 p-5 sm:mt-5">
|
||||
<div class="flex flex-col md:flex-row md:items-center">
|
||||
<div class="flex">
|
||||
<div>
|
||||
<div class="text-lg font-medium">$24,100,21</div>
|
||||
<div class="mt-1.5 text-xs uppercase opacity-70">This Month</div>
|
||||
</div>
|
||||
<div class="border-foreground/20 mx-4 h-12 w-px border border-r border-dotted xl:mx-6"></div>
|
||||
<div class="text-foreground/80">
|
||||
<div class="text-lg font-medium">$21,023,01</div>
|
||||
<div class="mt-1.5 text-xs uppercase opacity-70">Last Month</div>
|
||||
</div>
|
||||
</div>
|
||||
<MenuRoot class="w-auto mt-5 md:ms-auto md:mt-0">
|
||||
<MenuTrigger>Filter by Category</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent>
|
||||
<MenuCheckboxItem :checked="pc" :onCheckedChange="(checked) => (pc = checked)" value="checked">
|
||||
PC & Laptop
|
||||
</MenuCheckboxItem>
|
||||
<MenuCheckboxItem :checked="smartphone" :onCheckedChange="(checked) => (smartphone = checked)"
|
||||
value="checked">
|
||||
Smartphone
|
||||
</MenuCheckboxItem>
|
||||
<MenuCheckboxItem :checked="electronic" :onCheckedChange="(checked) => (electronic = checked)"
|
||||
value="checked">
|
||||
Electronic
|
||||
</MenuCheckboxItem>
|
||||
<MenuCheckboxItem :checked="photography" :onCheckedChange="(checked) => (photography = checked)"
|
||||
value="checked">
|
||||
Photography
|
||||
</MenuCheckboxItem>
|
||||
<MenuCheckboxItem :checked="sport" :onCheckedChange="(checked) => (sport = checked)"
|
||||
value="checked">
|
||||
Sport
|
||||
</MenuCheckboxItem>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
<Line1 class="mt-6 h-[275px]" />
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Sales Report -->
|
||||
<!-- BEGIN: Weekly Top Seller -->
|
||||
<div class="col-span-12 mt-5 sm:col-span-6 lg:col-span-3">
|
||||
<div class="flex h-10 items-center">
|
||||
<h2 class="me-5 truncate text-lg font-medium">Weekly Top Seller</h2>
|
||||
<a class="text-primary ms-auto truncate" href=""> Show More </a>
|
||||
</div>
|
||||
<Box class="mt-5 p-5">
|
||||
<div class="mt-3">
|
||||
<Pie1 class="h-[213px]" />
|
||||
</div>
|
||||
<div class="mx-auto mt-8 w-52 sm:w-auto">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-primary)]">
|
||||
</div>
|
||||
<span class="truncate">17 - 30 Years old</span>
|
||||
<span class="ms-auto">62%</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-pending)]">
|
||||
</div>
|
||||
<span class="truncate">31 - 50 Years old</span>
|
||||
<span class="ms-auto">33%</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-warning)]">
|
||||
</div>
|
||||
<span class="truncate">>= 50 Years old</span>
|
||||
<span class="ms-auto">10%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Weekly Top Seller -->
|
||||
<!-- BEGIN: Sales Report -->
|
||||
<div class="col-span-12 mt-5 sm:col-span-6 lg:col-span-3">
|
||||
<div class="flex h-10 items-center">
|
||||
<h2 class="me-5 truncate text-lg font-medium">Sales Report</h2>
|
||||
<a class="text-primary ms-auto truncate" href=""> Show More </a>
|
||||
</div>
|
||||
<Box class="mt-5 p-5">
|
||||
<div class="mt-3">
|
||||
<Donut1 class="h-[213px]" />
|
||||
</div>
|
||||
<div class="mx-auto mt-8 w-52 sm:w-auto">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-primary)]">
|
||||
</div>
|
||||
<span class="truncate">17 - 30 Years old</span>
|
||||
<span class="ms-auto">62%</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-pending)]">
|
||||
</div>
|
||||
<span class="truncate">31 - 50 Years old</span>
|
||||
<span class="ms-auto">33%</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div
|
||||
class="bg-(--color)/20 border-(--color)/60 me-3 size-2 rounded-full border [--color:var(--color-warning)]">
|
||||
</div>
|
||||
<span class="truncate">>= 50 Years old</span>
|
||||
<span class="ms-auto">10%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Sales Report -->
|
||||
<!-- BEGIN: Official Store -->
|
||||
<div class="col-span-12 mt-6 xl:col-span-8">
|
||||
<OfficialStores />
|
||||
</div>
|
||||
<!-- END: Official Store -->
|
||||
<!-- BEGIN: Weekly Best Sellers -->
|
||||
<div class="col-span-12 mt-6 xl:col-span-4">
|
||||
<WeeklyBestSellers />
|
||||
</div>
|
||||
<!-- END: Weekly Best Sellers -->
|
||||
<!-- BEGIN: General Report -->
|
||||
<div class="col-span-12 mt-8 grid grid-cols-12 gap-6">
|
||||
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
|
||||
<Box class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2/4 flex-none">
|
||||
<div class="truncate text-lg font-medium">Target Sales</div>
|
||||
<div class="mt-1 opacity-70">300 Sales</div>
|
||||
</div>
|
||||
<div class="relative ms-auto flex-none">
|
||||
<Donut2 class="w-[90px] h-[90px]" />
|
||||
<div class="absolute inset-s-0 top-0 flex h-full w-full items-center justify-center font-medium">
|
||||
20%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
|
||||
<Box class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="me-3 truncate text-lg font-medium">Social Media</div>
|
||||
<Badge variant="secondary" look="outline" class="ms-auto whitespace-nowrap">137 Sales</Badge>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<Line2 class="-ms-1 h-[58px]" />
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
|
||||
<Box class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2/4 flex-none">
|
||||
<div class="truncate text-lg font-medium">New Products</div>
|
||||
<div class="mt-1 opacity-70">1450 Products</div>
|
||||
</div>
|
||||
<div class="relative ms-auto flex-none">
|
||||
<Donut2 class="w-[90px] h-[90px]" />
|
||||
<div class="absolute inset-s-0 top-0 flex h-full w-full items-center justify-center font-medium">
|
||||
45%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-6 2xl:col-span-3">
|
||||
<Box class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="me-3 truncate text-lg font-medium">Posted Ads</div>
|
||||
<Badge variant="secondary" look="outline" class="ms-auto whitespace-nowrap">180 Campaign</Badge>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<Line2 class="-ms-1 h-[58px]" />
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END: General Report -->
|
||||
<!-- BEGIN: Weekly Top Products -->
|
||||
<div class="col-span-12 mt-6">
|
||||
<WeeklyTopProducts />
|
||||
</div>
|
||||
<!-- END: Weekly Top Products -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-12 2xl:col-span-3">
|
||||
<div class="-mb-10 h-full pb-10 2xl:border-l border-foreground/15">
|
||||
<div class="grid grid-cols-12 gap-x-6 gap-y-6 2xl:gap-x-0 2xl:pl-6">
|
||||
<!-- BEGIN: Transactions -->
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4 2xl:col-span-12">
|
||||
<Transactions />
|
||||
</div>
|
||||
<!-- END: Transactions -->
|
||||
<!-- BEGIN: Recent Activities -->
|
||||
<div class="col-span-12 mt-3 md:col-span-6 xl:col-span-4 2xl:col-span-12">
|
||||
<RecentActivities />
|
||||
</div>
|
||||
<!-- END: Recent Activities -->
|
||||
<!-- BEGIN: Daily Notes -->
|
||||
<div
|
||||
class="col-span-12 mt-3 md:col-span-6 xl:col-span-12 xl:col-start-1 xl:row-start-1 2xl:col-start-auto 2xl:row-start-auto">
|
||||
<DailyNotes />
|
||||
</div>
|
||||
<!-- END: Daily Notes -->
|
||||
<!-- BEGIN: Schedules -->
|
||||
<div
|
||||
class="col-span-12 mt-3 md:col-span-6 xl:col-span-4 xl:col-start-1 xl:row-start-2 2xl:col-span-12 2xl:col-start-auto 2xl:row-start-auto">
|
||||
<Schedules />
|
||||
</div>
|
||||
<!-- END: Schedules -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- BEGIN: Onboarding Dialog -->
|
||||
<DialogRoot :open="onboardingDialog" @openChange="handleOnboardingOpenChange">
|
||||
<DialogContent class="sm:max-w-xl">
|
||||
<DialogCloseTrigger />
|
||||
<div class="overflow-hidden">
|
||||
<div class="-my-5 -mx-10">
|
||||
<CarouselRoot :default-page="0" :slide-count="2" class="border-0">
|
||||
<CarouselItemGroup>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="0">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="phoneIllustration"
|
||||
alt="MyKOPKB" />
|
||||
</div>
|
||||
<div class="px-8">
|
||||
<div class="text-center text-xl font-medium">Selamat Datang ke Sistem MyKOPKB</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Sistem MyKOPKB adalah sistem yang membantu pengguna untuk menguruskan permohonan keahlian KOPKB.
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
|
||||
<a class="text-danger flex items-center gap-3 font-medium cursor-pointer"
|
||||
@click.prevent="onboardingDialog = false">
|
||||
Skip Intro
|
||||
</a>
|
||||
<CarouselNextTrigger class="text-primary flex items-center gap-3 font-medium me-0 px-5">
|
||||
Next
|
||||
<Lucide icon="MoveRight" />
|
||||
</CarouselNextTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="1">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="womanIllustration"
|
||||
alt="MyKOPKB" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="text-center text-xl font-medium">Example Request Information</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Your premium admin dashboard template.
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<FieldGroup class="px-5">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<Field>
|
||||
<FieldLabel>Full Name</FieldLabel>
|
||||
<Input type="text" placeholder="John Doe" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Event</FieldLabel>
|
||||
<NativeSelect>
|
||||
<NativeSelectOption>Corporate Event</NativeSelectOption>
|
||||
<NativeSelectOption>Wedding</NativeSelectOption>
|
||||
<NativeSelectOption>Birthday</NativeSelectOption>
|
||||
<NativeSelectOption>Other</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
|
||||
<CarouselPrevTrigger class="text-primary flex items-center gap-3 font-medium ms-0 px-5">
|
||||
<Lucide icon="MoveLeft" /> Previous
|
||||
</CarouselPrevTrigger>
|
||||
<a class="text-primary flex items-center gap-3 font-medium cursor-pointer me-0 px-5"
|
||||
@click.prevent="onboardingDialog = false">
|
||||
Selesai
|
||||
<Lucide icon="MoveRight" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselItemGroup>
|
||||
</CarouselRoot>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
<!-- END: Onboarding Dialog -->
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,258 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import phoneIllustration from '@/assets/images/phone-illustration.svg'
|
||||
import womanIllustration from '@/assets/images/woman-illustration.svg'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
CarouselRoot,
|
||||
CarouselPrevTrigger,
|
||||
CarouselNextTrigger,
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Lucide, type Icon } from '@/components/ui/lucide'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { completeOnboarding } from '@/modules/profile/services/profile.service'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const onboardingDialog = ref(false)
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
|
||||
const greeting = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Selamat pagi'
|
||||
if (hour < 18) return 'Selamat petang'
|
||||
return 'Selamat malam'
|
||||
})
|
||||
|
||||
const availableModules = computed(() => [
|
||||
{
|
||||
title: 'Profil',
|
||||
description: 'Kemas kini maklumat peribadi, pekerjaan, bank dan waris.',
|
||||
route: 'profile-overview-2',
|
||||
icon: 'User' as Icon,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
title: 'Permohonan Keahlian',
|
||||
description: 'Urus dan semak permohonan keahlian KOPKB.',
|
||||
route: 'list-membership-applications',
|
||||
icon: 'ClipboardList' as Icon,
|
||||
visible: hasPermission('lihat permohonan keahlian'),
|
||||
},
|
||||
{
|
||||
title: 'Aktiviti',
|
||||
description: 'Lihat dan urus aktiviti serta laporan berkaitan.',
|
||||
route: 'list-activities',
|
||||
icon: 'CalendarDays' as Icon,
|
||||
visible: hasPermission('lihat aktiviti'),
|
||||
},
|
||||
{
|
||||
title: 'Senarai Pengguna',
|
||||
description: 'Pantau dan urus akaun pengguna sistem.',
|
||||
route: 'list-users',
|
||||
icon: 'Users' as Icon,
|
||||
visible: hasPermission('lihat pengguna'),
|
||||
},
|
||||
].filter((module) => module.visible))
|
||||
|
||||
const upcomingFeatures = [
|
||||
'Ringkasan statistik keahlian',
|
||||
'Carta dan laporan bulanan',
|
||||
'Notifikasi dan aktiviti terkini',
|
||||
'Papan pemuka pentadbir',
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
onboardingDialog.value = !authStore.user?.onboarding_completed_at
|
||||
})
|
||||
|
||||
async function markOnboardingComplete() {
|
||||
if (authStore.user?.onboarding_completed_at) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await completeOnboarding()
|
||||
if (res.success) {
|
||||
authStore.setUserProfile(res.data)
|
||||
}
|
||||
} catch {
|
||||
// Will retry on next visit if the request failed
|
||||
}
|
||||
}
|
||||
|
||||
async function finishOnboarding() {
|
||||
onboardingDialog.value = false
|
||||
await markOnboardingComplete()
|
||||
await router.push({ name: 'profile-overview-2' })
|
||||
}
|
||||
|
||||
function handleOnboardingOpenChange(details: { open: boolean }) {
|
||||
onboardingDialog.value = details.open
|
||||
|
||||
if (!details.open) {
|
||||
void markOnboardingComplete()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AlertRoot class="mb-6" look="outline" variant="primary">
|
||||
<Lucide class="mr-2 size-4 shrink-0" icon="Construction" />
|
||||
<AlertTitle>Sistem Dalam Pembangunan</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ appName }} {{ appVersion }} masih dalam fasa pembangunan. Beberapa fungsi papan pemuka
|
||||
belum tersedia. Sila gunakan modul sedia ada melalui menu sisi atau pautan di bawah.
|
||||
</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="col-span-12">
|
||||
<Box class="relative overflow-hidden p-6 sm:p-8">
|
||||
<div class="pointer-events-none absolute -right-10 -top-10 size-40 rounded-full bg-primary/10 blur-2xl" />
|
||||
<div class="relative">
|
||||
<Badge variant="pending" class="mb-4">Beta</Badge>
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{{ greeting }}{{ authStore.userName ? `, ${authStore.userName}` : '' }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-2xl text-base leading-relaxed opacity-70">
|
||||
Selamat datang ke {{ appName }}. Papan pemuka utama sedang dibangunkan — buat masa ini,
|
||||
anda boleh mula dengan mengemas kini profil atau mengurus permohonan keahlian.
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-7">
|
||||
<div class="mb-4 flex h-10 items-center">
|
||||
<h3 class="text-lg font-medium">Modul Tersedia</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<Box v-for="module in availableModules" :key="module.route"
|
||||
class="flex h-full flex-col p-5 transition-colors hover:bg-foreground/2">
|
||||
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Lucide :icon="module.icon" class="size-5" />
|
||||
</div>
|
||||
<div class="text-base font-medium">{{ module.title }}</div>
|
||||
<p class="mt-2 flex-1 text-sm leading-relaxed opacity-70">
|
||||
{{ module.description }}
|
||||
</p>
|
||||
<Button as-child variant="ghost" class="mt-5 w-full border border-foreground/15">
|
||||
<RouterLink :to="{ name: module.route }">
|
||||
Pergi ke {{ module.title }}
|
||||
<Lucide icon="ArrowRight" class="size-4" />
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-5">
|
||||
<div class="mb-4 flex h-10 items-center">
|
||||
<h3 class="text-lg font-medium">Akan Datang</h3>
|
||||
</div>
|
||||
|
||||
<Box class="p-5">
|
||||
<p class="text-sm leading-relaxed opacity-70">
|
||||
Ciri-ciri berikut sedang dirancang untuk papan pemuka ini:
|
||||
</p>
|
||||
<ul class="mt-4 space-y-3">
|
||||
<li v-for="feature in upcomingFeatures" :key="feature" class="flex items-start gap-3 text-sm">
|
||||
<Lucide icon="CircleDashed" class="mt-0.5 size-4 shrink-0 text-primary/70" />
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
|
||||
<Box class="mt-4 p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<Lucide icon="Lightbulb" class="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<div>
|
||||
<div class="text-sm font-medium">Petua</div>
|
||||
<p class="mt-1 text-sm leading-relaxed opacity-70">
|
||||
Pastikan profil anda lengkap dan terkini. Maklumat yang
|
||||
tepat membantu proses kelulusan berjalan lebih lancar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BEGIN: Onboarding Dialog -->
|
||||
<DialogRoot :open="onboardingDialog" @openChange="handleOnboardingOpenChange">
|
||||
<DialogContent class="sm:max-w-xl">
|
||||
<DialogCloseTrigger />
|
||||
<div class="overflow-hidden">
|
||||
<div class="-my-5 -mx-10">
|
||||
<CarouselRoot :default-page="0" :slide-count="2" class="border-0">
|
||||
<CarouselItemGroup>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="0">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="phoneIllustration"
|
||||
alt="MyKOPKB" />
|
||||
</div>
|
||||
<div class="px-8">
|
||||
<div class="text-center text-xl font-medium">Selamat Datang ke Sistem MyKOPKB</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Sistem MyKOPKB adalah sistem integrasi bagi ahli Koperasi Permodalan Kelantan Berhad (KOPKB).
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex justify-end px-5">
|
||||
<CarouselNextTrigger class="text-primary flex items-center gap-3 font-medium me-0 px-5">
|
||||
Seterusnya
|
||||
<Lucide icon="MoveRight" />
|
||||
</CarouselNextTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
<CarouselItem class="border-transparent bg-none bg-transparent" :index="1">
|
||||
<div class="relative mx-3 flex flex-col items-center gap-1 px-3.5 pb-20">
|
||||
<div
|
||||
class="w-full bg-primary/5 mb-7 border-primary/10 shadow-lg shadow-black/10 relative rounded-3xl border h-52 overflow-hidden before:bg-noise before:absolute before:inset-0 before:opacity-30 after:bg-accent after:absolute after:inset-0 after:opacity-30 after:blur-2xl">
|
||||
<img class="absolute inset-0 mx-auto mt-10 w-2/5 scale-125" :src="womanIllustration"
|
||||
alt="MyKOPKB" />
|
||||
</div>
|
||||
<div class="px-8">
|
||||
<div class="text-center text-xl font-medium">Mulakan dengan Profil Anda</div>
|
||||
<div class="mt-3 text-center text-base leading-relaxed opacity-70">
|
||||
Lengkapkan maklumat peribadi, pekerjaan dan butiran bank.
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex place-content-between px-5">
|
||||
<CarouselPrevTrigger class="text-primary flex items-center gap-3 font-medium ms-0 px-5">
|
||||
<Lucide icon="MoveLeft" /> Kembali
|
||||
</CarouselPrevTrigger>
|
||||
<a class="inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3 font-semibold text-white shadow-md transition-all duration-200 hover:bg-primary/90 hover:shadow-lg active:scale-95 cursor-pointer"
|
||||
@click.prevent="finishOnboarding">
|
||||
<span>Selesai</span>
|
||||
<Lucide icon="Check" class="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselItemGroup>
|
||||
</CarouselRoot>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
<!-- END: Onboarding Dialog -->
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const dashboardLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard-overview',
|
||||
component: () => import('./pages/DashboardOverview.vue'),
|
||||
meta: { title: 'Dashboard Overview', module: 'dashboard' },
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useExternalSystemList } from './useExternalSystemList'
|
||||
import {
|
||||
getExternalSystemStatus,
|
||||
isExternalSystemAccessible,
|
||||
} from '../utils/external-system.utils'
|
||||
|
||||
export function useExternalSystemDetail() {
|
||||
const route = useRoute()
|
||||
const { getSystemById } = useExternalSystemList()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const systemId = computed(() => String(route.params.id ?? ''))
|
||||
|
||||
const system = computed(() => getSystemById(systemId.value) ?? null)
|
||||
|
||||
const status = computed(() => (system.value ? getExternalSystemStatus(system.value) : null))
|
||||
|
||||
const isAccessible = computed(() =>
|
||||
system.value ? isExternalSystemAccessible(system.value) : false,
|
||||
)
|
||||
|
||||
watch(
|
||||
systemId,
|
||||
() => {
|
||||
error.value = system.value ? null : 'Sistem luaran tidak dijumpai.'
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
system,
|
||||
loading,
|
||||
error,
|
||||
status,
|
||||
isAccessible,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { dummyExternalSystems } from '../data/dummy-external-systems'
|
||||
import {
|
||||
externalSystemStatusLabel,
|
||||
getExternalSystemStatus,
|
||||
} from '../utils/external-system.utils'
|
||||
import type { ExternalSystem } from '../types/external-system.types'
|
||||
|
||||
export function useExternalSystemList() {
|
||||
const search = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const systems = computed(() => {
|
||||
const query = search.value.trim().toLowerCase()
|
||||
if (!query) {
|
||||
return dummyExternalSystems
|
||||
}
|
||||
|
||||
return dummyExternalSystems.filter((system) => {
|
||||
const haystack = [
|
||||
system.name,
|
||||
system.code,
|
||||
system.description,
|
||||
externalSystemStatusLabel(getExternalSystemStatus(system)),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const availableCount = computed(
|
||||
() => systems.value.filter((system) => getExternalSystemStatus(system) === 'available').length,
|
||||
)
|
||||
|
||||
function getSystemById(id: string): ExternalSystem | undefined {
|
||||
return dummyExternalSystems.find((system) => system.id === id)
|
||||
}
|
||||
|
||||
return {
|
||||
systems,
|
||||
search,
|
||||
loading,
|
||||
availableCount,
|
||||
getSystemById,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ExternalSystem } from '../types/external-system.types'
|
||||
|
||||
export const dummyExternalSystems: ExternalSystem[] = [
|
||||
{
|
||||
id: 'ext-001',
|
||||
code: 'KOPKB-PORTAL',
|
||||
name: 'Portal Ahli KOPKB',
|
||||
description:
|
||||
'Sistem utama keahlian Koperasi Permodalan Kelantan Berhad untuk semakan dividen, penyata dan maklumat ahli.',
|
||||
url: 'https://anggota.koppkb.com',
|
||||
icon: 'Users',
|
||||
is_active: true,
|
||||
starts_at: '2026-01-01T00:00:00+08:00',
|
||||
ends_at: null,
|
||||
opens_in_new_tab: true,
|
||||
contact_email: 'sokongan@koppkb.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',
|
||||
description:
|
||||
'Platform pengundian dalam talian untuk Mesyuarat Agung Tahunan. Hanya tersedia semasa tempoh pengundian.',
|
||||
url: 'https://e-vote.erahn.com.my/login',
|
||||
icon: 'Vote',
|
||||
is_active: true,
|
||||
starts_at: '2026-05-01T08:00:00+08:00',
|
||||
ends_at: null,
|
||||
opens_in_new_tab: true,
|
||||
contact_email: 'agm@koppkb.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',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
export { externalSystemLayoutRoutes } from './routes'
|
||||
export { externalSystemMenu } from './menu'
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const externalSystemMenu: Menu[] = [
|
||||
{
|
||||
icon: 'ExternalLink',
|
||||
route_name: 'list-external-systems',
|
||||
title: 'Sistem Luaran',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { useExternalSystemDetail } from '../composables/useExternalSystemDetail'
|
||||
import {
|
||||
externalSystemStatusLabel,
|
||||
externalSystemStatusVariant,
|
||||
formatExternalSystemDateTime,
|
||||
openExternalSystem,
|
||||
} from '../utils/external-system.utils'
|
||||
|
||||
const router = useRouter()
|
||||
const { system, error, status, isAccessible } = useExternalSystemDetail()
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'list-external-systems' })
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
if (!system.value) return
|
||||
openExternalSystem(system.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-5 flex flex-wrap items-center gap-3">
|
||||
<Button variant="ghost" look="outline" @click="goBack">
|
||||
<Lucide icon="ArrowLeft" class="size-4" />
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mb-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<template v-else-if="system && status">
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex min-w-0 items-start gap-4">
|
||||
<div
|
||||
class="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary"
|
||||
>
|
||||
<Lucide :icon="system.icon" class="size-6" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm opacity-70">Sistem Luaran</div>
|
||||
<h2 class="text-xl font-semibold">{{ system.name }}</h2>
|
||||
<div class="mt-1 text-sm font-medium text-primary/80">{{ system.code }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge look="outline" :variant="externalSystemStatusVariant(status)">
|
||||
{{ externalSystemStatusLabel(status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p class="mt-5 max-w-3xl text-sm leading-relaxed opacity-80">
|
||||
{{ system.description }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<span class="opacity-70">URL:</span>
|
||||
<div class="mt-0.5 break-all font-medium">{{ system.url }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Tarikh Mula:</span>
|
||||
<div class="mt-0.5 font-medium">{{ formatExternalSystemDateTime(system.starts_at) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Tarikh Tamat:</span>
|
||||
<div class="mt-0.5 font-medium">{{ formatExternalSystemDateTime(system.ends_at) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">E-mel Sokongan:</span>
|
||||
<div class="mt-0.5 font-medium">{{ system.contact_email ?? '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="system.notes" class="mt-6 border-t border-foreground/10 pt-5">
|
||||
<div class="text-sm font-medium opacity-70">Nota</div>
|
||||
<p class="mt-2 text-sm leading-relaxed opacity-80">{{ system.notes }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-3 border-t border-foreground/10 pt-5">
|
||||
<Button
|
||||
look="outline"
|
||||
variant="primary"
|
||||
:disabled="!isAccessible"
|
||||
@click="handleOpen"
|
||||
>
|
||||
Buka Sistem
|
||||
<Lucide icon="ExternalLink" class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="!isAccessible" class="mt-4" look="outline" variant="pending">
|
||||
<Lucide class="mr-2 size-4 shrink-0" icon="Clock" />
|
||||
<AlertTitle>Sistem Tidak Tersedia</AlertTitle>
|
||||
<AlertDescription>
|
||||
Pautan ini hanya boleh dibuka semasa tempoh acara yang ditetapkan dan sistem berstatus
|
||||
aktif.
|
||||
</AlertDescription>
|
||||
</AlertRoot>
|
||||
</Box>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { useExternalSystemList } from '../composables/useExternalSystemList'
|
||||
import {
|
||||
externalSystemStatusLabel,
|
||||
externalSystemStatusVariant,
|
||||
formatExternalSystemDateTime,
|
||||
getExternalSystemStatus,
|
||||
openExternalSystem,
|
||||
} from '../utils/external-system.utils'
|
||||
import type { ExternalSystem } from '../types/external-system.types'
|
||||
|
||||
const router = useRouter()
|
||||
const { systems, search, loading, availableCount } = useExternalSystemList()
|
||||
|
||||
function goToDetail(system: ExternalSystem) {
|
||||
router.push({ name: 'view-external-system', params: { id: system.id } })
|
||||
}
|
||||
|
||||
function handleOpen(system: ExternalSystem) {
|
||||
openExternalSystem(system)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium">Sistem Luaran</h2>
|
||||
</div>
|
||||
<Badge look="outline" variant="primary">
|
||||
{{ availableCount }} tersedia
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid grid-cols-12 gap-x-6 gap-y-8">
|
||||
<div class="col-span-12 mt-2 flex flex-wrap items-center sm:flex-nowrap">
|
||||
<div class="w-full sm:w-auto">
|
||||
<div class="relative w-56">
|
||||
<Input v-model="search" class="w-56 pr-10" type="search" placeholder="Cari sistem..." :disabled="loading" />
|
||||
<Lucide class="absolute inset-y-0 right-0 my-auto mr-3 h-4 w-4" icon="Search" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="systems.length">
|
||||
<Box v-for="system in systems" :key="system.id"
|
||||
class="col-span-12 flex h-full flex-col p-5 md:col-span-6 xl:col-span-4">
|
||||
<div class="mb-4 flex items-start justify-between gap-3">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Lucide :icon="system.icon" class="size-5" />
|
||||
</div>
|
||||
<Badge look="outline" :variant="externalSystemStatusVariant(getExternalSystemStatus(system))">
|
||||
{{ externalSystemStatusLabel(getExternalSystemStatus(system)) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="text-base font-medium">{{ system.name }}</div>
|
||||
<div class="mt-1 text-xs font-medium uppercase tracking-wide text-primary/80">
|
||||
{{ system.code }}
|
||||
</div>
|
||||
<p class="mt-2 flex-1 text-sm leading-relaxed opacity-70">
|
||||
{{ system.description }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 space-y-1 text-xs opacity-70">
|
||||
<div>
|
||||
<span class="font-medium">Mula:</span>
|
||||
{{ formatExternalSystemDateTime(system.starts_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Tamat:</span>
|
||||
{{ formatExternalSystemDateTime(system.ends_at) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-2 sm:flex-row">
|
||||
<Button class="w-full sm:flex-1" look="outline" variant="primary"
|
||||
:disabled="getExternalSystemStatus(system) !== 'available'" @click="handleOpen(system)">
|
||||
Buka Sistem
|
||||
<Lucide icon="ExternalLink" class="size-4" />
|
||||
</Button>
|
||||
<Button class="w-full sm:flex-1" variant="ghost" @click="goToDetail(system)">
|
||||
Butiran
|
||||
<Lucide icon="ArrowRight" class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</template>
|
||||
|
||||
<Box v-else class="col-span-12 p-8 text-center">
|
||||
<Lucide icon="SearchX" class="mx-auto size-8 opacity-40" />
|
||||
<div class="mt-3 text-base font-medium">Tiada sistem dijumpai</div>
|
||||
<p class="mt-1 text-sm opacity-70">Cuba istilah carian yang berbeza.</p>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const externalSystemLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'external-systems',
|
||||
name: 'list-external-systems',
|
||||
component: () => import('./pages/ExternalSystemList.vue'),
|
||||
meta: {
|
||||
title: 'Senarai Sistem Luaran',
|
||||
module: 'external-system',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'external-systems/:id',
|
||||
name: 'view-external-system',
|
||||
component: () => import('./pages/ExternalSystemDetail.vue'),
|
||||
meta: {
|
||||
title: 'Butiran Sistem Luaran',
|
||||
module: 'external-system',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Icon } from '@/components/ui/lucide'
|
||||
|
||||
export type ExternalSystemStatus = 'available' | 'upcoming' | 'ended' | 'inactive'
|
||||
|
||||
export type ExternalSystem = {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
description: string
|
||||
url: string
|
||||
icon: Icon
|
||||
is_active: boolean
|
||||
starts_at: string | null
|
||||
ends_at: string | null
|
||||
opens_in_new_tab: boolean
|
||||
contact_email: string | null
|
||||
notes: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import dayjs from 'dayjs'
|
||||
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
|
||||
import type { ExternalSystem, ExternalSystemStatus } from '../types/external-system.types'
|
||||
|
||||
export function getExternalSystemStatus(
|
||||
system: ExternalSystem,
|
||||
now = dayjs(),
|
||||
): ExternalSystemStatus {
|
||||
if (!system.is_active) {
|
||||
return 'inactive'
|
||||
}
|
||||
|
||||
const startsAt = system.starts_at ? dayjs(system.starts_at) : null
|
||||
const endsAt = system.ends_at ? dayjs(system.ends_at) : null
|
||||
|
||||
if (startsAt?.isAfter(now)) {
|
||||
return 'upcoming'
|
||||
}
|
||||
|
||||
if (endsAt?.isBefore(now)) {
|
||||
return 'ended'
|
||||
}
|
||||
|
||||
return 'available'
|
||||
}
|
||||
|
||||
export function isExternalSystemAccessible(system: ExternalSystem, now = dayjs()): boolean {
|
||||
return getExternalSystemStatus(system, now) === 'available'
|
||||
}
|
||||
|
||||
export function externalSystemStatusLabel(status: ExternalSystemStatus): string {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return 'Tersedia'
|
||||
case 'upcoming':
|
||||
return 'Akan Datang'
|
||||
case 'ended':
|
||||
return 'Tamat'
|
||||
case 'inactive':
|
||||
return 'Tidak Aktif'
|
||||
}
|
||||
}
|
||||
|
||||
export function externalSystemStatusVariant(
|
||||
status: ExternalSystemStatus,
|
||||
): NonNullable<BadgeVariants['variant']> {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return 'success'
|
||||
case 'upcoming':
|
||||
return 'pending'
|
||||
case 'ended':
|
||||
return 'ghost'
|
||||
case 'inactive':
|
||||
return 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatExternalSystemDateTime(value: string | null): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
export function openExternalSystem(system: ExternalSystem) {
|
||||
if (!isExternalSystemAccessible(system)) {
|
||||
return
|
||||
}
|
||||
|
||||
window.open(
|
||||
system.url,
|
||||
system.opens_in_new_tab ? '_blank' : '_self',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import * as select from '@zag-js/select'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
@@ -27,16 +27,19 @@ import type {
|
||||
MembershipApplicationHeirForm,
|
||||
MembershipApplicationReferenceForm,
|
||||
} from '../types/membership-application.types'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const MAX_FILE_SIZE_MB = 10
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
const MIN_STOCK_MONTHLY_CONTRIBUTION = 50
|
||||
const INITIAL_MANDATORY_STOCK_MONTHLY_CONTRIBUTION = 84
|
||||
const MIN_FEE_MONTHLY_CONTRIBUTION = 30
|
||||
|
||||
const steps = [
|
||||
{ id: 1, label: 'Maklumat Peribadi' },
|
||||
{ id: 2, label: 'Hubungan & Alamat' },
|
||||
{ id: 3, label: 'Maklumat Pekerjaan' },
|
||||
{ id: 4, label: 'Maklumat Waris' },
|
||||
{ id: 4, label: 'Maklumat Penama' },
|
||||
{ id: 5, label: 'Dokumen & Hantar' },
|
||||
] as const
|
||||
|
||||
@@ -73,6 +76,31 @@ const RELATIONSHIP_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Lain-lain', value: 'Lain-lain' },
|
||||
]
|
||||
|
||||
// TODO: replace with API lookup
|
||||
const EMPLOYERS = [
|
||||
{
|
||||
name: 'INFRA QUEST SDN BHD',
|
||||
address: 'Lot 1045, Jalan Dato’ Lundang, 15200 Kota Bharu, Kelantan',
|
||||
},
|
||||
{
|
||||
name: 'Permodalan Kelantan Berhad',
|
||||
address: 'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
|
||||
},
|
||||
{
|
||||
name: 'Koperasi Permodalan Kelantan Berhad',
|
||||
address: 'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
|
||||
},
|
||||
{
|
||||
name: "An-Nisa'",
|
||||
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
|
||||
},
|
||||
] as const
|
||||
|
||||
const EMPLOYER_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
|
||||
label: employer.name,
|
||||
value: employer.name,
|
||||
}))
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
@@ -94,6 +122,11 @@ function apiValueToLabel(options: SelectOption[], value: string | undefined): st
|
||||
const genderCollection = createSelectCollection(GENDER_OPTIONS)
|
||||
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
|
||||
const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
|
||||
const employerCollection = createSelectCollection(EMPLOYER_OPTIONS)
|
||||
|
||||
function getEmployerAddress(name: string): string {
|
||||
return EMPLOYERS.find((employer) => employer.name === name)?.address ?? ''
|
||||
}
|
||||
|
||||
function createEmptyReference(): MembershipApplicationReferenceForm {
|
||||
return {
|
||||
@@ -149,6 +182,7 @@ const genderInitial = computed(() => apiValueToLabel(GENDER_OPTIONS, form.applic
|
||||
const marriageStatusInitial = computed(() =>
|
||||
apiValueToLabel(MARRIAGE_STATUS_OPTIONS, form.applicant.marriage_status),
|
||||
)
|
||||
const employerInitial = computed(() => apiValueToLabel(EMPLOYER_OPTIONS, form.applicant.employer_name))
|
||||
|
||||
function setGenderValue(details: { value: string[] }) {
|
||||
form.applicant.gender = labelToApiValue(GENDER_OPTIONS, details.value[0])
|
||||
@@ -160,6 +194,14 @@ function setMarriageStatusValue(details: { value: string[] }) {
|
||||
delete fieldErrors['applicant.marriage_status']
|
||||
}
|
||||
|
||||
function setEmployerValue(details: { value: string[] }) {
|
||||
const employerName = labelToApiValue(EMPLOYER_OPTIONS, details.value[0])
|
||||
form.applicant.employer_name = employerName
|
||||
form.applicant.employer_address = getEmployerAddress(employerName)
|
||||
delete fieldErrors['applicant.employer_name']
|
||||
delete fieldErrors['applicant.employer_address']
|
||||
}
|
||||
|
||||
function setHeirRelationshipValue(index: number, details: { value: string[] }) {
|
||||
const heir = form.heirs[index]
|
||||
if (!heir) return
|
||||
@@ -254,7 +296,7 @@ const stepTitle = computed(() => {
|
||||
case 3:
|
||||
return 'Maklumat Pekerjaan & Caruman'
|
||||
case 4:
|
||||
return 'Maklumat Waris'
|
||||
return 'Maklumat Penama'
|
||||
default:
|
||||
return 'Dokumen & Pengesahan'
|
||||
}
|
||||
@@ -269,7 +311,7 @@ const stepDescription = computed(() => {
|
||||
case 3:
|
||||
return 'Masukkan maklumat pekerjaan dan caruman bulanan.'
|
||||
case 4:
|
||||
return 'Tambah sekurang-kurangnya satu waris.'
|
||||
return 'Tambah sekurang-kurangnya satu Penama.'
|
||||
default:
|
||||
return 'Muat naik dokumen sokongan dan semak maklumat sebelum hantar.'
|
||||
}
|
||||
@@ -324,21 +366,45 @@ function validateStep(step: number): boolean {
|
||||
requireField(
|
||||
'applicant.stock_monthly_contribution',
|
||||
form.applicant.stock_monthly_contribution,
|
||||
'Caruman saham bulanan',
|
||||
'Potongan modal syer minima',
|
||||
)
|
||||
|
||||
const stockContribution = Number(form.applicant.stock_monthly_contribution)
|
||||
if (
|
||||
form.applicant.stock_monthly_contribution &&
|
||||
(Number.isNaN(stockContribution) || stockContribution < MIN_STOCK_MONTHLY_CONTRIBUTION)
|
||||
) {
|
||||
setError(
|
||||
'applicant.stock_monthly_contribution',
|
||||
`Potongan modal syer minima mestilah sekurang-kurangnya RM${MIN_STOCK_MONTHLY_CONTRIBUTION}.`,
|
||||
)
|
||||
valid = false
|
||||
}
|
||||
requireField(
|
||||
'applicant.fee_monthly_contribution',
|
||||
form.applicant.fee_monthly_contribution,
|
||||
'Caruman yuran bulanan',
|
||||
'Potongan yuran bulanan',
|
||||
)
|
||||
|
||||
const feeContribution = Number(form.applicant.fee_monthly_contribution)
|
||||
if (
|
||||
form.applicant.fee_monthly_contribution &&
|
||||
(Number.isNaN(feeContribution) || feeContribution < MIN_FEE_MONTHLY_CONTRIBUTION)
|
||||
) {
|
||||
setError(
|
||||
'applicant.fee_monthly_contribution',
|
||||
`Potongan yuran bulanan mestilah sekurang-kurangnya RM${MIN_FEE_MONTHLY_CONTRIBUTION}.`,
|
||||
)
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 4) {
|
||||
form.heirs.forEach((heir, index) => {
|
||||
requireField(`heirs.${index}.name`, heir.name, `Nama waris ${index + 1}`)
|
||||
requireField(`heirs.${index}.ic_number`, heir.ic_number, `No. KP waris ${index + 1}`)
|
||||
requireField(`heirs.${index}.relationship`, heir.relationship, `Hubungan waris ${index + 1}`)
|
||||
requireField(`heirs.${index}.phone_number`, heir.phone_number, `No. telefon waris ${index + 1}`)
|
||||
requireField(`heirs.${index}.name`, heir.name, `Nama Penama ${index + 1}`)
|
||||
requireField(`heirs.${index}.ic_number`, heir.ic_number, `No. KP Penama ${index + 1}`)
|
||||
requireField(`heirs.${index}.relationship`, heir.relationship, `Hubungan Penama ${index + 1}`)
|
||||
requireField(`heirs.${index}.phone_number`, heir.phone_number, `No. telefon Penama ${index + 1}`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -355,6 +421,11 @@ function validateStep(step: number): boolean {
|
||||
setError('documents.ic_copy', 'Salinan kad pengenalan diperlukan.')
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!form.documents.employer_letter) {
|
||||
setError('documents.employer_letter', 'Surat pengesahan majikan diperlukan.')
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
|
||||
return valid
|
||||
@@ -638,8 +709,23 @@ function stepLabelClass(stepId: number) {
|
||||
|
||||
<template v-else-if="currentStep === 3">
|
||||
<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" />
|
||||
<FieldLabel>Nama Majikan</FieldLabel>
|
||||
<SelectRoot :key="`employer-${form.applicant.employer_name}`" class="w-full"
|
||||
:collection="employerCollection" :default-value="employerInitial" @value-change="setEmployerValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.employer_name']">
|
||||
<SelectValueText placeholder="Pilih majikan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Nama Majikan</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in employerCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="fieldErrors['applicant.employer_name']">
|
||||
{{ fieldErrors['applicant.employer_name'] }}
|
||||
</FieldError>
|
||||
@@ -653,7 +739,7 @@ function stepLabelClass(stepId: number) {
|
||||
</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" />
|
||||
<Textarea id="employer_address" v-model="form.applicant.employer_address" rows="3" disabled />
|
||||
<FieldError v-if="fieldErrors['applicant.employer_address']">
|
||||
{{ fieldErrors['applicant.employer_address'] }}
|
||||
</FieldError>
|
||||
@@ -666,17 +752,27 @@ function stepLabelClass(stepId: number) {
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="stock_monthly_contribution">Caruman Saham (RM)</FieldLabel>
|
||||
<FieldLabel for="stock_monthly_contribution">Potongan Modal Syer Minima</FieldLabel>
|
||||
<FieldDescription>
|
||||
Minima RM{{ MIN_STOCK_MONTHLY_CONTRIBUTION }} setiap bulan.
|
||||
</FieldDescription>
|
||||
<Input id="stock_monthly_contribution" v-model="form.applicant.stock_monthly_contribution" type="number"
|
||||
min="0" step="0.01" />
|
||||
:min="MIN_STOCK_MONTHLY_CONTRIBUTION" step="0.01" />
|
||||
<FieldDescription class="mt-2">
|
||||
Potongan RM{{ INITIAL_MANDATORY_STOCK_MONTHLY_CONTRIBUTION }} setiap bulan adalah wajib bagi 6 bulan
|
||||
pertama bagi menjelaskan modal syer minimum RM500. Anda boleh memilih potongan lebih tinggi. Selepas
|
||||
RM500 dijelaskan, potongan boleh dikekalkan atau dikurangkan sehingga minima
|
||||
RM{{ MIN_STOCK_MONTHLY_CONTRIBUTION }} setiap bulan.
|
||||
</FieldDescription>
|
||||
<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>
|
||||
<FieldLabel for="fee_monthly_contribution">Potongan Yuran Bulanan</FieldLabel>
|
||||
<FieldDescription>Minima RM{{ MIN_FEE_MONTHLY_CONTRIBUTION }} setiap bulan.</FieldDescription>
|
||||
<Input id="fee_monthly_contribution" v-model="form.applicant.fee_monthly_contribution" type="number"
|
||||
min="0" step="0.01" />
|
||||
:min="MIN_FEE_MONTHLY_CONTRIBUTION" step="0.01" />
|
||||
<FieldError v-if="fieldErrors['applicant.fee_monthly_contribution']">
|
||||
{{ fieldErrors['applicant.fee_monthly_contribution'] }}
|
||||
</FieldError>
|
||||
@@ -688,7 +784,7 @@ function stepLabelClass(stepId: number) {
|
||||
<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>
|
||||
<div class="font-medium">Penama</div>
|
||||
<Button v-if="form.heirs.length > 1" type="button" look="outline" size="sm"
|
||||
@click="removeHeir(index)">
|
||||
Buang
|
||||
@@ -742,10 +838,11 @@ function stepLabelClass(stepId: number) {
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" look="outline" @click="addHeir">
|
||||
<!-- Penama hanya boleh 1 orang (disable in frontend)-->
|
||||
<!-- <Button type="button" look="outline" @click="addHeir">
|
||||
<Lucide icon="Plus" class="mr-2 size-4" />
|
||||
Tambah Waris
|
||||
</Button>
|
||||
Tambah Penama
|
||||
</Button> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -806,7 +903,7 @@ function stepLabelClass(stepId: number) {
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="employer_letter">Surat Pengesahan Majikan (Pilihan)</FieldLabel>
|
||||
<FieldLabel for="employer_letter">Surat Pengesahan Majikan *</FieldLabel>
|
||||
<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']
|
||||
@@ -820,7 +917,7 @@ function stepLabelClass(stepId: number) {
|
||||
<div><span class="opacity-70">Emel:</span> {{ form.applicant.email }}</div>
|
||||
<div><span class="opacity-70">No. KP:</span> {{ form.applicant.ic_number }}</div>
|
||||
<div><span class="opacity-70">Majikan:</span> {{ form.applicant.employer_name }}</div>
|
||||
<div><span class="opacity-70">Bil. Waris:</span> {{ form.heirs.length }}</div>
|
||||
<div><span class="opacity-70">Bil. Penama:</span> {{ form.heirs.length }}</div>
|
||||
<div>
|
||||
<span class="opacity-70">Pencadang:</span>
|
||||
{{ form.references.proposer.name || '-' }}
|
||||
|
||||
@@ -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, FileText, Pencil } from '@lucide/vue'
|
||||
import { CircleAlert, CircleCheck, Download, Eye, Pencil, Trash2 } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
@@ -21,9 +21,9 @@ import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
completeMembershipApplication,
|
||||
deleteMembershipApplicationDocument,
|
||||
downloadMembershipApplicationDocument,
|
||||
fetchMembershipApplicationDocument,
|
||||
generateMembershipApplicationResultLetter,
|
||||
getMembershipApplication,
|
||||
submitBoardReview,
|
||||
submitManagementReview,
|
||||
@@ -43,12 +43,15 @@ import type {
|
||||
MembershipApplicationReviewDetail,
|
||||
MembershipApplicationStatus,
|
||||
} from '../types/membership-application.types'
|
||||
import { RESULT_LETTER_DOCUMENT_TYPE } from '../types/membership-application.types'
|
||||
import {
|
||||
ADMIN_ATTACHMENT_DOCUMENT_TYPE,
|
||||
RESULT_LETTER_DOCUMENT_TYPE,
|
||||
} from '../types/membership-application.types'
|
||||
|
||||
const WORKFLOW_STEPS = [
|
||||
{ id: 1, label: 'Dihantar' },
|
||||
{ id: 2, label: 'Semakan Pentadbiran' },
|
||||
{ id: 3, label: 'Semakan Lembaga' },
|
||||
{ id: 3, label: 'Keputusan Ahli Lembaga Koperasi (ALK)' },
|
||||
{ id: 4, label: 'Makluman Keputusan' },
|
||||
{ id: 5, label: 'Selesai' },
|
||||
] as const
|
||||
@@ -58,6 +61,7 @@ const DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
||||
photo: 'Gambar Passport',
|
||||
salary_slip: 'Slip Gaji',
|
||||
employer_letter: 'Surat Pengesahan Majikan',
|
||||
admin_attachment: 'Lampiran Pentadbir',
|
||||
[RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan',
|
||||
}
|
||||
|
||||
@@ -82,19 +86,20 @@ const pendingAction = ref<
|
||||
| null
|
||||
>(null)
|
||||
const downloadingDocumentId = ref<string | null>(null)
|
||||
const deletingDocumentId = ref<string | null>(null)
|
||||
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)
|
||||
const deleteConfirmDialogOpen = ref(false)
|
||||
const pendingDeleteDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
|
||||
function statusLabel(status: MembershipApplicationStatus): string {
|
||||
const labels: Record<MembershipApplicationStatus, string> = {
|
||||
SUBMITTED: 'Dihantar',
|
||||
PENDING_BOARD: 'Menunggu Lembaga',
|
||||
PENDING_BOARD: 'Menunggu Keputusan Mesyuarat ALK',
|
||||
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
|
||||
PENDING_NOTIFICATION: 'Menunggu Makluman',
|
||||
COMPLETED: 'Selesai',
|
||||
@@ -142,16 +147,14 @@ const showCompleteAction = computed(
|
||||
application.value?.status === 'PENDING_NOTIFICATION',
|
||||
)
|
||||
|
||||
const resultLetterDocument = computed(() =>
|
||||
application.value?.documents.find((document) => document.type === RESULT_LETTER_DOCUMENT_TYPE) ?? null,
|
||||
const canEditAdminAttachments = computed(
|
||||
() =>
|
||||
hasPermission('kemaskini permohonan keahlian') &&
|
||||
application.value?.status !== 'COMPLETED',
|
||||
)
|
||||
|
||||
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 resultLetterDocument = computed(() =>
|
||||
application.value?.documents.find((document) => document.type === RESULT_LETTER_DOCUMENT_TYPE) ?? null,
|
||||
)
|
||||
|
||||
const applicant = computed(() => application.value?.applicant ?? null)
|
||||
@@ -167,8 +170,8 @@ const confirmDialogTitle = computed(() => {
|
||||
|
||||
if (pendingAction.value.type === 'board') {
|
||||
return pendingAction.value.decision === 'PASS'
|
||||
? 'Luluskan Semakan Lembaga?'
|
||||
: 'Gagalkan Semakan Lembaga?'
|
||||
? 'Luluskan?'
|
||||
: 'Gagalkan?'
|
||||
}
|
||||
|
||||
return 'Selesaikan Permohonan?'
|
||||
@@ -179,17 +182,17 @@ const confirmDialogDescription = computed(() => {
|
||||
|
||||
if (pendingAction.value.type === 'management') {
|
||||
return pendingAction.value.decision === 'APPROVED'
|
||||
? 'Permohonan akan dihantar ke semakan lembaga.'
|
||||
? 'Permohonan akan dihantar ke mesyuarat ALK.'
|
||||
: 'Permohonan akan ditolak pada peringkat pentadbiran.'
|
||||
}
|
||||
|
||||
if (pendingAction.value.type === 'board') {
|
||||
return pendingAction.value.decision === 'PASS'
|
||||
? 'Permohonan akan dihantar ke peringkat makluman keputusan.'
|
||||
: 'Permohonan akan ditandakan gagal semakan lembaga.'
|
||||
: 'Permohonan akan ditandakan gagal mesyuarat ALK.'
|
||||
}
|
||||
|
||||
return 'E-mel keputusan akan dihantar kepada pemohon. Akaun ahli akan dicipta jika permohonan lulus.'
|
||||
return 'Surat keputusan akan dijana, dilampirkan dalam e-mel keputusan, dan dihantar kepada pemohon. Akaun ahli akan dicipta jika permohonan lulus.'
|
||||
})
|
||||
|
||||
function workflowStepButtonClass(stepId: number) {
|
||||
@@ -242,6 +245,14 @@ const uploadedDocuments = computed(() =>
|
||||
application.value?.documents.filter((document) => document.type !== RESULT_LETTER_DOCUMENT_TYPE) ?? [],
|
||||
)
|
||||
|
||||
const applicantDocuments = computed(() =>
|
||||
uploadedDocuments.value.filter((document) => document.type !== ADMIN_ATTACHMENT_DOCUMENT_TYPE),
|
||||
)
|
||||
|
||||
const adminAttachments = computed(() =>
|
||||
uploadedDocuments.value.filter((document) => document.type === ADMIN_ATTACHMENT_DOCUMENT_TYPE),
|
||||
)
|
||||
|
||||
function displayValue(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value)
|
||||
@@ -309,7 +320,7 @@ function getReference(type: 'PROPOSER' | 'SUPPORTER'): MembershipApplicationRefe
|
||||
|
||||
function reviewStageLabel(stage: string): string {
|
||||
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
|
||||
if (stage === 'BOARD') return 'Semakan Lembaga'
|
||||
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
|
||||
return stage
|
||||
}
|
||||
|
||||
@@ -358,6 +369,11 @@ function openConfirmAction(
|
||||
| { type: 'board'; decision: BoardReviewDecision }
|
||||
| { type: 'complete' },
|
||||
) {
|
||||
if (action.type === 'complete') {
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
}
|
||||
|
||||
pendingAction.value = action
|
||||
confirmDialogOpen.value = true
|
||||
}
|
||||
@@ -365,6 +381,8 @@ function openConfirmAction(
|
||||
function closeConfirmDialog() {
|
||||
confirmDialogOpen.value = false
|
||||
pendingAction.value = null
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
}
|
||||
|
||||
async function confirmPendingAction() {
|
||||
@@ -372,8 +390,14 @@ async function confirmPendingAction() {
|
||||
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
boardMeetingReferenceError.value = null
|
||||
|
||||
if (pendingAction.value.type === 'complete') {
|
||||
const reference = boardMeetingReference.value.trim()
|
||||
if (!reference) {
|
||||
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
completeSubmitting.value = true
|
||||
} else {
|
||||
reviewSubmitting.value = true
|
||||
@@ -395,7 +419,9 @@ async function confirmPendingAction() {
|
||||
})
|
||||
boardRemarks.value = ''
|
||||
} else {
|
||||
response = await completeMembershipApplication(applicationId.value)
|
||||
response = await completeMembershipApplication(applicationId.value, {
|
||||
board_meeting_reference: boardMeetingReference.value.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
application.value = response.data
|
||||
@@ -407,55 +433,15 @@ async function confirmPendingAction() {
|
||||
if (validationErrors?.remarks?.[0]) {
|
||||
error.value = validationErrors.remarks[0]
|
||||
}
|
||||
if (validationErrors?.board_meeting_reference?.[0]) {
|
||||
boardMeetingReferenceError.value = validationErrors.board_meeting_reference[0]
|
||||
}
|
||||
} finally {
|
||||
reviewSubmitting.value = false
|
||||
completeSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -475,6 +461,43 @@ async function handleDownloadDocument(document: MembershipApplicationDocumentDet
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAdminAttachment(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value || !canEditAdminAttachments.value || deletingDocumentId.value) return
|
||||
|
||||
pendingDeleteDocument.value = document
|
||||
deleteConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeDeleteConfirmDialog() {
|
||||
deleteConfirmDialogOpen.value = false
|
||||
pendingDeleteDocument.value = null
|
||||
}
|
||||
|
||||
async function confirmDeleteAdminAttachment() {
|
||||
const document = pendingDeleteDocument.value
|
||||
if (!application.value || !document || deletingDocumentId.value) return
|
||||
|
||||
deletingDocumentId.value = document.id
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const response = await deleteMembershipApplicationDocument(application.value.id, document.id)
|
||||
application.value = response.data
|
||||
|
||||
if (previewDocument.value?.id === document.id) {
|
||||
handlePreviewOpenChange(false)
|
||||
}
|
||||
|
||||
successMessage.value = response.message || 'Lampiran pentadbir berjaya dipadam.'
|
||||
closeDeleteConfirmDialog()
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memadam lampiran pentadbir.')
|
||||
} finally {
|
||||
deletingDocumentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewDocument(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value) return
|
||||
|
||||
@@ -649,51 +672,31 @@ onUnmounted(() => {
|
||||
<Box v-if="showCompleteAction" class="p-5 sm:p-6">
|
||||
<div class="font-medium">Makluman Keputusan</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Hantar e-mel keputusan kepada pemohon
|
||||
<span v-if="application.board_result === 'PASS'"> dan cipta akaun ahli</span>.
|
||||
Jana surat keputusan, hantar e-mel dengan lampiran surat kepada pemohon
|
||||
<span v-if="application.board_result === 'PASS'">, dan cipta akaun ahli</span>.
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button type="button" variant="primary" :disabled="reviewSubmitting || completeSubmitting"
|
||||
@click="openConfirmAction({ type: 'complete' })">
|
||||
Selesaikan & Hantar Makluman
|
||||
Selesaikan, Jana Surat & Hantar E-mel
|
||||
</Button>
|
||||
</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">
|
||||
<Box v-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"
|
||||
<Button type="button" look="outline"
|
||||
:disabled="previewLoading && previewDocument?.id === resultLetterDocument.id"
|
||||
@click="handleViewDocument(resultLetterDocument)"
|
||||
>
|
||||
@click="handleViewDocument(resultLetterDocument)">
|
||||
<Eye class="mr-2 size-4" />
|
||||
Lihat
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
:disabled="downloadingDocumentId === resultLetterDocument.id"
|
||||
@click="handleDownloadDocument(resultLetterDocument)"
|
||||
>
|
||||
<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>
|
||||
@@ -722,7 +725,7 @@ onUnmounted(() => {
|
||||
<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
|
||||
Penama
|
||||
</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"
|
||||
@@ -834,7 +837,7 @@ onUnmounted(() => {
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="(heir, index) in application.heirs" :key="heir.id"
|
||||
class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="mb-4 font-medium">Waris {{ index + 1 }}</div>
|
||||
<div class="mb-4 font-medium">Penama</div>
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Nama</FieldLabel>
|
||||
@@ -890,30 +893,62 @@ onUnmounted(() => {
|
||||
|
||||
<TabsContent value="documents" class="mt-6">
|
||||
<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 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>
|
||||
<div class="mt-1 text-sm opacity-70">
|
||||
{{ document.name }} · {{ formatFileSize(document.file_size) }}
|
||||
<template v-else>
|
||||
<div v-if="applicantDocuments.length" class="space-y-3">
|
||||
<div class="font-medium">Dokumen Pemohon</div>
|
||||
<div v-for="document in applicantDocuments" :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>
|
||||
<div class="mt-1 text-sm opacity-70">
|
||||
{{ document.name }} · {{ formatFileSize(document.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" look="outline" size="sm"
|
||||
:disabled="previewLoading && previewDocument?.id === document.id"
|
||||
@click="handleViewDocument(document)">
|
||||
<Eye class="mr-2 size-4" />
|
||||
{{ previewLoading && previewDocument?.id === document.id ? 'Memuatkan...' : 'Lihat' }}
|
||||
</Button>
|
||||
<Button type="button" look="outline" size="sm" :disabled="downloadingDocumentId === document.id"
|
||||
@click="handleDownloadDocument(document)">
|
||||
<Download class="mr-2 size-4" />
|
||||
{{ downloadingDocumentId === document.id ? 'Memuat turun...' : 'Muat Turun' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" look="outline" size="sm"
|
||||
:disabled="previewLoading && previewDocument?.id === document.id"
|
||||
@click="handleViewDocument(document)">
|
||||
<Eye class="mr-2 size-4" />
|
||||
{{ previewLoading && previewDocument?.id === document.id ? 'Memuatkan...' : 'Lihat' }}
|
||||
</Button>
|
||||
<Button type="button" look="outline" size="sm" :disabled="downloadingDocumentId === document.id"
|
||||
@click="handleDownloadDocument(document)">
|
||||
<Download class="mr-2 size-4" />
|
||||
{{ downloadingDocumentId === document.id ? 'Memuat turun...' : 'Muat Turun' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="adminAttachments.length" class="mt-8 space-y-3">
|
||||
<div class="font-medium">Lampiran Pentadbir</div>
|
||||
<div v-for="document in adminAttachments" :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">{{ document.name }}</div>
|
||||
<div class="mt-1 text-sm opacity-70">{{ formatFileSize(document.file_size) }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" look="outline" size="sm"
|
||||
:disabled="previewLoading && previewDocument?.id === document.id"
|
||||
@click="handleViewDocument(document)">
|
||||
<Eye class="mr-2 size-4" />
|
||||
{{ previewLoading && previewDocument?.id === document.id ? 'Memuatkan...' : 'Lihat' }}
|
||||
</Button>
|
||||
<Button type="button" look="outline" size="sm" :disabled="downloadingDocumentId === document.id"
|
||||
@click="handleDownloadDocument(document)">
|
||||
<Download class="mr-2 size-4" />
|
||||
{{ downloadingDocumentId === document.id ? 'Memuat turun...' : 'Muat Turun' }}
|
||||
</Button>
|
||||
<Button v-if="canEditAdminAttachments" type="button" look="outline" size="sm" variant="danger"
|
||||
:disabled="deletingDocumentId === document.id" @click="handleDeleteAdminAttachment(document)">
|
||||
<Trash2 class="mr-2 size-4" />
|
||||
{{ deletingDocumentId === document.id ? 'Memadam...' : 'Padam' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="application.reviews.length" value="reviews" class="mt-6">
|
||||
@@ -932,11 +967,8 @@ onUnmounted(() => {
|
||||
{{ formatDateTime(review.reviewed_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="reviewDecisionBadgeVariant(review.decision, review.stage)"
|
||||
:look="reviewDecisionBadgeLook(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>
|
||||
@@ -959,11 +991,18 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<DialogRoot :open="confirmDialogOpen"
|
||||
@openChange="(details) => { confirmDialogOpen = details.open; if (!details.open) pendingAction = null }">
|
||||
@openChange="(details) => { if (!details.open) closeConfirmDialog(); else confirmDialogOpen = details.open }">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-2 text-2xl font-medium">{{ confirmDialogTitle }}</div>
|
||||
<div class="mt-2 opacity-70">{{ confirmDialogDescription }}</div>
|
||||
<Field v-if="pendingAction?.type === 'complete'" class="mt-5 text-left">
|
||||
<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="completeSubmitting"
|
||||
@input="boardMeetingReferenceError = null" />
|
||||
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-28" :disabled="reviewSubmitting || completeSubmitting">
|
||||
@@ -977,38 +1016,22 @@ onUnmounted(() => {
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<DialogRoot :open="generateDialogOpen" @openChange="(details) => { if (!details.open) closeGenerateDialog() }">
|
||||
<DialogRoot :open="deleteConfirmDialogOpen"
|
||||
@openChange="(details) => { if (!details.open) closeDeleteConfirmDialog() }">
|
||||
<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 class="p-5 text-center">
|
||||
<div class="mt-2 text-2xl font-medium">Padam Lampiran Pentadbir?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
{{ pendingDeleteDocument?.name ?? 'Lampiran pentadbir' }} akan dipadam secara kekal.
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-32" :disabled="generateSubmitting" @click="closeGenerateDialog">
|
||||
<DialogCloseTrigger class="mr-2 w-28" :disabled="!!deletingDocumentId" @click="closeDeleteConfirmDialog">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-32"
|
||||
type="button"
|
||||
variant="primary"
|
||||
:disabled="generateSubmitting"
|
||||
@click="confirmGenerateLetter"
|
||||
>
|
||||
{{ generateSubmitting ? 'Menjana...' : 'Jana Surat' }}
|
||||
<Button class="w-28" type="button" variant="danger" :disabled="!!deletingDocumentId"
|
||||
@click="confirmDeleteAdminAttachment">
|
||||
{{ deletingDocumentId ? 'Memadam...' : 'Padam' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { CircleAlert, CircleCheck, Download, Eye, Plus } from '@lucide/vue'
|
||||
import { CircleAlert, CircleCheck, Download, Eye, Plus, Trash2 } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
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, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
downloadMembershipApplicationDocument,
|
||||
deleteMembershipApplicationDocument,
|
||||
fetchMembershipApplicationDocument,
|
||||
getMembershipApplication,
|
||||
lookupMemberByIcNumber,
|
||||
@@ -52,9 +54,10 @@ import type {
|
||||
MembershipApplicationReviewDetail,
|
||||
MembershipApplicationStatus,
|
||||
} from '../types/membership-application.types'
|
||||
import { ADMIN_ATTACHMENT_DOCUMENT_TYPE } from '../types/membership-application.types'
|
||||
import {
|
||||
APPLICANT_DOCUMENT_UPLOAD_TYPES,
|
||||
DOCUMENT_TYPE_LABELS,
|
||||
DOCUMENT_UPLOAD_TYPES,
|
||||
GENDER_OPTIONS,
|
||||
MARRIAGE_STATUS_OPTIONS,
|
||||
RELATIONSHIP_OPTIONS,
|
||||
@@ -73,7 +76,7 @@ const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
const WORKFLOW_STEPS = [
|
||||
{ id: 1, label: 'Dihantar' },
|
||||
{ id: 2, label: 'Semakan Pentadbiran' },
|
||||
{ id: 3, label: 'Semakan Lembaga' },
|
||||
{ id: 3, label: 'Keputusan Ahli Lembaga Koperasi (ALK)' },
|
||||
{ id: 4, label: 'Makluman Keputusan' },
|
||||
{ id: 5, label: 'Selesai' },
|
||||
] as const
|
||||
@@ -91,11 +94,15 @@ const application = ref<MembershipApplicationDetail | null>(null)
|
||||
const form = reactive<MembershipApplicationFormState>(createEmptyFormState())
|
||||
const fieldErrors = reactive<Record<string, string>>({})
|
||||
const downloadingDocumentId = ref<string | null>(null)
|
||||
const deletingDocumentId = ref<string | null>(null)
|
||||
const uploadingDocumentType = ref<DocumentUploadType | null>(null)
|
||||
const uploadingAdminAttachment = ref(false)
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
const deleteConfirmDialogOpen = ref(false)
|
||||
const pendingDeleteDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
|
||||
const referenceLookupLoading = reactive({
|
||||
proposer: false,
|
||||
@@ -131,17 +138,22 @@ const sortedReviews = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const documentsByType = computed(() => {
|
||||
function buildDocumentsByType(types: readonly DocumentUploadType[]) {
|
||||
const map: Partial<Record<DocumentUploadType, MembershipApplicationDocumentDetail>> = {}
|
||||
|
||||
application.value?.documents.forEach((document) => {
|
||||
if (DOCUMENT_UPLOAD_TYPES.includes(document.type as DocumentUploadType)) {
|
||||
if (types.includes(document.type as DocumentUploadType)) {
|
||||
map[document.type as DocumentUploadType] = document
|
||||
}
|
||||
})
|
||||
|
||||
return map
|
||||
})
|
||||
}
|
||||
|
||||
const applicantDocumentsByType = computed(() => buildDocumentsByType(APPLICANT_DOCUMENT_UPLOAD_TYPES))
|
||||
const adminAttachments = computed(() =>
|
||||
application.value?.documents.filter((document) => document.type === ADMIN_ATTACHMENT_DOCUMENT_TYPE) ?? [],
|
||||
)
|
||||
|
||||
function getWorkflowProgress(status: MembershipApplicationStatus) {
|
||||
switch (status) {
|
||||
@@ -163,7 +175,7 @@ function getWorkflowProgress(status: MembershipApplicationStatus) {
|
||||
function statusLabel(status: MembershipApplicationStatus): string {
|
||||
const labels: Record<MembershipApplicationStatus, string> = {
|
||||
SUBMITTED: 'Dihantar',
|
||||
PENDING_BOARD: 'Menunggu Lembaga',
|
||||
PENDING_BOARD: 'Menunggu Keputusan Mesyuarat ALK',
|
||||
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
|
||||
PENDING_NOTIFICATION: 'Menunggu Makluman',
|
||||
COMPLETED: 'Selesai',
|
||||
@@ -247,7 +259,7 @@ const isPreviewPdf = computed(() => (previewDocument.value ? isPdfDocument(previ
|
||||
|
||||
function reviewStageLabel(stage: string): string {
|
||||
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
|
||||
if (stage === 'BOARD') return 'Semakan Lembaga'
|
||||
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
|
||||
return stage
|
||||
}
|
||||
|
||||
@@ -468,6 +480,91 @@ async function handleDocumentUpload(type: DocumentUploadType, event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdminAttachmentUpload(event: Event) {
|
||||
if (!application.value || !canEdit.value || uploadingAdminAttachment.value) return
|
||||
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
|
||||
if (!files.length) return
|
||||
|
||||
const oversizedFile = files.find((file) => file.size > MAX_FILE_SIZE_BYTES)
|
||||
if (oversizedFile) {
|
||||
fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`] =
|
||||
`Saiz fail melebihi ${MAX_FILE_SIZE_MB}MB.`
|
||||
return
|
||||
}
|
||||
|
||||
delete fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`]
|
||||
uploadingAdminAttachment.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
let latestResponse = null
|
||||
|
||||
for (const file of files) {
|
||||
latestResponse = await uploadMembershipApplicationDocument(
|
||||
applicationId.value,
|
||||
ADMIN_ATTACHMENT_DOCUMENT_TYPE,
|
||||
file,
|
||||
)
|
||||
application.value = latestResponse.data
|
||||
}
|
||||
|
||||
successMessage.value =
|
||||
files.length > 1
|
||||
? `${files.length} lampiran pentadbir berjaya dimuat naik.`
|
||||
: latestResponse?.message || 'Lampiran pentadbir berjaya dimuat naik.'
|
||||
} catch (err) {
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
if (validationErrors) {
|
||||
setFieldErrors(validationErrors)
|
||||
}
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat naik lampiran pentadbir.')
|
||||
} finally {
|
||||
uploadingAdminAttachment.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAdminAttachment(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value || !canEdit.value || deletingDocumentId.value) return
|
||||
|
||||
pendingDeleteDocument.value = document
|
||||
deleteConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeDeleteConfirmDialog() {
|
||||
deleteConfirmDialogOpen.value = false
|
||||
pendingDeleteDocument.value = null
|
||||
}
|
||||
|
||||
async function confirmDeleteAdminAttachment() {
|
||||
const document = pendingDeleteDocument.value
|
||||
if (!application.value || !document || deletingDocumentId.value) return
|
||||
|
||||
deletingDocumentId.value = document.id
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const response = await deleteMembershipApplicationDocument(applicationId.value, document.id)
|
||||
application.value = response.data
|
||||
|
||||
if (previewDocument.value?.id === document.id) {
|
||||
handlePreviewOpenChange(false)
|
||||
}
|
||||
|
||||
successMessage.value = response.message || 'Lampiran pentadbir berjaya dipadam.'
|
||||
closeDeleteConfirmDialog()
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memadam lampiran pentadbir.')
|
||||
} finally {
|
||||
deletingDocumentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadDocument(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value || downloadingDocumentId.value) return
|
||||
|
||||
@@ -635,7 +732,7 @@ onUnmounted(() => {
|
||||
<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
|
||||
Penama
|
||||
</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"
|
||||
@@ -770,13 +867,13 @@ onUnmounted(() => {
|
||||
<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>
|
||||
</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>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel>
|
||||
@@ -805,7 +902,7 @@ onUnmounted(() => {
|
||||
<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 class="mb-4 flex items-center justify-between">
|
||||
<div class="font-medium">Waris {{ index + 1 }}</div>
|
||||
<div class="font-medium">Penama</div>
|
||||
<Button v-if="canEdit && form.heirs.length > 1" type="button" look="outline" size="sm"
|
||||
@click="removeHeir(index)">
|
||||
Buang
|
||||
@@ -822,7 +919,7 @@ onUnmounted(() => {
|
||||
<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>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Hubungan</FieldLabel>
|
||||
@@ -855,10 +952,10 @@ onUnmounted(() => {
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="canEdit" type="button" look="outline" @click="addHeir">
|
||||
<!-- <Button v-if="canEdit" type="button" look="outline" @click="addHeir">
|
||||
<Plus class="mr-2 size-4" />
|
||||
Tambah Waris
|
||||
</Button>
|
||||
Tambah Penama
|
||||
</Button> -->
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -900,25 +997,27 @@ 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 APPLICANT_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>
|
||||
<div v-if="documentsByType[type]" class="mt-1 text-sm opacity-70">
|
||||
{{ documentsByType[type]?.name }} · {{ formatFileSize(documentsByType[type]?.file_size) }}
|
||||
<div v-if="applicantDocumentsByType[type]" class="mt-1 text-sm opacity-70">
|
||||
{{ applicantDocumentsByType[type]?.name }} ·
|
||||
{{ formatFileSize(applicantDocumentsByType[type]?.file_size) }}
|
||||
</div>
|
||||
<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">
|
||||
<div v-if="applicantDocumentsByType[type]" class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" look="outline" size="sm"
|
||||
:disabled="previewLoading && previewDocument?.id === documentsByType[type]?.id"
|
||||
@click="documentsByType[type] && handleViewDocument(documentsByType[type]!)">
|
||||
:disabled="previewLoading && previewDocument?.id === applicantDocumentsByType[type]?.id"
|
||||
@click="applicantDocumentsByType[type] && handleViewDocument(applicantDocumentsByType[type]!)">
|
||||
<Eye class="mr-2 size-4" />
|
||||
Lihat
|
||||
</Button>
|
||||
<Button type="button" look="outline" size="sm"
|
||||
:disabled="downloadingDocumentId === documentsByType[type]?.id"
|
||||
@click="documentsByType[type] && handleDownloadDocument(documentsByType[type]!)">
|
||||
:disabled="downloadingDocumentId === applicantDocumentsByType[type]?.id"
|
||||
@click="applicantDocumentsByType[type] && handleDownloadDocument(applicantDocumentsByType[type]!)">
|
||||
<Download class="mr-2 size-4" />
|
||||
Muat Turun
|
||||
</Button>
|
||||
@@ -926,7 +1025,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<Field v-if="canEdit" class="mt-4">
|
||||
<FieldLabel :for="`document-${type}`">
|
||||
{{ documentsByType[type] ? 'Ganti Dokumen' : 'Muat Naik Dokumen' }}
|
||||
{{ applicantDocumentsByType[type] ? 'Ganti Dokumen' : 'Muat Naik Dokumen' }}
|
||||
</FieldLabel>
|
||||
<Input :id="`document-${type}`" type="file" :accept="documentAccept(type)"
|
||||
:disabled="uploadingDocumentType === type" @change="handleDocumentUpload(type, $event)" />
|
||||
@@ -935,6 +1034,51 @@ onUnmounted(() => {
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="mb-4 font-medium">Lampiran Pentadbir</div>
|
||||
<p class="mb-4 text-sm opacity-70">
|
||||
Muat naik satu atau lebih dokumen tambahan semasa semakan permohonan. Setiap muat naik akan
|
||||
menambah lampiran baharu.
|
||||
</p>
|
||||
<div v-if="adminAttachments.length" class="mb-4 space-y-3">
|
||||
<div v-for="document in adminAttachments" :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">{{ document.name }}</div>
|
||||
<div class="mt-1 text-sm opacity-70">{{ formatFileSize(document.file_size) }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" look="outline" size="sm"
|
||||
:disabled="previewLoading && previewDocument?.id === document.id"
|
||||
@click="handleViewDocument(document)">
|
||||
<Eye class="mr-2 size-4" />
|
||||
Lihat
|
||||
</Button>
|
||||
<Button type="button" look="outline" size="sm" :disabled="downloadingDocumentId === document.id"
|
||||
@click="handleDownloadDocument(document)">
|
||||
<Download class="mr-2 size-4" />
|
||||
Muat Turun
|
||||
</Button>
|
||||
<Button v-if="canEdit" type="button" look="outline" size="sm" variant="danger"
|
||||
:disabled="deletingDocumentId === document.id" @click="handleDeleteAdminAttachment(document)">
|
||||
<Trash2 class="mr-2 size-4" />
|
||||
{{ deletingDocumentId === document.id ? 'Memadam...' : 'Padam' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mb-4 text-sm opacity-70">Tiada lampiran pentadbir dimuat naik.</div>
|
||||
<Field v-if="canEdit" class="rounded-lg border border-foreground/10 p-4">
|
||||
<FieldLabel for="document-admin-attachment">Tambah Lampiran Pentadbir</FieldLabel>
|
||||
<Input id="document-admin-attachment" type="file" accept=".pdf,.jpg,.jpeg,.png" multiple
|
||||
:disabled="uploadingAdminAttachment" @change="handleAdminAttachmentUpload" />
|
||||
<FieldError v-if="fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`]">
|
||||
{{ fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`] }}
|
||||
</FieldError>
|
||||
<p v-if="uploadingAdminAttachment" class="mt-1 text-sm opacity-70">Memuat naik...</p>
|
||||
</Field>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="application.reviews.length" value="reviews" class="mt-6">
|
||||
@@ -998,6 +1142,27 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DialogRoot :open="deleteConfirmDialogOpen"
|
||||
@openChange="(details) => { if (!details.open) closeDeleteConfirmDialog() }">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-2 text-2xl font-medium">Padam Lampiran Pentadbir?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
{{ pendingDeleteDocument?.name ?? 'Lampiran pentadbir' }} akan dipadam secara kekal.
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-28" :disabled="!!deletingDocumentId" @click="closeDeleteConfirmDialog">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button class="w-28" type="button" variant="danger" :disabled="!!deletingDocumentId"
|
||||
@click="confirmDeleteAdminAttachment">
|
||||
{{ deletingDocumentId ? 'Memadam...' : 'Padam' }}
|
||||
</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"
|
||||
|
||||
@@ -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, FileText, Download } from '@lucide/vue'
|
||||
import { CircleAlert, CircleCheck, Search, Eye, Pencil, Download } from '@lucide/vue'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import {
|
||||
@@ -36,7 +36,6 @@ import { usePermissions } from '@/composables/usePermissions'
|
||||
import {
|
||||
batchCompleteMembershipApplications,
|
||||
downloadMembershipApplicationDocument,
|
||||
generateMembershipApplicationResultLetter,
|
||||
} from '../services/membership-application.service'
|
||||
import {
|
||||
boardResultBadgeVariant,
|
||||
@@ -46,7 +45,6 @@ import {
|
||||
import type {
|
||||
BatchCompleteFailedItem,
|
||||
BatchCompleteResponse,
|
||||
GenerateResultLetterResponse,
|
||||
MembershipApplicationBoardResult,
|
||||
MembershipApplicationListItem,
|
||||
MembershipApplicationStatus,
|
||||
@@ -57,7 +55,7 @@ type SelectOption = { label: string; value: string }
|
||||
const STATUS_FILTER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Semua Status', value: '' },
|
||||
{ label: 'Dihantar', value: 'SUBMITTED' },
|
||||
{ label: 'Menunggu Lembaga', value: 'PENDING_BOARD' },
|
||||
{ label: 'Menunggu Keputusan ALK', value: 'PENDING_BOARD' },
|
||||
{ label: 'Ditolak Pentadbiran', value: 'MANAGEMENT_REJECTED' },
|
||||
{ label: 'Menunggu Makluman', value: 'PENDING_NOTIFICATION' },
|
||||
{ label: 'Selesai', value: 'COMPLETED' },
|
||||
@@ -105,18 +103,13 @@ 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(() =>
|
||||
@@ -167,19 +160,28 @@ function toggleSelectAllOnPage(checked: boolean) {
|
||||
|
||||
function openBatchConfirm() {
|
||||
if (!selectedIds.value.length) return
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
batchConfirmOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmBatchComplete() {
|
||||
if (!selectedIds.value.length || batchSubmitting.value) return
|
||||
|
||||
const reference = boardMeetingReference.value.trim()
|
||||
if (!reference) {
|
||||
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
|
||||
batchSubmitting.value = true
|
||||
batchSuccessMessage.value = null
|
||||
batchFailedItems.value = []
|
||||
boardMeetingReferenceError.value = null
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await batchCompleteMembershipApplications(selectedIds.value)
|
||||
const response = await batchCompleteMembershipApplications(selectedIds.value, reference)
|
||||
|
||||
if (response.success) {
|
||||
batchSuccessMessage.value = response.message
|
||||
@@ -196,6 +198,8 @@ async function confirmBatchComplete() {
|
||||
const responseData = err.response.data as BatchCompleteResponse
|
||||
batchFailedItems.value = responseData.data?.failed ?? []
|
||||
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
|
||||
} else {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
|
||||
}
|
||||
@@ -213,7 +217,7 @@ const statusFilterInitial = computed(() => apiValueToLabel(STATUS_FILTER_OPTIONS
|
||||
function statusLabel(status: MembershipApplicationStatus): string {
|
||||
const labels: Record<MembershipApplicationStatus, string> = {
|
||||
SUBMITTED: 'Dihantar',
|
||||
PENDING_BOARD: 'Menunggu Lembaga',
|
||||
PENDING_BOARD: 'Menunggu Keputusan ALK',
|
||||
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
|
||||
PENDING_NOTIFICATION: 'Menunggu Makluman',
|
||||
COMPLETED: 'Selesai',
|
||||
@@ -241,70 +245,10 @@ 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
|
||||
@@ -384,13 +328,6 @@ 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>
|
||||
@@ -482,11 +419,8 @@ const headers = computed<TableHeader[]>(() => {
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge
|
||||
:variant="statusBadgeVariant((item as MembershipApplicationListItem).status)"
|
||||
:look="statusBadgeLook((item as MembershipApplicationListItem).status)"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
<Badge :variant="statusBadgeVariant((item as MembershipApplicationListItem).status)"
|
||||
:look="statusBadgeLook((item as MembershipApplicationListItem).status)" class="whitespace-nowrap">
|
||||
{{ statusLabel((item as MembershipApplicationListItem).status) }}
|
||||
</Badge>
|
||||
</template>
|
||||
@@ -516,27 +450,10 @@ const headers = computed<TableHeader[]>(() => {
|
||||
@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"
|
||||
<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)"
|
||||
>
|
||||
@click="handleDownloadResultLetter(item as MembershipApplicationListItem)">
|
||||
<Download class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -548,8 +465,16 @@ const headers = computed<TableHeader[]>(() => {
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-2 text-2xl font-medium">Selesaikan Permohonan Terpilih?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
{{ selectedIds.length }} permohonan akan diselesaikan dan e-mel keputusan dihantar.
|
||||
{{ selectedIds.length }} permohonan akan diselesaikan. Surat keputusan dijana dan e-mel dengan lampiran
|
||||
surat dihantar.
|
||||
</div>
|
||||
<Field class="mt-5 text-left">
|
||||
<FieldLabel for="batch-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
|
||||
<Input id="batch-board-meeting-reference" v-model="boardMeetingReference" type="text"
|
||||
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026" :disabled="batchSubmitting"
|
||||
@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="batchSubmitting">
|
||||
@@ -562,45 +487,5 @@ const headers = computed<TableHeader[]>(() => {
|
||||
</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="generateSubmitting"
|
||||
@click="confirmGenerateLetter"
|
||||
>
|
||||
{{ generateSubmitting ? 'Menjana...' : 'Jana Surat' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -157,6 +157,21 @@ export async function uploadMembershipApplicationDocument(
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteMembershipApplicationDocument(
|
||||
id: string,
|
||||
documentId: string,
|
||||
): Promise<MembershipApplicationUpdateResponse> {
|
||||
const { data } = await api.delete<MembershipApplicationUpdateResponse>(
|
||||
`/v1/membership-applications/${id}/documents/${documentId}`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to delete document')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Submit management review
|
||||
export async function submitManagementReview(
|
||||
id: string,
|
||||
@@ -194,9 +209,11 @@ export async function submitBoardReview(
|
||||
// Complete membership application
|
||||
export async function completeMembershipApplication(
|
||||
id: string,
|
||||
payload: GenerateResultLetterPayload,
|
||||
): Promise<MembershipApplicationReviewResponse> {
|
||||
const { data } = await api.post<MembershipApplicationReviewResponse>(
|
||||
`/v1/membership-applications/${id}/complete`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
@@ -208,10 +225,14 @@ export async function completeMembershipApplication(
|
||||
|
||||
export async function batchCompleteMembershipApplications(
|
||||
applicationIds: string[],
|
||||
boardMeetingReference: string,
|
||||
): Promise<BatchCompleteResponse> {
|
||||
const { data } = await api.post<BatchCompleteResponse>(
|
||||
'/v1/membership-applications/batch-complete',
|
||||
{ application_ids: applicationIds },
|
||||
{
|
||||
application_ids: applicationIds,
|
||||
board_meeting_reference: boardMeetingReference,
|
||||
},
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@@ -81,6 +81,7 @@ export type MembershipApplicationStatus =
|
||||
export type MembershipApplicationBoardResult = 'PASS' | 'FAIL'
|
||||
|
||||
export const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter' as const
|
||||
export const ADMIN_ATTACHMENT_DOCUMENT_TYPE = 'admin_attachment' as const
|
||||
|
||||
export interface MembershipApplicationApplicantSummary {
|
||||
name: string
|
||||
@@ -208,7 +209,12 @@ export type MembershipApplicationUpdateResponse = MembershipApplicationApiRespon
|
||||
message: string
|
||||
}
|
||||
|
||||
export type DocumentUploadType = 'ic_copy' | 'photo' | 'salary_slip' | 'employer_letter'
|
||||
export type DocumentUploadType =
|
||||
| 'ic_copy'
|
||||
| 'photo'
|
||||
| 'salary_slip'
|
||||
| 'employer_letter'
|
||||
| 'admin_attachment'
|
||||
|
||||
export type ManagementReviewDecision = 'APPROVED' | 'REJECTED'
|
||||
export type BoardReviewDecision = 'PASS' | 'FAIL'
|
||||
|
||||
@@ -40,10 +40,26 @@ export const DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
||||
photo: 'Gambar Passport',
|
||||
salary_slip: 'Slip Gaji',
|
||||
employer_letter: 'Surat Pengesahan Majikan',
|
||||
admin_attachment: 'Lampiran Pentadbir',
|
||||
}
|
||||
|
||||
export const DOCUMENT_UPLOAD_TYPES = ['ic_copy', 'photo', 'salary_slip', 'employer_letter'] as const
|
||||
export type DocumentUploadType = (typeof DOCUMENT_UPLOAD_TYPES)[number]
|
||||
export const APPLICANT_DOCUMENT_UPLOAD_TYPES = [
|
||||
'ic_copy',
|
||||
'photo',
|
||||
'salary_slip',
|
||||
'employer_letter',
|
||||
] as const
|
||||
|
||||
export const ADMIN_DOCUMENT_UPLOAD_TYPES = ['admin_attachment'] as const
|
||||
|
||||
export const DOCUMENT_UPLOAD_TYPES = [
|
||||
...APPLICANT_DOCUMENT_UPLOAD_TYPES,
|
||||
...ADMIN_DOCUMENT_UPLOAD_TYPES,
|
||||
] as const
|
||||
|
||||
export type ApplicantDocumentUploadType = (typeof APPLICANT_DOCUMENT_UPLOAD_TYPES)[number]
|
||||
export type AdminDocumentUploadType = (typeof ADMIN_DOCUMENT_UPLOAD_TYPES)[number]
|
||||
export type DocumentUploadType = ApplicantDocumentUploadType | AdminDocumentUploadType
|
||||
|
||||
export function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { downloadMemberDigitalCard } from '../services/member-digital-card.service'
|
||||
import { toProxiedStorageUrl } from '../utils/member-digital-card.utils'
|
||||
import MemberDigitalCardFlip from './MemberDigitalCardFlip.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
memberNumber?: string | number | null
|
||||
memberName?: string | null
|
||||
memberType?: string | null
|
||||
companyName?: string | null
|
||||
profileUrl?: string | null
|
||||
imageUrl?: string | null
|
||||
large?: boolean
|
||||
}>()
|
||||
|
||||
const previewMaxWidthClass = computed(() =>
|
||||
props.large ? 'max-w-sm sm:max-w-md lg:max-w-lg' : 'max-w-68 sm:max-w-xs',
|
||||
)
|
||||
|
||||
const resolvedImageUrl = computed(() => toProxiedStorageUrl(props.imageUrl))
|
||||
|
||||
const isFlipped = ref(false)
|
||||
const expandedOpen = ref(false)
|
||||
const isPortraitPhone = ref(false)
|
||||
const downloading = ref(false)
|
||||
|
||||
let portraitQuery: MediaQueryList | null = null
|
||||
|
||||
function updatePortraitPhone() {
|
||||
isPortraitPhone.value = portraitQuery?.matches ?? false
|
||||
}
|
||||
|
||||
function openExpanded() {
|
||||
expandedOpen.value = true
|
||||
}
|
||||
|
||||
function closeExpanded() {
|
||||
expandedOpen.value = false
|
||||
}
|
||||
|
||||
function toggleFlip() {
|
||||
isFlipped.value = !isFlipped.value
|
||||
}
|
||||
|
||||
async function downloadCard() {
|
||||
if (downloading.value) return
|
||||
|
||||
downloading.value = true
|
||||
const side = isFlipped.value ? 'belakang' : 'depan'
|
||||
|
||||
try {
|
||||
await downloadMemberDigitalCard(props.memberNumber, side)
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: `Kad ${side} berjaya disimpan.`,
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan kad.'),
|
||||
})
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(expandedOpen, (open) => {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
portraitQuery = window.matchMedia('(max-width: 767px) and (orientation: portrait)')
|
||||
updatePortraitPhone()
|
||||
portraitQuery.addEventListener('change', updatePortraitPhone)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.body.style.overflow = ''
|
||||
portraitQuery?.removeEventListener('change', updatePortraitPhone)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full flex-col items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="relative w-full cursor-pointer border-0 bg-transparent p-0 transition-transform active:scale-[0.98]"
|
||||
:class="previewMaxWidthClass"
|
||||
aria-label="Buka kad digital penuh"
|
||||
@click="openExpanded">
|
||||
<MemberDigitalCardFlip :member-number="memberNumber" :member-name="memberName"
|
||||
:member-type="memberType" :company-name="companyName" :profile-url="profileUrl"
|
||||
:image-url="resolvedImageUrl" :is-flipped="isFlipped" />
|
||||
</button>
|
||||
|
||||
<p class="text-center text-[11px] text-slate-500">
|
||||
Klik kad untuk paparan penuh
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-xs"
|
||||
:aria-pressed="isFlipped" :disabled="downloading" @click="toggleFlip">
|
||||
<Lucide class="mr-2 size-4" icon="RotateCw" />
|
||||
{{ isFlipped ? 'Papar depan kad' : 'Imbas kod QR' }}
|
||||
</Button>
|
||||
|
||||
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-xs"
|
||||
:disabled="downloading" @click="downloadCard">
|
||||
<Lucide class="mr-2 size-4" :icon="downloading ? 'LoaderCircle' : 'Download'"
|
||||
:class="{ 'animate-spin': downloading }" />
|
||||
{{ downloading ? 'Menyimpan...' : 'Simpan kad' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="expandedOpen"
|
||||
class="fixed inset-0 z-70 flex flex-col items-center justify-center gap-4 bg-black/90 p-5"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Kad digital anggota"
|
||||
@click.self="closeExpanded">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-4 top-4 flex size-10 items-center justify-center rounded-full border border-white/20 bg-white/10 text-white"
|
||||
aria-label="Tutup"
|
||||
@click="closeExpanded">
|
||||
<Lucide class="size-5" icon="X" />
|
||||
</button>
|
||||
|
||||
<div v-if="isPortraitPhone" class="flex items-center justify-center" @click.stop>
|
||||
<div class="w-[min(90vh,34rem)] rotate-90">
|
||||
<MemberDigitalCardFlip :member-number="memberNumber" :member-name="memberName"
|
||||
:member-type="memberType" :company-name="companyName" :profile-url="profileUrl"
|
||||
:image-url="resolvedImageUrl" :is-flipped="isFlipped" expanded />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="w-[min(100vw-2rem,32rem)] lg:w-[min(100vw-2rem,40rem)]" @click.stop>
|
||||
<MemberDigitalCardFlip :member-number="memberNumber" :member-name="memberName"
|
||||
:member-type="memberType" :company-name="companyName" :profile-url="profileUrl"
|
||||
:image-url="resolvedImageUrl" :is-flipped="isFlipped" expanded />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-white/20 bg-white/10 text-xs text-white shadow-none hover:bg-white/15"
|
||||
:aria-pressed="isFlipped"
|
||||
:disabled="downloading"
|
||||
@click.stop="toggleFlip">
|
||||
<Lucide class="mr-2 size-4" icon="RotateCw" />
|
||||
{{ isFlipped ? 'Papar depan kad' : 'Imbas kod QR' }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-white/20 bg-white/10 text-xs text-white shadow-none hover:bg-white/15"
|
||||
:disabled="downloading"
|
||||
@click.stop="downloadCard">
|
||||
<Lucide class="mr-2 size-4" :icon="downloading ? 'LoaderCircle' : 'Download'"
|
||||
:class="{ 'animate-spin': downloading }" />
|
||||
{{ downloading ? 'Menyimpan...' : 'Simpan kad' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs text-white/60">
|
||||
Klik di luar kad untuk tutup
|
||||
</p>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import QRCode from 'qrcode'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import { displayCardValue } from '../utils/member-digital-card.utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
profileUrl?: string | null
|
||||
memberNumber?: string | number | null
|
||||
expanded?: boolean
|
||||
}>(),
|
||||
{ expanded: false },
|
||||
)
|
||||
|
||||
const qrDataUrl = ref('')
|
||||
const qrError = ref(false)
|
||||
|
||||
const qrPixelSize = computed(() => (props.expanded ? 220 : 120))
|
||||
|
||||
async function renderQrCode() {
|
||||
if (!props.profileUrl) {
|
||||
qrDataUrl.value = ''
|
||||
qrError.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
qrDataUrl.value = await QRCode.toDataURL(props.profileUrl, {
|
||||
margin: 1,
|
||||
width: qrPixelSize.value,
|
||||
color: {
|
||||
dark: '#0f172a',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
qrError.value = false
|
||||
} catch {
|
||||
qrDataUrl.value = ''
|
||||
qrError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.profileUrl, () => props.expanded], renderQrCode, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'relative h-full w-full overflow-hidden rounded-2xl bg-linear-to-br from-primary/90 via-primary to-primary/80 text-primary-foreground shadow-lg ring-1 ring-white/20',
|
||||
expanded ? 'p-5 sm:p-6' : 'p-3 sm:p-4',
|
||||
]">
|
||||
<div class="pointer-events-none absolute inset-0 bg-noise opacity-30" />
|
||||
<div class="pointer-events-none absolute -right-12 -top-12 rounded-full bg-white/10"
|
||||
:class="expanded ? 'size-44' : 'size-32'" />
|
||||
<div class="pointer-events-none absolute -bottom-16 -left-10 rounded-full bg-white/5"
|
||||
:class="expanded ? 'size-48' : 'size-36'" />
|
||||
|
||||
<div class="relative flex h-full min-h-0 flex-col">
|
||||
<div class="flex shrink-0 items-center justify-between gap-2">
|
||||
<img :src="logoUrl" alt="" class="w-auto shrink-0 brightness-0 invert"
|
||||
:class="expanded ? 'h-7 sm:h-9' : 'h-5 sm:h-6'" />
|
||||
<div class="text-right font-semibold uppercase opacity-75"
|
||||
:class="expanded ? 'text-sm tracking-[0.18em]' : 'text-[9px] tracking-[0.18em]'">
|
||||
Belakang · Kod QR
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 items-center" :class="expanded ? 'mt-5 gap-6' : 'mt-3 gap-3'">
|
||||
<div class="shrink-0 rounded-lg bg-white shadow-sm" :class="expanded ? 'p-3' : 'p-1.5'">
|
||||
<img v-if="qrDataUrl" :src="qrDataUrl" alt="Kod QR profil anggota" class="block"
|
||||
:class="expanded ? 'size-32 sm:size-36' : 'size-18 sm:size-20'" />
|
||||
<div v-else class="flex items-center justify-center"
|
||||
:class="expanded ? 'size-32 sm:size-36' : 'size-18 sm:size-20'">
|
||||
<span class="px-1 text-center leading-tight text-slate-500" :class="expanded ? 'text-sm' : 'text-[9px]'">
|
||||
{{ qrError ? 'Kod QR tidak tersedia.' : 'Memuatkan...' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center" :class="expanded ? 'gap-5' : 'gap-3'">
|
||||
<p class="leading-snug opacity-85" :class="expanded ? 'text-base sm:text-lg' : 'text-[9px] sm:text-[10px]'">
|
||||
Imbas untuk sahkan profil anggota MyKOPKB.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<div class="font-medium uppercase tracking-widest opacity-60" :class="expanded ? 'text-sm' : 'text-[9px]'">
|
||||
No. Anggota
|
||||
</div>
|
||||
<div class="mt-0.5 font-mono font-semibold tracking-widest"
|
||||
:class="expanded ? 'text-3xl sm:text-4xl' : 'text-base sm:text-lg'">
|
||||
{{ displayCardValue(memberNumber) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 border-t border-white/15 text-center" :class="expanded ? 'mt-4 pt-3' : 'mt-2 pt-2'">
|
||||
<p class="uppercase opacity-50" :class="expanded ? 'text-xs tracking-[0.2em]' : 'text-[8px] tracking-[0.2em]'">
|
||||
Koperasi Permodalan Kelantan Berhad (KOPKB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" setup>
|
||||
import MemberDigitalCardBack from './MemberDigitalCardBack.vue'
|
||||
import MemberDigitalCardFront from './MemberDigitalCardFront.vue'
|
||||
|
||||
defineProps<{
|
||||
memberNumber?: string | number | null
|
||||
memberName?: string | null
|
||||
memberType?: string | null
|
||||
companyName?: string | null
|
||||
profileUrl?: string | null
|
||||
imageUrl?: string | null
|
||||
isFlipped: boolean
|
||||
expanded?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full" style="perspective: 1000px">
|
||||
<div
|
||||
class="relative aspect-7/4.5 w-full transition-transform duration-500 ease-in-out"
|
||||
:style="{
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
|
||||
}">
|
||||
<div class="absolute inset-0" style="backface-visibility: hidden">
|
||||
<MemberDigitalCardFront :member-number="memberNumber" :member-name="memberName"
|
||||
:member-type="memberType" :company-name="companyName" :image-url="imageUrl" :expanded="expanded" />
|
||||
</div>
|
||||
<div class="absolute inset-0" :style="{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }">
|
||||
<MemberDigitalCardBack :profile-url="profileUrl" :member-number="memberNumber" :expanded="expanded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import { displayCardValue } from '../utils/member-digital-card.utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
memberNumber?: string | number | null
|
||||
memberName?: string | null
|
||||
memberType?: string | null
|
||||
companyName?: string | null
|
||||
imageUrl?: string | null
|
||||
expanded?: boolean
|
||||
}>(),
|
||||
{ expanded: false },
|
||||
)
|
||||
|
||||
const avatarFallback = computed(() => {
|
||||
const name = props.memberName?.trim()
|
||||
if (!name) return '--'
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'relative h-full w-full overflow-hidden rounded-2xl bg-linear-to-br from-primary via-primary/95 to-primary/75 text-primary-foreground shadow-lg ring-1 ring-white/20',
|
||||
expanded ? 'p-6 sm:p-8' : 'p-4 sm:p-5',
|
||||
]">
|
||||
<div class="pointer-events-none absolute inset-0 bg-noise opacity-30" />
|
||||
<div class="pointer-events-none absolute -right-10 -top-10 rounded-full bg-white/10"
|
||||
:class="expanded ? 'size-48' : 'size-36'" />
|
||||
<div class="pointer-events-none absolute -bottom-12 -left-8 rounded-full bg-white/5"
|
||||
:class="expanded ? 'size-52' : 'size-40'" />
|
||||
<div
|
||||
class="pointer-events-none absolute top-1/2 -translate-y-1/2 overflow-hidden rounded-md border border-white/25 bg-white/10 shadow-sm"
|
||||
:class="expanded ? 'right-6 size-20 sm:size-24' : 'right-4 size-14'">
|
||||
<img v-if="imageUrl" :src="imageUrl" :alt="memberName ?? 'Profil anggota'" class="size-full object-cover" />
|
||||
<div v-else class="flex size-full items-center justify-center bg-white/15 font-semibold uppercase tracking-wide"
|
||||
:class="expanded ? 'text-base' : 'text-[11px]'">
|
||||
{{ avatarFallback }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex h-full min-h-0 flex-col">
|
||||
<div class="flex shrink-0 items-start justify-between gap-3">
|
||||
<img :src="logoUrl" alt="" class="w-auto brightness-0 invert"
|
||||
:class="expanded ? 'h-8 sm:h-10' : 'h-6 sm:h-7'" />
|
||||
<div class="text-right font-semibold uppercase opacity-80"
|
||||
:class="expanded ? 'text-sm tracking-[0.2em]' : 'text-[10px] tracking-[0.2em]'">
|
||||
Kad Digital
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col justify-center py-2" :class="expanded ? 'gap-4' : 'gap-2'">
|
||||
<div>
|
||||
<div class="font-medium uppercase tracking-widest opacity-70" :class="expanded ? 'text-sm' : 'text-[10px]'">
|
||||
No. Anggota
|
||||
</div>
|
||||
<div class="mt-0.5 font-mono font-semibold"
|
||||
:class="expanded ? 'text-4xl tracking-[0.15em] sm:text-5xl' : 'text-xl tracking-[0.15em] sm:text-2xl'">
|
||||
{{ displayCardValue(memberNumber) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0" :class="expanded ? 'pr-28 sm:pr-32' : 'pr-16'">
|
||||
<div class="font-medium uppercase tracking-widest opacity-70" :class="expanded ? 'text-sm' : 'text-[10px]'">
|
||||
Unit
|
||||
</div>
|
||||
<div class="truncate font-medium" :class="expanded ? 'text-lg sm:text-xl' : 'text-xs'">
|
||||
{{ displayCardValue(companyName) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-end justify-between gap-3 border-t border-white/15"
|
||||
:class="expanded ? 'pt-4' : 'pt-2'">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-medium" :class="expanded ? 'text-xl sm:text-2xl' : 'text-sm'">
|
||||
{{ memberName || '-' }}
|
||||
</div>
|
||||
<div class="mt-0.5 uppercase tracking-wide opacity-60" :class="expanded ? 'text-sm' : 'text-[10px]'">
|
||||
Nama
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<div class="font-semibold" :class="expanded ? 'text-xl sm:text-2xl' : 'text-sm'">
|
||||
{{ displayCardValue(memberType) }}
|
||||
</div>
|
||||
<div class="mt-0.5 uppercase tracking-wide opacity-60" :class="expanded ? 'text-sm' : 'text-[10px]'">
|
||||
Jenis Anggota
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,2 +1,2 @@
|
||||
export { profileLayoutRoutes } from './routes'
|
||||
export { profileLayoutRoutes, profilePublicRoutes } from './routes'
|
||||
export { profileMenu } from './menu'
|
||||
|
||||
@@ -69,6 +69,33 @@ const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Freelance', value: 'Freelance' },
|
||||
]
|
||||
|
||||
// TODO: replace with API lookup
|
||||
const EMPLOYERS = [
|
||||
{
|
||||
name: 'Infra Quest Sdn Bhd (IQSB)',
|
||||
address: 'Lot 1045, Jalan Dato’ Lundang, 15200 Kota Bharu, Kelantan',
|
||||
},
|
||||
{
|
||||
name: 'Permodalan Kelantan Berhad (PKB)',
|
||||
address:
|
||||
'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
|
||||
},
|
||||
{
|
||||
name: 'Koperasi Permodalan Kelantan Berhad (KOPKB)',
|
||||
address:
|
||||
'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
|
||||
},
|
||||
{
|
||||
name: "An-Nisa'",
|
||||
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
|
||||
},
|
||||
] as const
|
||||
|
||||
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
|
||||
label: employer.name,
|
||||
value: employer.name,
|
||||
}))
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
@@ -88,9 +115,12 @@ function apiValueToLabel(options: SelectOption[], value: string | null | undefin
|
||||
}
|
||||
|
||||
const employmentTypeCollection = createSelectCollection(EMPLOYMENT_TYPE_OPTIONS)
|
||||
const companyNameCollection = createSelectCollection(COMPANY_OPTIONS)
|
||||
|
||||
const employmentTypeValue = ref<string[]>([])
|
||||
const employmentTypeInitial = ref<string[]>([])
|
||||
const companyNameValue = ref<string[]>([])
|
||||
const companyNameInitial = ref<string[]>([])
|
||||
|
||||
function clearEmploymentFieldError(field: EmploymentFieldKey) {
|
||||
delete employmentErrors[field]
|
||||
@@ -140,6 +170,10 @@ const employmentTypeLabel = computed(() =>
|
||||
|
||||
const isEditingEmployment = computed(() => editingEmploymentId.value !== null)
|
||||
|
||||
const canAddEmployment = computed(() => !loadingEmployments.value && employments.value.length === 0)
|
||||
|
||||
const showEmploymentForm = computed(() => isEditingEmployment.value || canAddEmployment.value)
|
||||
|
||||
function setEmploymentTypeValue(details: { value: string[] }) {
|
||||
employmentTypeValue.value = details.value
|
||||
clearEmploymentFieldError('employment_type')
|
||||
@@ -147,9 +181,17 @@ function setEmploymentTypeValue(details: { value: string[] }) {
|
||||
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function setCompanyNameValue(details: { value: string[] }) {
|
||||
companyNameValue.value = details.value
|
||||
clearEmploymentFieldError('company_name')
|
||||
employmentForm.company_name = details.value[0] ?? ''
|
||||
}
|
||||
|
||||
function syncEmploymentSelectValues() {
|
||||
employmentTypeValue.value = apiValueToLabel(EMPLOYMENT_TYPE_OPTIONS, employmentForm.employment_type)
|
||||
employmentTypeInitial.value = [...employmentTypeValue.value]
|
||||
companyNameValue.value = apiValueToLabel(COMPANY_OPTIONS, employmentForm.company_name)
|
||||
companyNameInitial.value = [...companyNameValue.value]
|
||||
}
|
||||
|
||||
function resetEmploymentForm() {
|
||||
@@ -194,7 +236,7 @@ function validateEmploymentForm(): boolean {
|
||||
|
||||
let valid = true
|
||||
|
||||
if (!employmentForm.company_name.trim()) {
|
||||
if (!companyNameValue.value[0]?.trim()) {
|
||||
employmentErrors.company_name = 'Nama syarikat diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
@@ -441,7 +483,8 @@ onMounted(async () => {
|
||||
Tiada pekerjaan direkodkan.
|
||||
</div>
|
||||
|
||||
<form class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveEmployment">
|
||||
<form v-if="showEmploymentForm" class="space-y-6 border-t border-foreground/10 pt-6"
|
||||
@submit.prevent="onSaveEmployment">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-slate-900">
|
||||
@@ -470,10 +513,24 @@ onMounted(async () => {
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="employment-company">Nama Syarikat</FieldLabel>
|
||||
<Input id="employment-company" v-model="employmentForm.company_name" type="text"
|
||||
placeholder="Nama syarikat" :aria-invalid="!!employmentErrors.company_name"
|
||||
@input="clearEmploymentFieldError('company_name')" />
|
||||
<FieldLabel>Nama Syarikat</FieldLabel>
|
||||
<SelectRoot :key="`company-name-${editingEmploymentId ?? 'new'}`" class="w-full"
|
||||
:collection="companyNameCollection" :default-value="companyNameInitial" :disabled="savingEmployment"
|
||||
@value-change="setCompanyNameValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!employmentErrors.company_name">
|
||||
<SelectValueText placeholder="Pilih syarikat" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in companyNameCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="employmentErrors.company_name">{{ employmentErrors.company_name }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
|
||||
@@ -90,7 +90,10 @@ const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
|
||||
const relationshipValue = ref<string[]>([])
|
||||
const relationshipInitial = ref<string[]>([])
|
||||
|
||||
const MAX_HEIRS = 1
|
||||
const isEditingHeir = computed(() => editingHeirId.value !== null)
|
||||
const hasReachedHeirLimit = computed(() => heirs.value.length >= MAX_HEIRS)
|
||||
const showHeirForm = computed(() => isEditingHeir.value || !hasReachedHeirLimit.value)
|
||||
|
||||
function emptyHeirForm() {
|
||||
return {
|
||||
@@ -204,7 +207,7 @@ async function fetchHeirs() {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan pewaris.'),
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan penama.'),
|
||||
})
|
||||
} finally {
|
||||
loadingHeirs.value = false
|
||||
@@ -224,6 +227,15 @@ function startEditHeir(heir: Heir) {
|
||||
}
|
||||
|
||||
async function onSaveHeir() {
|
||||
if (!isEditingHeir.value && hasReachedHeirLimit.value) {
|
||||
await Swal.fire({
|
||||
icon: 'info',
|
||||
title: 'Had penama',
|
||||
text: 'Hanya satu penama dibenarkan.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateHeirForm()) {
|
||||
return
|
||||
}
|
||||
@@ -238,7 +250,7 @@ async function onSaveHeir() {
|
||||
: await createHeir(payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal menyimpan pewaris.')
|
||||
throw new Error(res.message ?? 'Gagal menyimpan penama.')
|
||||
}
|
||||
|
||||
await fetchHeirs()
|
||||
@@ -248,7 +260,7 @@ async function onSaveHeir() {
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: wasEditing ? 'Pewaris berjaya dikemas kini.' : 'Pewaris berjaya ditambah.',
|
||||
title: wasEditing ? 'Penama berjaya dikemas kini.' : 'Penama berjaya ditambah.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
@@ -257,7 +269,7 @@ async function onSaveHeir() {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan pewaris.'),
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan penama.'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
@@ -268,7 +280,7 @@ async function onSaveHeir() {
|
||||
async function onDeleteHeir(heir: Heir) {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Padam pewaris?',
|
||||
title: 'Padam penama?',
|
||||
text: 'Tindakan ini tidak boleh dibatalkan.',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Padam',
|
||||
@@ -283,7 +295,7 @@ async function onDeleteHeir(heir: Heir) {
|
||||
const res = await deleteHeir(heir.id)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal memadam pewaris.')
|
||||
throw new Error(res.message ?? 'Gagal memadam penama.')
|
||||
}
|
||||
|
||||
if (editingHeirId.value === heir.id) {
|
||||
@@ -296,7 +308,7 @@ async function onDeleteHeir(heir: Heir) {
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: 'Pewaris berjaya dipadam.',
|
||||
title: 'Penama berjaya dipadam.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
@@ -304,7 +316,7 @@ async function onDeleteHeir(heir: Heir) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memadam pewaris.'),
|
||||
text: getApiErrorMessage(error, 'Gagal memadam penama.'),
|
||||
})
|
||||
} finally {
|
||||
deletingHeirId.value = null
|
||||
@@ -323,23 +335,20 @@ onMounted(async () => {
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Pewaris</h3>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Penama</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Urus maklumat pewaris anda.
|
||||
Urus maklumat penama anda. Hanya satu penama dibenarkan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingHeirs" class="text-sm text-slate-500">
|
||||
Memuatkan pewaris...
|
||||
Memuatkan penama...
|
||||
</div>
|
||||
|
||||
<div v-else-if="heirs.length" class="space-y-3">
|
||||
<div
|
||||
v-for="heir in heirs"
|
||||
:key="heir.id"
|
||||
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div v-for="heir in heirs" :key="heir.id"
|
||||
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-slate-900">{{ heir.name }}</span>
|
||||
@@ -351,64 +360,47 @@ onMounted(async () => {
|
||||
<p class="mt-1 text-sm text-slate-700">{{ heir.address }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="deletingHeirId === heir.id"
|
||||
@click="startEditHeir(heir)"
|
||||
>
|
||||
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none"
|
||||
:disabled="deletingHeirId === heir.id" @click="startEditHeir(heir)">
|
||||
<Lucide class="mr-2 size-4" icon="Pencil" />
|
||||
Kemaskini
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none text-danger"
|
||||
:disabled="deletingHeirId === heir.id"
|
||||
@click="onDeleteHeir(heir)"
|
||||
>
|
||||
<Lucide
|
||||
class="mr-2 size-4"
|
||||
:icon="deletingHeirId === heir.id ? 'LoaderCircle' : 'Trash'"
|
||||
:class="{ 'animate-spin': deletingHeirId === heir.id }"
|
||||
/>
|
||||
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-danger"
|
||||
:disabled="deletingHeirId === heir.id" @click="onDeleteHeir(heir)">
|
||||
<Lucide class="mr-2 size-4" :icon="deletingHeirId === heir.id ? 'LoaderCircle' : 'Trash'"
|
||||
:class="{ 'animate-spin': deletingHeirId === heir.id }" />
|
||||
Padam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
Tiada pewaris direkodkan.
|
||||
<div v-else class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500">
|
||||
Tiada penama direkodkan.
|
||||
</div>
|
||||
|
||||
<form class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveHeir">
|
||||
<div v-if="hasReachedHeirLimit && !isEditingHeir"
|
||||
class="rounded-lg border border-foreground/10 bg-foreground/5 p-4 text-sm text-slate-600">
|
||||
Had penama telah dicapai. Kemaskini atau padam penama sedia ada untuk membuat perubahan.
|
||||
</div>
|
||||
|
||||
<form v-if="showHeirForm" class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveHeir">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-slate-900">
|
||||
{{ isEditingHeir ? 'Kemaskini Pewaris' : 'Tambah Pewaris' }}
|
||||
{{ isEditingHeir ? 'Kemaskini Penama' : 'Tambah Penama' }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{
|
||||
isEditingHeir
|
||||
? 'Kemas kini maklumat pewaris yang dipilih.'
|
||||
: 'Tambah pewaris baharu ke profil anda.'
|
||||
? 'Kemas kini maklumat penama yang dipilih.'
|
||||
: 'Tambah penama baharu ke profil anda.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="isEditingHeir"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="savingHeir"
|
||||
@click="resetHeirForm"
|
||||
>
|
||||
<Button v-if="isEditingHeir" type="button" variant="ghost" class="border border-foreground/15 shadow-none"
|
||||
:disabled="savingHeir" @click="resetHeirForm">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="savingHeir">
|
||||
@@ -421,38 +413,21 @@ onMounted(async () => {
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="heir-name">Nama</FieldLabel>
|
||||
<Input
|
||||
id="heir-name"
|
||||
v-model="heirForm.name"
|
||||
type="text"
|
||||
placeholder="Nama penuh"
|
||||
:aria-invalid="!!heirErrors.name"
|
||||
@input="clearHeirFieldError('name')"
|
||||
/>
|
||||
<Input id="heir-name" v-model="heirForm.name" type="text" placeholder="Nama penuh"
|
||||
:aria-invalid="!!heirErrors.name" @input="clearHeirFieldError('name')" />
|
||||
<FieldError v-if="heirErrors.name">{{ heirErrors.name }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="heir-ic">No. Kad Pengenalan</FieldLabel>
|
||||
<Input
|
||||
id="heir-ic"
|
||||
v-model="heirForm.ic_number"
|
||||
type="text"
|
||||
placeholder="No. kad pengenalan"
|
||||
:aria-invalid="!!heirErrors.ic_number"
|
||||
@input="clearHeirFieldError('ic_number')"
|
||||
/>
|
||||
<FieldLabel for="heir-ic-number">No. Kad Pengenalan</FieldLabel>
|
||||
<Input id="heir-ic-number" v-model="heirForm.ic_number" type="text" placeholder="Contoh: 900101011234"
|
||||
:aria-invalid="!!heirErrors.ic_number" @input="clearHeirFieldError('ic_number')" />
|
||||
<FieldError v-if="heirErrors.ic_number">{{ heirErrors.ic_number }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Hubungan</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`heir-relationship-${editingHeirId ?? 'new'}`"
|
||||
class="w-full"
|
||||
:collection="relationshipCollection"
|
||||
:default-value="relationshipInitial"
|
||||
:disabled="savingHeir"
|
||||
@value-change="setRelationshipValue"
|
||||
>
|
||||
<SelectRoot :key="`heir-relationship-${editingHeirId ?? 'new'}`" class="w-full"
|
||||
:collection="relationshipCollection" :default-value="relationshipInitial" :disabled="savingHeir"
|
||||
@value-change="setRelationshipValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!heirErrors.relationship">
|
||||
<SelectValueText placeholder="Pilih hubungan" />
|
||||
@@ -461,11 +436,7 @@ onMounted(async () => {
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Hubungan</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in relationshipCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItem v-for="item in relationshipCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
@@ -474,37 +445,23 @@ onMounted(async () => {
|
||||
<FieldError v-if="heirErrors.relationship">{{ heirErrors.relationship }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="heir-phone">No. Telefon</FieldLabel>
|
||||
<Input
|
||||
id="heir-phone"
|
||||
v-model="heirForm.phone_number"
|
||||
type="text"
|
||||
placeholder="No. telefon"
|
||||
:aria-invalid="!!heirErrors.phone_number"
|
||||
@input="clearHeirFieldError('phone_number')"
|
||||
/>
|
||||
<FieldLabel for="heir-phone-number">No. Telefon</FieldLabel>
|
||||
<Input id="heir-phone-number" v-model="heirForm.phone_number" type="text"
|
||||
placeholder="Contoh: 0123456789" :aria-invalid="!!heirErrors.phone_number"
|
||||
@input="clearHeirFieldError('phone_number')" />
|
||||
<FieldError v-if="heirErrors.phone_number">{{ heirErrors.phone_number }}</FieldError>
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel for="heir-address">Alamat</FieldLabel>
|
||||
<Textarea
|
||||
id="heir-address"
|
||||
v-model="heirForm.address"
|
||||
placeholder="Alamat penuh"
|
||||
class="resize-none"
|
||||
:aria-invalid="!!heirErrors.address"
|
||||
@input="clearHeirFieldError('address')"
|
||||
/>
|
||||
<Textarea id="heir-address" v-model="heirForm.address" rows="3" placeholder="Alamat penama"
|
||||
:aria-invalid="!!heirErrors.address" @input="clearHeirFieldError('address')" />
|
||||
<FieldError v-if="heirErrors.address">{{ heirErrors.address }}</FieldError>
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<CheckboxRoot
|
||||
:checked="heirForm.is_primary"
|
||||
:disabled="savingHeir"
|
||||
@checked-change="({ checked }) => (heirForm.is_primary = checked === true)"
|
||||
>
|
||||
<CheckboxRoot :checked="heirForm.is_primary" :disabled="savingHeir"
|
||||
@checked-change="({ checked }) => (heirForm.is_primary = checked === true)">
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Pewaris utama</CheckboxLabel>
|
||||
<CheckboxLabel>Penama utama</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Swal from 'sweetalert2'
|
||||
import fakers from '@/utils/faker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { SwitchRoot, SwitchControl } from '@/components/ui/switch'
|
||||
import { ProgressRoot, ProgressTrack, ProgressRange } from '@/components/ui/progress-linear'
|
||||
import {
|
||||
CarouselRoot,
|
||||
CarouselPrevTrigger,
|
||||
CarouselNextTrigger,
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { FileIcon } from '@/components/ui/file-icon'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import logoUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listEmployments } from '@/modules/profile/services/employment.service'
|
||||
import { uploadProfileImage } from '@/modules/profile/services/profile.service'
|
||||
import type { Employment } from '@/modules/profile/types/employment.types'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import MemberDigitalCard from '../components/MemberDigitalCard.vue'
|
||||
import ProfileTab from './ProfileTab.vue'
|
||||
import EmploymentTab from './EmploymentTab.vue'
|
||||
import BankDetailTab from './BankDetailTab.vue'
|
||||
@@ -34,12 +23,59 @@ const authStore = useAuthStore()
|
||||
const uploadingImage = ref(false)
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null)
|
||||
const imagePreviewUrl = ref<string | null>(null)
|
||||
const employments = ref<Employment[]>([])
|
||||
|
||||
const companyName = computed(() => {
|
||||
const currentEmployment = employments.value.find((employment) => employment.is_current)
|
||||
return currentEmployment?.company_name ?? employments.value[0]?.company_name ?? null
|
||||
})
|
||||
|
||||
const profileUrl = computed(() => {
|
||||
const token = authStore.user?.public_profile_token
|
||||
if (!token) return null
|
||||
|
||||
const baseUrl = (typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: import.meta.env.VITE_APP_URL || ''
|
||||
).replace(/\/$/, '')
|
||||
|
||||
return `${baseUrl}/v/${token}`
|
||||
})
|
||||
|
||||
const displayValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value).trim() || '-'
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Aktif',
|
||||
pending: 'Menunggu',
|
||||
inactive: 'Tidak Aktif',
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const status = authStore.userStatus
|
||||
if (!status) return '-'
|
||||
return STATUS_LABELS[status] ?? status.charAt(0).toUpperCase() + status.slice(1)
|
||||
})
|
||||
|
||||
const statusBadgeVariant = computed(() => {
|
||||
if (authStore.isAccountActive) return 'success' as const
|
||||
if (authStore.isAccountPending) return 'pending' as const
|
||||
return 'secondary' as const
|
||||
})
|
||||
|
||||
function formatDateLabel(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat('ms-MY', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const avatarSrc = computed(
|
||||
() => imagePreviewUrl.value ?? authStore.userImageUrl ?? undefined,
|
||||
)
|
||||
@@ -98,10 +134,21 @@ async function onImageSelected(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEmployments() {
|
||||
try {
|
||||
const res = await listEmployments()
|
||||
employments.value = res.data
|
||||
} catch {
|
||||
employments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!authStore.user) {
|
||||
await authStore.fetchSession()
|
||||
}
|
||||
|
||||
await fetchEmployments()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -113,9 +160,10 @@ onMounted(async () => {
|
||||
<TabsRoot defaultValue="1">
|
||||
<!-- BEGIN: Profile Info -->
|
||||
<Box raised="single" class="mt-5 p-0">
|
||||
<div class="flex flex-col border-b border-foreground/15 p-5 lg:flex-row">
|
||||
<div class="flex flex-1 items-center justify-center px-5 lg:justify-start">
|
||||
<div class="relative" :class="{ 'opacity-60': uploadingImage }">
|
||||
<div class="flex flex-col border-b border-foreground/15 lg:flex-row">
|
||||
<!-- Identity -->
|
||||
<div class="flex flex-1 items-center justify-center p-5 lg:justify-start">
|
||||
<div class="relative shrink-0" :class="{ 'opacity-60': uploadingImage }">
|
||||
<AvatarRoot class="size-20 border-5 bg-background rounded-full sm:size-24 lg:size-32">
|
||||
<AvatarFallback>{{ authStore.userName }}</AvatarFallback>
|
||||
<AvatarImage v-if="avatarSrc" :src="avatarSrc" :alt="authStore.userName" />
|
||||
@@ -129,87 +177,103 @@ onMounted(async () => {
|
||||
<input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/jpg,image/gif" class="hidden"
|
||||
@change="onImageSelected" />
|
||||
</div>
|
||||
<div class="ml-5">
|
||||
<div class="w-24 truncate text-lg font-medium sm:w-40 sm:whitespace-normal">
|
||||
<div class="ml-5 min-w-0">
|
||||
<div class="truncate text-lg font-medium sm:whitespace-normal">
|
||||
{{ authStore.userName || '-' }}
|
||||
</div>
|
||||
<div v-if="authStore.userMemberType"
|
||||
class="mt-1 truncate text-sm capitalize opacity-70 sm:whitespace-normal">
|
||||
{{ authStore.userMemberType }}
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Badge :variant="statusBadgeVariant">{{ statusLabel }}</Badge>
|
||||
<Badge v-if="authStore.userMemberNumber" look="outline" variant="secondary">
|
||||
No. {{ authStore.userMemberNumber }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-6 flex-1 border-t border-l border-r border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-t-0 lg:pt-0">
|
||||
<div class="text-center font-medium lg:mt-3 lg:text-left">Maklumat Hubungan</div>
|
||||
<div class="mt-4 flex flex-col items-center justify-center lg:items-start">
|
||||
|
||||
<!-- Contact & membership -->
|
||||
<div class="flex-1 border-t border-foreground/15 p-5 lg:border-t-0 lg:border-l">
|
||||
<div class="text-center font-medium lg:text-left">Maklumat Hubungan</div>
|
||||
<div class="mt-4 flex flex-col items-center lg:items-start">
|
||||
<div class="flex items-center truncate sm:whitespace-normal">
|
||||
<Lucide class="mr-2 size-4" icon="Mail" />
|
||||
<Lucide class="mr-2 size-4 shrink-0" icon="Mail" />
|
||||
{{ displayValue(authStore.user?.email) }}
|
||||
</div>
|
||||
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
|
||||
<Lucide class="mr-2 size-4" icon="Phone" />
|
||||
<Lucide class="mr-2 size-4 shrink-0" icon="Phone" />
|
||||
{{ displayValue(authStore.user?.phone_number) }}
|
||||
</div>
|
||||
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
|
||||
<Lucide class="mr-2 size-4" icon="IdCard" />
|
||||
<Lucide class="mr-2 size-4 shrink-0" icon="IdCard" />
|
||||
{{ displayValue(authStore.user?.ic_number) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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-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" />
|
||||
<div
|
||||
class="pointer-events-none absolute right-4 top-1/2 size-10 -translate-y-1/2 rounded-md border border-white/20 bg-white/10" />
|
||||
|
||||
<div class="relative flex h-full flex-col justify-between">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<img :src="logoUrl" alt="" class="h-7 w-auto brightness-0 invert sm:h-8" />
|
||||
<div class="text-right text-[10px] font-semibold uppercase tracking-[0.2em] opacity-80">
|
||||
Kad Digital
|
||||
</div>
|
||||
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<div class="text-center lg:text-left">
|
||||
<div class="truncate text-base font-medium">
|
||||
{{ displayValue(authStore.userPosition) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-[10px] font-medium uppercase tracking-widest opacity-70">No. Anggota</div>
|
||||
<div class="mt-1 font-mono text-2xl font-semibold tracking-[0.15em] sm:text-3xl">
|
||||
{{ displayValue(authStore.userMemberNumber) }}
|
||||
</div>
|
||||
<div class="text-xs opacity-70">Jawatan</div>
|
||||
</div>
|
||||
<div class="text-center lg:text-left">
|
||||
<div class="truncate text-base font-medium">
|
||||
{{ displayValue(companyName) }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between gap-3 border-t border-white/15 pt-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{{ authStore.userName || '-' }}</div>
|
||||
<div class="mt-0.5 text-[10px] uppercase tracking-wide opacity-60">Nama</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<div class="text-sm font-semibold">{{ displayValue(authStore.userMemberType) }}</div>
|
||||
<div class="mt-0.5 text-[10px] uppercase tracking-wide opacity-60">Jenis Anggota</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-70">Unit</div>
|
||||
</div>
|
||||
<div class="col-span-2 text-center sm:col-span-1 lg:text-left">
|
||||
<div class="truncate text-base font-medium">
|
||||
{{ formatDateLabel(authStore.userJoinDate) }}
|
||||
</div>
|
||||
<div class="text-xs opacity-70">Tarikh Sertai</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Digital card -->
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-center border-t border-foreground/15 p-5 lg:border-t-0 lg:border-l">
|
||||
<MemberDigitalCard large :member-number="authStore.userMemberNumber" :member-name="authStore.userName"
|
||||
:member-type="authStore.userMemberType" :company-name="companyName" :profile-url="profileUrl"
|
||||
:image-url="avatarSrc" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tabs title -->
|
||||
<div class="px-5 py-4">
|
||||
<TabsList class="w-full mb-0 flex justify-between">
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="1">
|
||||
<Lucide class="mr-2 size-4" icon="User" /> Profil
|
||||
<TabsList class="mb-0 flex w-full">
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
||||
value="1" aria-label="Profil">
|
||||
<Lucide class="size-4 shrink-0 md:mr-2" icon="User" />
|
||||
<span class="hidden md:inline">Profil</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="5">
|
||||
<Lucide class="mr-2 size-4" icon="Briefcase" /> Pekerjaan
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
||||
value="5" aria-label="Pekerjaan">
|
||||
<Lucide class="size-4 shrink-0 md:mr-2" icon="Briefcase" />
|
||||
<span class="hidden md:inline">Pekerjaan</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="3">
|
||||
<Lucide class="mr-2 size-4" icon="Banknote" /> Bank
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
||||
value="3" aria-label="Bank">
|
||||
<Lucide class="size-4 shrink-0 md:mr-2" icon="Banknote" />
|
||||
<span class="hidden md:inline">Bank</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="6">
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Pewaris
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
||||
value="6" aria-label="Penama">
|
||||
<Lucide class="size-4 shrink-0 md:mr-2" icon="Users" />
|
||||
<span class="hidden md:inline">Penama</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="2">
|
||||
<Lucide class="mr-2 size-4" icon="Lock" /> Kata Laluan
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
||||
value="2" aria-label="Kata Laluan">
|
||||
<Lucide class="size-4 shrink-0 md:mr-2" icon="Lock" />
|
||||
<span class="hidden md:inline">Kata Laluan</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
@@ -230,7 +294,7 @@ onMounted(async () => {
|
||||
<TabsContent value="2" class="mt-8">
|
||||
<ChangePasswordTab embedded />
|
||||
</TabsContent>
|
||||
<!-- Pewaris -->
|
||||
<!-- Penama -->
|
||||
<TabsContent value="6" class="mt-8">
|
||||
<HeirTab embedded />
|
||||
</TabsContent>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getPublicMemberProfile } from '../services/public-member-profile.service'
|
||||
import type { PublicMemberProfile } from '../types/public-member-profile.types'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const member = ref<PublicMemberProfile | null>(null)
|
||||
|
||||
const token = computed(() => String(route.params.token ?? '').trim())
|
||||
|
||||
const avatarFallback = computed(() => {
|
||||
const name = member.value?.name?.trim()
|
||||
if (!name) return '--'
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
const displayValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value).trim() || '-'
|
||||
}
|
||||
|
||||
async function fetchMemberProfile() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
member.value = null
|
||||
|
||||
if (!token.value) {
|
||||
error.value = 'Pautan pengesahan tidak sah.'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getPublicMemberProfile(token.value)
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.message ?? 'Anggota tidak dijumpai atau tidak sah.')
|
||||
}
|
||||
|
||||
member.value = response.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan profil anggota.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMemberProfile()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-100 px-4 py-10">
|
||||
<div class="mx-auto w-full max-w-md">
|
||||
<div class="mb-6 flex flex-col items-center text-center">
|
||||
<img :src="logoUrl" alt="MyKOPKB" class="h-10 w-auto" />
|
||||
<h1 class="mt-4 text-xl font-semibold text-slate-900">Maklumat Anggota</h1>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Pengesahan gagal</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box v-else-if="loading" raised="single" class="p-8 text-center text-sm text-slate-500">
|
||||
Memuatkan maklumat anggota...
|
||||
</Box>
|
||||
|
||||
<Box v-else-if="member" raised="single" class="overflow-hidden p-0">
|
||||
<div class="bg-linear-to-br from-primary via-primary/95 to-primary/75 p-6 text-primary-foreground">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<Badge class="bg-white/15 text-white">Disahkan</Badge>
|
||||
<div class="text-right text-[10px] font-semibold uppercase tracking-[0.2em] opacity-80">
|
||||
MyKOPKB
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center gap-4">
|
||||
<AvatarRoot class="size-16 border-4 border-white/20 bg-white/10">
|
||||
<AvatarFallback>{{ avatarFallback }}</AvatarFallback>
|
||||
<AvatarImage v-if="member.image_url" :src="member.image_url" :alt="member.name" />
|
||||
</AvatarRoot>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-lg font-semibold">{{ member.name }}</div>
|
||||
<div class="mt-1 text-sm opacity-80">{{ displayValue(member.member_type) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-6">
|
||||
<div class="flex items-center gap-3 rounded-lg border border-foreground/10 p-4">
|
||||
<Lucide class="size-5 text-primary" icon="IdCard" />
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">No. Anggota</div>
|
||||
<div class="font-mono text-base font-semibold text-slate-900">
|
||||
{{ displayValue(member.member_number) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 rounded-lg border border-foreground/10 p-4">
|
||||
<Lucide class="size-5 text-primary" icon="Briefcase" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Unit</div>
|
||||
<div class="truncate text-base font-medium text-slate-900">
|
||||
{{ displayValue(member.company_name) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 rounded-lg border border-foreground/10 p-4">
|
||||
<Lucide class="size-5 text-primary" icon="CircleCheck" />
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Status</div>
|
||||
<div class="text-base font-medium capitalize text-slate-900">
|
||||
{{ displayValue(member.status) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs text-slate-500">
|
||||
Disahkan pada {{ new Date(member.verified_at).toLocaleString('ms-MY') }}
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const profilePublicRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/v/:token',
|
||||
name: 'public-member-profile',
|
||||
component: () => import('./pages/PublicMemberProfile.vue'),
|
||||
meta: { public: true, module: 'profile' },
|
||||
},
|
||||
]
|
||||
|
||||
export const profileLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'profile-overview-2',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { saveAs } from 'file-saver'
|
||||
import axios from 'axios'
|
||||
import { api } from '@/core/services/api'
|
||||
import { buildCardDownloadFileName } from '../utils/member-digital-card.utils'
|
||||
|
||||
export async function downloadMemberDigitalCard(
|
||||
memberNumber: string | number | null | undefined,
|
||||
side: 'depan' | 'belakang',
|
||||
) {
|
||||
try {
|
||||
const { data } = await api.get<Blob>('/v1/profile/digital-card', {
|
||||
params: { side },
|
||||
responseType: 'blob',
|
||||
})
|
||||
|
||||
saveAs(data, buildCardDownloadFileName(memberNumber, side))
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
|
||||
const text = await error.response.data.text()
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string }
|
||||
throw new Error(payload.message ?? 'Gagal menyimpan kad.')
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof SyntaxError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw parseError
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -63,3 +63,8 @@ export async function updatePassword(payload: UpdatePasswordPayload): Promise<Up
|
||||
const { data } = await api.put<UpdatePasswordResponse>('/v1/profile/password', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function completeOnboarding(): Promise<UpdateProfileResponse> {
|
||||
const { data } = await api.post<UpdateProfileResponse>('/v1/profile/onboarding/complete')
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PublicMemberProfileApiResponse } from '../types/public-member-profile.types'
|
||||
|
||||
export async function getPublicMemberProfile(
|
||||
token: string,
|
||||
): Promise<PublicMemberProfileApiResponse> {
|
||||
const { data } = await api.get<PublicMemberProfileApiResponse>(
|
||||
`/v1/public/members/${encodeURIComponent(token)}`,
|
||||
)
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface PublicMemberProfile {
|
||||
name: string
|
||||
member_number: number | null
|
||||
member_type: string | null
|
||||
status: string
|
||||
image_url: string | null
|
||||
company_name: string | null
|
||||
verified_at: string
|
||||
}
|
||||
|
||||
export interface PublicMemberProfileApiResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
code?: 'public_profile_not_found' | 'public_profile_token_expired'
|
||||
data: PublicMemberProfile | null
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function displayCardValue(value: string | number | null | undefined) {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value).trim() || '-'
|
||||
}
|
||||
|
||||
export function buildCardDownloadFileName(
|
||||
memberNumber: string | number | null | undefined,
|
||||
side: 'depan' | 'belakang',
|
||||
) {
|
||||
const number = memberNumber ?? 'anggota'
|
||||
return `kad-digital-${number}-${side}.png`
|
||||
}
|
||||
|
||||
export function toProxiedStorageUrl(url: string | null | undefined): string | null | undefined {
|
||||
if (!url) return url
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin)
|
||||
if (parsed.pathname.startsWith('/storage/')) {
|
||||
return `${parsed.pathname}${parsed.search}`
|
||||
}
|
||||
} catch {
|
||||
// Keep original URL when parsing fails.
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
@@ -13,16 +13,12 @@ defineProps<{
|
||||
<div :class="embedded ? '' : 'mt-5'">
|
||||
<Box raised="single" class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Pewaris</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Senarai pewaris pengguna.</p>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Penama</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Senarai penama pengguna.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="user.heirs?.length" class="space-y-3">
|
||||
<div
|
||||
v-for="heir in user.heirs"
|
||||
:key="heir.id"
|
||||
class="rounded-lg border border-foreground/10 p-4"
|
||||
>
|
||||
<div v-for="heir in user.heirs" :key="heir.id" class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-slate-900">{{ heir.name }}</span>
|
||||
<Badge v-if="heir.is_primary" class="bg-green-500 text-white">Utama</Badge>
|
||||
@@ -34,11 +30,8 @@ defineProps<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
Tiada pewaris direkodkan.
|
||||
<div v-else class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500">
|
||||
Tiada penama direkodkan.
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,7 @@ onMounted(() => {
|
||||
<Lucide class="mr-2 size-4" icon="Banknote" /> Bank
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="inline-flex w-1/4 items-center justify-center" value="6">
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Pewaris
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Penama
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user