513 lines
16 KiB
Vue
513 lines
16 KiB
Vue
<script lang="ts" setup>
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import * as select from '@zag-js/select'
|
|
import { Pencil, Trash2 } from '@lucide/vue'
|
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
|
import { Box } from '@/components/ui/box'
|
|
import { Button } from '@/components/ui/button'
|
|
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
|
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 { usePermissions } from '@/composables/usePermissions'
|
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
|
import { listInvestmentTypes } from '../services/investment-type.service'
|
|
import {
|
|
createProfileInvestmentLimit,
|
|
deleteProfileInvestmentLimit,
|
|
getInvestmentLimitProfile,
|
|
updateInvestmentLimitProfile,
|
|
updateProfileInvestmentLimit,
|
|
} from '../services/investment-limit.service'
|
|
import type {
|
|
InvestmentLimitProfile,
|
|
ProfileInvestmentLimit,
|
|
} from '../types/investment-limit.types'
|
|
import type { InvestmentType } from '../types/investment-type.types'
|
|
import { formatInvestmentAmount, toOptionalNumber } from '../utils/investment-type.utils'
|
|
|
|
type SelectOption = { label: string; value: string }
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const { hasPermission } = usePermissions()
|
|
|
|
const canUpdate = computed(() => hasPermission('kemaskini had pelaburan'))
|
|
const canCreate = computed(() => hasPermission('tambah had pelaburan'))
|
|
const canDelete = computed(() => hasPermission('hapus had pelaburan'))
|
|
|
|
const profile = ref<InvestmentLimitProfile | null>(null)
|
|
const limits = ref<ProfileInvestmentLimit[]>([])
|
|
const investmentTypes = ref<InvestmentType[]>([])
|
|
|
|
const loading = ref(true)
|
|
const savingProfile = ref(false)
|
|
const savingLimit = ref(false)
|
|
const error = ref<string | null>(null)
|
|
const successMessage = ref<string | null>(null)
|
|
|
|
const profileForm = reactive({
|
|
name: '',
|
|
description: '',
|
|
})
|
|
|
|
const limitDialogOpen = ref(false)
|
|
const editingLimit = ref<ProfileInvestmentLimit | null>(null)
|
|
const limitForm = reactive({
|
|
investment_type_id: '',
|
|
max_investment_amount: '',
|
|
max_transaction_amount: '',
|
|
})
|
|
const limitError = ref<string | null>(null)
|
|
|
|
const deleteOpen = ref(false)
|
|
const limitToDelete = ref<ProfileInvestmentLimit | null>(null)
|
|
const deleting = ref(false)
|
|
const deleteError = ref<string | null>(null)
|
|
|
|
const typeOptions = computed<SelectOption[]>(() => {
|
|
const usedIds = new Set(
|
|
limits.value
|
|
.filter((limit) => limit.id !== editingLimit.value?.id)
|
|
.map((limit) => limit.investment_type_id),
|
|
)
|
|
|
|
return investmentTypes.value
|
|
.filter((type) => type.status === 'ACTIVE' && !usedIds.has(type.id))
|
|
.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 === limitForm.investment_type_id)
|
|
if (option) return [option.label]
|
|
|
|
// Keep current type visible when editing even if already "used"
|
|
if (editingLimit.value?.investment_type) {
|
|
return [
|
|
`${editingLimit.value.investment_type.code} — ${editingLimit.value.investment_type.name}`,
|
|
]
|
|
}
|
|
|
|
return []
|
|
})
|
|
|
|
const editTypeOptions = computed<SelectOption[]>(() => {
|
|
if (!editingLimit.value?.investment_type) return typeOptions.value
|
|
|
|
const current = {
|
|
label: `${editingLimit.value.investment_type.code} — ${editingLimit.value.investment_type.name}`,
|
|
value: editingLimit.value.investment_type_id,
|
|
}
|
|
|
|
if (typeOptions.value.some((item) => item.value === current.value)) {
|
|
return typeOptions.value
|
|
}
|
|
|
|
return [current, ...typeOptions.value]
|
|
})
|
|
|
|
const editTypeCollection = computed(() =>
|
|
select.collection({
|
|
items: editTypeOptions.value,
|
|
itemToValue: (item) => item.label,
|
|
}),
|
|
)
|
|
|
|
function setTypeValue(details: { value: string[] }) {
|
|
const label = details.value[0]
|
|
limitForm.investment_type_id =
|
|
editTypeOptions.value.find((item) => item.label === label)?.value ?? ''
|
|
}
|
|
|
|
async function loadAll() {
|
|
loading.value = true
|
|
error.value = null
|
|
|
|
try {
|
|
const [profileData, typesData] = await Promise.all([
|
|
getInvestmentLimitProfile(String(route.params.id)),
|
|
listInvestmentTypes({ page: 1, per_page: 100, sort_by: 'name', sort_order: 'asc' }),
|
|
])
|
|
|
|
profile.value = profileData
|
|
profileForm.name = profileData.name
|
|
profileForm.description = profileData.description ?? ''
|
|
limits.value = profileData.profile_limits ?? []
|
|
investmentTypes.value = typesData.data
|
|
} catch (err) {
|
|
error.value = getApiErrorMessage(err, 'Gagal memuatkan profil had.')
|
|
profile.value = null
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function saveProfile() {
|
|
if (!profile.value || !canUpdate.value) return
|
|
|
|
savingProfile.value = true
|
|
error.value = null
|
|
successMessage.value = null
|
|
|
|
try {
|
|
const updated = await updateInvestmentLimitProfile(profile.value.id, {
|
|
name: profileForm.name.trim(),
|
|
description: profileForm.description.trim() || null,
|
|
})
|
|
profile.value = { ...profile.value, ...updated }
|
|
successMessage.value = 'Profil had berjaya dikemaskini.'
|
|
} catch (err) {
|
|
error.value = getApiErrorMessage(err, 'Gagal mengemaskini profil had.')
|
|
} finally {
|
|
savingProfile.value = false
|
|
}
|
|
}
|
|
|
|
function openCreateLimit() {
|
|
editingLimit.value = null
|
|
limitForm.investment_type_id = ''
|
|
limitForm.max_investment_amount = ''
|
|
limitForm.max_transaction_amount = ''
|
|
limitError.value = null
|
|
limitDialogOpen.value = true
|
|
}
|
|
|
|
function openEditLimit(limit: ProfileInvestmentLimit) {
|
|
editingLimit.value = limit
|
|
limitForm.investment_type_id = limit.investment_type_id
|
|
limitForm.max_investment_amount =
|
|
limit.max_investment_amount == null ? '' : String(limit.max_investment_amount)
|
|
limitForm.max_transaction_amount =
|
|
limit.max_transaction_amount == null ? '' : String(limit.max_transaction_amount)
|
|
limitError.value = null
|
|
limitDialogOpen.value = true
|
|
}
|
|
|
|
async function saveLimit() {
|
|
if (!profile.value) return
|
|
|
|
savingLimit.value = true
|
|
limitError.value = null
|
|
|
|
const payload = {
|
|
investment_type_id: limitForm.investment_type_id,
|
|
max_investment_amount: toOptionalNumber(limitForm.max_investment_amount),
|
|
max_transaction_amount: toOptionalNumber(limitForm.max_transaction_amount),
|
|
}
|
|
|
|
try {
|
|
if (editingLimit.value) {
|
|
await updateProfileInvestmentLimit(profile.value.id, editingLimit.value.id, payload)
|
|
} else {
|
|
await createProfileInvestmentLimit(profile.value.id, payload)
|
|
}
|
|
|
|
limitDialogOpen.value = false
|
|
await loadAll()
|
|
successMessage.value = editingLimit.value
|
|
? 'Had profil berjaya dikemaskini.'
|
|
: 'Had profil berjaya ditambah.'
|
|
} catch (err) {
|
|
limitError.value = getApiErrorMessage(err, 'Gagal menyimpan had profil.')
|
|
} finally {
|
|
savingLimit.value = false
|
|
}
|
|
}
|
|
|
|
function openDeleteLimit(limit: ProfileInvestmentLimit) {
|
|
limitToDelete.value = limit
|
|
deleteError.value = null
|
|
deleteOpen.value = true
|
|
}
|
|
|
|
async function confirmDeleteLimit() {
|
|
if (!profile.value || !limitToDelete.value || deleting.value) return
|
|
|
|
deleting.value = true
|
|
deleteError.value = null
|
|
|
|
try {
|
|
await deleteProfileInvestmentLimit(profile.value.id, limitToDelete.value.id)
|
|
deleteOpen.value = false
|
|
limitToDelete.value = null
|
|
await loadAll()
|
|
} catch (err) {
|
|
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus had profil.')
|
|
} finally {
|
|
deleting.value = false
|
|
}
|
|
}
|
|
|
|
function goBack() {
|
|
router.push({ name: 'list-investment-limit-profiles' })
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadAll()
|
|
})
|
|
</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">Urus Profil Had</h2>
|
|
<p v-if="profile" class="mt-1 text-sm opacity-70">{{ profile.name }}</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>
|
|
|
|
<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>
|
|
|
|
<template v-else-if="profile">
|
|
<Box class="p-6">
|
|
<h3 class="text-base font-medium">Maklumat Profil</h3>
|
|
<form class="mt-4 space-y-4" @submit.prevent="saveProfile">
|
|
<Field>
|
|
<FieldLabel required>Nama</FieldLabel>
|
|
<Input v-model="profileForm.name" :disabled="savingProfile || !canUpdate" />
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel>Keterangan</FieldLabel>
|
|
<Textarea
|
|
v-model="profileForm.description"
|
|
rows="3"
|
|
:disabled="savingProfile || !canUpdate"
|
|
/>
|
|
</Field>
|
|
<div v-if="canUpdate" class="flex justify-end">
|
|
<Button type="submit" variant="primary" :disabled="savingProfile || !profileForm.name.trim()">
|
|
{{ savingProfile ? 'Menyimpan...' : 'Simpan Profil' }}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Box>
|
|
|
|
<Box class="p-6">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<div class="mr-auto">
|
|
<h3 class="text-base font-medium">Had Mengikut Jenis Pelaburan</h3>
|
|
<p class="mt-1 text-sm opacity-70">
|
|
Had kumulatif dan had setiap transaksi untuk produk dalam profil ini.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
v-if="canCreate"
|
|
look="outline"
|
|
variant="primary"
|
|
type="button"
|
|
@click="openCreateLimit"
|
|
>
|
|
Tambah Had
|
|
</Button>
|
|
</div>
|
|
|
|
<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">Had Kumulatif</th>
|
|
<th class="py-2 pr-4 font-medium">Had Transaksi</th>
|
|
<th class="py-2 font-medium">Tindakan</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="limit in limits" :key="limit.id" class="border-b border-foreground/5">
|
|
<td class="py-3 pr-4">
|
|
<div class="font-medium">{{ limit.investment_type?.name ?? '-' }}</div>
|
|
<div class="text-xs uppercase opacity-60">
|
|
{{ limit.investment_type?.code ?? '' }}
|
|
</div>
|
|
</td>
|
|
<td class="py-3 pr-4">
|
|
{{ formatInvestmentAmount(limit.max_investment_amount) }}
|
|
</td>
|
|
<td class="py-3 pr-4">
|
|
{{ formatInvestmentAmount(limit.max_transaction_amount) }}
|
|
</td>
|
|
<td class="py-3">
|
|
<div class="flex items-center gap-2">
|
|
<Button
|
|
v-if="canUpdate"
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
title="Edit"
|
|
@click="openEditLimit(limit)"
|
|
>
|
|
<Pencil class="size-4" />
|
|
</Button>
|
|
<Button
|
|
v-if="canDelete"
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
class="text-danger"
|
|
title="Hapus"
|
|
@click="openDeleteLimit(limit)"
|
|
>
|
|
<Trash2 class="size-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="!limits.length">
|
|
<td colspan="4" class="py-6 text-center opacity-60">
|
|
Tiada had ditetapkan untuk profil ini.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Box>
|
|
</template>
|
|
|
|
<DialogRoot
|
|
:open="limitDialogOpen"
|
|
:close-on-interact-outside="false"
|
|
@openChange="(details) => (limitDialogOpen = details.open)"
|
|
>
|
|
<DialogContent>
|
|
<div class="space-y-4 p-5">
|
|
<div class="text-xl font-medium">
|
|
{{ editingLimit ? 'Kemaskini Had' : 'Tambah Had' }}
|
|
</div>
|
|
|
|
<AlertRoot v-if="limitError" variant="danger">
|
|
<AlertTitle>Ralat</AlertTitle>
|
|
<AlertDescription>{{ limitError }}</AlertDescription>
|
|
</AlertRoot>
|
|
|
|
<Field>
|
|
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
|
<!-- Mount only while open so zag select gets the current collection -->
|
|
<SelectRoot
|
|
v-if="limitDialogOpen && editTypeOptions.length"
|
|
:key="`limit-type-${editingLimit?.id ?? 'new'}-${editTypeOptions.map((i) => i.value).join(',')}`"
|
|
class="w-full"
|
|
:collection="editTypeCollection"
|
|
:default-value="typeInitial"
|
|
:disabled="savingLimit"
|
|
@value-change="setTypeValue"
|
|
>
|
|
<SelectControl>
|
|
<SelectTrigger aria-label="Pilih jenis pelaburan">
|
|
<SelectValueText placeholder="Pilih jenis pelaburan" />
|
|
</SelectTrigger>
|
|
</SelectControl>
|
|
<SelectContent>
|
|
<SelectItemGroup>
|
|
<SelectItem
|
|
v-for="item in editTypeCollection.items"
|
|
:key="item.value"
|
|
:item="item"
|
|
>
|
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
|
</SelectItem>
|
|
</SelectItemGroup>
|
|
</SelectContent>
|
|
</SelectRoot>
|
|
<p v-else-if="limitDialogOpen" class="text-sm opacity-60">
|
|
Tiada jenis pelaburan aktif yang tersedia.
|
|
</p>
|
|
</Field>
|
|
|
|
<Field>
|
|
<FieldLabel>Had Kumulatif (MYR)</FieldLabel>
|
|
<Input
|
|
v-model="limitForm.max_investment_amount"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
:disabled="savingLimit"
|
|
placeholder="Kosong = tiada had"
|
|
/>
|
|
</Field>
|
|
|
|
<Field>
|
|
<FieldLabel>Had Transaksi (MYR)</FieldLabel>
|
|
<Input
|
|
v-model="limitForm.max_transaction_amount"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
:disabled="savingLimit"
|
|
placeholder="Kosong = tiada had"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<div class="flex justify-end gap-2 px-5 pb-5">
|
|
<DialogCloseTrigger :disabled="savingLimit">Batal</DialogCloseTrigger>
|
|
<Button
|
|
type="button"
|
|
variant="primary"
|
|
:disabled="savingLimit || !limitForm.investment_type_id"
|
|
@click="saveLimit"
|
|
>
|
|
{{ savingLimit ? 'Menyimpan...' : 'Simpan' }}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</DialogRoot>
|
|
|
|
<DialogRoot :open="deleteOpen" @openChange="(details) => (deleteOpen = details.open)">
|
|
<DialogContent>
|
|
<div class="p-5 text-center">
|
|
<div class="mt-5 text-2xl font-medium">Hapus had ini?</div>
|
|
<div class="mt-2 opacity-70">
|
|
{{ limitToDelete?.investment_type?.name ?? 'Had profil' }} akan dipadam.
|
|
</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="confirmDeleteLimit"
|
|
>
|
|
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</DialogRoot>
|
|
</div>
|
|
</template>
|