DONE: investment types crud for admin; remove logger when login
This commit is contained in:
committed by
ISMAIL MASSERAN
parent
6bc80ea23b
commit
007cf1f804
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { investmentLayoutRoutes } from './routes'
|
||||
export { investmentMenu } from './menu'
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user