DONE: admin frontend for investment limit engine, salary deduction and contribution; WIP: dashboard for members
This commit is contained in:
@@ -9,7 +9,8 @@ export const selectIndicator =
|
||||
"ms-auto transition data-[state=open]:rotate-180";
|
||||
export const selectClearTrigger =
|
||||
"text-danger/90 cursor-pointer text-xs hidden ms-1";
|
||||
export const selectPositioner = "!z-50 !w-auto";
|
||||
/* Above dialog/sheet (z-70) so portaled menus remain visible inside modals */
|
||||
export const selectPositioner = "!z-80 !w-auto";
|
||||
export const selectContent =
|
||||
"min-w-(--reference-width) p-0 z-(--layer-index) [&>div]:p-4 [&>div]:max-h-78 [&>div]:overflow-y-auto [&>div]:flex [&>div]:flex-col [&>div]:gap-5";
|
||||
export const selectItemGroup = "flex flex-col gap-3";
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listUsers } from '@/modules/user/services/user.service'
|
||||
import type { UserListItem } from '@/modules/user/types/user.types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
selectedLabel?: string
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
select: [member: UserListItem]
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const memberSearch = ref(props.selectedLabel ?? '')
|
||||
const memberResults = ref<UserListItem[]>([])
|
||||
const searchingMembers = ref(false)
|
||||
const memberSearchError = ref<string | null>(null)
|
||||
const hasSelection = ref(Boolean(props.modelValue))
|
||||
|
||||
watch(
|
||||
() => props.selectedLabel,
|
||||
(value) => {
|
||||
if (value) {
|
||||
memberSearch.value = value
|
||||
hasSelection.value = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function searchMembers() {
|
||||
const query = memberSearch.value.trim()
|
||||
if (query.length < 2 || hasSelection.value) {
|
||||
memberResults.value = []
|
||||
memberSearchError.value = null
|
||||
return
|
||||
}
|
||||
|
||||
searchingMembers.value = true
|
||||
memberSearchError.value = null
|
||||
|
||||
try {
|
||||
const data = await listUsers({
|
||||
page: 1,
|
||||
per_page: 8,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
search: query,
|
||||
status: 'active',
|
||||
})
|
||||
memberResults.value = data.data
|
||||
} catch (err) {
|
||||
memberSearchError.value = getApiErrorMessage(err, 'Gagal mencari ahli.')
|
||||
memberResults.value = []
|
||||
} finally {
|
||||
searchingMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedMemberSearch = debounce(() => {
|
||||
searchMembers()
|
||||
}, 350)
|
||||
|
||||
watch(memberSearch, () => {
|
||||
if (hasSelection.value) return
|
||||
debouncedMemberSearch()
|
||||
})
|
||||
|
||||
function selectMember(member: UserListItem) {
|
||||
hasSelection.value = true
|
||||
memberSearch.value = `${member.name} (${member.ic_number})`
|
||||
memberResults.value = []
|
||||
memberSearchError.value = null
|
||||
emit('update:modelValue', member.id)
|
||||
emit('select', member)
|
||||
}
|
||||
|
||||
function clearMember() {
|
||||
hasSelection.value = false
|
||||
memberSearch.value = ''
|
||||
memberResults.value = []
|
||||
emit('update:modelValue', '')
|
||||
emit('clear')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Field>
|
||||
<FieldLabel :required="required">{{ label ?? 'Ahli' }}</FieldLabel>
|
||||
<div class="relative">
|
||||
<Input
|
||||
v-model="memberSearch"
|
||||
type="search"
|
||||
placeholder="Cari nama, e-mel atau no. KP..."
|
||||
:disabled="disabled || hasSelection"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
v-if="hasSelection"
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 px-3 text-sm opacity-70 hover:opacity-100"
|
||||
:disabled="disabled"
|
||||
@click="clearMember"
|
||||
>
|
||||
Tukar
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="searchingMembers" class="mt-1 text-xs opacity-60">Mencari ahli...</p>
|
||||
<p v-if="memberSearchError" class="mt-1 text-xs text-danger">{{ memberSearchError }}</p>
|
||||
|
||||
<div
|
||||
v-if="memberResults.length && !hasSelection"
|
||||
class="mt-2 max-h-56 overflow-y-auto rounded-md border border-foreground/10"
|
||||
>
|
||||
<button
|
||||
v-for="member in memberResults"
|
||||
:key="member.id"
|
||||
type="button"
|
||||
class="flex w-full flex-col items-start px-3 py-2 text-left text-sm hover:bg-foreground/5"
|
||||
@click="selectMember(member)"
|
||||
>
|
||||
<span class="font-medium">{{ member.name }}</span>
|
||||
<span class="text-xs opacity-60">
|
||||
{{ member.ic_number }}
|
||||
<span v-if="member.member_number"> · No. {{ member.member_number }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</template>
|
||||
@@ -0,0 +1,273 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import MemberSearchField from './MemberSearchField.vue'
|
||||
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||
import type { InvestmentType } from '../types/investment-type.types'
|
||||
import type {
|
||||
SalaryContribution,
|
||||
SalaryContributionPayload,
|
||||
SalaryContributionStatus,
|
||||
} from '../types/salary.types'
|
||||
import { SALARY_CONTRIBUTION_STATUS_OPTIONS } from '../utils/salary.utils'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const props = defineProps<{
|
||||
initial?: SalaryContribution | null
|
||||
saving?: boolean
|
||||
submitLabel?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: SalaryContributionPayload]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
user_id: '',
|
||||
investment_type_id: '',
|
||||
monthly_amount: '',
|
||||
start_date: dayjs().format('YYYY-MM-DD'),
|
||||
end_date: '',
|
||||
status: 'ACTIVE' as SalaryContributionStatus,
|
||||
})
|
||||
|
||||
const investmentTypes = ref<InvestmentType[]>([])
|
||||
const loadingTypes = ref(false)
|
||||
const typesError = ref<string | null>(null)
|
||||
const selectedMemberLabel = ref('')
|
||||
|
||||
const isEdit = computed(() => Boolean(props.initial))
|
||||
|
||||
const typeOptions = computed<SelectOption[]>(() =>
|
||||
investmentTypes.value
|
||||
.filter((type) => type.status === 'ACTIVE' && type.allow_salary_deduction)
|
||||
.map((type) => ({
|
||||
label: `${type.code} — ${type.name}`,
|
||||
value: type.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const typeCollection = computed(() =>
|
||||
select.collection({
|
||||
items: typeOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeInitial = computed(() => {
|
||||
const option = typeOptions.value.find((item) => item.value === form.investment_type_id)
|
||||
return option ? [option.label] : []
|
||||
})
|
||||
|
||||
const statusCollection = select.collection({
|
||||
items: SALARY_CONTRIBUTION_STATUS_OPTIONS as SelectOption[],
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
|
||||
const statusInitial = computed(() => {
|
||||
const option = SALARY_CONTRIBUTION_STATUS_OPTIONS.find((item) => item.value === form.status)
|
||||
return option ? [option.label] : ['Aktif']
|
||||
})
|
||||
|
||||
function setTypeValue(details: { value: string[] }) {
|
||||
form.investment_type_id =
|
||||
typeOptions.value.find((item) => item.label === details.value[0])?.value ?? ''
|
||||
}
|
||||
|
||||
function setStatusValue(details: { value: string[] }) {
|
||||
form.status =
|
||||
SALARY_CONTRIBUTION_STATUS_OPTIONS.find((item) => item.label === details.value[0])?.value
|
||||
?? 'ACTIVE'
|
||||
}
|
||||
|
||||
function syncFromInitial(item: SalaryContribution | null | undefined) {
|
||||
if (!item) return
|
||||
|
||||
form.user_id = item.user_id
|
||||
form.investment_type_id = item.investment_type_id
|
||||
form.monthly_amount = String(item.monthly_amount)
|
||||
form.start_date = item.start_date
|
||||
form.end_date = item.end_date ?? ''
|
||||
form.status = item.status
|
||||
selectedMemberLabel.value = item.user
|
||||
? `${item.user.name}${item.user.ic_number ? ` (${item.user.ic_number})` : ''}`
|
||||
: ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initial,
|
||||
(value) => {
|
||||
syncFromInitial(value)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadInvestmentTypes() {
|
||||
loadingTypes.value = true
|
||||
typesError.value = null
|
||||
|
||||
try {
|
||||
const data = await listInvestmentTypes({
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
})
|
||||
investmentTypes.value = data.data
|
||||
} catch (err) {
|
||||
typesError.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
|
||||
investmentTypes.value = []
|
||||
} finally {
|
||||
loadingTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const amount = Number(form.monthly_amount)
|
||||
emit('submit', {
|
||||
user_id: form.user_id,
|
||||
investment_type_id: form.investment_type_id,
|
||||
monthly_amount: Number.isNaN(amount) ? 0 : amount,
|
||||
start_date: form.start_date,
|
||||
end_date: form.end_date || null,
|
||||
status: form.status,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadInvestmentTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<MemberSearchField
|
||||
v-model="form.user_id"
|
||||
:selected-label="selectedMemberLabel"
|
||||
:disabled="saving || isEdit"
|
||||
required
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
||||
<p v-if="loadingTypes" class="text-sm opacity-60">Memuatkan jenis pelaburan...</p>
|
||||
<SelectRoot
|
||||
v-else-if="typeOptions.length"
|
||||
:key="`${form.investment_type_id}-${typeOptions.map((item) => item.value).join(',')}`"
|
||||
class="w-full"
|
||||
:collection="typeCollection"
|
||||
:default-value="typeInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih jenis pelaburan">
|
||||
<SelectValueText placeholder="Pilih jenis (benarkan potongan gaji)" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in typeCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<p v-if="typesError" class="mt-1 text-xs text-danger">{{ typesError }}</p>
|
||||
<p v-else-if="!loadingTypes && !typeOptions.length" class="mt-1 text-xs opacity-60">
|
||||
Tiada jenis pelaburan aktif yang membenarkan potongan gaji.
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Jumlah Bulanan (MYR)</FieldLabel>
|
||||
<Input
|
||||
v-model="form.monthly_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel required>Tarikh Mula</FieldLabel>
|
||||
<Input v-model="form.start_date" type="date" :disabled="saving" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Tarikh Tamat</FieldLabel>
|
||||
<Input v-model="form.end_date" type="date" :disabled="saving" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Status</FieldLabel>
|
||||
<SelectRoot
|
||||
class="w-full"
|
||||
:collection="statusCollection"
|
||||
:default-value="statusInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setStatusValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih status">
|
||||
<SelectValueText placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in statusCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="emit('cancel')">
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
:disabled="
|
||||
saving ||
|
||||
!form.user_id ||
|
||||
!form.investment_type_id ||
|
||||
!form.monthly_amount ||
|
||||
Number(form.monthly_amount) <= 0 ||
|
||||
!form.start_date
|
||||
"
|
||||
>
|
||||
{{ saving ? 'Menyimpan...' : (submitLabel ?? 'Simpan') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import MemberSearchField from './MemberSearchField.vue'
|
||||
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||
import type { UserInvestmentLimit, UserInvestmentLimitPayload } from '../types/investment-limit.types'
|
||||
import type { InvestmentType } from '../types/investment-type.types'
|
||||
import { toOptionalNumber } from '../utils/investment-type.utils'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const props = defineProps<{
|
||||
initial?: UserInvestmentLimit | null
|
||||
saving?: boolean
|
||||
submitLabel?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: UserInvestmentLimitPayload]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
user_id: '',
|
||||
investment_type_id: '',
|
||||
max_investment_amount: '',
|
||||
max_transaction_amount: '',
|
||||
effective_date: dayjs().format('YYYY-MM-DD'),
|
||||
expiry_date: '',
|
||||
})
|
||||
|
||||
const investmentTypes = ref<InvestmentType[]>([])
|
||||
const loadingTypes = ref(false)
|
||||
const typesError = ref<string | null>(null)
|
||||
const selectedMemberLabel = ref('')
|
||||
|
||||
const isEdit = computed(() => Boolean(props.initial))
|
||||
|
||||
const typeOptions = computed<SelectOption[]>(() =>
|
||||
investmentTypes.value
|
||||
.filter((type) => type.status === 'ACTIVE')
|
||||
.map((type) => ({
|
||||
label: `${type.code} — ${type.name}`,
|
||||
value: type.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const typeCollection = computed(() =>
|
||||
select.collection({
|
||||
items: typeOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeInitial = computed(() => {
|
||||
const option = typeOptions.value.find((item) => item.value === form.investment_type_id)
|
||||
return option ? [option.label] : []
|
||||
})
|
||||
|
||||
function setTypeValue(details: { value: string[] }) {
|
||||
const label = details.value[0]
|
||||
form.investment_type_id =
|
||||
typeOptions.value.find((item) => item.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function syncFromInitial(item: UserInvestmentLimit | null | undefined) {
|
||||
if (!item) return
|
||||
|
||||
form.user_id = item.user_id
|
||||
form.investment_type_id = item.investment_type_id
|
||||
form.max_investment_amount =
|
||||
item.max_investment_amount == null ? '' : String(item.max_investment_amount)
|
||||
form.max_transaction_amount =
|
||||
item.max_transaction_amount == null ? '' : String(item.max_transaction_amount)
|
||||
form.effective_date = item.effective_date
|
||||
form.expiry_date = item.expiry_date ?? ''
|
||||
selectedMemberLabel.value = item.user
|
||||
? `${item.user.name}${item.user.ic_number ? ` (${item.user.ic_number})` : ''}`
|
||||
: ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initial,
|
||||
(value) => {
|
||||
syncFromInitial(value)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadInvestmentTypes() {
|
||||
loadingTypes.value = true
|
||||
typesError.value = null
|
||||
|
||||
try {
|
||||
const data = await listInvestmentTypes({
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
})
|
||||
investmentTypes.value = data.data
|
||||
} catch (err) {
|
||||
typesError.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
|
||||
investmentTypes.value = []
|
||||
} finally {
|
||||
loadingTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
emit('submit', {
|
||||
user_id: form.user_id,
|
||||
investment_type_id: form.investment_type_id,
|
||||
max_investment_amount: toOptionalNumber(form.max_investment_amount),
|
||||
max_transaction_amount: toOptionalNumber(form.max_transaction_amount),
|
||||
effective_date: form.effective_date,
|
||||
expiry_date: form.expiry_date || null,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadInvestmentTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<MemberSearchField
|
||||
v-model="form.user_id"
|
||||
:selected-label="selectedMemberLabel"
|
||||
:disabled="saving || isEdit"
|
||||
required
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
||||
<p v-if="loadingTypes" class="text-sm opacity-60">Memuatkan jenis pelaburan...</p>
|
||||
<SelectRoot
|
||||
v-else-if="typeOptions.length"
|
||||
:key="`${form.investment_type_id}-${typeOptions.map((item) => item.value).join(',')}`"
|
||||
class="w-full"
|
||||
:collection="typeCollection"
|
||||
:default-value="typeInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih jenis pelaburan">
|
||||
<SelectValueText placeholder="Pilih jenis pelaburan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in typeCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<p v-if="typesError" class="mt-1 text-xs text-danger">{{ typesError }}</p>
|
||||
<p v-else-if="!loadingTypes && !typeOptions.length" class="mt-1 text-xs opacity-60">
|
||||
Tiada jenis pelaburan aktif.
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Had Kumulatif (MYR)</FieldLabel>
|
||||
<Input
|
||||
v-model="form.max_investment_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
:disabled="saving"
|
||||
placeholder="Kosong = tiada had"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Had Transaksi (MYR)</FieldLabel>
|
||||
<Input
|
||||
v-model="form.max_transaction_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
:disabled="saving"
|
||||
placeholder="Kosong = tiada had"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel required>Tarikh Berkuat Kuasa</FieldLabel>
|
||||
<Input v-model="form.effective_date" type="date" :disabled="saving" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Tarikh Tamat</FieldLabel>
|
||||
<Input v-model="form.expiry_date" type="date" :disabled="saving" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="emit('cancel')">
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
:disabled="
|
||||
saving ||
|
||||
!form.user_id ||
|
||||
!form.investment_type_id ||
|
||||
!form.effective_date
|
||||
"
|
||||
>
|
||||
{{ saving ? 'Menyimpan...' : (submitLabel ?? 'Simpan') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listInvestmentLimitProfiles } from '../services/investment-limit.service'
|
||||
import type { InvestmentLimitProfile } from '../types/investment-limit.types'
|
||||
|
||||
export function useInvestmentLimitProfileList() {
|
||||
const items = ref<InvestmentLimitProfile[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchItems(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const activeSort = sortBy.value[0]
|
||||
const data = await listInvestmentLimitProfiles({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: activeSort?.key ?? 'name',
|
||||
sort_order: activeSort?.order ?? 'asc',
|
||||
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 profil had pelaburan.')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
fetchItems(1)
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchItems(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchItems(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchItems(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listSalaryContributions } from '../services/salary.service'
|
||||
import type {
|
||||
SalaryContribution,
|
||||
SalaryContributionStatus,
|
||||
} from '../types/salary.types'
|
||||
|
||||
export function useSalaryContributionList() {
|
||||
const items = ref<SalaryContribution[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const statusFilter = ref<SalaryContributionStatus | ''>('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'created_at', order: 'desc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchItems(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const activeSort = sortBy.value[0]
|
||||
const data = await listSalaryContributions({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: activeSort?.key ?? 'created_at',
|
||||
sort_order: activeSort?.order ?? 'desc',
|
||||
search: search.value.trim() || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
})
|
||||
|
||||
items.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan caruman potongan gaji.')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
fetchItems(1)
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchItems(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch(statusFilter, () => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchItems(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchItems(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listSalaryDeductionBatches } from '../services/salary.service'
|
||||
import type {
|
||||
SalaryDeductionBatch,
|
||||
SalaryDeductionBatchStatus,
|
||||
} from '../types/salary.types'
|
||||
|
||||
export function useSalaryDeductionBatchList() {
|
||||
const items = ref<SalaryDeductionBatch[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const statusFilter = ref<SalaryDeductionBatchStatus | ''>('')
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchItems(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await listSalaryDeductionBatches({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
status: statusFilter.value || undefined,
|
||||
})
|
||||
|
||||
items.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan batch potongan gaji.')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(statusFilter, () => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchItems(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchItems(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
statusFilter,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
fetchItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listUserInvestmentLimits } from '../services/investment-limit.service'
|
||||
import type { UserInvestmentLimit } from '../types/investment-limit.types'
|
||||
|
||||
export function useUserInvestmentLimitList() {
|
||||
const items = ref<UserInvestmentLimit[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'created_at', order: 'desc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchItems(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const activeSort = sortBy.value[0]
|
||||
const data = await listUserInvestmentLimits({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: activeSort?.key ?? 'created_at',
|
||||
sort_order: activeSort?.order ?? 'desc',
|
||||
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 had pelaburan ahli.')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
fetchItems(1)
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchItems(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchItems(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchItems(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listUserLimitProfiles } from '../services/investment-limit.service'
|
||||
import type { UserLimitProfile } from '../types/investment-limit.types'
|
||||
|
||||
export function useUserLimitProfileList() {
|
||||
const items = ref<UserLimitProfile[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const activeOnly = ref(true)
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchItems(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await listUserLimitProfiles({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
active_only: activeOnly.value ? 1 : 0,
|
||||
})
|
||||
|
||||
items.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan tugasan profil had.')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeOnly, () => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchItems(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchItems(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems(1)
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
activeOnly,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
fetchItems,
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,34 @@ export const investmentMenu: Menu[] = [
|
||||
title: 'Jenis Pelaburan',
|
||||
permission: 'lihat jenis pelaburan',
|
||||
},
|
||||
{
|
||||
icon: 'Shield',
|
||||
route_name: 'list-investment-limit-profiles',
|
||||
title: 'Profil Had Pelaburan',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
{
|
||||
icon: 'UserCog',
|
||||
route_name: 'list-user-limit-profiles',
|
||||
title: 'Tugasan Profil Had',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
{
|
||||
icon: 'SlidersHorizontal',
|
||||
route_name: 'list-user-investment-limits',
|
||||
title: 'Had Pelaburan Ahli',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
{
|
||||
icon: 'Banknote',
|
||||
route_name: 'list-salary-contributions',
|
||||
title: 'Caruman Potongan Gaji',
|
||||
permission: 'lihat potongan gaji',
|
||||
},
|
||||
{
|
||||
icon: 'ClipboardList',
|
||||
route_name: 'list-salary-deduction-batches',
|
||||
title: 'Batch Potongan Gaji',
|
||||
permission: 'lihat potongan gaji',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts" setup>
|
||||
import { reactive, 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 { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { createInvestmentLimitProfile } from '../services/investment-limit.service'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const profile = await createInvestmentLimitProfile({
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
})
|
||||
router.push({ name: 'view-investment-limit-profile', params: { id: profile.id } })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menambah profil had.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-investment-limit-profiles' })
|
||||
}
|
||||
</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 Profil Had</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>
|
||||
|
||||
<Box class="p-6">
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<Field>
|
||||
<FieldLabel required>Nama</FieldLabel>
|
||||
<Input v-model="form.name" :disabled="saving" placeholder="cth. Ahli Biasa" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Keterangan</FieldLabel>
|
||||
<Textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
:disabled="saving"
|
||||
placeholder="Keterangan pilihan..."
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="saving || !form.name.trim()">
|
||||
{{ saving ? 'Menyimpan...' : 'Tambah & Urus Had' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,512 @@
|
||||
<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>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Eye, Trash2 } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { useInvestmentLimitProfileList } from '../composables/useInvestmentLimitProfileList'
|
||||
import { deleteInvestmentLimitProfile } from '../services/investment-limit.service'
|
||||
import type { InvestmentLimitProfile } from '../types/investment-limit.types'
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canCreate = computed(() => hasPermission('tambah had pelaburan'))
|
||||
const canView = computed(() => hasPermission('lihat had pelaburan'))
|
||||
const canDelete = computed(() => hasPermission('hapus had pelaburan'))
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const itemToDelete = ref<InvestmentLimitProfile | null>(null)
|
||||
const deleting = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
} = useInvestmentLimitProfileList()
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Nama', key: 'name', sortable: true },
|
||||
{ title: 'Keterangan', key: 'description', sortable: false },
|
||||
{ title: 'Bil. Had', key: 'profile_limits_count', sortable: false },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
function goToCreate() {
|
||||
router.push({ name: 'create-investment-limit-profile' })
|
||||
}
|
||||
|
||||
function goToDetail(id: string) {
|
||||
router.push({ name: 'view-investment-limit-profile', params: { id } })
|
||||
}
|
||||
|
||||
function openDelete(item: InvestmentLimitProfile) {
|
||||
itemToDelete.value = item
|
||||
deleteError.value = null
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!itemToDelete.value || deleting.value) return
|
||||
|
||||
deleting.value = true
|
||||
deleteError.value = null
|
||||
|
||||
try {
|
||||
await deleteInvestmentLimitProfile(itemToDelete.value.id)
|
||||
deleteOpen.value = false
|
||||
itemToDelete.value = null
|
||||
await fetchItems(page.value)
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus profil had.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Profil Had Pelaburan</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Tetapkan had kumulatif dan had transaksi mengikut kumpulan ahli.
|
||||
</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 Profil
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5">
|
||||
<DataTable
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:current-sort="sortBy"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="profil-had-pelaburan"
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate"
|
||||
>
|
||||
<template #toolbar>
|
||||
<Input
|
||||
v-model="search"
|
||||
class="w-full max-w-md"
|
||||
type="search"
|
||||
placeholder="Cari nama atau keterangan..."
|
||||
aria-label="Cari profil had"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item.description="{ item }">
|
||||
<span class="line-clamp-2">
|
||||
{{ (item as InvestmentLimitProfile).description || '-' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #item.profile_limits_count="{ item }">
|
||||
{{ (item as InvestmentLimitProfile).profile_limits_count ?? 0 }}
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="canView"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-green-600 text-white"
|
||||
title="Lihat / urus"
|
||||
@click="goToDetail((item as InvestmentLimitProfile).id)"
|
||||
>
|
||||
<Eye class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-danger text-white"
|
||||
title="Hapus"
|
||||
@click="openDelete(item as InvestmentLimitProfile)"
|
||||
>
|
||||
<Trash2 class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<DialogRoot :open="deleteOpen" @openChange="(details) => (deleteOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-5 text-2xl font-medium">Hapus profil had?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Hapus
|
||||
<span v-if="itemToDelete" class="font-medium">{{ itemToDelete.name }}</span>?
|
||||
Profil yang masih ditugaskan kepada ahli tidak boleh 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="confirmDelete"
|
||||
>
|
||||
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<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 SalaryContributionForm from '../components/SalaryContributionForm.vue'
|
||||
import { createSalaryContribution } from '../services/salary.service'
|
||||
import type { SalaryContributionPayload } from '../types/salary.types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function handleSubmit(payload: SalaryContributionPayload) {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await createSalaryContribution(payload)
|
||||
router.push({ name: 'list-salary-contributions' })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menambah caruman potongan gaji.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-salary-contributions' })
|
||||
}
|
||||
</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">Tambah Caruman Potongan Gaji</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Tetapkan arahan potongan bulanan untuk ahli.</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box class="p-6">
|
||||
<SalaryContributionForm
|
||||
:saving="saving"
|
||||
submit-label="Tambah Caruman"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts" setup>
|
||||
import { 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 SalaryContributionForm from '../components/SalaryContributionForm.vue'
|
||||
import {
|
||||
getSalaryContribution,
|
||||
updateSalaryContribution,
|
||||
} from '../services/salary.service'
|
||||
import type {
|
||||
SalaryContribution,
|
||||
SalaryContributionPayload,
|
||||
} from '../types/salary.types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const item = ref<SalaryContribution | null>(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function loadItem() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
item.value = await getSalaryContribution(String(route.params.id))
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan caruman.')
|
||||
item.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: SalaryContributionPayload) {
|
||||
if (!item.value) return
|
||||
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await updateSalaryContribution(item.value.id, payload)
|
||||
router.push({ name: 'list-salary-contributions' })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemaskini caruman.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-salary-contributions' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItem()
|
||||
})
|
||||
</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 Caruman Potongan Gaji</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>
|
||||
|
||||
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||
|
||||
<Box v-else-if="item" class="p-6">
|
||||
<SalaryContributionForm
|
||||
:initial="item"
|
||||
:saving="saving"
|
||||
submit-label="Kemaskini"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,315 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Pencil, Trash2 } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { useSalaryContributionList } from '../composables/useSalaryContributionList'
|
||||
import { deleteSalaryContribution } from '../services/salary.service'
|
||||
import type { SalaryContribution, SalaryContributionStatus } from '../types/salary.types'
|
||||
import {
|
||||
SALARY_CONTRIBUTION_STATUS_OPTIONS,
|
||||
formatInvestmentAmount,
|
||||
salaryContributionStatusLabel,
|
||||
salaryContributionStatusVariant,
|
||||
} from '../utils/salary.utils'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const STATUS_FILTER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Semua Status', value: '' },
|
||||
...SALARY_CONTRIBUTION_STATUS_OPTIONS,
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [options[0]?.label ?? '']
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canCreate = computed(() => hasPermission('tambah potongan gaji'))
|
||||
const canUpdate = computed(() => hasPermission('kemaskini potongan gaji'))
|
||||
const canDelete = computed(() => hasPermission('hapus potongan gaji'))
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const itemToDelete = ref<SalaryContribution | null>(null)
|
||||
const deleting = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
} = useSalaryContributionList()
|
||||
|
||||
const statusCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
|
||||
const statusInitial = computed(() => valueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Ahli', key: 'user', sortable: false },
|
||||
{ title: 'Jenis', key: 'investment_type', sortable: false },
|
||||
{ title: 'Jumlah Bulanan', key: 'monthly_amount', sortable: true },
|
||||
{ title: 'Mula', key: 'start_date', sortable: true },
|
||||
{ title: 'Tamat', key: 'end_date', sortable: true },
|
||||
{ title: 'Status', key: 'status', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
function setStatusFilter(details: { value: string[] }) {
|
||||
statusFilter.value = labelToValue(
|
||||
STATUS_FILTER_OPTIONS,
|
||||
details.value[0],
|
||||
) as SalaryContributionStatus | ''
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY')
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push({ name: 'create-salary-contribution' })
|
||||
}
|
||||
|
||||
function goToEdit(id: string) {
|
||||
router.push({ name: 'edit-salary-contribution', params: { id } })
|
||||
}
|
||||
|
||||
function openDelete(item: SalaryContribution) {
|
||||
itemToDelete.value = item
|
||||
deleteError.value = null
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!itemToDelete.value || deleting.value) return
|
||||
|
||||
deleting.value = true
|
||||
deleteError.value = null
|
||||
|
||||
try {
|
||||
await deleteSalaryContribution(itemToDelete.value.id)
|
||||
deleteOpen.value = false
|
||||
itemToDelete.value = null
|
||||
await fetchItems(page.value)
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus caruman.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Caruman Potongan Gaji</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Arahan potongan bulanan ahli. Digunakan untuk menjana batch bulanan.
|
||||
</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 Caruman
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5">
|
||||
<DataTable
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:current-sort="sortBy"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="caruman-potongan-gaji"
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="flex w-full flex-wrap items-center gap-3">
|
||||
<Input
|
||||
v-model="search"
|
||||
class="w-full max-w-md"
|
||||
type="search"
|
||||
placeholder="Cari ahli atau jenis..."
|
||||
/>
|
||||
<SelectRoot
|
||||
class="w-full sm:w-52"
|
||||
:collection="statusCollection"
|
||||
:default-value="statusInitial"
|
||||
@value-change="setStatusFilter"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis status">
|
||||
<SelectValueText placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in statusCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.user="{ item }">
|
||||
<div>{{ (item as SalaryContribution).user?.name ?? '-' }}</div>
|
||||
<div class="mt-0.5 text-xs opacity-60">
|
||||
{{ (item as SalaryContribution).user?.ic_number
|
||||
|| (item as SalaryContribution).user?.email
|
||||
|| '' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.investment_type="{ item }">
|
||||
<div>{{ (item as SalaryContribution).investment_type?.name ?? '-' }}</div>
|
||||
<div class="mt-0.5 text-xs uppercase opacity-60">
|
||||
{{ (item as SalaryContribution).investment_type?.code ?? '' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.monthly_amount="{ item }">
|
||||
{{ formatInvestmentAmount((item as SalaryContribution).monthly_amount) }}
|
||||
</template>
|
||||
|
||||
<template #item.start_date="{ item }">
|
||||
{{ formatDate((item as SalaryContribution).start_date) }}
|
||||
</template>
|
||||
|
||||
<template #item.end_date="{ item }">
|
||||
{{ formatDate((item as SalaryContribution).end_date) }}
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge
|
||||
look="outline"
|
||||
:variant="salaryContributionStatusVariant((item as SalaryContribution).status)"
|
||||
>
|
||||
{{ salaryContributionStatusLabel((item as SalaryContribution).status) }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="canUpdate"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Edit"
|
||||
@click="goToEdit((item as SalaryContribution).id)"
|
||||
>
|
||||
<Pencil class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-danger text-white"
|
||||
title="Hapus"
|
||||
@click="openDelete(item as SalaryContribution)"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<DialogRoot :open="deleteOpen" @openChange="(details) => (deleteOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-5 text-2xl font-medium">Hapus caruman?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Caruman
|
||||
<span v-if="itemToDelete" class="font-medium">
|
||||
{{ itemToDelete.user?.name }} / {{ itemToDelete.investment_type?.name }}
|
||||
</span>
|
||||
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="confirmDelete"
|
||||
>
|
||||
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,482 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { 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 { 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 { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import MemberSearchField from '../components/MemberSearchField.vue'
|
||||
import { listInvestmentTypes } from '../services/investment-type.service'
|
||||
import {
|
||||
generateSalaryDeductionBatch,
|
||||
uploadSalaryDeductionBatch,
|
||||
} from '../services/salary.service'
|
||||
import type { InvestmentType } from '../types/investment-type.types'
|
||||
import type { UserListItem } from '@/modules/user/types/user.types'
|
||||
import { MONTH_OPTIONS, formatInvestmentAmount } from '../utils/salary.utils'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
type Mode = 'generate' | 'manual'
|
||||
|
||||
type ManualDetail = {
|
||||
key: string
|
||||
user_id: string
|
||||
user_label: string
|
||||
investment_type_id: string
|
||||
amount: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const mode = ref<Mode>('generate')
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
month: dayjs().month() + 1,
|
||||
year: dayjs().year(),
|
||||
})
|
||||
|
||||
const investmentTypes = ref<InvestmentType[]>([])
|
||||
const loadingTypes = ref(false)
|
||||
|
||||
const draftDetail = reactive({
|
||||
user_id: '',
|
||||
investment_type_id: '',
|
||||
amount: '',
|
||||
})
|
||||
const draftMemberLabel = ref('')
|
||||
const draftMemberKey = ref(0)
|
||||
const details = ref<ManualDetail[]>([])
|
||||
|
||||
const yearOptions = computed(() => {
|
||||
const current = dayjs().year()
|
||||
return Array.from({ length: 6 }, (_, index) => {
|
||||
const year = current - 2 + index
|
||||
return { label: String(year), value: String(year) }
|
||||
})
|
||||
})
|
||||
|
||||
const monthOptions = computed<SelectOption[]>(() =>
|
||||
MONTH_OPTIONS.map((item) => ({ label: item.label, value: String(item.value) })),
|
||||
)
|
||||
|
||||
const monthCollection = computed(() =>
|
||||
select.collection({
|
||||
items: monthOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const yearCollection = computed(() =>
|
||||
select.collection({
|
||||
items: yearOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const monthInitial = computed(() => {
|
||||
const option = monthOptions.value.find((item) => item.value === String(form.month))
|
||||
return option ? [option.label] : []
|
||||
})
|
||||
|
||||
const yearInitial = computed(() => {
|
||||
const option = yearOptions.value.find((item) => item.value === String(form.year))
|
||||
return option ? [option.label] : []
|
||||
})
|
||||
|
||||
const typeOptions = computed<SelectOption[]>(() =>
|
||||
investmentTypes.value
|
||||
.filter((type) => type.status === 'ACTIVE' && type.allow_salary_deduction)
|
||||
.map((type) => ({
|
||||
label: `${type.code} — ${type.name}`,
|
||||
value: type.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const typeCollection = computed(() =>
|
||||
select.collection({
|
||||
items: typeOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const totalAmount = computed(() =>
|
||||
details.value.reduce((sum, detail) => sum + (Number(detail.amount) || 0), 0),
|
||||
)
|
||||
|
||||
function setMonthValue(event: { value: string[] }) {
|
||||
form.month = Number(
|
||||
monthOptions.value.find((item) => item.label === event.value[0])?.value ?? form.month,
|
||||
)
|
||||
}
|
||||
|
||||
function setYearValue(event: { value: string[] }) {
|
||||
form.year = Number(
|
||||
yearOptions.value.find((item) => item.label === event.value[0])?.value ?? form.year,
|
||||
)
|
||||
}
|
||||
|
||||
function setDraftTypeValue(event: { value: string[] }) {
|
||||
draftDetail.investment_type_id =
|
||||
typeOptions.value.find((item) => item.label === event.value[0])?.value ?? ''
|
||||
}
|
||||
|
||||
function onMemberSelect(member: UserListItem) {
|
||||
draftDetail.user_id = member.id
|
||||
draftMemberLabel.value = `${member.name} (${member.ic_number})`
|
||||
}
|
||||
|
||||
function clearDraftMember() {
|
||||
draftDetail.user_id = ''
|
||||
draftMemberLabel.value = ''
|
||||
}
|
||||
|
||||
function addDetail() {
|
||||
if (!draftDetail.user_id || !draftDetail.investment_type_id || Number(draftDetail.amount) <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
details.value.push({
|
||||
key: `${Date.now()}-${details.value.length}`,
|
||||
user_id: draftDetail.user_id,
|
||||
user_label: draftMemberLabel.value || draftDetail.user_id,
|
||||
investment_type_id: draftDetail.investment_type_id,
|
||||
amount: draftDetail.amount,
|
||||
})
|
||||
|
||||
draftDetail.user_id = ''
|
||||
draftDetail.investment_type_id = ''
|
||||
draftDetail.amount = ''
|
||||
draftMemberLabel.value = ''
|
||||
draftMemberKey.value += 1
|
||||
}
|
||||
|
||||
function removeDetail(key: string) {
|
||||
details.value = details.value.filter((detail) => detail.key !== key)
|
||||
}
|
||||
|
||||
function typeLabel(typeId: string): string {
|
||||
return typeOptions.value.find((item) => item.value === typeId)?.label ?? typeId
|
||||
}
|
||||
|
||||
async function loadTypes() {
|
||||
loadingTypes.value = true
|
||||
try {
|
||||
const data = await listInvestmentTypes({
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
})
|
||||
investmentTypes.value = data.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan jenis pelaburan.')
|
||||
} finally {
|
||||
loadingTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const batch = await generateSalaryDeductionBatch({
|
||||
month: form.month,
|
||||
year: form.year,
|
||||
})
|
||||
router.push({ name: 'view-salary-deduction-batch', params: { id: batch.id } })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menjana batch.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!details.value.length) {
|
||||
error.value = 'Tambah sekurang-kurangnya satu rekod potongan.'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const batch = await uploadSalaryDeductionBatch({
|
||||
month: form.month,
|
||||
year: form.year,
|
||||
details: details.value.map((detail) => ({
|
||||
user_id: detail.user_id,
|
||||
investment_type_id: detail.investment_type_id,
|
||||
amount: Number(detail.amount),
|
||||
})),
|
||||
})
|
||||
router.push({ name: 'view-salary-deduction-batch', params: { id: batch.id } })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat naik batch.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-salary-deduction-batches' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTypes()
|
||||
})
|
||||
</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">Jana / Muat Naik Batch</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Jana daripada caruman aktif, atau muat naik rekod secara manual.
|
||||
</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
:variant="mode === 'generate' ? 'primary' : 'secondary'"
|
||||
@click="mode = 'generate'"
|
||||
>
|
||||
Jana dari Caruman
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
:variant="mode === 'manual' ? 'primary' : 'secondary'"
|
||||
@click="mode = 'manual'"
|
||||
>
|
||||
Muat Naik Manual
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Box class="space-y-6 p-6">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel required>Bulan</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`month-${form.month}`"
|
||||
class="w-full"
|
||||
:collection="monthCollection"
|
||||
:default-value="monthInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setMonthValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih bulan">
|
||||
<SelectValueText placeholder="Pilih bulan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in monthCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Tahun</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`year-${form.year}`"
|
||||
class="w-full"
|
||||
:collection="yearCollection"
|
||||
:default-value="yearInitial"
|
||||
:disabled="saving"
|
||||
@value-change="setYearValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih tahun">
|
||||
<SelectValueText placeholder="Pilih tahun" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in yearCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<template v-if="mode === 'generate'">
|
||||
<p class="text-sm opacity-70">
|
||||
Sistem akan menjana satu rekod untuk setiap caruman aktif yang meliputi bulan/tahun ini.
|
||||
Satu batch sahaja dibenarkan setiap tempoh.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="button" variant="primary" :disabled="saving" @click="handleGenerate">
|
||||
{{ saving ? 'Menjana...' : 'Jana Batch' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-4 rounded-md border border-foreground/10 p-4">
|
||||
<h3 class="text-sm font-medium">Tambah Rekod</h3>
|
||||
<MemberSearchField
|
||||
:key="draftMemberKey"
|
||||
v-model="draftDetail.user_id"
|
||||
required
|
||||
:disabled="saving"
|
||||
@select="onMemberSelect"
|
||||
@clear="clearDraftMember"
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Jenis Pelaburan</FieldLabel>
|
||||
<p v-if="loadingTypes" class="text-sm opacity-60">Memuatkan...</p>
|
||||
<SelectRoot
|
||||
v-else-if="typeOptions.length"
|
||||
:key="`${draftMemberKey}-${typeOptions.map((item) => item.value).join(',')}`"
|
||||
class="w-full"
|
||||
:collection="typeCollection"
|
||||
:disabled="saving"
|
||||
@value-change="setDraftTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih jenis">
|
||||
<SelectValueText placeholder="Pilih jenis pelaburan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in typeCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Jumlah (MYR)</FieldLabel>
|
||||
<Input v-model="draftDetail.amount" type="number" min="0" step="0.01" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
variant="primary"
|
||||
:disabled="
|
||||
saving ||
|
||||
!draftDetail.user_id ||
|
||||
!draftDetail.investment_type_id ||
|
||||
Number(draftDetail.amount) <= 0
|
||||
"
|
||||
@click="addDetail"
|
||||
>
|
||||
Tambah ke Senarai
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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">Ahli</th>
|
||||
<th class="py-2 pr-4 font-medium">Jenis</th>
|
||||
<th class="py-2 pr-4 font-medium">Jumlah</th>
|
||||
<th class="py-2 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="detail in details" :key="detail.key" class="border-b border-foreground/5">
|
||||
<td class="py-2 pr-4">{{ detail.user_label }}</td>
|
||||
<td class="py-2 pr-4">{{ typeLabel(detail.investment_type_id) }}</td>
|
||||
<td class="py-2 pr-4">{{ formatInvestmentAmount(detail.amount) }}</td>
|
||||
<td class="py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-danger"
|
||||
@click="removeDetail(detail.key)"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!details.length">
|
||||
<td colspan="4" class="py-6 text-center opacity-60">Belum ada rekod ditambah.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="text-sm opacity-70">
|
||||
{{ details.length }} rekod · Jumlah {{ formatInvestmentAmount(totalAmount) }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
:disabled="saving || !details.length"
|
||||
@click="handleUpload"
|
||||
>
|
||||
{{ saving ? 'Memuat naik...' : 'Muat Naik Batch' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,378 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { Check, X } from '@lucide/vue'
|
||||
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 { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
getSalaryDeductionBatch,
|
||||
markSalaryDeductionBatchUnderReview,
|
||||
rejectSalaryDeductionBatch,
|
||||
rejectSalaryDeductionDetail,
|
||||
verifyAllSalaryDeductionBatch,
|
||||
verifySalaryDeductionDetail,
|
||||
} from '../services/salary.service'
|
||||
import type {
|
||||
SalaryDeductionBatch,
|
||||
SalaryDeductionBatchDetail,
|
||||
} from '../types/salary.types'
|
||||
import {
|
||||
formatInvestmentAmount,
|
||||
monthLabel,
|
||||
salaryBatchStatusLabel,
|
||||
salaryBatchStatusVariant,
|
||||
salaryDetailStatusLabel,
|
||||
salaryDetailStatusVariant,
|
||||
} from '../utils/salary.utils'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canVerify = computed(() => hasPermission('sahkan potongan gaji'))
|
||||
|
||||
const batch = ref<SalaryDeductionBatch | null>(null)
|
||||
const loading = ref(true)
|
||||
const acting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const actionDialogOpen = ref(false)
|
||||
const actionType = ref<'verify-detail' | 'reject-detail' | 'reject-batch' | null>(null)
|
||||
const actionDetail = ref<SalaryDeductionBatchDetail | null>(null)
|
||||
const actionRemarks = ref('')
|
||||
const actionError = ref<string | null>(null)
|
||||
|
||||
const pendingCount = computed(
|
||||
() => batch.value?.details?.filter((detail) => detail.status === 'PENDING').length ?? 0,
|
||||
)
|
||||
|
||||
const canProcessBatch = computed(
|
||||
() =>
|
||||
batch.value?.status === 'UPLOADED' || batch.value?.status === 'UNDER_REVIEW',
|
||||
)
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
async function loadBatch() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
batch.value = await getSalaryDeductionBatch(String(route.params.id))
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan batch.')
|
||||
batch.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markUnderReview() {
|
||||
if (!batch.value || acting.value) return
|
||||
|
||||
acting.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
batch.value = await markSalaryDeductionBatchUnderReview(batch.value.id)
|
||||
successMessage.value = 'Batch dihantar untuk semakan.'
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menghantar untuk semakan.')
|
||||
} finally {
|
||||
acting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAll() {
|
||||
if (!batch.value || acting.value) return
|
||||
|
||||
acting.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
batch.value = await verifyAllSalaryDeductionBatch(batch.value.id)
|
||||
successMessage.value = 'Semua rekod pending berjaya disahkan.'
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengesahkan semua rekod.')
|
||||
} finally {
|
||||
acting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openVerifyDetail(detail: SalaryDeductionBatchDetail) {
|
||||
actionType.value = 'verify-detail'
|
||||
actionDetail.value = detail
|
||||
actionRemarks.value = ''
|
||||
actionError.value = null
|
||||
actionDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openRejectDetail(detail: SalaryDeductionBatchDetail) {
|
||||
actionType.value = 'reject-detail'
|
||||
actionDetail.value = detail
|
||||
actionRemarks.value = ''
|
||||
actionError.value = null
|
||||
actionDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openRejectBatch() {
|
||||
actionType.value = 'reject-batch'
|
||||
actionDetail.value = null
|
||||
actionRemarks.value = ''
|
||||
actionError.value = null
|
||||
actionDialogOpen.value = true
|
||||
}
|
||||
|
||||
const actionTitle = computed(() => {
|
||||
if (actionType.value === 'verify-detail') return 'Sahkan rekod ini?'
|
||||
if (actionType.value === 'reject-detail') return 'Tolak rekod ini?'
|
||||
if (actionType.value === 'reject-batch') return 'Tolak keseluruhan batch?'
|
||||
return ''
|
||||
})
|
||||
|
||||
async function confirmAction() {
|
||||
if (!batch.value || !actionType.value || acting.value) return
|
||||
|
||||
acting.value = true
|
||||
actionError.value = null
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
if (actionType.value === 'verify-detail' && actionDetail.value) {
|
||||
await verifySalaryDeductionDetail(
|
||||
batch.value.id,
|
||||
actionDetail.value.id,
|
||||
actionRemarks.value.trim() || null,
|
||||
)
|
||||
successMessage.value = 'Rekod berjaya disahkan dan dihantar ke ledger.'
|
||||
} else if (actionType.value === 'reject-detail' && actionDetail.value) {
|
||||
await rejectSalaryDeductionDetail(
|
||||
batch.value.id,
|
||||
actionDetail.value.id,
|
||||
actionRemarks.value.trim() || null,
|
||||
)
|
||||
successMessage.value = 'Rekod berjaya ditolak.'
|
||||
} else if (actionType.value === 'reject-batch') {
|
||||
await rejectSalaryDeductionBatch(
|
||||
batch.value.id,
|
||||
actionRemarks.value.trim() || null,
|
||||
)
|
||||
successMessage.value = 'Batch berjaya ditolak.'
|
||||
}
|
||||
|
||||
actionDialogOpen.value = false
|
||||
await loadBatch()
|
||||
} catch (err) {
|
||||
actionError.value = getApiErrorMessage(err, 'Tindakan gagal.')
|
||||
} finally {
|
||||
acting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'list-salary-deduction-batches' })
|
||||
}
|
||||
|
||||
function goToInvestment(id: string) {
|
||||
router.push({ name: 'view-investment', params: { id } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBatch()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Butiran Batch Potongan Gaji</h2>
|
||||
<p v-if="batch" class="mt-1 text-sm opacity-70">{{ batch.batch_no }}</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="loading || acting" @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="batch">
|
||||
<Box class="space-y-4 p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm opacity-70">Tempoh</div>
|
||||
<div class="mt-1 text-xl font-medium">
|
||||
{{ monthLabel(batch.month) }} {{ batch.year }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge look="outline" :variant="salaryBatchStatusVariant(batch.status)">
|
||||
{{ salaryBatchStatusLabel(batch.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4 text-sm">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Jumlah Rekod</div>
|
||||
<div class="mt-1 font-medium">{{ batch.total_records }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Jumlah Amaun</div>
|
||||
<div class="mt-1 font-medium">{{ formatInvestmentAmount(batch.total_amount) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Dimuat Naik</div>
|
||||
<div class="mt-1">{{ formatDate(batch.uploaded_at) }}</div>
|
||||
<div v-if="batch.uploaded_by_user" class="opacity-70">
|
||||
oleh {{ batch.uploaded_by_user.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-60">Disahkan</div>
|
||||
<div class="mt-1">{{ formatDate(batch.verified_at) }}</div>
|
||||
<div v-if="batch.verified_by_user" class="opacity-70">
|
||||
oleh {{ batch.verified_by_user.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canVerify && canProcessBatch" class="flex flex-wrap gap-2 border-t border-foreground/10 pt-4">
|
||||
<Button v-if="batch.status === 'UPLOADED'" type="button" look="outline" variant="secondary" :disabled="acting"
|
||||
@click="markUnderReview">
|
||||
Sahkan Semakan
|
||||
</Button>
|
||||
<Button v-if="pendingCount > 0" type="button" variant="primary" :disabled="acting" @click="verifyAll">
|
||||
Sahkan Semua ({{ pendingCount }})
|
||||
</Button>
|
||||
<Button type="button" look="outline" variant="danger" :disabled="acting" @click="openRejectBatch">
|
||||
Tolak Semua
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box class="p-6">
|
||||
<h3 class="text-base font-medium">Rekod Potongan</h3>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-foreground/10 text-xs uppercase opacity-60">
|
||||
<tr>
|
||||
<th class="py-2 pr-4 font-medium">Ahli</th>
|
||||
<th class="py-2 pr-4 font-medium">Jenis</th>
|
||||
<th class="py-2 pr-4 font-medium">Jumlah</th>
|
||||
<th class="py-2 pr-4 font-medium">Status</th>
|
||||
<th class="py-2 pr-4 font-medium">Pelaburan</th>
|
||||
<th class="py-2 font-medium">Tindakan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="detail in batch.details" :key="detail.id" class="border-b border-foreground/5">
|
||||
<td class="py-3 pr-4">
|
||||
<div class="font-medium">{{ detail.user?.name ?? '-' }}</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{{ detail.user?.ic_number || detail.user?.email || '' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4">
|
||||
<div>{{ detail.investment_type?.name ?? '-' }}</div>
|
||||
<div class="text-xs uppercase opacity-60">
|
||||
{{ detail.investment_type?.code ?? '' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4">{{ formatInvestmentAmount(detail.amount) }}</td>
|
||||
<td class="py-3 pr-4">
|
||||
<Badge look="outline" :variant="salaryDetailStatusVariant(detail.status)">
|
||||
{{ salaryDetailStatusLabel(detail.status) }}
|
||||
</Badge>
|
||||
<div v-if="detail.remarks" class="mt-1 text-xs opacity-60">
|
||||
{{ detail.remarks }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4">
|
||||
<button v-if="detail.investment_id" type="button"
|
||||
class="text-left text-sm underline opacity-80 hover:opacity-100"
|
||||
@click="goToInvestment(detail.investment_id)">
|
||||
{{ detail.investment?.reference_no ?? 'Lihat' }}
|
||||
</button>
|
||||
<span v-else class="opacity-50">-</span>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div v-if="canVerify && canProcessBatch && detail.status === 'PENDING'"
|
||||
class="flex items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" class="bg-green-600 text-white" title="Sahkan"
|
||||
:disabled="acting" @click="openVerifyDetail(detail)">
|
||||
<Check class="size-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" class="bg-danger text-white" title="Tolak"
|
||||
:disabled="acting" @click="openRejectDetail(detail)">
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span v-else class="opacity-40">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!batch.details?.length">
|
||||
<td colspan="6" class="py-6 text-center opacity-60">Tiada rekod dalam batch.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Box>
|
||||
</template>
|
||||
|
||||
<DialogRoot :open="actionDialogOpen" @openChange="(details) => (actionDialogOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="space-y-4 p-5">
|
||||
<div class="text-xl font-medium">{{ actionTitle }}</div>
|
||||
<p v-if="actionDetail" class="text-sm opacity-70">
|
||||
{{ actionDetail.user?.name }} ·
|
||||
{{ formatInvestmentAmount(actionDetail.amount) }}
|
||||
</p>
|
||||
<p v-else-if="actionType === 'reject-batch'" class="text-sm opacity-70">
|
||||
Batch yang sudah ada rekod disahkan tidak boleh ditolak sepenuhnya.
|
||||
</p>
|
||||
|
||||
<AlertRoot v-if="actionError" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ actionError }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Catatan</FieldLabel>
|
||||
<Textarea v-model="actionRemarks" rows="3" :disabled="acting" placeholder="Catatan pilihan..." />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 px-5 pb-5">
|
||||
<DialogCloseTrigger :disabled="acting">Batal</DialogCloseTrigger>
|
||||
<Button type="button" :variant="actionType === 'verify-detail' ? 'primary' : 'danger'" :disabled="acting"
|
||||
@click="confirmAction">
|
||||
{{ acting ? 'Memproses...' : 'Sahkan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,219 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { Eye } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { useSalaryDeductionBatchList } from '../composables/useSalaryDeductionBatchList'
|
||||
import type {
|
||||
SalaryDeductionBatch,
|
||||
SalaryDeductionBatchStatus,
|
||||
} from '../types/salary.types'
|
||||
import {
|
||||
SALARY_BATCH_STATUS_OPTIONS,
|
||||
formatInvestmentAmount,
|
||||
monthLabel,
|
||||
salaryBatchStatusLabel,
|
||||
salaryBatchStatusVariant,
|
||||
} from '../utils/salary.utils'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const STATUS_FILTER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Semua Status', value: '' },
|
||||
...SALARY_BATCH_STATUS_OPTIONS,
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToValue(options: SelectOption[], label: string | undefined): string {
|
||||
if (!label) return ''
|
||||
return options.find((option) => option.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
function valueToLabel(options: SelectOption[], value: string): string[] {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [options[0]?.label ?? '']
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canCreate = computed(() => hasPermission('tambah potongan gaji'))
|
||||
const canView = computed(() => hasPermission('lihat potongan gaji'))
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
statusFilter,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
} = useSalaryDeductionBatchList()
|
||||
|
||||
const statusCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
|
||||
const statusInitial = computed(() => valueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'No. Batch', key: 'batch_no', sortable: false },
|
||||
{ title: 'Tempoh', key: 'period', sortable: false },
|
||||
{ title: 'Rekod', key: 'total_records', sortable: false },
|
||||
{ title: 'Jumlah', key: 'total_amount', sortable: false },
|
||||
{ title: 'Status', key: 'status', sortable: false },
|
||||
{ title: 'Dimuat Naik', key: 'uploaded_at', sortable: false },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
function setStatusFilter(details: { value: string[] }) {
|
||||
statusFilter.value = labelToValue(
|
||||
STATUS_FILTER_OPTIONS,
|
||||
details.value[0],
|
||||
) as SalaryDeductionBatchStatus | ''
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push({ name: 'create-salary-deduction-batch' })
|
||||
}
|
||||
|
||||
function goToDetail(id: string) {
|
||||
router.push({ name: 'view-salary-deduction-batch', params: { id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Batch Potongan Gaji</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Muat naik / jana batch bulanan, kemudian sahkan setiap rekod ke ledger.
|
||||
</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"
|
||||
>
|
||||
Jana / Muat Naik Batch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5">
|
||||
<DataTable
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="batch-potongan-gaji"
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
>
|
||||
<template #toolbar>
|
||||
<SelectRoot
|
||||
class="w-full sm:w-56"
|
||||
:collection="statusCollection"
|
||||
:default-value="statusInitial"
|
||||
@value-change="setStatusFilter"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Tapis status">
|
||||
<SelectValueText placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in statusCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</template>
|
||||
|
||||
<template #item.batch_no="{ item }">
|
||||
<div class="font-medium">{{ (item as SalaryDeductionBatch).batch_no }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.period="{ item }">
|
||||
{{ monthLabel((item as SalaryDeductionBatch).month) }}
|
||||
{{ (item as SalaryDeductionBatch).year }}
|
||||
</template>
|
||||
|
||||
<template #item.total_amount="{ item }">
|
||||
{{ formatInvestmentAmount((item as SalaryDeductionBatch).total_amount) }}
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge
|
||||
look="outline"
|
||||
:variant="salaryBatchStatusVariant((item as SalaryDeductionBatch).status)"
|
||||
>
|
||||
{{ salaryBatchStatusLabel((item as SalaryDeductionBatch).status) }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.uploaded_at="{ item }">
|
||||
{{ formatDate((item as SalaryDeductionBatch).uploaded_at) }}
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<Button
|
||||
v-if="canView"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-green-600 text-white"
|
||||
title="Lihat & sahkan"
|
||||
@click="goToDetail((item as SalaryDeductionBatch).id)"
|
||||
>
|
||||
<Eye class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<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 UserInvestmentLimitForm from '../components/UserInvestmentLimitForm.vue'
|
||||
import { createUserInvestmentLimit } from '../services/investment-limit.service'
|
||||
import type { UserInvestmentLimitPayload } from '../types/investment-limit.types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function handleSubmit(payload: UserInvestmentLimitPayload) {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await createUserInvestmentLimit(payload)
|
||||
router.push({ name: 'list-user-investment-limits' })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menambah had pelaburan ahli.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-user-investment-limits' })
|
||||
}
|
||||
</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">Tambah Had Pelaburan Ahli</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Override had profil untuk ahli dan jenis pelaburan ini.</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box class="p-6">
|
||||
<UserInvestmentLimitForm
|
||||
:saving="saving"
|
||||
submit-label="Tambah Override"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts" setup>
|
||||
import { 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 UserInvestmentLimitForm from '../components/UserInvestmentLimitForm.vue'
|
||||
import {
|
||||
getUserInvestmentLimit,
|
||||
updateUserInvestmentLimit,
|
||||
} from '../services/investment-limit.service'
|
||||
import type {
|
||||
UserInvestmentLimit,
|
||||
UserInvestmentLimitPayload,
|
||||
} from '../types/investment-limit.types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const item = ref<UserInvestmentLimit | null>(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function loadItem() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
item.value = await getUserInvestmentLimit(String(route.params.id))
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan had pelaburan ahli.')
|
||||
item.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: UserInvestmentLimitPayload) {
|
||||
if (!item.value) return
|
||||
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await updateUserInvestmentLimit(item.value.id, payload)
|
||||
router.push({ name: 'list-user-investment-limits' })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemaskini had pelaburan ahli.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-user-investment-limits' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItem()
|
||||
})
|
||||
</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 Had Pelaburan Ahli</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>
|
||||
|
||||
<Box v-if="loading" class="p-6 text-center opacity-70">Memuatkan...</Box>
|
||||
|
||||
<Box v-else-if="item" class="p-6">
|
||||
<UserInvestmentLimitForm
|
||||
:initial="item"
|
||||
:saving="saving"
|
||||
submit-label="Kemaskini"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,232 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { Pencil, Trash2 } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { useUserInvestmentLimitList } from '../composables/useUserInvestmentLimitList'
|
||||
import { deleteUserInvestmentLimit } from '../services/investment-limit.service'
|
||||
import type { UserInvestmentLimit } from '../types/investment-limit.types'
|
||||
import { formatInvestmentAmount } from '../utils/investment-type.utils'
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canCreate = computed(() => hasPermission('tambah had pelaburan'))
|
||||
const canUpdate = computed(() => hasPermission('kemaskini had pelaburan'))
|
||||
const canDelete = computed(() => hasPermission('hapus had pelaburan'))
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const itemToDelete = ref<UserInvestmentLimit | null>(null)
|
||||
const deleting = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchItems,
|
||||
} = useUserInvestmentLimitList()
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Ahli', key: 'user', sortable: false },
|
||||
{ title: 'Jenis', key: 'investment_type', sortable: false },
|
||||
{ title: 'Had Kumulatif', key: 'max_investment_amount', sortable: true },
|
||||
{ title: 'Had Transaksi', key: 'max_transaction_amount', sortable: true },
|
||||
{ title: 'Mula', key: 'effective_date', sortable: true },
|
||||
{ title: 'Tamat', key: 'expiry_date', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY')
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push({ name: 'create-user-investment-limit' })
|
||||
}
|
||||
|
||||
function goToEdit(id: string) {
|
||||
router.push({ name: 'edit-user-investment-limit', params: { id } })
|
||||
}
|
||||
|
||||
function openDelete(item: UserInvestmentLimit) {
|
||||
itemToDelete.value = item
|
||||
deleteError.value = null
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!itemToDelete.value || deleting.value) return
|
||||
|
||||
deleting.value = true
|
||||
deleteError.value = null
|
||||
|
||||
try {
|
||||
await deleteUserInvestmentLimit(itemToDelete.value.id)
|
||||
deleteOpen.value = false
|
||||
itemToDelete.value = null
|
||||
await fetchItems(page.value)
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus had ahli.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Had Pelaburan Ahli</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Override had profil untuk ahli tertentu. Override aktif mengatasi had profil.
|
||||
</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 Override
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5">
|
||||
<DataTable
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:current-sort="sortBy"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="had-pelaburan-ahli"
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate"
|
||||
>
|
||||
<template #toolbar>
|
||||
<Input
|
||||
v-model="search"
|
||||
class="w-full max-w-md"
|
||||
type="search"
|
||||
placeholder="Cari ahli atau jenis pelaburan..."
|
||||
aria-label="Cari had ahli"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item.user="{ item }">
|
||||
<div>{{ (item as UserInvestmentLimit).user?.name ?? '-' }}</div>
|
||||
<div class="mt-0.5 text-xs opacity-60">
|
||||
{{ (item as UserInvestmentLimit).user?.ic_number
|
||||
|| (item as UserInvestmentLimit).user?.email
|
||||
|| '' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.investment_type="{ item }">
|
||||
<div>{{ (item as UserInvestmentLimit).investment_type?.name ?? '-' }}</div>
|
||||
<div class="mt-0.5 text-xs uppercase opacity-60">
|
||||
{{ (item as UserInvestmentLimit).investment_type?.code ?? '' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.max_investment_amount="{ item }">
|
||||
{{ formatInvestmentAmount((item as UserInvestmentLimit).max_investment_amount) }}
|
||||
</template>
|
||||
|
||||
<template #item.max_transaction_amount="{ item }">
|
||||
{{ formatInvestmentAmount((item as UserInvestmentLimit).max_transaction_amount) }}
|
||||
</template>
|
||||
|
||||
<template #item.effective_date="{ item }">
|
||||
{{ formatDate((item as UserInvestmentLimit).effective_date) }}
|
||||
</template>
|
||||
|
||||
<template #item.expiry_date="{ item }">
|
||||
{{ formatDate((item as UserInvestmentLimit).expiry_date) }}
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="canUpdate"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Edit"
|
||||
@click="goToEdit((item as UserInvestmentLimit).id)"
|
||||
>
|
||||
<Pencil class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-danger text-white"
|
||||
title="Hapus"
|
||||
@click="openDelete(item as UserInvestmentLimit)"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<DialogRoot :open="deleteOpen" @openChange="(details) => (deleteOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-5 text-2xl font-medium">Hapus override had?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Override untuk
|
||||
<span v-if="itemToDelete" class="font-medium">
|
||||
{{ itemToDelete.user?.name }} / {{ itemToDelete.investment_type?.name }}
|
||||
</span>
|
||||
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="confirmDelete"
|
||||
>
|
||||
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import MemberSearchField from '../components/MemberSearchField.vue'
|
||||
import {
|
||||
assignUserLimitProfile,
|
||||
listInvestmentLimitProfiles,
|
||||
} from '../services/investment-limit.service'
|
||||
import type { InvestmentLimitProfile } from '../types/investment-limit.types'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const loadingProfiles = ref(false)
|
||||
const profiles = ref<InvestmentLimitProfile[]>([])
|
||||
|
||||
const form = reactive({
|
||||
user_id: '',
|
||||
profile_id: '',
|
||||
effective_date: dayjs().format('YYYY-MM-DD'),
|
||||
})
|
||||
|
||||
const profileOptions = computed<SelectOption[]>(() =>
|
||||
profiles.value.map((profile) => ({
|
||||
label: profile.name,
|
||||
value: profile.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const profileCollection = computed(() =>
|
||||
select.collection({
|
||||
items: profileOptions.value,
|
||||
itemToValue: (item) => item.label,
|
||||
}),
|
||||
)
|
||||
|
||||
function setProfileValue(details: { value: string[] }) {
|
||||
const label = details.value[0]
|
||||
form.profile_id = profileOptions.value.find((item) => item.label === label)?.value ?? ''
|
||||
}
|
||||
|
||||
async function loadProfiles() {
|
||||
loadingProfiles.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await listInvestmentLimitProfiles({
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
})
|
||||
profiles.value = data.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai profil had.')
|
||||
profiles.value = []
|
||||
} finally {
|
||||
loadingProfiles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await assignUserLimitProfile({
|
||||
user_id: form.user_id,
|
||||
profile_id: form.profile_id,
|
||||
effective_date: form.effective_date,
|
||||
})
|
||||
router.push({ name: 'list-user-limit-profiles' })
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menugaskan profil had.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push({ name: 'list-user-limit-profiles' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProfiles()
|
||||
})
|
||||
</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">Tugaskan Profil Had</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Ahli akan menggunakan had daripada profil yang dipilih.
|
||||
</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box class="p-6">
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<MemberSearchField v-model="form.user_id" required :disabled="saving" />
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Profil Had</FieldLabel>
|
||||
<p v-if="loadingProfiles" class="text-sm opacity-60">Memuatkan profil...</p>
|
||||
<SelectRoot
|
||||
v-else-if="profileOptions.length"
|
||||
:key="profileOptions.map((item) => item.value).join(',')"
|
||||
class="w-full"
|
||||
:collection="profileCollection"
|
||||
:disabled="saving"
|
||||
@value-change="setProfileValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger aria-label="Pilih profil had">
|
||||
<SelectValueText placeholder="Pilih profil had" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItem
|
||||
v-for="item in profileCollection.items"
|
||||
:key="item.value"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<p v-else class="text-sm opacity-60">Tiada profil had. Cipta profil dahulu.</p>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel required>Tarikh Berkuat Kuasa</FieldLabel>
|
||||
<Input v-model="form.effective_date" type="date" :disabled="saving" />
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving" @click="handleCancel">
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
:disabled="saving || !form.user_id || !form.profile_id || !form.effective_date"
|
||||
>
|
||||
{{ saving ? 'Menyimpan...' : 'Tugaskan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { useUserLimitProfileList } from '../composables/useUserLimitProfileList'
|
||||
import type { UserLimitProfile } from '../types/investment-limit.types'
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const canAssign = computed(() => hasPermission('tambah had pelaburan'))
|
||||
|
||||
const {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
activeOnly,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
} = useUserLimitProfileList()
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Ahli', key: 'user', sortable: false },
|
||||
{ title: 'Profil', key: 'profile', sortable: false },
|
||||
{ title: 'Mula', key: 'effective_date', sortable: false },
|
||||
{ title: 'Tamat', key: 'expiry_date', sortable: false },
|
||||
{ title: 'Status', key: 'status', sortable: false },
|
||||
]
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY')
|
||||
}
|
||||
|
||||
function isActive(item: UserLimitProfile): boolean {
|
||||
const today = dayjs().startOf('day')
|
||||
if (dayjs(item.effective_date).startOf('day').isAfter(today)) return false
|
||||
if (!item.expiry_date) return true
|
||||
return !dayjs(item.expiry_date).startOf('day').isBefore(today)
|
||||
}
|
||||
|
||||
function goToAssign() {
|
||||
router.push({ name: 'assign-user-limit-profile' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Tugasan Profil Had</h2>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Tugaskan profil had kepada ahli. Tugasan baharu akan tamatkan tugasan aktif sedia ada.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 flex w-full sm:mt-0 sm:w-auto">
|
||||
<Button
|
||||
v-if="canAssign"
|
||||
look="outline"
|
||||
variant="primary"
|
||||
:disabled="loading"
|
||||
@click="goToAssign"
|
||||
>
|
||||
Tugaskan Profil
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5">
|
||||
<DataTable
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="tugasan-profil-had"
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
>
|
||||
<template #toolbar>
|
||||
<CheckboxRoot
|
||||
class="flex items-center gap-2"
|
||||
:checked="activeOnly"
|
||||
@checked-change="({ checked }) => (activeOnly = checked === true)"
|
||||
>
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Aktif sahaja</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
|
||||
<template #item.user="{ item }">
|
||||
<div>{{ (item as UserLimitProfile).user?.name ?? '-' }}</div>
|
||||
<div class="mt-0.5 text-xs opacity-60">
|
||||
{{ (item as UserLimitProfile).user?.ic_number
|
||||
|| (item as UserLimitProfile).user?.email
|
||||
|| '' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.profile="{ item }">
|
||||
{{ (item as UserLimitProfile).profile?.name ?? '-' }}
|
||||
</template>
|
||||
|
||||
<template #item.effective_date="{ item }">
|
||||
{{ formatDate((item as UserLimitProfile).effective_date) }}
|
||||
</template>
|
||||
|
||||
<template #item.expiry_date="{ item }">
|
||||
{{ formatDate((item as UserLimitProfile).expiry_date) }}
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge
|
||||
look="outline"
|
||||
:variant="isActive(item as UserLimitProfile) ? 'success' : 'secondary'"
|
||||
>
|
||||
{{ isActive(item as UserLimitProfile) ? 'Aktif' : 'Tamat' }}
|
||||
</Badge>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,4 +61,144 @@ export const investmentLayoutRoutes: RouteRecordRaw[] = [
|
||||
permission: 'kemaskini jenis pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list-investment-limit-profiles',
|
||||
name: 'list-investment-limit-profiles',
|
||||
component: () => import('./pages/InvestmentLimitProfileList.vue'),
|
||||
meta: {
|
||||
title: 'Profil Had Pelaburan',
|
||||
module: 'investment',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'investment-limit-profiles/create',
|
||||
name: 'create-investment-limit-profile',
|
||||
component: () => import('./pages/InvestmentLimitProfileCreate.vue'),
|
||||
meta: {
|
||||
title: 'Tambah Profil Had',
|
||||
module: 'investment',
|
||||
permission: 'tambah had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'investment-limit-profiles/:id',
|
||||
name: 'view-investment-limit-profile',
|
||||
component: () => import('./pages/InvestmentLimitProfileDetail.vue'),
|
||||
meta: {
|
||||
title: 'Urus Profil Had',
|
||||
module: 'investment',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list-user-limit-profiles',
|
||||
name: 'list-user-limit-profiles',
|
||||
component: () => import('./pages/UserLimitProfileList.vue'),
|
||||
meta: {
|
||||
title: 'Tugasan Profil Had',
|
||||
module: 'investment',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'user-limit-profiles/assign',
|
||||
name: 'assign-user-limit-profile',
|
||||
component: () => import('./pages/UserLimitProfileAssign.vue'),
|
||||
meta: {
|
||||
title: 'Tugaskan Profil Had',
|
||||
module: 'investment',
|
||||
permission: 'tambah had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list-user-investment-limits',
|
||||
name: 'list-user-investment-limits',
|
||||
component: () => import('./pages/UserInvestmentLimitList.vue'),
|
||||
meta: {
|
||||
title: 'Had Pelaburan Ahli',
|
||||
module: 'investment',
|
||||
permission: 'lihat had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'user-investment-limits/create',
|
||||
name: 'create-user-investment-limit',
|
||||
component: () => import('./pages/UserInvestmentLimitCreate.vue'),
|
||||
meta: {
|
||||
title: 'Tambah Had Pelaburan Ahli',
|
||||
module: 'investment',
|
||||
permission: 'tambah had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'user-investment-limits/:id/edit',
|
||||
name: 'edit-user-investment-limit',
|
||||
component: () => import('./pages/UserInvestmentLimitEdit.vue'),
|
||||
meta: {
|
||||
title: 'Kemaskini Had Pelaburan Ahli',
|
||||
module: 'investment',
|
||||
permission: 'kemaskini had pelaburan',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list-salary-contributions',
|
||||
name: 'list-salary-contributions',
|
||||
component: () => import('./pages/SalaryContributionList.vue'),
|
||||
meta: {
|
||||
title: 'Caruman Potongan Gaji',
|
||||
module: 'investment',
|
||||
permission: 'lihat potongan gaji',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'salary-contributions/create',
|
||||
name: 'create-salary-contribution',
|
||||
component: () => import('./pages/SalaryContributionCreate.vue'),
|
||||
meta: {
|
||||
title: 'Tambah Caruman Potongan Gaji',
|
||||
module: 'investment',
|
||||
permission: 'tambah potongan gaji',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'salary-contributions/:id/edit',
|
||||
name: 'edit-salary-contribution',
|
||||
component: () => import('./pages/SalaryContributionEdit.vue'),
|
||||
meta: {
|
||||
title: 'Kemaskini Caruman Potongan Gaji',
|
||||
module: 'investment',
|
||||
permission: 'kemaskini potongan gaji',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list-salary-deduction-batches',
|
||||
name: 'list-salary-deduction-batches',
|
||||
component: () => import('./pages/SalaryDeductionBatchList.vue'),
|
||||
meta: {
|
||||
title: 'Batch Potongan Gaji',
|
||||
module: 'investment',
|
||||
permission: 'lihat potongan gaji',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'salary-deduction-batches/create',
|
||||
name: 'create-salary-deduction-batch',
|
||||
component: () => import('./pages/SalaryDeductionBatchCreate.vue'),
|
||||
meta: {
|
||||
title: 'Jana / Muat Naik Batch',
|
||||
module: 'investment',
|
||||
permission: 'tambah potongan gaji',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'salary-deduction-batches/:id',
|
||||
name: 'view-salary-deduction-batch',
|
||||
component: () => import('./pages/SalaryDeductionBatchDetail.vue'),
|
||||
meta: {
|
||||
title: 'Butiran Batch Potongan Gaji',
|
||||
module: 'investment',
|
||||
permission: 'lihat potongan gaji',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type {
|
||||
AssignUserLimitProfilePayload,
|
||||
InvestmentLimitProfile,
|
||||
InvestmentLimitProfilePayload,
|
||||
ListInvestmentLimitProfilesParams,
|
||||
ListUserInvestmentLimitsParams,
|
||||
ListUserLimitProfilesParams,
|
||||
ProfileInvestmentLimit,
|
||||
ProfileInvestmentLimitPayload,
|
||||
UserInvestmentLimit,
|
||||
UserInvestmentLimitPayload,
|
||||
UserLimitProfile,
|
||||
} from '../types/investment-limit.types'
|
||||
|
||||
type ProfileApiResponse = {
|
||||
success: boolean
|
||||
data: InvestmentLimitProfile
|
||||
message?: string
|
||||
}
|
||||
|
||||
type ProfileLimitApiResponse = {
|
||||
success: boolean
|
||||
data: ProfileInvestmentLimit
|
||||
message?: string
|
||||
}
|
||||
|
||||
type ProfileLimitsApiResponse = {
|
||||
success: boolean
|
||||
data: ProfileInvestmentLimit[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
type UserLimitProfileApiResponse = {
|
||||
success: boolean
|
||||
data: UserLimitProfile
|
||||
message?: string
|
||||
}
|
||||
|
||||
type UserInvestmentLimitApiResponse = {
|
||||
success: boolean
|
||||
data: UserInvestmentLimit
|
||||
message?: string
|
||||
}
|
||||
|
||||
type MessageApiResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function listInvestmentLimitProfiles(
|
||||
params?: ListInvestmentLimitProfilesParams,
|
||||
): Promise<PaginatedApiResponse<InvestmentLimitProfile>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<InvestmentLimitProfile>>(
|
||||
'/v1/investment-limit-profiles',
|
||||
{ params },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan profil had pelaburan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getInvestmentLimitProfile(id: string): Promise<InvestmentLimitProfile> {
|
||||
const { data } = await api.get<ProfileApiResponse>(`/v1/investment-limit-profiles/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan profil had pelaburan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createInvestmentLimitProfile(
|
||||
payload: InvestmentLimitProfilePayload,
|
||||
): Promise<InvestmentLimitProfile> {
|
||||
const { data } = await api.post<ProfileApiResponse>('/v1/investment-limit-profiles', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah profil had pelaburan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateInvestmentLimitProfile(
|
||||
id: string,
|
||||
payload: InvestmentLimitProfilePayload,
|
||||
): Promise<InvestmentLimitProfile> {
|
||||
const { data } = await api.put<ProfileApiResponse>(
|
||||
`/v1/investment-limit-profiles/${id}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemaskini profil had pelaburan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteInvestmentLimitProfile(id: string): Promise<void> {
|
||||
const { data } = await api.delete<MessageApiResponse>(`/v1/investment-limit-profiles/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menghapus profil had pelaburan.')
|
||||
}
|
||||
}
|
||||
|
||||
export async function listProfileInvestmentLimits(
|
||||
profileId: string,
|
||||
): Promise<ProfileInvestmentLimit[]> {
|
||||
const { data } = await api.get<ProfileLimitsApiResponse>(
|
||||
`/v1/investment-limit-profiles/${profileId}/limits`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan had profil.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createProfileInvestmentLimit(
|
||||
profileId: string,
|
||||
payload: ProfileInvestmentLimitPayload,
|
||||
): Promise<ProfileInvestmentLimit> {
|
||||
const { data } = await api.post<ProfileLimitApiResponse>(
|
||||
`/v1/investment-limit-profiles/${profileId}/limits`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah had profil.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateProfileInvestmentLimit(
|
||||
profileId: string,
|
||||
limitId: string,
|
||||
payload: ProfileInvestmentLimitPayload,
|
||||
): Promise<ProfileInvestmentLimit> {
|
||||
const { data } = await api.put<ProfileLimitApiResponse>(
|
||||
`/v1/investment-limit-profiles/${profileId}/limits/${limitId}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemaskini had profil.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteProfileInvestmentLimit(
|
||||
profileId: string,
|
||||
limitId: string,
|
||||
): Promise<void> {
|
||||
const { data } = await api.delete<MessageApiResponse>(
|
||||
`/v1/investment-limit-profiles/${profileId}/limits/${limitId}`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menghapus had profil.')
|
||||
}
|
||||
}
|
||||
|
||||
export async function listUserLimitProfiles(
|
||||
params?: ListUserLimitProfilesParams,
|
||||
): Promise<PaginatedApiResponse<UserLimitProfile>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<UserLimitProfile>>(
|
||||
'/v1/user-limit-profiles',
|
||||
{ params },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan tugasan profil had.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function assignUserLimitProfile(
|
||||
payload: AssignUserLimitProfilePayload,
|
||||
): Promise<UserLimitProfile> {
|
||||
const { data } = await api.post<UserLimitProfileApiResponse>(
|
||||
'/v1/user-limit-profiles',
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menugaskan profil had.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function listUserInvestmentLimits(
|
||||
params?: ListUserInvestmentLimitsParams,
|
||||
): Promise<PaginatedApiResponse<UserInvestmentLimit>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<UserInvestmentLimit>>(
|
||||
'/v1/user-investment-limits',
|
||||
{ params },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan had pelaburan ahli.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUserInvestmentLimit(id: string): Promise<UserInvestmentLimit> {
|
||||
const { data } = await api.get<UserInvestmentLimitApiResponse>(
|
||||
`/v1/user-investment-limits/${id}`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan had pelaburan ahli.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createUserInvestmentLimit(
|
||||
payload: UserInvestmentLimitPayload,
|
||||
): Promise<UserInvestmentLimit> {
|
||||
const { data } = await api.post<UserInvestmentLimitApiResponse>(
|
||||
'/v1/user-investment-limits',
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah had pelaburan ahli.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateUserInvestmentLimit(
|
||||
id: string,
|
||||
payload: UserInvestmentLimitPayload,
|
||||
): Promise<UserInvestmentLimit> {
|
||||
const { data } = await api.put<UserInvestmentLimitApiResponse>(
|
||||
`/v1/user-investment-limits/${id}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemaskini had pelaburan ahli.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteUserInvestmentLimit(id: string): Promise<void> {
|
||||
const { data } = await api.delete<MessageApiResponse>(`/v1/user-investment-limits/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menghapus had pelaburan ahli.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type {
|
||||
GenerateSalaryDeductionBatchPayload,
|
||||
ListSalaryContributionsParams,
|
||||
ListSalaryDeductionBatchesParams,
|
||||
SalaryContribution,
|
||||
SalaryContributionPayload,
|
||||
SalaryDeductionBatch,
|
||||
SalaryDeductionBatchDetail,
|
||||
UploadSalaryDeductionBatchPayload,
|
||||
} from '../types/salary.types'
|
||||
|
||||
type ContributionApiResponse = {
|
||||
success: boolean
|
||||
data: SalaryContribution
|
||||
message?: string
|
||||
}
|
||||
|
||||
type BatchApiResponse = {
|
||||
success: boolean
|
||||
data: SalaryDeductionBatch
|
||||
message?: string
|
||||
}
|
||||
|
||||
type DetailApiResponse = {
|
||||
success: boolean
|
||||
data: SalaryDeductionBatchDetail
|
||||
message?: string
|
||||
}
|
||||
|
||||
type MessageApiResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function listSalaryContributions(
|
||||
params?: ListSalaryContributionsParams,
|
||||
): Promise<PaginatedApiResponse<SalaryContribution>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<SalaryContribution>>(
|
||||
'/v1/salary-contributions',
|
||||
{ params },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan caruman potongan gaji.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSalaryContribution(id: string): Promise<SalaryContribution> {
|
||||
const { data } = await api.get<ContributionApiResponse>(`/v1/salary-contributions/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan caruman potongan gaji.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createSalaryContribution(
|
||||
payload: SalaryContributionPayload,
|
||||
): Promise<SalaryContribution> {
|
||||
const { data } = await api.post<ContributionApiResponse>('/v1/salary-contributions', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah caruman potongan gaji.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateSalaryContribution(
|
||||
id: string,
|
||||
payload: SalaryContributionPayload,
|
||||
): Promise<SalaryContribution> {
|
||||
const { data } = await api.put<ContributionApiResponse>(
|
||||
`/v1/salary-contributions/${id}`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemaskini caruman potongan gaji.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteSalaryContribution(id: string): Promise<void> {
|
||||
const { data } = await api.delete<MessageApiResponse>(`/v1/salary-contributions/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menghapus caruman potongan gaji.')
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSalaryDeductionBatches(
|
||||
params?: ListSalaryDeductionBatchesParams,
|
||||
): Promise<PaginatedApiResponse<SalaryDeductionBatch>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<SalaryDeductionBatch>>(
|
||||
'/v1/salary-deduction-batches',
|
||||
{ params },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan batch potongan gaji.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSalaryDeductionBatch(id: string): Promise<SalaryDeductionBatch> {
|
||||
const { data } = await api.get<BatchApiResponse>(`/v1/salary-deduction-batches/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan batch potongan gaji.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function uploadSalaryDeductionBatch(
|
||||
payload: UploadSalaryDeductionBatchPayload,
|
||||
): Promise<SalaryDeductionBatch> {
|
||||
const { data } = await api.post<BatchApiResponse>('/v1/salary-deduction-batches', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuat naik batch potongan gaji.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function generateSalaryDeductionBatch(
|
||||
payload: GenerateSalaryDeductionBatchPayload,
|
||||
): Promise<SalaryDeductionBatch> {
|
||||
const { data } = await api.post<BatchApiResponse>(
|
||||
'/v1/salary-deduction-batches/generate',
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menjana batch potongan gaji.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function markSalaryDeductionBatchUnderReview(
|
||||
id: string,
|
||||
): Promise<SalaryDeductionBatch> {
|
||||
const { data } = await api.post<BatchApiResponse>(
|
||||
`/v1/salary-deduction-batches/${id}/under-review`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menghantar batch untuk semakan.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function verifyAllSalaryDeductionBatch(
|
||||
id: string,
|
||||
): Promise<SalaryDeductionBatch> {
|
||||
const { data } = await api.post<BatchApiResponse>(
|
||||
`/v1/salary-deduction-batches/${id}/verify-all`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengesahkan semua rekod.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function rejectSalaryDeductionBatch(
|
||||
id: string,
|
||||
remarks?: string | null,
|
||||
): Promise<SalaryDeductionBatch> {
|
||||
const { data } = await api.post<BatchApiResponse>(
|
||||
`/v1/salary-deduction-batches/${id}/reject`,
|
||||
{ remarks: remarks ?? null },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menolak batch.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function verifySalaryDeductionDetail(
|
||||
batchId: string,
|
||||
detailId: string,
|
||||
remarks?: string | null,
|
||||
): Promise<SalaryDeductionBatchDetail> {
|
||||
const { data } = await api.post<DetailApiResponse>(
|
||||
`/v1/salary-deduction-batches/${batchId}/details/${detailId}/verify`,
|
||||
{ remarks: remarks ?? null },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengesahkan rekod.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function rejectSalaryDeductionDetail(
|
||||
batchId: string,
|
||||
detailId: string,
|
||||
remarks?: string | null,
|
||||
): Promise<SalaryDeductionBatchDetail> {
|
||||
const { data } = await api.post<DetailApiResponse>(
|
||||
`/v1/salary-deduction-batches/${batchId}/details/${detailId}/reject`,
|
||||
{ remarks: remarks ?? null },
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menolak rekod.')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
export type InvestmentLimitProfile = {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
profile_limits_count?: number
|
||||
profile_limits?: ProfileInvestmentLimit[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type InvestmentLimitProfilePayload = {
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type ProfileInvestmentLimit = {
|
||||
id: string
|
||||
profile_id: string
|
||||
investment_type_id: string
|
||||
max_investment_amount: string | number | null
|
||||
max_transaction_amount: string | number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
investment_type?: {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export type ProfileInvestmentLimitPayload = {
|
||||
investment_type_id: string
|
||||
max_investment_amount?: number | null
|
||||
max_transaction_amount?: number | null
|
||||
}
|
||||
|
||||
export type UserLimitProfile = {
|
||||
id: string
|
||||
user_id: string
|
||||
profile_id: string
|
||||
effective_date: string
|
||||
expiry_date: string | null
|
||||
created_at: string
|
||||
user?: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number?: string | null
|
||||
} | null
|
||||
profile?: {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type AssignUserLimitProfilePayload = {
|
||||
user_id: string
|
||||
profile_id: string
|
||||
effective_date: string
|
||||
}
|
||||
|
||||
export type UserInvestmentLimit = {
|
||||
id: string
|
||||
user_id: string
|
||||
investment_type_id: string
|
||||
max_investment_amount: string | number | null
|
||||
max_transaction_amount: string | number | null
|
||||
effective_date: string
|
||||
expiry_date: string | null
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user?: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number?: string | null
|
||||
} | null
|
||||
investment_type?: {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export type UserInvestmentLimitPayload = {
|
||||
user_id: string
|
||||
investment_type_id: string
|
||||
max_investment_amount?: number | null
|
||||
max_transaction_amount?: number | null
|
||||
effective_date: string
|
||||
expiry_date?: string | null
|
||||
}
|
||||
|
||||
export type ListInvestmentLimitProfilesParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export type ListUserLimitProfilesParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
user_id?: string
|
||||
active_only?: boolean | number
|
||||
}
|
||||
|
||||
export type ListUserInvestmentLimitsParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
user_id?: string
|
||||
investment_type_id?: string
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
export type SalaryContributionStatus = 'ACTIVE' | 'PAUSED' | 'ENDED'
|
||||
|
||||
export type SalaryDeductionBatchStatus =
|
||||
| 'UPLOADED'
|
||||
| 'UNDER_REVIEW'
|
||||
| 'VERIFIED'
|
||||
| 'REJECTED'
|
||||
|
||||
export type SalaryDeductionDetailStatus = 'PENDING' | 'VERIFIED' | 'REJECTED'
|
||||
|
||||
export type SalaryContribution = {
|
||||
id: string
|
||||
user_id: string
|
||||
investment_type_id: string
|
||||
monthly_amount: string | number
|
||||
start_date: string
|
||||
end_date: string | null
|
||||
status: SalaryContributionStatus
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user?: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number?: string | null
|
||||
} | null
|
||||
investment_type?: {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export type SalaryContributionPayload = {
|
||||
user_id: string
|
||||
investment_type_id: string
|
||||
monthly_amount: number
|
||||
start_date: string
|
||||
end_date?: string | null
|
||||
status: SalaryContributionStatus
|
||||
}
|
||||
|
||||
export type SalaryDeductionBatchDetail = {
|
||||
id: string
|
||||
batch_id: string
|
||||
user_id: string
|
||||
salary_contribution_id: string | null
|
||||
investment_type_id: string
|
||||
investment_id: string | null
|
||||
amount: string | number
|
||||
status: SalaryDeductionDetailStatus
|
||||
remarks: string | null
|
||||
verified_by: string | null
|
||||
verified_at: string | null
|
||||
created_at: string
|
||||
user?: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number?: string | null
|
||||
} | null
|
||||
investment_type?: {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
} | null
|
||||
investment?: {
|
||||
id: string
|
||||
reference_no: string
|
||||
status: string
|
||||
amount: string | number
|
||||
} | null
|
||||
}
|
||||
|
||||
export type SalaryDeductionBatch = {
|
||||
id: string
|
||||
batch_no: string
|
||||
month: number
|
||||
year: number
|
||||
total_records: number
|
||||
total_amount: string | number
|
||||
status: SalaryDeductionBatchStatus
|
||||
uploaded_by: string | null
|
||||
verified_by: string | null
|
||||
uploaded_at: string | null
|
||||
verified_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
uploaded_by_user?: { id: string; name: string; email: string } | null
|
||||
verified_by_user?: { id: string; name: string; email: string } | null
|
||||
details?: SalaryDeductionBatchDetail[]
|
||||
}
|
||||
|
||||
export type UploadSalaryDeductionBatchPayload = {
|
||||
month: number
|
||||
year: number
|
||||
details: Array<{
|
||||
user_id: string
|
||||
investment_type_id: string
|
||||
amount: number
|
||||
salary_contribution_id?: string | null
|
||||
remarks?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export type GenerateSalaryDeductionBatchPayload = {
|
||||
month: number
|
||||
year: number
|
||||
}
|
||||
|
||||
export type ListSalaryContributionsParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
status?: SalaryContributionStatus | ''
|
||||
user_id?: string
|
||||
}
|
||||
|
||||
export type ListSalaryDeductionBatchesParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
status?: SalaryDeductionBatchStatus | ''
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
|
||||
import type {
|
||||
SalaryContributionStatus,
|
||||
SalaryDeductionBatchStatus,
|
||||
SalaryDeductionDetailStatus,
|
||||
} from '../types/salary.types'
|
||||
import { formatInvestmentAmount } from './investment-type.utils'
|
||||
|
||||
export { formatInvestmentAmount }
|
||||
|
||||
export const SALARY_CONTRIBUTION_STATUS_OPTIONS: {
|
||||
label: string
|
||||
value: SalaryContributionStatus
|
||||
}[] = [
|
||||
{ label: 'Aktif', value: 'ACTIVE' },
|
||||
{ label: 'Ditangguh', value: 'PAUSED' },
|
||||
{ label: 'Tamat', value: 'ENDED' },
|
||||
]
|
||||
|
||||
export const SALARY_BATCH_STATUS_OPTIONS: {
|
||||
label: string
|
||||
value: SalaryDeductionBatchStatus
|
||||
}[] = [
|
||||
{ label: 'Dimuat Naik', value: 'UPLOADED' },
|
||||
{ label: 'Dalam Semakan', value: 'UNDER_REVIEW' },
|
||||
{ label: 'Disahkan', value: 'VERIFIED' },
|
||||
{ label: 'Ditolak', value: 'REJECTED' },
|
||||
]
|
||||
|
||||
export const SALARY_DETAIL_STATUS_OPTIONS: {
|
||||
label: string
|
||||
value: SalaryDeductionDetailStatus
|
||||
}[] = [
|
||||
{ label: 'Pending', value: 'PENDING' },
|
||||
{ label: 'Disahkan', value: 'VERIFIED' },
|
||||
{ label: 'Ditolak', value: 'REJECTED' },
|
||||
]
|
||||
|
||||
export const MONTH_OPTIONS = [
|
||||
{ label: 'Januari', value: 1 },
|
||||
{ label: 'Februari', value: 2 },
|
||||
{ label: 'Mac', value: 3 },
|
||||
{ label: 'April', value: 4 },
|
||||
{ label: 'Mei', value: 5 },
|
||||
{ label: 'Jun', value: 6 },
|
||||
{ label: 'Julai', value: 7 },
|
||||
{ label: 'Ogos', value: 8 },
|
||||
{ label: 'September', value: 9 },
|
||||
{ label: 'Oktober', value: 10 },
|
||||
{ label: 'November', value: 11 },
|
||||
{ label: 'Disember', value: 12 },
|
||||
]
|
||||
|
||||
export function salaryContributionStatusLabel(status: string): string {
|
||||
return SALARY_CONTRIBUTION_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
export function salaryBatchStatusLabel(status: string): string {
|
||||
return SALARY_BATCH_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
export function salaryDetailStatusLabel(status: string): string {
|
||||
return SALARY_DETAIL_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? status
|
||||
}
|
||||
|
||||
export function salaryContributionStatusVariant(status: string): BadgeVariants['variant'] {
|
||||
if (status === 'ACTIVE') return 'success'
|
||||
if (status === 'PAUSED') return 'pending'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
export function salaryBatchStatusVariant(status: string): BadgeVariants['variant'] {
|
||||
if (status === 'VERIFIED') return 'success'
|
||||
if (status === 'UNDER_REVIEW') return 'pending'
|
||||
if (status === 'REJECTED') return 'danger'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
export function salaryDetailStatusVariant(status: string): BadgeVariants['variant'] {
|
||||
if (status === 'VERIFIED') return 'success'
|
||||
if (status === 'REJECTED') return 'danger'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
export function monthLabel(month: number): string {
|
||||
return MONTH_OPTIONS.find((item) => item.value === month)?.label ?? String(month)
|
||||
}
|
||||
@@ -136,13 +136,10 @@ async function handleSubmit() {
|
||||
|
||||
if (!status) throw new Error('Status pengguna diperlukan.')
|
||||
requireSelectValue(genderValue.value[0], 'Jantina')
|
||||
requireSelectValue(marriageStatusValue.value[0], 'Status perkahwinan')
|
||||
requireSelectValue(memberTypeValue.value[0], 'Jenis anggota')
|
||||
|
||||
if (!form.member_number) throw new Error('Nombor anggota diperlukan.')
|
||||
if (!form.join_date) throw new Error('Tarikh sertai diperlukan.')
|
||||
if (!form.birth_date) throw new Error('Tarikh lahir diperlukan.')
|
||||
if (!form.birth_place.trim()) throw new Error('Tempat lahir diperlukan.')
|
||||
|
||||
await createUser({
|
||||
name: form.name.trim(),
|
||||
@@ -190,13 +187,8 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Daftar Pengguna</h2>
|
||||
<Button
|
||||
look="outline"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="router.push({ name: 'list-users' })"
|
||||
>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving"
|
||||
@click="router.push({ name: 'list-users' })">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
@@ -215,86 +207,43 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-name">Nama</FieldLabel>
|
||||
<Input
|
||||
id="user-name"
|
||||
v-model="form.name"
|
||||
class="w-full"
|
||||
type="text"
|
||||
placeholder="Nama penuh"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
@input="handleNameInput"
|
||||
/>
|
||||
<FieldLabel for="user-name" required>Nama</FieldLabel>
|
||||
<Input id="user-name" v-model="form.name" class="w-full" type="text" placeholder="Nama penuh"
|
||||
:disabled="formDisabled" required @input="handleNameInput" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-email">Emel</FieldLabel>
|
||||
<Input
|
||||
id="user-email"
|
||||
v-model="form.email"
|
||||
class="w-full"
|
||||
type="email"
|
||||
placeholder="emel@example.com"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
/>
|
||||
<FieldLabel for="user-email" required>Emel</FieldLabel>
|
||||
<Input id="user-email" v-model="form.email" class="w-full" type="email" placeholder="emel@example.com"
|
||||
:disabled="formDisabled" required />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-ic">Nombor Kad Pengenalan</FieldLabel>
|
||||
<Input
|
||||
id="user-ic"
|
||||
v-model="form.ic_number"
|
||||
class="w-full"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="15"
|
||||
placeholder="Contoh: 900101011234"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
@input="handleIcNumberInput"
|
||||
/>
|
||||
<FieldLabel for="user-ic" required>Nombor Kad Pengenalan</FieldLabel>
|
||||
<Input id="user-ic" v-model="form.ic_number" class="w-full" type="text" inputmode="numeric" maxlength="15"
|
||||
placeholder="Contoh: 900101011234" :disabled="formDisabled" required @input="handleIcNumberInput" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-phone">Nombor Telefon</FieldLabel>
|
||||
<Input
|
||||
id="user-phone"
|
||||
v-model="form.phone_number"
|
||||
class="w-full"
|
||||
type="text"
|
||||
placeholder="Nombor telefon"
|
||||
:disabled="formDisabled"
|
||||
/>
|
||||
<FieldLabel for="user-phone" required>Nombor Telefon</FieldLabel>
|
||||
<Input id="user-phone" v-model="form.phone_number" class="w-full" type="text" placeholder="Nombor telefon"
|
||||
:disabled="formDisabled" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-position">Jawatan</FieldLabel>
|
||||
<Input
|
||||
id="user-position"
|
||||
v-model="form.position"
|
||||
class="w-full"
|
||||
type="text"
|
||||
placeholder="Jawatan"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
/>
|
||||
<Input id="user-position" v-model="form.position" class="w-full" type="text" placeholder="Jawatan"
|
||||
:disabled="formDisabled" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status Pengguna</FieldLabel>
|
||||
<SelectRoot
|
||||
class="w-full"
|
||||
:collection="statusCollection"
|
||||
:default-value="statusInitial"
|
||||
:disabled="formDisabled"
|
||||
@value-change="setStatusValue"
|
||||
>
|
||||
<FieldLabel required>Status Pengguna</FieldLabel>
|
||||
<SelectRoot class="w-full" :collection="statusCollection" :default-value="statusInitial"
|
||||
:disabled="formDisabled" @value-change="setStatusValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih status" />
|
||||
@@ -303,11 +252,7 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in statusCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
@@ -318,13 +263,9 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Jantina</FieldLabel>
|
||||
<SelectRoot
|
||||
class="w-full"
|
||||
:collection="genderCollection"
|
||||
:disabled="formDisabled"
|
||||
@value-change="setGenderValue"
|
||||
>
|
||||
<FieldLabel required>Jantina</FieldLabel>
|
||||
<SelectRoot class="w-full" :collection="genderCollection" :disabled="formDisabled"
|
||||
@value-change="setGenderValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih jantina" />
|
||||
@@ -333,11 +274,7 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jantina</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in genderCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItem v-for="item in genderCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
@@ -347,12 +284,8 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status Perkahwinan</FieldLabel>
|
||||
<SelectRoot
|
||||
class="w-full"
|
||||
:collection="marriageStatusCollection"
|
||||
:disabled="formDisabled"
|
||||
@value-change="setMarriageStatusValue"
|
||||
>
|
||||
<SelectRoot class="w-full" :collection="marriageStatusCollection" :disabled="formDisabled"
|
||||
@value-change="setMarriageStatusValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih status perkahwinan" />
|
||||
@@ -361,11 +294,7 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status Perkahwinan</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in marriageStatusCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItem v-for="item in marriageStatusCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
@@ -376,27 +305,15 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-member-number">Nombor Anggota</FieldLabel>
|
||||
<Input
|
||||
id="user-member-number"
|
||||
v-model="form.member_number"
|
||||
class="w-full"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="Nombor anggota"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
/>
|
||||
<FieldLabel for="user-member-number" required>Nombor Anggota</FieldLabel>
|
||||
<Input id="user-member-number" v-model="form.member_number" class="w-full" type="number" min="0"
|
||||
placeholder="Nombor anggota" :disabled="formDisabled" required />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Jenis Anggota</FieldLabel>
|
||||
<SelectRoot
|
||||
class="w-full"
|
||||
:collection="memberTypeCollection"
|
||||
:disabled="formDisabled"
|
||||
@value-change="setMemberTypeValue"
|
||||
>
|
||||
<FieldLabel required>Jenis Anggota</FieldLabel>
|
||||
<SelectRoot class="w-full" :collection="memberTypeCollection" :disabled="formDisabled"
|
||||
@value-change="setMemberTypeValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih jenis anggota" />
|
||||
@@ -405,11 +322,7 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jenis Anggota</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in memberTypeCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItem v-for="item in memberTypeCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
@@ -420,52 +333,26 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-join-date">Tarikh Sertai</FieldLabel>
|
||||
<Input
|
||||
id="user-join-date"
|
||||
v-model="form.join_date"
|
||||
class="w-full"
|
||||
type="date"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
/>
|
||||
<FieldLabel for="user-join-date" required>Tarikh Sertai</FieldLabel>
|
||||
<Input id="user-join-date" v-model="form.join_date" class="w-full" type="date" :disabled="formDisabled"
|
||||
required />
|
||||
</Field>
|
||||
|
||||
<Field v-if="isInactiveStatus">
|
||||
<FieldLabel for="user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel>
|
||||
<Input
|
||||
id="user-leave-date"
|
||||
v-model="form.leave_date"
|
||||
class="w-full"
|
||||
type="date"
|
||||
:disabled="formDisabled"
|
||||
/>
|
||||
<Input id="user-leave-date" v-model="form.leave_date" class="w-full" type="date" :disabled="formDisabled" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
|
||||
<Input
|
||||
id="user-birth-date"
|
||||
v-model="form.birth_date"
|
||||
class="w-full"
|
||||
type="date"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
/>
|
||||
<Input id="user-birth-date" v-model="form.birth_date" class="w-full" type="date" :disabled="formDisabled" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-birth-place">Tempat Lahir</FieldLabel>
|
||||
<Input
|
||||
id="user-birth-place"
|
||||
v-model="form.birth_place"
|
||||
class="w-full"
|
||||
type="text"
|
||||
placeholder="Tempat lahir"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
/>
|
||||
<Input id="user-birth-place" v-model="form.birth_place" class="w-full" type="text" placeholder="Tempat lahir"
|
||||
:disabled="formDisabled" />
|
||||
</Field>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-2">
|
||||
|
||||
@@ -317,6 +317,7 @@ async function confirmRestore() {
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Bil.', key: '#', sortable: false },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
{ title: 'Name', key: 'name', sortable: true },
|
||||
{
|
||||
title: 'Unit',
|
||||
@@ -345,7 +346,6 @@ const headers: TableHeader[] = [
|
||||
exportValue: (item) => formatUserRoles(item.roles),
|
||||
},
|
||||
{ title: 'Status', key: 'status', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
// deleted users table headers
|
||||
|
||||
Reference in New Issue
Block a user