DONE: investment types crud for admin; remove logger when login

This commit is contained in:
ISMAIL MASSERAN
2026-08-02 09:56:01 +08:00
committed by ISMAIL MASSERAN
parent 6bc80ea23b
commit 007cf1f804
14 changed files with 965 additions and 1 deletions
+7 -1
View File
@@ -3,13 +3,19 @@ import { cn } from "@mykopkb/core/utils/cn";
import { Label } from "@/components/ui/label";
import { fieldLabel } from "@mykopkb/core/styles/field.styles";
const { class: className, ...props } = defineProps<{
const {
class: className,
required = false,
...props
} = defineProps<{
class?: string;
required?: boolean;
}>();
</script>
<template>
<Label data-part="field-label" :class="cn(fieldLabel, className)" v-bind="{ ...props }">
<slot />
<span v-if="required" class="text-danger" aria-hidden="true">*</span>
</Label>
</template>
+3
View File
@@ -7,6 +7,7 @@ import { dashboardMenu } from '@/modules/dashboard/menu'
import { externalSystemMenu } from '@/modules/external-system/menu'
import { activityLogMenu } from '@/modules/activity-log/menu'
import { feedbackMenu } from '@/modules/feedback'
import { investmentMenu } from '@/modules/investment'
export type { Menu }
@@ -22,6 +23,8 @@ const mainMenu: (string | Menu)[] = [
'Pentadbiran',
...membershipApplicationMenu,
...userMenu,
'Akaun',
...investmentMenu,
]
export default mainMenu
@@ -0,0 +1,227 @@
<script lang="ts" setup>
import { computed, reactive, watch } from 'vue'
import * as select from '@zag-js/select'
import { Button } from '@/components/ui/button'
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
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 {
INVESTMENT_TYPE_STATUS_OPTIONS,
toOptionalNumber,
} from '../utils/investment-type.utils'
import type {
InvestmentType,
InvestmentTypePayload,
InvestmentTypeStatus,
} from '../types/investment-type.types'
type SelectOption = { label: string; value: string }
const props = defineProps<{
initial?: InvestmentType | null
saving?: boolean
submitLabel?: string
}>()
const emit = defineEmits<{
submit: [payload: InvestmentTypePayload]
cancel: []
}>()
const form = reactive({
code: '',
name: '',
description: '',
status: 'ACTIVE' as InvestmentTypeStatus,
minimum_amount: '0',
maximum_amount: '',
total_fund_limit: '',
start_date: '',
end_date: '',
allow_billplz: true,
allow_salary_deduction: false,
billplz_collection_id: '',
})
const statusCollection = select.collection({
items: INVESTMENT_TYPE_STATUS_OPTIONS as SelectOption[],
itemToValue: (item) => item.label,
})
const statusInitial = computed(() => {
const option = INVESTMENT_TYPE_STATUS_OPTIONS.find((item) => item.value === form.status)
return option ? [option.label] : ['Aktif']
})
function labelToStatus(label: string | undefined): InvestmentTypeStatus {
return (
INVESTMENT_TYPE_STATUS_OPTIONS.find((item) => item.label === label)?.value ?? 'ACTIVE'
)
}
function setStatusValue(details: { value: string[] }) {
form.status = labelToStatus(details.value[0])
}
function syncFromInitial(item: InvestmentType | null | undefined) {
if (!item) return
form.code = item.code ?? ''
form.name = item.name ?? ''
form.description = item.description ?? ''
form.status = item.status ?? 'ACTIVE'
form.minimum_amount = item.minimum_amount == null ? '0' : String(item.minimum_amount)
form.maximum_amount = item.maximum_amount == null ? '' : String(item.maximum_amount)
form.total_fund_limit = item.total_fund_limit == null ? '' : String(item.total_fund_limit)
form.start_date = item.start_date ?? ''
form.end_date = item.end_date ?? ''
form.allow_billplz = Boolean(item.allow_billplz)
form.allow_salary_deduction = Boolean(item.allow_salary_deduction)
form.billplz_collection_id = item.billplz_collection_id ?? ''
}
watch(
() => props.initial,
(value) => {
syncFromInitial(value)
},
{ immediate: true },
)
function handleSubmit() {
const minimumAmount = Number(form.minimum_amount)
const payload: InvestmentTypePayload = {
code: form.code.trim(),
name: form.name.trim(),
description: form.description.trim() || null,
status: form.status,
minimum_amount: Number.isNaN(minimumAmount) ? 0 : minimumAmount,
maximum_amount: toOptionalNumber(form.maximum_amount),
total_fund_limit: toOptionalNumber(form.total_fund_limit),
start_date: form.start_date || null,
end_date: form.end_date || null,
allow_billplz: form.allow_billplz,
allow_salary_deduction: form.allow_salary_deduction,
billplz_collection_id: form.billplz_collection_id.trim() || null,
}
emit('submit', payload)
}
</script>
<template>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="investment-type-code" required>Kod</FieldLabel>
<Input id="investment-type-code" v-model="form.code" class="w-full" type="text" placeholder="Contoh: GOLD_FUND"
:disabled="saving" required />
</Field>
<Field>
<FieldLabel for="investment-type-name" required>Nama</FieldLabel>
<Input id="investment-type-name" v-model="form.name" class="w-full" type="text" placeholder="Contoh: Dana Emas"
:disabled="saving" required />
</Field>
</div>
<Field>
<FieldLabel for="investment-type-description">Penerangan</FieldLabel>
<Textarea id="investment-type-description" v-model="form.description" class="w-full" rows="3"
placeholder="Penerangan produk pelaburan" :disabled="saving" />
</Field>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel required>Status</FieldLabel>
<SelectRoot :key="statusInitial[0]" class="w-full" :collection="statusCollection" :default-value="statusInitial"
:disabled="saving" @value-change="setStatusValue">
<SelectControl>
<SelectTrigger>
<SelectValueText placeholder="Pilih status" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
</Field>
<Field>
<FieldLabel for="investment-type-billplz-collection">Billplz Collection ID</FieldLabel>
<Input id="investment-type-billplz-collection" v-model="form.billplz_collection_id" class="w-full" type="text"
placeholder="Pilihan (kosong = koleksi global)" :disabled="saving" />
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<Field>
<FieldLabel for="investment-type-min" required>Jumlah Minimum (MYR)</FieldLabel>
<Input id="investment-type-min" v-model="form.minimum_amount" class="w-full" type="number" min="0" step="0.01"
:disabled="saving" required />
</Field>
<Field>
<FieldLabel for="investment-type-max">Jumlah Maksimum (MYR)</FieldLabel>
<Input id="investment-type-max" v-model="form.maximum_amount" class="w-full" type="number" min="0" step="0.01"
placeholder="Pilihan" :disabled="saving" />
</Field>
<Field>
<FieldLabel for="investment-type-fund-limit">Had Dana (MYR)</FieldLabel>
<Input id="investment-type-fund-limit" v-model="form.total_fund_limit" class="w-full" type="number" min="0"
step="0.01" placeholder="Pilihan" :disabled="saving" />
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field>
<FieldLabel for="investment-type-start">Tarikh Mula</FieldLabel>
<Input id="investment-type-start" v-model="form.start_date" class="w-full" type="date" :disabled="saving" />
</Field>
<Field>
<FieldLabel for="investment-type-end">Tarikh Tamat</FieldLabel>
<Input id="investment-type-end" v-model="form.end_date" class="w-full" type="date" :disabled="saving" />
</Field>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<CheckboxRoot :checked="form.allow_billplz" :disabled="saving"
@checked-change="({ checked }) => (form.allow_billplz = checked === true)">
<CheckboxControl />
<CheckboxLabel>Benarkan pembayaran Billplz</CheckboxLabel>
</CheckboxRoot>
<CheckboxRoot :checked="form.allow_salary_deduction" :disabled="saving"
@checked-change="({ checked }) => (form.allow_salary_deduction = checked === true)">
<CheckboxControl />
<CheckboxLabel>Benarkan potongan gaji</CheckboxLabel>
</CheckboxRoot>
</div>
<div class="flex flex-wrap justify-end gap-2 pt-2">
<Button type="button" look="outline" variant="secondary" :disabled="saving" @click="emit('cancel')">
Batal
</Button>
<Button type="submit" look="outline" variant="primary" :disabled="saving">
{{ saving ? 'Menyimpan...' : submitLabel || 'Simpan' }}
</Button>
</div>
</form>
</template>
@@ -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 { listInvestmentTypes } from '../services/investment-type.service'
import type { InvestmentType } from '../types/investment-type.types'
export function useInvestmentTypeList() {
const items = ref<InvestmentType[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const search = ref('')
const page = ref(1)
const itemsPerPage = ref(12)
const sortBy = ref('name')
const sortOrder = ref<'asc' | 'desc'>('asc')
const { pagination, applyPagination } = useApiPagination({ per_page: 12 })
async function fetchItems(requestPage = page.value) {
loading.value = true
error.value = null
try {
const data = await listInvestmentTypes({
page: requestPage,
per_page: itemsPerPage.value,
sort_by: sortBy.value,
sort_order: sortOrder.value,
search: search.value.trim() || undefined,
})
items.value = data.data
applyPagination(data.pagination)
page.value = data.pagination.current_page
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai jenis pelaburan.')
items.value = []
} finally {
loading.value = false
}
}
const debouncedSearch = debounce(() => {
fetchItems(1)
}, 400)
watch(search, () => {
debouncedSearch()
})
watch(page, (nextPage, previousPage) => {
if (nextPage !== previousPage) {
fetchItems(nextPage)
}
})
onMounted(() => {
fetchItems(1)
})
return {
items,
loading,
error,
search,
page,
itemsPerPage,
sortBy,
sortOrder,
pagination,
fetchItems,
}
}
+2
View File
@@ -0,0 +1,2 @@
export { investmentLayoutRoutes } from './routes'
export { investmentMenu } from './menu'
+10
View File
@@ -0,0 +1,10 @@
import type { Menu } from '@/core/types/menu'
export const investmentMenu: Menu[] = [
{
icon: 'PiggyBank',
route_name: 'list-investment-types',
title: 'Jenis Pelaburan',
permission: 'lihat jenis pelaburan',
},
]
@@ -0,0 +1,75 @@
<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 InvestmentTypeForm from '../components/InvestmentTypeForm.vue'
import { createInvestmentType } from '../services/investment-type.service'
import type { InvestmentTypePayload } from '../types/investment-type.types'
const router = useRouter()
const saving = ref(false)
const error = ref<string | null>(null)
const successMessage = ref<string | null>(null)
async function handleSubmit(payload: InvestmentTypePayload) {
saving.value = true
error.value = null
successMessage.value = null
try {
await createInvestmentType(payload)
successMessage.value = 'Jenis pelaburan berjaya ditambah.'
setTimeout(() => {
router.push({ name: 'list-investment-types' })
}, 300)
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal menambah jenis pelaburan.')
} finally {
saving.value = false
}
}
function handleCancel() {
router.push({ name: 'list-investment-types' })
}
</script>
<template>
<div class="w-full space-y-6">
<div class="flex flex-wrap items-center gap-3">
<h2 class="mr-auto text-lg font-medium">Tambah Jenis Pelaburan</h2>
<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>
<InvestmentTypeForm
:saving="saving"
submit-label="Tambah"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</Box>
</div>
</template>
@@ -0,0 +1,104 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, 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 InvestmentTypeForm from '../components/InvestmentTypeForm.vue'
import {
getInvestmentType,
updateInvestmentType,
} from '../services/investment-type.service'
import type { InvestmentType, InvestmentTypePayload } from '../types/investment-type.types'
const router = useRouter()
const route = useRoute()
const investmentTypeId = computed(() => String(route.params.id ?? ''))
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const successMessage = ref<string | null>(null)
const item = ref<InvestmentType | null>(null)
async function fetchItem() {
loading.value = true
error.value = null
try {
item.value = await getInvestmentType(investmentTypeId.value)
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
item.value = null
} finally {
loading.value = false
}
}
async function handleSubmit(payload: InvestmentTypePayload) {
saving.value = true
error.value = null
successMessage.value = null
try {
item.value = await updateInvestmentType(investmentTypeId.value, payload)
successMessage.value = 'Jenis pelaburan berjaya dikemaskini.'
setTimeout(() => {
router.push({ name: 'list-investment-types' })
}, 300)
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal mengemaskini jenis pelaburan.')
} finally {
saving.value = false
}
}
function handleCancel() {
router.push({ name: 'list-investment-types' })
}
onMounted(() => {
fetchItem()
})
</script>
<template>
<div class="w-full space-y-6">
<div class="flex flex-wrap items-center gap-3">
<h2 class="mr-auto text-lg font-medium">Kemaskini Jenis Pelaburan</h2>
<Button
look="outline"
variant="secondary"
type="button"
:disabled="saving || loading"
@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 v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
<Box v-else-if="item">
<InvestmentTypeForm
:initial="item"
:saving="saving"
submit-label="Kemaskini"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</Box>
</div>
</template>
@@ -0,0 +1,261 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
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 { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Lucide } from '@/components/ui/lucide'
import {
PaginationContext,
PaginationRoot,
PaginationItem,
PaginationPrevTrigger,
PaginationNextTrigger,
PaginationEllipsis,
} from '@/components/ui/pagination'
import { usePermissions } from '@/composables/usePermissions'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { useInvestmentTypeList } from '../composables/useInvestmentTypeList'
import { deleteInvestmentType } from '../services/investment-type.service'
import type { InvestmentType } from '../types/investment-type.types'
import {
formatInvestmentAmount,
investmentTypeStatusLabel,
investmentTypeStatusVariant,
} from '../utils/investment-type.utils'
const router = useRouter()
const { hasPermission } = usePermissions()
const canCreate = computed(() => hasPermission('tambah jenis pelaburan'))
const canEdit = computed(() => hasPermission('kemaskini jenis pelaburan'))
const canDelete = computed(() => hasPermission('hapus jenis pelaburan'))
const deleteConfirmationOpen = ref(false)
const itemToDelete = ref<InvestmentType | null>(null)
const deleting = ref(false)
const deleteError = ref<string | null>(null)
const {
items,
loading,
error,
search,
page,
itemsPerPage,
pagination,
fetchItems,
} = useInvestmentTypeList()
function handlePageChange(details: { page: number }) {
page.value = details.page
}
function goToCreate() {
router.push({ name: 'create-investment-type' })
}
function goToEdit(id: string) {
router.push({ name: 'edit-investment-type', params: { id } })
}
function openDeleteConfirmation(item: InvestmentType) {
itemToDelete.value = item
deleteError.value = null
deleteConfirmationOpen.value = true
}
async function confirmDelete() {
if (!itemToDelete.value || deleting.value) return
deleting.value = true
deleteError.value = null
try {
await deleteInvestmentType(itemToDelete.value.id)
deleteConfirmationOpen.value = false
itemToDelete.value = null
await fetchItems(page.value)
} catch (err) {
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus jenis pelaburan.')
} finally {
deleting.value = false
}
}
function channelLabel(item: InvestmentType): string {
const channels: string[] = []
if (item.allow_billplz) channels.push('Billplz')
if (item.allow_salary_deduction) channels.push('Potongan Gaji')
return channels.length ? channels.join(' · ') : 'Tiada saluran'
}
</script>
<template>
<div>
<div class="flex flex-col items-center sm:flex-row">
<div class="mr-auto">
<h2 class="text-lg font-medium">Jenis Pelaburan</h2>
<p class="mt-1 text-sm opacity-70">Urus produk pelaburan koperasi.</p>
</div>
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
<Button
v-if="canCreate"
look="outline"
variant="primary"
:disabled="loading"
@click="goToCreate"
>
Tambah Jenis Pelaburan
</Button>
</div>
</div>
<AlertRoot v-if="error" class="mt-6" variant="danger">
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</AlertRoot>
<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 jenis pelaburan..."
:disabled="loading"
/>
<Lucide class="absolute inset-y-0 right-0 my-auto mr-3 h-4 w-4" icon="Search" />
</div>
</div>
</div>
<div
v-for="item in items"
:key="item.id"
class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3"
>
<Box class="p-0">
<div class="p-5">
<div class="rounded-lg border border-foreground/10 bg-foreground/2 p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="truncate text-base font-medium">{{ item.name }}</div>
<div class="mt-1 truncate text-xs uppercase tracking-wide opacity-70">
{{ item.code }}
</div>
</div>
<Badge
class="shrink-0"
look="outline"
:variant="investmentTypeStatusVariant(item.status)"
>
{{ investmentTypeStatusLabel(item.status) }}
</Badge>
</div>
</div>
<div class="mt-5 space-y-2 text-sm opacity-70">
<div class="flex items-center">
<Lucide class="mr-2 h-4 w-4" icon="Wallet" />
Min: {{ formatInvestmentAmount(item.minimum_amount) }}
</div>
<div class="flex items-center">
<Lucide class="mr-2 h-4 w-4" icon="Landmark" />
Had dana: {{ formatInvestmentAmount(item.total_fund_limit) }}
</div>
<div class="flex items-center">
<Lucide class="mr-2 h-4 w-4" icon="CreditCard" />
{{ channelLabel(item) }}
</div>
</div>
</div>
<div class="flex items-center justify-center border-t border-foreground/8 p-5 lg:justify-end">
<a
v-if="canEdit"
class="mr-3 flex items-center"
href="#"
@click.prevent="goToEdit(item.id)"
>
<Lucide class="mr-1 h-4 w-4" icon="CheckSquare" /> Edit
</a>
<a
v-if="canDelete"
class="text-danger flex items-center"
href="#"
@click.prevent="openDeleteConfirmation(item)"
>
<Lucide class="mr-1 h-4 w-4" icon="Trash" /> Hapus
</a>
</div>
</Box>
</div>
<div v-if="!loading && !items.length" class="col-span-12">
<Box class="p-6 text-center opacity-70">Tiada rekod ditemui</Box>
</div>
<div class="col-span-12 flex flex-wrap items-center sm:flex-row sm:flex-nowrap">
<PaginationRoot
:count="pagination.total"
:page="page"
:page-size="itemsPerPage"
:siblingCount="1"
:onPageChange="handlePageChange"
class="w-full sm:mr-auto sm:w-auto"
>
<PaginationPrevTrigger>Previous</PaginationPrevTrigger>
<PaginationContext v-slot="{ pagination: pages }">
<template v-for="(pageItem, index) in pages?.pages" :key="index">
<PaginationItem v-if="pageItem.type === 'page'" v-bind="{ ...pageItem }">
{{ pageItem.value }}
</PaginationItem>
<PaginationEllipsis v-else :index="index" />
</template>
</PaginationContext>
<PaginationNextTrigger>Next</PaginationNextTrigger>
</PaginationRoot>
</div>
</div>
<DialogRoot
:open="deleteConfirmationOpen"
@openChange="(details) => (deleteConfirmationOpen = details.open)"
>
<DialogContent>
<div class="p-5 text-center">
<Lucide class="text-danger mx-auto mt-3 size-16 stroke-1" icon="CircleX" />
<div class="mt-5 text-2xl font-medium">Adakah anda yakin?</div>
<div class="mt-2 opacity-70">
Hapus jenis pelaburan
<span v-if="itemToDelete" class="font-medium">{{ itemToDelete.name }}</span>?
<br />
Proses ini boleh dikembalikan melalui soft delete di pangkalan data.
</div>
<div v-if="deleteError" class="mt-4 text-sm text-danger">
{{ deleteError }}
</div>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-24" :disabled="deleting">Batal</DialogCloseTrigger>
<Button
class="w-24"
type="button"
variant="danger"
look="outline"
:disabled="deleting"
@click="confirmDelete"
>
{{ deleting ? 'Menghapus...' : 'Hapus' }}
</Button>
</div>
</DialogContent>
</DialogRoot>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
import type { RouteRecordRaw } from 'vue-router'
export const investmentLayoutRoutes: RouteRecordRaw[] = [
{
path: 'list-investment-types',
name: 'list-investment-types',
component: () => import('./pages/InvestmentTypeList.vue'),
meta: {
title: 'Jenis Pelaburan',
module: 'investment',
permission: 'lihat jenis pelaburan',
},
},
{
path: 'investment-types/create',
name: 'create-investment-type',
component: () => import('./pages/InvestmentTypeCreate.vue'),
meta: {
title: 'Tambah Jenis Pelaburan',
module: 'investment',
permission: 'tambah jenis pelaburan',
},
},
{
path: 'investment-types/:id/edit',
name: 'edit-investment-type',
component: () => import('./pages/InvestmentTypeEdit.vue'),
meta: {
title: 'Kemaskini Jenis Pelaburan',
module: 'investment',
permission: 'kemaskini jenis pelaburan',
},
},
]
@@ -0,0 +1,75 @@
import { api } from '@/core/services/api'
import type { PaginatedApiResponse } from '@/core/types/api'
import type {
InvestmentType,
InvestmentTypePayload,
ListInvestmentTypesParams,
} from '../types/investment-type.types'
type InvestmentTypeApiResponse = {
success: boolean
data: InvestmentType
message?: string
}
type DeleteInvestmentTypeApiResponse = {
success: boolean
message?: string
}
export async function listInvestmentTypes(
params?: ListInvestmentTypesParams,
): Promise<PaginatedApiResponse<InvestmentType>> {
const { data } = await api.get<PaginatedApiResponse<InvestmentType>>('/v1/investment-types', {
params,
})
if (!data.success) {
throw new Error(data.message ?? 'Gagal memuatkan senarai jenis pelaburan.')
}
return data
}
export async function getInvestmentType(id: string): Promise<InvestmentType> {
const { data } = await api.get<InvestmentTypeApiResponse>(`/v1/investment-types/${id}`)
if (!data.success) {
throw new Error(data.message ?? 'Gagal memuatkan jenis pelaburan.')
}
return data.data
}
export async function createInvestmentType(
payload: InvestmentTypePayload,
): Promise<InvestmentType> {
const { data } = await api.post<InvestmentTypeApiResponse>('/v1/investment-types', payload)
if (!data.success) {
throw new Error(data.message ?? 'Gagal menambah jenis pelaburan.')
}
return data.data
}
export async function updateInvestmentType(
id: string,
payload: InvestmentTypePayload,
): Promise<InvestmentType> {
const { data } = await api.put<InvestmentTypeApiResponse>(`/v1/investment-types/${id}`, payload)
if (!data.success) {
throw new Error(data.message ?? 'Gagal mengemaskini jenis pelaburan.')
}
return data.data
}
export async function deleteInvestmentType(id: string): Promise<void> {
const { data } = await api.delete<DeleteInvestmentTypeApiResponse>(`/v1/investment-types/${id}`)
if (!data.success) {
throw new Error(data.message ?? 'Gagal menghapus jenis pelaburan.')
}
}
@@ -0,0 +1,45 @@
export type InvestmentTypeStatus = 'ACTIVE' | 'INACTIVE' | 'CLOSED'
export type InvestmentType = {
id: string
code: string
name: string
description: string | null
status: InvestmentTypeStatus
minimum_amount: string | number
maximum_amount: string | number | null
total_fund_limit: string | number | null
reserved_amount: string | number
start_date: string | null
end_date: string | null
allow_billplz: boolean
allow_salary_deduction: boolean
billplz_collection_id: string | null
created_by: string | null
updated_by: string | null
created_at: string
updated_at: string
}
export type InvestmentTypePayload = {
code: string
name: string
description?: string | null
status: InvestmentTypeStatus
minimum_amount: number
maximum_amount?: number | null
total_fund_limit?: number | null
start_date?: string | null
end_date?: string | null
allow_billplz: boolean
allow_salary_deduction: boolean
billplz_collection_id?: string | null
}
export type ListInvestmentTypesParams = {
page?: number
per_page?: number
search?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}
@@ -0,0 +1,46 @@
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
import type { InvestmentTypeStatus } from '../types/investment-type.types'
export const INVESTMENT_TYPE_STATUS_OPTIONS: { label: string; value: InvestmentTypeStatus }[] = [
{ label: 'Aktif', value: 'ACTIVE' },
{ label: 'Tidak Aktif', value: 'INACTIVE' },
{ label: 'Ditutup', value: 'CLOSED' },
]
export function investmentTypeStatusLabel(status: InvestmentTypeStatus): string {
return INVESTMENT_TYPE_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status
}
export function investmentTypeStatusVariant(
status: InvestmentTypeStatus,
): BadgeVariants['variant'] {
if (status === 'ACTIVE') return 'success'
if (status === 'INACTIVE') return 'pending'
return 'danger'
}
export function formatInvestmentAmount(value: string | number | null | undefined): string {
if (value == null || value === '') return '-'
const amount = typeof value === 'number' ? value : Number(value)
if (Number.isNaN(amount)) return '-'
return new Intl.NumberFormat('ms-MY', {
style: 'currency',
currency: 'MYR',
}).format(amount)
}
export function toOptionalNumber(value: string | number | null | undefined): number | null {
if (value == null || value === '') return null
if (typeof value === 'number') {
return Number.isNaN(value) ? null : value
}
const trimmed = String(value).trim()
if (!trimmed) return null
const amount = Number(trimmed)
return Number.isNaN(amount) ? null : amount
}
+2
View File
@@ -14,6 +14,7 @@ import { dashboardLayoutRoutes } from '@/modules/dashboard'
import { externalSystemLayoutRoutes } from '@/modules/external-system'
import { activityLogLayoutRoutes } from '@/modules/activity-log'
import { feedbackPublicRoutes, feedbackLayoutRoutes } from '@/modules/feedback'
import { investmentLayoutRoutes } from '@/modules/investment'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -32,6 +33,7 @@ const router = createRouter({
...externalSystemLayoutRoutes,
...membershipApplicationLayoutRoutes,
...feedbackLayoutRoutes,
...investmentLayoutRoutes,
],
},
...authPublicRoutes,