WIP: billplz api integration testing; DONE: backend for module investment, investment UI for admin

This commit is contained in:
ISMAIL MASSERAN
2026-08-02 12:46:15 +08:00
parent 007cf1f804
commit 8f1dddb80a
10 changed files with 1183 additions and 0 deletions
@@ -0,0 +1,296 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import debounce from 'lodash/debounce'
import * as select from '@zag-js/select'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
SelectRoot,
SelectControl,
SelectTrigger,
SelectValueText,
SelectContent,
SelectItemGroup,
SelectItem,
SelectItemText,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { listUsers } from '@/modules/user/services/user.service'
import type { UserListItem } from '@/modules/user/types/user.types'
import { listInvestmentTypes } from '../services/investment-type.service'
import type { InvestmentType } from '../types/investment-type.types'
import type { ManualInvestmentPayload } from '../types/investment.types'
type SelectOption = { label: string; value: string }
const props = defineProps<{
saving?: boolean
}>()
const emit = defineEmits<{
submit: [payload: ManualInvestmentPayload]
cancel: []
}>()
const form = reactive({
user_id: '',
investment_type_id: '',
amount: '',
remarks: '',
})
const memberSearch = ref('')
const memberResults = ref<UserListItem[]>([])
const selectedMember = ref<UserListItem | null>(null)
const searchingMembers = ref(false)
const memberSearchError = ref<string | null>(null)
const investmentTypes = ref<InvestmentType[]>([])
const loadingTypes = ref(false)
const typesError = ref<string | null>(null)
const typeOptions = computed<SelectOption[]>(() =>
investmentTypes.value.map((type) => ({
label: `${type.code} — ${type.name}`,
value: type.id,
})),
)
const typeCollection = computed(() =>
select.collection({
items: typeOptions.value,
itemToValue: (item) => item.label,
}),
)
const typeInitial = computed(() => {
const option = typeOptions.value.find((item) => item.value === form.investment_type_id)
return option ? [option.label] : []
})
function setTypeValue(details: { value: string[] }) {
const label = details.value[0]
form.investment_type_id =
typeOptions.value.find((item) => item.label === label)?.value ?? ''
}
async function loadInvestmentTypes() {
loadingTypes.value = true
typesError.value = null
try {
const data = await listInvestmentTypes({
page: 1,
per_page: 100,
sort_by: 'name',
sort_order: 'asc',
})
investmentTypes.value = data.data.filter((type) => type.status === 'ACTIVE')
} catch (err) {
typesError.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
investmentTypes.value = []
} finally {
loadingTypes.value = false
}
}
async function searchMembers(page = 1) {
const query = memberSearch.value.trim()
if (query.length < 2) {
memberResults.value = []
memberSearchError.value = null
return
}
searchingMembers.value = true
memberSearchError.value = null
try {
const data = await listUsers({
page,
per_page: 8,
sort_by: 'name',
sort_order: 'asc',
search: query,
status: 'active',
})
memberResults.value = data.data
} catch (err) {
memberSearchError.value = getApiErrorMessage(err, 'Gagal mencari ahli.')
memberResults.value = []
} finally {
searchingMembers.value = false
}
}
const debouncedMemberSearch = debounce(() => {
searchMembers(1)
}, 350)
watch(memberSearch, () => {
if (selectedMember.value) return
debouncedMemberSearch()
})
function selectMember(member: UserListItem) {
selectedMember.value = member
form.user_id = member.id
memberSearch.value = `${member.name} (${member.ic_number})`
memberResults.value = []
memberSearchError.value = null
}
function clearMember() {
selectedMember.value = null
form.user_id = ''
memberSearch.value = ''
memberResults.value = []
}
function handleSubmit() {
const amount = Number(form.amount)
emit('submit', {
user_id: form.user_id,
investment_type_id: form.investment_type_id,
amount: Number.isNaN(amount) ? 0 : amount,
remarks: form.remarks.trim() || null,
})
}
onMounted(() => {
loadInvestmentTypes()
})
</script>
<template>
<form class="space-y-6" @submit.prevent="handleSubmit">
<Field>
<FieldLabel required>Ahli</FieldLabel>
<div class="relative">
<Input
v-model="memberSearch"
type="search"
placeholder="Cari nama, e-mel atau no. KP..."
:disabled="props.saving || Boolean(selectedMember)"
autocomplete="off"
/>
<button
v-if="selectedMember"
type="button"
class="absolute inset-y-0 right-0 px-3 text-sm opacity-70 hover:opacity-100"
:disabled="props.saving"
@click="clearMember"
>
Tukar
</button>
</div>
<p v-if="searchingMembers" class="mt-1 text-xs opacity-60">Mencari ahli...</p>
<p v-if="memberSearchError" class="mt-1 text-xs text-danger">{{ memberSearchError }}</p>
<div
v-if="memberResults.length && !selectedMember"
class="mt-2 max-h-56 overflow-y-auto rounded-md border border-foreground/10"
>
<button
v-for="member in memberResults"
:key="member.id"
type="button"
class="flex w-full flex-col items-start px-3 py-2 text-left text-sm hover:bg-foreground/5"
@click="selectMember(member)"
>
<span class="font-medium">{{ member.name }}</span>
<span class="text-xs opacity-60">
{{ member.ic_number }}
<span v-if="member.member_number"> · No. {{ member.member_number }}</span>
</span>
</button>
</div>
</Field>
<Field>
<FieldLabel required>Jenis Pelaburan</FieldLabel>
<p v-if="loadingTypes" class="text-sm opacity-60">Memuatkan jenis pelaburan...</p>
<!-- Mount after load: SelectRoot/zag machine keeps initial disabled/collection -->
<SelectRoot
v-else-if="typeOptions.length"
:key="typeOptions.map((item) => item.value).join(',')"
class="w-full"
:collection="typeCollection"
:default-value="typeInitial"
:disabled="props.saving"
@value-change="setTypeValue"
>
<SelectControl>
<SelectTrigger aria-label="Pilih jenis pelaburan">
<SelectValueText placeholder="Pilih jenis pelaburan" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItem
v-for="item in typeCollection.items"
:key="item.value"
:item="item"
>
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<p v-if="typesError" class="mt-1 text-xs text-danger">{{ typesError }}</p>
<p v-else-if="!loadingTypes && !typeOptions.length" class="mt-1 text-xs opacity-60">
Tiada jenis pelaburan aktif.
</p>
</Field>
<Field>
<FieldLabel required>Jumlah (MYR)</FieldLabel>
<Input
v-model="form.amount"
type="number"
min="0"
step="0.01"
placeholder="0.00"
:disabled="props.saving"
/>
</Field>
<Field>
<FieldLabel>Catatan</FieldLabel>
<Textarea
v-model="form.remarks"
rows="3"
placeholder="Catatan pilihan..."
:disabled="props.saving"
/>
</Field>
<div class="flex flex-wrap justify-end gap-2">
<Button
look="outline"
variant="secondary"
type="button"
:disabled="props.saving"
@click="emit('cancel')"
>
Batal
</Button>
<Button
type="submit"
variant="primary"
:disabled="
props.saving ||
!form.user_id ||
!form.investment_type_id ||
!form.amount ||
Number(form.amount) <= 0
"
>
{{ props.saving ? 'Menyimpan...' : 'Rekod Pelaburan' }}
</Button>
</div>
</form>
</template>
@@ -0,0 +1,100 @@
import { onMounted, ref, watch } from 'vue'
import debounce from 'lodash/debounce'
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
import { useApiPagination } from '@/composables/useApiPagination'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { listInvestments } from '../services/investment.service'
import type {
Investment,
InvestmentSource,
InvestmentStatus,
} from '../types/investment.types'
export function useInvestmentList() {
const items = ref<Investment[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const search = ref('')
const statusFilter = ref<InvestmentStatus | ''>('')
const sourceFilter = ref<InvestmentSource | ''>('')
const sortBy = ref<SortConfig[]>([{ key: 'created_at', order: 'desc' }])
const page = ref(1)
const itemsPerPage = ref(10)
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
async function fetchItems(requestPage = page.value) {
loading.value = true
error.value = null
try {
const activeSort = sortBy.value[0]
const data = await listInvestments({
page: requestPage,
per_page: itemsPerPage.value,
sort_by: activeSort?.key ?? 'created_at',
sort_order: activeSort?.order ?? 'desc',
search: search.value.trim() || undefined,
status: statusFilter.value || undefined,
source: sourceFilter.value || undefined,
})
items.value = data.data
applyPagination(data.pagination)
page.value = data.pagination.current_page
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai pelaburan.')
items.value = []
} finally {
loading.value = false
}
}
function handleSortUpdate(value: SortConfig[]) {
sortBy.value = value
fetchItems(1)
}
const debouncedSearch = debounce(() => {
fetchItems(1)
}, 400)
watch(search, () => {
debouncedSearch()
})
watch([statusFilter, sourceFilter], () => {
fetchItems(1)
})
watch(page, (nextPage, previousPage) => {
if (nextPage !== previousPage) {
fetchItems(nextPage)
}
})
watch(itemsPerPage, (nextValue, previousValue) => {
if (nextValue !== previousValue) {
fetchItems(1)
}
})
onMounted(() => {
fetchItems(1)
})
return {
items,
loading,
error,
search,
statusFilter,
sourceFilter,
sortBy,
page,
itemsPerPage,
pagination,
handleSortUpdate,
fetchItems,
}
}
+6
View File
@@ -1,6 +1,12 @@
import type { Menu } from '@/core/types/menu'
export const investmentMenu: Menu[] = [
{
icon: 'Wallet',
route_name: 'list-investments',
title: 'Pelaburan',
permission: 'lihat pelaburan',
},
{
icon: 'PiggyBank',
route_name: 'list-investment-types',
@@ -0,0 +1,193 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
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 { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { getInvestment } from '../services/investment.service'
import type { Investment } from '../types/investment.types'
import {
formatInvestmentAmount,
investmentSourceLabel,
investmentStatusLabel,
investmentStatusVariant,
} from '../utils/investment.utils'
const route = useRoute()
const router = useRouter()
const investment = ref<Investment | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
function formatDate(value: string | null | undefined): string {
if (!value) return '-'
return dayjs(value).format('DD MMM YYYY, HH:mm')
}
async function loadInvestment() {
loading.value = true
error.value = null
try {
investment.value = await getInvestment(String(route.params.id))
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan pelaburan.')
investment.value = null
} finally {
loading.value = false
}
}
function goBack() {
router.push({ name: 'list-investments' })
}
onMounted(() => {
loadInvestment()
})
</script>
<template>
<div class="w-full space-y-6">
<div class="flex flex-wrap items-center gap-3">
<div class="mr-auto">
<h2 class="text-lg font-medium">Butiran Pelaburan</h2>
<p v-if="investment" class="mt-1 text-sm opacity-70">
{{ investment.reference_no }}
</p>
</div>
<Button look="outline" variant="secondary" type="button" :disabled="loading" @click="goBack">
Kembali
</Button>
</div>
<AlertRoot v-if="error" variant="danger">
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</AlertRoot>
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
<template v-else-if="investment">
<Box class="space-y-6 p-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm opacity-70">Jumlah</div>
<div class="mt-1 text-2xl font-medium">
{{ formatInvestmentAmount(investment.amount) }}
</div>
</div>
<Badge look="outline" :variant="investmentStatusVariant(investment.status)">
{{ investmentStatusLabel(investment.status) }}
</Badge>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<div>
<div class="text-xs uppercase tracking-wide opacity-60">Ahli</div>
<div class="mt-1 font-medium">{{ investment.user?.name ?? '-' }}</div>
<div class="text-sm opacity-70">{{ investment.user?.ic_number ?? '' }}</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide opacity-60">Jenis Pelaburan</div>
<div class="mt-1 font-medium">{{ investment.investment_type?.name ?? '-' }}</div>
<div class="text-sm uppercase opacity-70">
{{ investment.investment_type?.code ?? '' }}
</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide opacity-60">Sumber</div>
<div class="mt-1 font-medium">{{ investmentSourceLabel(investment.source) }}</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide opacity-60">Dicipta</div>
<div class="mt-1">{{ formatDate(investment.created_at) }}</div>
<div v-if="investment.created_by_user" class="text-sm opacity-70">
oleh {{ investment.created_by_user.name }}
</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide opacity-60">Diluluskan</div>
<div class="mt-1">{{ formatDate(investment.approved_at) }}</div>
<div v-if="investment.approved_by_user" class="text-sm opacity-70">
oleh {{ investment.approved_by_user.name }}
</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide opacity-60">Selesai</div>
<div class="mt-1">{{ formatDate(investment.completed_at) }}</div>
</div>
</div>
<div v-if="investment.remarks">
<div class="text-xs uppercase tracking-wide opacity-60">Catatan</div>
<div class="mt-1 whitespace-pre-wrap">{{ investment.remarks }}</div>
</div>
<div v-if="investment.account">
<div class="text-xs uppercase tracking-wide opacity-60">Baki Akaun</div>
<div class="mt-1 text-sm">
Semasa: {{ formatInvestmentAmount(investment.account.current_balance) }}
· Pending: {{ formatInvestmentAmount(investment.account.pending_balance) }}
</div>
</div>
</Box>
<Box v-if="investment.ledger_entries?.length" class="p-6">
<h3 class="text-base font-medium">Entri Ledger</h3>
<div class="mt-4 overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="border-b border-foreground/10 text-xs uppercase opacity-60">
<tr>
<th class="py-2 pr-4 font-medium">Jenis</th>
<th class="py-2 pr-4 font-medium">Kredit</th>
<th class="py-2 pr-4 font-medium">Debit</th>
<th class="py-2 pr-4 font-medium">Baki</th>
<th class="py-2 font-medium">Tarikh</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in investment.ledger_entries"
:key="entry.id"
class="border-b border-foreground/5"
>
<td class="py-2 pr-4">{{ entry.transaction_type }}</td>
<td class="py-2 pr-4">{{ formatInvestmentAmount(entry.credit) }}</td>
<td class="py-2 pr-4">{{ formatInvestmentAmount(entry.debit) }}</td>
<td class="py-2 pr-4">{{ formatInvestmentAmount(entry.balance_after) }}</td>
<td class="py-2">{{ formatDate(entry.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
</Box>
<Box v-if="investment.payment" class="p-6">
<h3 class="text-base font-medium">Pembayaran</h3>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 text-sm">
<div>
<div class="text-xs uppercase tracking-wide opacity-60">Gateway</div>
<div class="mt-1">{{ investment.payment.gateway }}</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide opacity-60">Status</div>
<div class="mt-1">{{ investment.payment.status }}</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide opacity-60">Dibayar</div>
<div class="mt-1">{{ formatDate(investment.payment.paid_at) }}</div>
</div>
<div v-if="investment.payment.billplz?.bill_id">
<div class="text-xs uppercase tracking-wide opacity-60">Billplz Bill ID</div>
<div class="mt-1">{{ investment.payment.billplz.bill_id }}</div>
</div>
</div>
</Box>
</template>
</div>
</template>
@@ -0,0 +1,277 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import dayjs from 'dayjs'
import * as select from '@zag-js/select'
import { Eye } from '@lucide/vue'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
SelectRoot,
SelectControl,
SelectTrigger,
SelectValueText,
SelectContent,
SelectItemGroup,
SelectItemGroupLabel,
SelectItem,
SelectItemText,
} from '@/components/ui/select'
import DataTable from '@/components/ui/usage/DataTable.vue'
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
import { usePermissions } from '@/composables/usePermissions'
import { useInvestmentList } from '../composables/useInvestmentList'
import type { Investment, InvestmentSource, InvestmentStatus } from '../types/investment.types'
import {
INVESTMENT_SOURCE_OPTIONS,
INVESTMENT_STATUS_OPTIONS,
formatInvestmentAmount,
investmentSourceLabel,
investmentStatusLabel,
investmentStatusVariant,
} from '../utils/investment.utils'
type SelectOption = { label: string; value: string }
const STATUS_FILTER_OPTIONS: SelectOption[] = [
{ label: 'Semua Status', value: '' },
...INVESTMENT_STATUS_OPTIONS,
]
const SOURCE_FILTER_OPTIONS: SelectOption[] = [
{ label: 'Semua Sumber', value: '' },
...INVESTMENT_SOURCE_OPTIONS,
]
function createSelectCollection(options: SelectOption[]) {
return select.collection({
items: options,
itemToValue: (item) => item.label,
})
}
function labelToValue(options: SelectOption[], label: string | undefined): string {
if (!label) return ''
return options.find((option) => option.label === label)?.value ?? ''
}
function valueToLabel(options: SelectOption[], value: string): string[] {
const option = options.find((item) => item.value === value)
return option ? [option.label] : [options[0]?.label ?? '']
}
const headers: TableHeader[] = [
{ title: 'Rujukan', key: 'reference_no', sortable: true },
{ title: 'Ahli', key: 'user', sortable: false },
{ title: 'Jenis', key: 'investment_type', sortable: false },
{ title: 'Jumlah', key: 'amount', sortable: true },
{ title: 'Sumber', key: 'source', sortable: true },
{ title: 'Status', key: 'status', sortable: true },
{ title: 'Tarikh', key: 'created_at', sortable: true },
{ title: 'Tindakan', key: 'actions', sortable: false },
]
const statusCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
const sourceCollection = createSelectCollection(SOURCE_FILTER_OPTIONS)
const router = useRouter()
const { hasPermission } = usePermissions()
const {
items,
loading,
error,
search,
statusFilter,
sourceFilter,
sortBy,
page,
itemsPerPage,
pagination,
handleSortUpdate,
} = useInvestmentList()
const canCreateManual = computed(() => hasPermission('tambah pelaburan manual'))
const canView = computed(() => hasPermission('lihat pelaburan'))
const statusInitial = computed(() => valueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
const sourceInitial = computed(() => valueToLabel(SOURCE_FILTER_OPTIONS, sourceFilter.value))
function setStatusFilter(details: { value: string[] }) {
statusFilter.value = labelToValue(STATUS_FILTER_OPTIONS, details.value[0]) as InvestmentStatus | ''
}
function setSourceFilter(details: { value: string[] }) {
sourceFilter.value = labelToValue(SOURCE_FILTER_OPTIONS, details.value[0]) as InvestmentSource | ''
}
function formatDate(value: string | null | undefined): string {
if (!value) return '-'
return dayjs(value).format('DD MMM YYYY, HH:mm')
}
function goToDetail(id: string) {
router.push({ name: 'view-investment', params: { id } })
}
function goToCreateManual() {
router.push({ name: 'create-manual-investment' })
}
</script>
<template>
<div>
<div class="flex flex-col items-center sm:flex-row">
<div class="mr-auto">
<h2 class="text-lg font-medium">Pelaburan</h2>
<p class="mt-1 text-sm opacity-70">Senarai transaksi pelaburan ahli.</p>
</div>
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
<Button
v-if="canCreateManual"
look="outline"
variant="primary"
:disabled="loading"
@click="goToCreateManual"
>
Rekod Pelaburan Manual
</Button>
</div>
</div>
<AlertRoot v-if="error" class="mt-6" variant="danger">
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</AlertRoot>
<div class="mt-5">
<DataTable
:headers="headers"
:items="items"
:loading="loading"
:pagination="pagination"
:current-sort="sortBy"
show-pagination
exportable
export-file-name="pelaburan"
v-model:page="page"
v-model:items-per-page="itemsPerPage"
@update:sort-by="handleSortUpdate"
>
<template #toolbar>
<div class="flex w-full flex-wrap items-center gap-3">
<Input
v-model="search"
class="w-full max-w-md"
type="search"
placeholder="Cari rujukan, ahli, jenis..."
aria-label="Cari pelaburan"
/>
<SelectRoot
class="w-full sm:w-52"
:collection="statusCollection"
:default-value="statusInitial"
@value-change="setStatusFilter"
>
<SelectControl>
<SelectTrigger aria-label="Tapis status">
<SelectValueText placeholder="Semua Status" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
<SelectItem
v-for="item in statusCollection.items"
:key="item.label"
:item="item"
>
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<SelectRoot
class="w-full sm:w-52"
:collection="sourceCollection"
:default-value="sourceInitial"
@value-change="setSourceFilter"
>
<SelectControl>
<SelectTrigger aria-label="Tapis sumber">
<SelectValueText placeholder="Semua Sumber" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Sumber</SelectItemGroupLabel>
<SelectItem
v-for="item in sourceCollection.items"
:key="item.label"
:item="item"
>
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</div>
</template>
<template #item.reference_no="{ item }">
<div class="font-medium">{{ (item as Investment).reference_no }}</div>
</template>
<template #item.user="{ item }">
<div>{{ (item as Investment).user?.name ?? '-' }}</div>
<div class="mt-0.5 text-xs opacity-60">
{{ (item as Investment).user?.ic_number ?? '' }}
</div>
</template>
<template #item.investment_type="{ item }">
<div>{{ (item as Investment).investment_type?.name ?? '-' }}</div>
<div class="mt-0.5 text-xs uppercase opacity-60">
{{ (item as Investment).investment_type?.code ?? '' }}
</div>
</template>
<template #item.amount="{ item }">
{{ formatInvestmentAmount((item as Investment).amount) }}
</template>
<template #item.source="{ item }">
{{ investmentSourceLabel((item as Investment).source) }}
</template>
<template #item.status="{ item }">
<Badge look="outline" :variant="investmentStatusVariant((item as Investment).status)">
{{ investmentStatusLabel((item as Investment).status) }}
</Badge>
</template>
<template #item.created_at="{ item }">
{{ formatDate((item as Investment).created_at) }}
</template>
<template #item.actions="{ item }">
<Button
v-if="canView"
type="button"
variant="ghost"
size="sm"
class="bg-green-600 text-white"
title="Lihat butiran"
@click="goToDetail((item as Investment).id)"
>
<Eye class="size-4" aria-hidden="true" />
</Button>
</template>
</DataTable>
</div>
</div>
</template>
@@ -0,0 +1,79 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import ManualInvestmentForm from '../components/ManualInvestmentForm.vue'
import { createManualInvestment } from '../services/investment.service'
import type { ManualInvestmentPayload } from '../types/investment.types'
const router = useRouter()
const saving = ref(false)
const error = ref<string | null>(null)
const successMessage = ref<string | null>(null)
async function handleSubmit(payload: ManualInvestmentPayload) {
saving.value = true
error.value = null
successMessage.value = null
try {
const investment = await createManualInvestment(payload)
successMessage.value = 'Pelaburan manual berjaya direkodkan.'
setTimeout(() => {
router.push({ name: 'view-investment', params: { id: investment.id } })
}, 300)
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal merekod pelaburan manual.')
} finally {
saving.value = false
}
}
function handleCancel() {
router.push({ name: 'list-investments' })
}
</script>
<template>
<div class="w-full space-y-6">
<div class="flex flex-wrap items-center gap-3">
<div class="mr-auto">
<h2 class="text-lg font-medium">Rekod Pelaburan Manual</h2>
<p class="mt-1 text-sm opacity-70">
Rekod pelaburan bagi pihak ahli. Transaksi akan terus dihantar ke ledger.
</p>
</div>
<Button
look="outline"
variant="secondary"
type="button"
:disabled="saving"
@click="handleCancel"
>
Kembali
</Button>
</div>
<AlertRoot v-if="error" variant="danger">
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</AlertRoot>
<AlertRoot v-if="successMessage" variant="success">
<AlertTitle>Berjaya</AlertTitle>
<AlertDescription>{{ successMessage }}</AlertDescription>
</AlertRoot>
<Box class="p-6">
<ManualInvestmentForm
:saving="saving"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</Box>
</div>
</template>
+30
View File
@@ -1,6 +1,36 @@
import type { RouteRecordRaw } from 'vue-router'
export const investmentLayoutRoutes: RouteRecordRaw[] = [
{
path: 'list-investments',
name: 'list-investments',
component: () => import('./pages/InvestmentList.vue'),
meta: {
title: 'Pelaburan',
module: 'investment',
permission: 'lihat pelaburan',
},
},
{
path: 'investments/manual/create',
name: 'create-manual-investment',
component: () => import('./pages/InvestmentManualCreate.vue'),
meta: {
title: 'Rekod Pelaburan Manual',
module: 'investment',
permission: 'tambah pelaburan manual',
},
},
{
path: 'investments/:id',
name: 'view-investment',
component: () => import('./pages/InvestmentDetail.vue'),
meta: {
title: 'Butiran Pelaburan',
module: 'investment',
permission: 'lihat pelaburan',
},
},
{
path: 'list-investment-types',
name: 'list-investment-types',
@@ -0,0 +1,49 @@
import { api } from '@/core/services/api'
import type { PaginatedApiResponse } from '@/core/types/api'
import type {
Investment,
ListInvestmentsParams,
ManualInvestmentPayload,
} from '../types/investment.types'
type InvestmentApiResponse = {
success: boolean
data: Investment
message?: string
}
export async function listInvestments(
params?: ListInvestmentsParams,
): Promise<PaginatedApiResponse<Investment>> {
const { data } = await api.get<PaginatedApiResponse<Investment>>('/v1/investments', {
params,
})
if (!data.success) {
throw new Error(data.message ?? 'Gagal memuatkan senarai pelaburan.')
}
return data
}
export async function getInvestment(id: string): Promise<Investment> {
const { data } = await api.get<InvestmentApiResponse>(`/v1/investments/${id}`)
if (!data.success) {
throw new Error(data.message ?? 'Gagal memuatkan pelaburan.')
}
return data.data
}
export async function createManualInvestment(
payload: ManualInvestmentPayload,
): Promise<Investment> {
const { data } = await api.post<InvestmentApiResponse>('/v1/investments/manual', payload)
if (!data.success) {
throw new Error(data.message ?? 'Gagal merekod pelaburan manual.')
}
return data.data
}
@@ -0,0 +1,106 @@
export type InvestmentSource =
| 'BILLPLZ'
| 'SALARY_DEDUCTION'
| 'MANUAL'
| 'ADJUSTMENT'
| 'REFUND'
export type InvestmentStatus =
| 'DRAFT'
| 'PENDING_PAYMENT'
| 'PENDING_VERIFICATION'
| 'COMPLETED'
| 'REJECTED'
| 'CANCELLED'
export type InvestmentUserSummary = {
id: string
name: string
email: string
ic_number?: string | null
member_number?: number | null
}
export type InvestmentTypeSummary = {
id: string
code: string
name: string
}
export type InvestmentAccountSummary = {
id: string
current_balance: string | number
pending_balance: string | number
status: string
}
export type InvestmentLedgerEntry = {
id: string
transaction_type: string
credit: string | number
debit: string | number
balance_after: string | number
created_at: string
}
export type InvestmentPaymentSummary = {
id: string
gateway: string
amount: string | number
currency: string
status: string
external_reference: string | null
paid_at: string | null
billplz?: {
bill_id: string | null
bill_url: string | null
state: string | null
transaction_status: string | null
signature_verified: boolean
} | null
}
export type Investment = {
id: string
reference_no: string
user_id: string
investment_account_id: string
investment_type_id: string
amount: string | number
source: InvestmentSource
status: InvestmentStatus
remarks: string | null
created_by: string | null
approved_by: string | null
approved_at: string | null
completed_at: string | null
cancelled_at: string | null
created_at: string
updated_at: string
user?: InvestmentUserSummary | null
investment_type?: InvestmentTypeSummary | null
account?: InvestmentAccountSummary | null
created_by_user?: { id: string; name: string; email: string } | null
approved_by_user?: { id: string; name: string; email: string } | null
ledger_entries?: InvestmentLedgerEntry[]
payment?: InvestmentPaymentSummary | null
}
export type ManualInvestmentPayload = {
user_id: string
investment_type_id: string
amount: number
remarks?: string | null
}
export type ListInvestmentsParams = {
page?: number
per_page?: number
search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
status?: InvestmentStatus | ''
source?: InvestmentSource | ''
user_id?: string
investment_type_id?: string
}
@@ -0,0 +1,47 @@
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
import type { InvestmentSource, InvestmentStatus } from '../types/investment.types'
import { formatInvestmentAmount } from './investment-type.utils'
export { formatInvestmentAmount }
export const INVESTMENT_STATUS_OPTIONS: { label: string; value: InvestmentStatus }[] = [
{ label: 'Draf', value: 'DRAFT' },
{ label: 'Menunggu Bayaran', value: 'PENDING_PAYMENT' },
{ label: 'Menunggu Pengesahan', value: 'PENDING_VERIFICATION' },
{ label: 'Selesai', value: 'COMPLETED' },
{ label: 'Ditolak', value: 'REJECTED' },
{ label: 'Dibatalkan', value: 'CANCELLED' },
]
export const INVESTMENT_SOURCE_OPTIONS: { label: string; value: InvestmentSource }[] = [
{ label: 'Manual', value: 'MANUAL' },
{ label: 'Billplz', value: 'BILLPLZ' },
{ label: 'Potongan Gaji', value: 'SALARY_DEDUCTION' },
{ label: 'Pelarasan', value: 'ADJUSTMENT' },
{ label: 'Bayaran Balik', value: 'REFUND' },
]
export function investmentStatusLabel(status: InvestmentStatus | string): string {
return INVESTMENT_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status
}
export function investmentSourceLabel(source: InvestmentSource | string): string {
return INVESTMENT_SOURCE_OPTIONS.find((option) => option.value === source)?.label ?? source
}
export function investmentStatusVariant(
status: InvestmentStatus | string,
): BadgeVariants['variant'] {
switch (status) {
case 'COMPLETED':
return 'success'
case 'PENDING_PAYMENT':
case 'PENDING_VERIFICATION':
return 'pending'
case 'REJECTED':
case 'CANCELLED':
return 'danger'
default:
return 'secondary'
}
}