DONE: admin frontend for investment limit engine, salary deduction and contribution; WIP: dashboard for members

This commit is contained in:
ISMAIL MASSERAN
2026-08-03 12:30:48 +08:00
parent 8f1dddb80a
commit 1b7df24ee9
32 changed files with 5147 additions and 156 deletions
+2 -1
View File
@@ -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,
}
}
+30
View File
@@ -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>
+140
View File
@@ -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)
}
+41 -154
View File
@@ -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">
+1 -1
View File
@@ -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