Files
mykopkb-fe/src/modules/investment/pages/InvestmentLimitProfileDetail.vue
T

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>