From 1b7df24ee97a93000f7334cead5d020c204b0e86 Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Mon, 3 Aug 2026 12:30:48 +0800 Subject: [PATCH] DONE: admin frontend for investment limit engine, salary deduction and contribution; WIP: dashboard for members --- src/components/ui/styles/select.styles.ts | 3 +- .../components/MemberSearchField.vue | 139 +++++ .../components/SalaryContributionForm.vue | 273 ++++++++++ .../components/UserInvestmentLimitForm.vue | 236 ++++++++ .../useInvestmentLimitProfileList.ts | 86 +++ .../composables/useSalaryContributionList.ts | 96 ++++ .../useSalaryDeductionBatchList.ts | 72 +++ .../composables/useUserInvestmentLimitList.ts | 86 +++ .../composables/useUserLimitProfileList.ts | 69 +++ src/modules/investment/menu.ts | 30 + .../pages/InvestmentLimitProfileCreate.vue | 87 +++ .../pages/InvestmentLimitProfileDetail.vue | 512 ++++++++++++++++++ .../pages/InvestmentLimitProfileList.vue | 199 +++++++ .../pages/SalaryContributionCreate.vue | 62 +++ .../pages/SalaryContributionEdit.vue | 91 ++++ .../pages/SalaryContributionList.vue | 315 +++++++++++ .../pages/SalaryDeductionBatchCreate.vue | 482 +++++++++++++++++ .../pages/SalaryDeductionBatchDetail.vue | 378 +++++++++++++ .../pages/SalaryDeductionBatchList.vue | 219 ++++++++ .../pages/UserInvestmentLimitCreate.vue | 62 +++ .../pages/UserInvestmentLimitEdit.vue | 91 ++++ .../pages/UserInvestmentLimitList.vue | 232 ++++++++ .../pages/UserLimitProfileAssign.vue | 184 +++++++ .../investment/pages/UserLimitProfileList.vue | 137 +++++ src/modules/investment/routes.ts | 140 +++++ .../services/investment-limit.service.ts | 267 +++++++++ .../investment/services/salary.service.ts | 226 ++++++++ .../types/investment-limit.types.ts | 119 ++++ src/modules/investment/types/salary.types.ts | 126 +++++ src/modules/investment/utils/salary.utils.ts | 87 +++ src/modules/user/pages/UserCreate.vue | 195 ++----- src/modules/user/pages/UserList.vue | 2 +- 32 files changed, 5147 insertions(+), 156 deletions(-) create mode 100644 src/modules/investment/components/MemberSearchField.vue create mode 100644 src/modules/investment/components/SalaryContributionForm.vue create mode 100644 src/modules/investment/components/UserInvestmentLimitForm.vue create mode 100644 src/modules/investment/composables/useInvestmentLimitProfileList.ts create mode 100644 src/modules/investment/composables/useSalaryContributionList.ts create mode 100644 src/modules/investment/composables/useSalaryDeductionBatchList.ts create mode 100644 src/modules/investment/composables/useUserInvestmentLimitList.ts create mode 100644 src/modules/investment/composables/useUserLimitProfileList.ts create mode 100644 src/modules/investment/pages/InvestmentLimitProfileCreate.vue create mode 100644 src/modules/investment/pages/InvestmentLimitProfileDetail.vue create mode 100644 src/modules/investment/pages/InvestmentLimitProfileList.vue create mode 100644 src/modules/investment/pages/SalaryContributionCreate.vue create mode 100644 src/modules/investment/pages/SalaryContributionEdit.vue create mode 100644 src/modules/investment/pages/SalaryContributionList.vue create mode 100644 src/modules/investment/pages/SalaryDeductionBatchCreate.vue create mode 100644 src/modules/investment/pages/SalaryDeductionBatchDetail.vue create mode 100644 src/modules/investment/pages/SalaryDeductionBatchList.vue create mode 100644 src/modules/investment/pages/UserInvestmentLimitCreate.vue create mode 100644 src/modules/investment/pages/UserInvestmentLimitEdit.vue create mode 100644 src/modules/investment/pages/UserInvestmentLimitList.vue create mode 100644 src/modules/investment/pages/UserLimitProfileAssign.vue create mode 100644 src/modules/investment/pages/UserLimitProfileList.vue create mode 100644 src/modules/investment/services/investment-limit.service.ts create mode 100644 src/modules/investment/services/salary.service.ts create mode 100644 src/modules/investment/types/investment-limit.types.ts create mode 100644 src/modules/investment/types/salary.types.ts create mode 100644 src/modules/investment/utils/salary.utils.ts 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 @@ + + +