WIP: billplz api integration testing; DONE: backend for module investment, investment UI for admin
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user