diff --git a/src/components/ui/styles/select.styles.ts b/src/components/ui/styles/select.styles.ts index 1f8a7aa..7b8a385 100644 --- a/src/components/ui/styles/select.styles.ts +++ b/src/components/ui/styles/select.styles.ts @@ -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"; diff --git a/src/modules/investment/components/MemberSearchField.vue b/src/modules/investment/components/MemberSearchField.vue new file mode 100644 index 0000000..f26cfad --- /dev/null +++ b/src/modules/investment/components/MemberSearchField.vue @@ -0,0 +1,139 @@ + + + diff --git a/src/modules/investment/components/SalaryContributionForm.vue b/src/modules/investment/components/SalaryContributionForm.vue new file mode 100644 index 0000000..62af9a6 --- /dev/null +++ b/src/modules/investment/components/SalaryContributionForm.vue @@ -0,0 +1,273 @@ + + + diff --git a/src/modules/investment/components/UserInvestmentLimitForm.vue b/src/modules/investment/components/UserInvestmentLimitForm.vue new file mode 100644 index 0000000..4f9ccfe --- /dev/null +++ b/src/modules/investment/components/UserInvestmentLimitForm.vue @@ -0,0 +1,236 @@ + + + diff --git a/src/modules/investment/composables/useInvestmentLimitProfileList.ts b/src/modules/investment/composables/useInvestmentLimitProfileList.ts new file mode 100644 index 0000000..664c89d --- /dev/null +++ b/src/modules/investment/composables/useInvestmentLimitProfileList.ts @@ -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([]) + const loading = ref(false) + const error = ref(null) + const search = ref('') + const sortBy = ref([{ 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, + } +} diff --git a/src/modules/investment/composables/useSalaryContributionList.ts b/src/modules/investment/composables/useSalaryContributionList.ts new file mode 100644 index 0000000..f60db5e --- /dev/null +++ b/src/modules/investment/composables/useSalaryContributionList.ts @@ -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([]) + const loading = ref(false) + const error = ref(null) + const search = ref('') + const statusFilter = ref('') + const sortBy = ref([{ 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, + } +} diff --git a/src/modules/investment/composables/useSalaryDeductionBatchList.ts b/src/modules/investment/composables/useSalaryDeductionBatchList.ts new file mode 100644 index 0000000..42983f1 --- /dev/null +++ b/src/modules/investment/composables/useSalaryDeductionBatchList.ts @@ -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([]) + const loading = ref(false) + const error = ref(null) + const statusFilter = ref('') + 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, + } +} diff --git a/src/modules/investment/composables/useUserInvestmentLimitList.ts b/src/modules/investment/composables/useUserInvestmentLimitList.ts new file mode 100644 index 0000000..58bf4ea --- /dev/null +++ b/src/modules/investment/composables/useUserInvestmentLimitList.ts @@ -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([]) + const loading = ref(false) + const error = ref(null) + const search = ref('') + const sortBy = ref([{ 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, + } +} diff --git a/src/modules/investment/composables/useUserLimitProfileList.ts b/src/modules/investment/composables/useUserLimitProfileList.ts new file mode 100644 index 0000000..8edc9ee --- /dev/null +++ b/src/modules/investment/composables/useUserLimitProfileList.ts @@ -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([]) + const loading = ref(false) + const error = ref(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, + } +} diff --git a/src/modules/investment/menu.ts b/src/modules/investment/menu.ts index 73caa32..4c96996 100644 --- a/src/modules/investment/menu.ts +++ b/src/modules/investment/menu.ts @@ -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', + }, ] diff --git a/src/modules/investment/pages/InvestmentLimitProfileCreate.vue b/src/modules/investment/pages/InvestmentLimitProfileCreate.vue new file mode 100644 index 0000000..97256c4 --- /dev/null +++ b/src/modules/investment/pages/InvestmentLimitProfileCreate.vue @@ -0,0 +1,87 @@ + + +