diff --git a/src/components/ui/field/FieldLabel.vue b/src/components/ui/field/FieldLabel.vue index 3ae5b56..91b70b8 100644 --- a/src/components/ui/field/FieldLabel.vue +++ b/src/components/ui/field/FieldLabel.vue @@ -3,13 +3,19 @@ import { cn } from "@mykopkb/core/utils/cn"; import { Label } from "@/components/ui/label"; import { fieldLabel } from "@mykopkb/core/styles/field.styles"; -const { class: className, ...props } = defineProps<{ +const { + class: className, + required = false, + ...props +} = defineProps<{ class?: string; + required?: boolean; }>(); + * diff --git a/src/main/side-menu.ts b/src/main/side-menu.ts index 8958ae5..2a120c8 100644 --- a/src/main/side-menu.ts +++ b/src/main/side-menu.ts @@ -7,6 +7,7 @@ import { dashboardMenu } from '@/modules/dashboard/menu' import { externalSystemMenu } from '@/modules/external-system/menu' import { activityLogMenu } from '@/modules/activity-log/menu' import { feedbackMenu } from '@/modules/feedback' +import { investmentMenu } from '@/modules/investment' export type { Menu } @@ -22,6 +23,8 @@ const mainMenu: (string | Menu)[] = [ 'Pentadbiran', ...membershipApplicationMenu, ...userMenu, + 'Akaun', + ...investmentMenu, ] export default mainMenu diff --git a/src/modules/investment/components/InvestmentTypeForm.vue b/src/modules/investment/components/InvestmentTypeForm.vue new file mode 100644 index 0000000..b1cd7ef --- /dev/null +++ b/src/modules/investment/components/InvestmentTypeForm.vue @@ -0,0 +1,227 @@ + + + + + + + Kod + + + + + Nama + + + + + + Penerangan + + + + + + Status + + + + + + + + + + {{ item.label }} + + + + + + + + Billplz Collection ID + + + + + + + Jumlah Minimum (MYR) + + + + + Jumlah Maksimum (MYR) + + + + + Had Dana (MYR) + + + + + + + Tarikh Mula + + + + + Tarikh Tamat + + + + + + (form.allow_billplz = checked === true)"> + + Benarkan pembayaran Billplz + + + (form.allow_salary_deduction = checked === true)"> + + Benarkan potongan gaji + + + + + + Batal + + + {{ saving ? 'Menyimpan...' : submitLabel || 'Simpan' }} + + + + diff --git a/src/modules/investment/composables/useInvestmentTypeList.ts b/src/modules/investment/composables/useInvestmentTypeList.ts new file mode 100644 index 0000000..2e32c52 --- /dev/null +++ b/src/modules/investment/composables/useInvestmentTypeList.ts @@ -0,0 +1,74 @@ +import { onMounted, ref, watch } from 'vue' +import debounce from 'lodash/debounce' +import { useApiPagination } from '@/composables/useApiPagination' +import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage' +import { listInvestmentTypes } from '../services/investment-type.service' +import type { InvestmentType } from '../types/investment-type.types' + +export function useInvestmentTypeList() { + const items = ref([]) + const loading = ref(false) + const error = ref(null) + const search = ref('') + const page = ref(1) + const itemsPerPage = ref(12) + const sortBy = ref('name') + const sortOrder = ref<'asc' | 'desc'>('asc') + + const { pagination, applyPagination } = useApiPagination({ per_page: 12 }) + + async function fetchItems(requestPage = page.value) { + loading.value = true + error.value = null + + try { + const data = await listInvestmentTypes({ + page: requestPage, + per_page: itemsPerPage.value, + sort_by: sortBy.value, + sort_order: sortOrder.value, + 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 senarai jenis pelaburan.') + items.value = [] + } finally { + loading.value = false + } + } + + const debouncedSearch = debounce(() => { + fetchItems(1) + }, 400) + + watch(search, () => { + debouncedSearch() + }) + + watch(page, (nextPage, previousPage) => { + if (nextPage !== previousPage) { + fetchItems(nextPage) + } + }) + + onMounted(() => { + fetchItems(1) + }) + + return { + items, + loading, + error, + search, + page, + itemsPerPage, + sortBy, + sortOrder, + pagination, + fetchItems, + } +} diff --git a/src/modules/investment/index.ts b/src/modules/investment/index.ts new file mode 100644 index 0000000..2d270b6 --- /dev/null +++ b/src/modules/investment/index.ts @@ -0,0 +1,2 @@ +export { investmentLayoutRoutes } from './routes' +export { investmentMenu } from './menu' diff --git a/src/modules/investment/menu.ts b/src/modules/investment/menu.ts new file mode 100644 index 0000000..a234d44 --- /dev/null +++ b/src/modules/investment/menu.ts @@ -0,0 +1,10 @@ +import type { Menu } from '@/core/types/menu' + +export const investmentMenu: Menu[] = [ + { + icon: 'PiggyBank', + route_name: 'list-investment-types', + title: 'Jenis Pelaburan', + permission: 'lihat jenis pelaburan', + }, +] diff --git a/src/modules/investment/pages/InvestmentTypeCreate.vue b/src/modules/investment/pages/InvestmentTypeCreate.vue new file mode 100644 index 0000000..01bc0da --- /dev/null +++ b/src/modules/investment/pages/InvestmentTypeCreate.vue @@ -0,0 +1,75 @@ + + + + + + Tambah Jenis Pelaburan + + Kembali + + + + + Ralat + {{ error }} + + + + Berjaya + {{ successMessage }} + + + + + + + diff --git a/src/modules/investment/pages/InvestmentTypeEdit.vue b/src/modules/investment/pages/InvestmentTypeEdit.vue new file mode 100644 index 0000000..05481b0 --- /dev/null +++ b/src/modules/investment/pages/InvestmentTypeEdit.vue @@ -0,0 +1,104 @@ + + + + + + Kemaskini Jenis Pelaburan + + Kembali + + + + + Ralat + {{ error }} + + + + Berjaya + {{ successMessage }} + + + Memuatkan... + + + + + + diff --git a/src/modules/investment/pages/InvestmentTypeList.vue b/src/modules/investment/pages/InvestmentTypeList.vue new file mode 100644 index 0000000..8649ac7 --- /dev/null +++ b/src/modules/investment/pages/InvestmentTypeList.vue @@ -0,0 +1,261 @@ + + + + + + + Jenis Pelaburan + Urus produk pelaburan koperasi. + + + + Tambah Jenis Pelaburan + + + + + + Ralat + {{ error }} + + + + + + + + + + + + + + + + + + + {{ item.name }} + + {{ item.code }} + + + + {{ investmentTypeStatusLabel(item.status) }} + + + + + + + + Min: {{ formatInvestmentAmount(item.minimum_amount) }} + + + + Had dana: {{ formatInvestmentAmount(item.total_fund_limit) }} + + + + {{ channelLabel(item) }} + + + + + + + Edit + + + Hapus + + + + + + + Tiada rekod ditemui + + + + + Previous + + + + {{ pageItem.value }} + + + + + Next + + + + + (deleteConfirmationOpen = details.open)" + > + + + + Adakah anda yakin? + + Hapus jenis pelaburan + {{ itemToDelete.name }}? + + Proses ini boleh dikembalikan melalui soft delete di pangkalan data. + + + {{ deleteError }} + + + + Batal + + {{ deleting ? 'Menghapus...' : 'Hapus' }} + + + + + + diff --git a/src/modules/investment/routes.ts b/src/modules/investment/routes.ts new file mode 100644 index 0000000..d0116e9 --- /dev/null +++ b/src/modules/investment/routes.ts @@ -0,0 +1,34 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const investmentLayoutRoutes: RouteRecordRaw[] = [ + { + path: 'list-investment-types', + name: 'list-investment-types', + component: () => import('./pages/InvestmentTypeList.vue'), + meta: { + title: 'Jenis Pelaburan', + module: 'investment', + permission: 'lihat jenis pelaburan', + }, + }, + { + path: 'investment-types/create', + name: 'create-investment-type', + component: () => import('./pages/InvestmentTypeCreate.vue'), + meta: { + title: 'Tambah Jenis Pelaburan', + module: 'investment', + permission: 'tambah jenis pelaburan', + }, + }, + { + path: 'investment-types/:id/edit', + name: 'edit-investment-type', + component: () => import('./pages/InvestmentTypeEdit.vue'), + meta: { + title: 'Kemaskini Jenis Pelaburan', + module: 'investment', + permission: 'kemaskini jenis pelaburan', + }, + }, +] diff --git a/src/modules/investment/services/investment-type.service.ts b/src/modules/investment/services/investment-type.service.ts new file mode 100644 index 0000000..10a9206 --- /dev/null +++ b/src/modules/investment/services/investment-type.service.ts @@ -0,0 +1,75 @@ +import { api } from '@/core/services/api' +import type { PaginatedApiResponse } from '@/core/types/api' +import type { + InvestmentType, + InvestmentTypePayload, + ListInvestmentTypesParams, +} from '../types/investment-type.types' + +type InvestmentTypeApiResponse = { + success: boolean + data: InvestmentType + message?: string +} + +type DeleteInvestmentTypeApiResponse = { + success: boolean + message?: string +} + +export async function listInvestmentTypes( + params?: ListInvestmentTypesParams, +): Promise> { + const { data } = await api.get>('/v1/investment-types', { + params, + }) + + if (!data.success) { + throw new Error(data.message ?? 'Gagal memuatkan senarai jenis pelaburan.') + } + + return data +} + +export async function getInvestmentType(id: string): Promise { + const { data } = await api.get(`/v1/investment-types/${id}`) + + if (!data.success) { + throw new Error(data.message ?? 'Gagal memuatkan jenis pelaburan.') + } + + return data.data +} + +export async function createInvestmentType( + payload: InvestmentTypePayload, +): Promise { + const { data } = await api.post('/v1/investment-types', payload) + + if (!data.success) { + throw new Error(data.message ?? 'Gagal menambah jenis pelaburan.') + } + + return data.data +} + +export async function updateInvestmentType( + id: string, + payload: InvestmentTypePayload, +): Promise { + const { data } = await api.put(`/v1/investment-types/${id}`, payload) + + if (!data.success) { + throw new Error(data.message ?? 'Gagal mengemaskini jenis pelaburan.') + } + + return data.data +} + +export async function deleteInvestmentType(id: string): Promise { + const { data } = await api.delete(`/v1/investment-types/${id}`) + + if (!data.success) { + throw new Error(data.message ?? 'Gagal menghapus jenis pelaburan.') + } +} diff --git a/src/modules/investment/types/investment-type.types.ts b/src/modules/investment/types/investment-type.types.ts new file mode 100644 index 0000000..bd62e1f --- /dev/null +++ b/src/modules/investment/types/investment-type.types.ts @@ -0,0 +1,45 @@ +export type InvestmentTypeStatus = 'ACTIVE' | 'INACTIVE' | 'CLOSED' + +export type InvestmentType = { + id: string + code: string + name: string + description: string | null + status: InvestmentTypeStatus + minimum_amount: string | number + maximum_amount: string | number | null + total_fund_limit: string | number | null + reserved_amount: string | number + start_date: string | null + end_date: string | null + allow_billplz: boolean + allow_salary_deduction: boolean + billplz_collection_id: string | null + created_by: string | null + updated_by: string | null + created_at: string + updated_at: string +} + +export type InvestmentTypePayload = { + code: string + name: string + description?: string | null + status: InvestmentTypeStatus + minimum_amount: number + maximum_amount?: number | null + total_fund_limit?: number | null + start_date?: string | null + end_date?: string | null + allow_billplz: boolean + allow_salary_deduction: boolean + billplz_collection_id?: string | null +} + +export type ListInvestmentTypesParams = { + page?: number + per_page?: number + search?: string + sort_by?: string + sort_order?: 'asc' | 'desc' +} diff --git a/src/modules/investment/utils/investment-type.utils.ts b/src/modules/investment/utils/investment-type.utils.ts new file mode 100644 index 0000000..7ff243b --- /dev/null +++ b/src/modules/investment/utils/investment-type.utils.ts @@ -0,0 +1,46 @@ +import type { BadgeVariants } from '@/components/ui/styles/badge.styles' +import type { InvestmentTypeStatus } from '../types/investment-type.types' + +export const INVESTMENT_TYPE_STATUS_OPTIONS: { label: string; value: InvestmentTypeStatus }[] = [ + { label: 'Aktif', value: 'ACTIVE' }, + { label: 'Tidak Aktif', value: 'INACTIVE' }, + { label: 'Ditutup', value: 'CLOSED' }, +] + +export function investmentTypeStatusLabel(status: InvestmentTypeStatus): string { + return INVESTMENT_TYPE_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status +} + +export function investmentTypeStatusVariant( + status: InvestmentTypeStatus, +): BadgeVariants['variant'] { + if (status === 'ACTIVE') return 'success' + if (status === 'INACTIVE') return 'pending' + return 'danger' +} + +export function formatInvestmentAmount(value: string | number | null | undefined): string { + if (value == null || value === '') return '-' + + const amount = typeof value === 'number' ? value : Number(value) + if (Number.isNaN(amount)) return '-' + + return new Intl.NumberFormat('ms-MY', { + style: 'currency', + currency: 'MYR', + }).format(amount) +} + +export function toOptionalNumber(value: string | number | null | undefined): number | null { + if (value == null || value === '') return null + + if (typeof value === 'number') { + return Number.isNaN(value) ? null : value + } + + const trimmed = String(value).trim() + if (!trimmed) return null + + const amount = Number(trimmed) + return Number.isNaN(amount) ? null : amount +} diff --git a/src/router/index.ts b/src/router/index.ts index b25cac3..508832e 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -14,6 +14,7 @@ import { dashboardLayoutRoutes } from '@/modules/dashboard' import { externalSystemLayoutRoutes } from '@/modules/external-system' import { activityLogLayoutRoutes } from '@/modules/activity-log' import { feedbackPublicRoutes, feedbackLayoutRoutes } from '@/modules/feedback' +import { investmentLayoutRoutes } from '@/modules/investment' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -32,6 +33,7 @@ const router = createRouter({ ...externalSystemLayoutRoutes, ...membershipApplicationLayoutRoutes, ...feedbackLayoutRoutes, + ...investmentLayoutRoutes, ], }, ...authPublicRoutes,
Urus produk pelaburan koperasi.