DONE: use full name for date in letter, include password in email after membership is done, block ic_number to exclude - (#9)
Build Docker Image / build-backend (push) Successful in 44s
Build Docker Image / build-frontend (push) Successful in 18s

Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-07-08 12:15:41 +08:00
parent 18c0d268b1
commit ebd3caecc6
23 changed files with 352 additions and 41 deletions
+13 -1
View File
@@ -100,6 +100,8 @@ const props = withDefaults(defineProps<{
currentSort?: SortConfig[];
exportable?: boolean;
exportFileName?: string;
/** Title shown at the top of exported PDF documents. */
exportPdfTitle?: string;
/** When true, table body scrolls inside a max-height box instead of growing the page. */
scrollable?: boolean;
/** Max height of the scrollable table area (CSS value). */
@@ -622,12 +624,22 @@ const exportToPDF = () => {
else if (colCount > 8) fontSize = 7;
const cellPadding = colCount > 8 ? 4 : 8;
let tableStartY = 20;
if (props.exportPdfTitle?.trim()) {
doc.setFontSize(14);
doc.text(props.exportPdfTitle.trim(), doc.internal.pageSize.getWidth() / 2, 28, {
align: 'center',
});
tableStartY = 48;
}
const tableOptions: Parameters<typeof autoTable>[1] = {
head: [headers],
body: data,
startY: tableStartY,
styles: { fontSize, cellPadding },
headStyles: { fillColor: [66, 139, 202] },
margin: { top: 20 },
margin: { top: tableStartY },
};
// For wide tables: let table use natural column widths and split across pages
@@ -59,7 +59,7 @@ const availableModules = computed(() => [
visible: hasPermission('lihat aktiviti'),
},
{
title: 'Senarai Pengguna',
title: 'Senarai Daftar Anggota',
description: 'Pantau dan urus akaun pengguna sistem.',
route: 'list-users',
icon: 'Users' as Icon,
@@ -22,6 +22,7 @@ import { AlertRoot, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Lucide } from '@/components/ui/lucide'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
import { sanitizeIcNumberInput } from '../utils/membership-application-form.utils'
import type {
MembershipApplicationFormState,
MembershipApplicationHeirForm,
@@ -37,7 +38,7 @@ const MIN_FEE_MONTHLY_CONTRIBUTION = 30
const steps = [
{ id: 1, label: 'Maklumat Peribadi' },
{ id: 2, label: 'Hubungan & Alamat' },
{ id: 2, label: 'No. Telefon & Alamat' },
{ id: 3, label: 'Maklumat Pekerjaan' },
{ id: 4, label: 'Maklumat Penama' },
{ id: 5, label: 'Dokumen & Hantar' },
@@ -221,9 +222,22 @@ function clearReference(role: 'proposer' | 'supporter') {
}
function handleReferenceIcInput(role: 'proposer' | 'supporter') {
form.references[role].ic_number = sanitizeIcNumberInput(form.references[role].ic_number)
clearReference(role)
}
function handleApplicantIcInput() {
form.applicant.ic_number = sanitizeIcNumberInput(form.applicant.ic_number)
delete fieldErrors['applicant.ic_number']
}
function handleHeirIcInput(index: number) {
const heir = form.heirs[index]
if (!heir) return
heir.ic_number = sanitizeIcNumberInput(heir.ic_number)
delete fieldErrors[`heirs.${index}.ic_number`]
}
async function lookupReference(role: 'proposer' | 'supporter') {
const reference = form.references[role]
const icNumber = reference.ic_number.trim()
@@ -618,7 +632,8 @@ function stepLabelClass(stepId: number) {
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="ic_number">No. Kad Pengenalan</FieldLabel>
<Input id="ic_number" v-model="form.applicant.ic_number" type="text" />
<Input id="ic_number" v-model="form.applicant.ic_number" type="text" inputmode="numeric" maxlength="15"
placeholder="Contoh: 900101011234" @input="handleApplicantIcInput" />
<FieldError v-if="fieldErrors['applicant.ic_number']">{{ fieldErrors['applicant.ic_number'] }}
</FieldError>
</Field>
@@ -800,7 +815,8 @@ function stepLabelClass(stepId: number) {
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel>
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" />
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" inputmode="numeric"
maxlength="15" placeholder="Contoh: 900101011234" @input="handleHeirIcInput(index)" />
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">
{{ fieldErrors[`heirs.${index}.ic_number`] }}
</FieldError>
@@ -857,8 +873,9 @@ function stepLabelClass(stepId: number) {
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="proposer_ic_number">No. KP Pencadang</FieldLabel>
<Input id="proposer_ic_number" v-model="form.references.proposer.ic_number" type="text"
placeholder="Contoh: 900101011234" :disabled="referenceLookupLoading.proposer"
@input="handleReferenceIcInput('proposer')" @blur="lookupReference('proposer')" />
inputmode="numeric" maxlength="12" placeholder="Contoh: 900101011234"
:disabled="referenceLookupLoading.proposer" @input="handleReferenceIcInput('proposer')"
@blur="lookupReference('proposer')" />
<p v-if="form.references.proposer.name" class="mt-1 text-sm text-success">
{{ form.references.proposer.name }}
</p>
@@ -870,8 +887,9 @@ function stepLabelClass(stepId: number) {
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="supporter_ic_number">No. KP Penyokong</FieldLabel>
<Input id="supporter_ic_number" v-model="form.references.supporter.ic_number" type="text"
placeholder="Contoh: 850505055678" :disabled="referenceLookupLoading.supporter"
@input="handleReferenceIcInput('supporter')" @blur="lookupReference('supporter')" />
inputmode="numeric" maxlength="12" placeholder="Contoh: 850505055678"
:disabled="referenceLookupLoading.supporter" @input="handleReferenceIcInput('supporter')"
@blur="lookupReference('supporter')" />
<p v-if="form.references.supporter.name" class="mt-1 text-sm text-success">
{{ form.references.supporter.name }}
</p>
@@ -907,7 +925,7 @@ function stepLabelClass(stepId: number) {
<Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png"
@change="handleFileChange('employer_letter', $event)" />
<FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter']
}}</FieldError>
}}</FieldError>
</Field>
<div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4">
@@ -65,6 +65,8 @@ const DOCUMENT_TYPE_LABELS: Record<string, string> = {
[RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan',
}
const BOARD_MEETING_REFERENCE_PREFIX = 'Mesyuarat Lembaga Bil.'
const router = useRouter()
const route = useRoute()
const { hasPermission } = usePermissions()
@@ -430,8 +432,10 @@ async function confirmPendingAction() {
})
boardRemarks.value = ''
} else {
const boardMeetingReferenceValue = `${BOARD_MEETING_REFERENCE_PREFIX} ${boardMeetingReference.value.trim()}`
response = await completeMembershipApplication(applicationId.value, {
board_meeting_reference: boardMeetingReference.value.trim(),
board_meeting_reference: boardMeetingReferenceValue,
board_meeting_date: boardMeetingDate.value,
})
}
@@ -1013,9 +1017,15 @@ onUnmounted(() => {
<div class="mt-2 opacity-70">{{ confirmDialogDescription }}</div>
<Field v-if="pendingAction?.type === 'complete'" class="mt-5 text-left">
<FieldLabel for="detail-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
<Input id="detail-board-meeting-reference" v-model="boardMeetingReference" type="text"
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026" :disabled="completeSubmitting"
@input="boardMeetingReferenceError = null" />
<div class="flex">
<span
class="inline-flex items-center rounded-l-lg border border-r-0 border-foreground/10 bg-foreground/5 px-3 text-sm opacity-80">
{{ BOARD_MEETING_REFERENCE_PREFIX }}
</span>
<Input id="detail-board-meeting-reference" v-model="boardMeetingReference" type="text"
class="rounded-l-none" placeholder="Contoh: 3/2026" :disabled="completeSubmitting"
@input="boardMeetingReferenceError = null" />
</div>
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
</Field>
<Field v-if="pendingAction?.type === 'complete'" class="mt-4 text-left">
@@ -61,6 +61,10 @@ export type ApplicantDocumentUploadType = (typeof APPLICANT_DOCUMENT_UPLOAD_TYPE
export type AdminDocumentUploadType = (typeof ADMIN_DOCUMENT_UPLOAD_TYPES)[number]
export type DocumentUploadType = ApplicantDocumentUploadType | AdminDocumentUploadType
export function sanitizeIcNumberInput(value: string): string {
return value.replace(/\D/g, '')
}
export function createSelectCollection(options: SelectOption[]) {
return select.collection({
items: options,
+36 -1
View File
@@ -1,4 +1,4 @@
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import debounce from 'lodash/debounce'
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
import { useApiPagination } from '@/composables/useApiPagination'
@@ -12,12 +12,29 @@ export function useUserList() {
const error = ref<string | null>(null)
const search = ref('')
const statusFilter = ref('')
const joinDateFrom = ref('')
const joinDateTo = ref('')
const leaveDateFrom = ref('')
const leaveDateTo = ref('')
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
const page = ref(1)
const itemsPerPage = ref(10)
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
const hasJoinDateFilters = computed(() => Boolean(joinDateFrom.value || joinDateTo.value))
const hasLeaveDateFilters = computed(() => Boolean(leaveDateFrom.value || leaveDateTo.value))
function clearJoinDateFilters() {
joinDateFrom.value = ''
joinDateTo.value = ''
}
function clearLeaveDateFilters() {
leaveDateFrom.value = ''
leaveDateTo.value = ''
}
async function fetchUsers(requestPage = page.value) {
loading.value = true
error.value = null
@@ -31,11 +48,17 @@ export function useUserList() {
sort_order: activeSort?.order ?? 'asc',
search: search.value.trim() || undefined,
status: statusFilter.value.trim() || undefined,
join_date_from: joinDateFrom.value || undefined,
join_date_to: joinDateTo.value || undefined,
leave_date_from: leaveDateFrom.value || undefined,
leave_date_to: leaveDateTo.value || undefined,
})
users.value = data.data
applyPagination(data.pagination)
page.value = data.pagination.current_page
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai pengguna.')
} finally {
loading.value = false
}
@@ -58,6 +81,10 @@ export function useUserList() {
fetchUsers(1)
})
watch([joinDateFrom, joinDateTo, leaveDateFrom, leaveDateTo], () => {
fetchUsers(1)
})
watch(page, (nextPage, previousPage) => {
if (nextPage !== previousPage) {
fetchUsers(nextPage)
@@ -80,6 +107,14 @@ export function useUserList() {
error,
search,
statusFilter,
joinDateFrom,
joinDateTo,
leaveDateFrom,
leaveDateTo,
hasJoinDateFilters,
hasLeaveDateFilters,
clearJoinDateFilters,
clearLeaveDateFilters,
sortBy,
page,
itemsPerPage,
+1 -1
View File
@@ -4,7 +4,7 @@ export const userMenu: Menu[] = [
{
icon: 'Users',
route_name: 'list-users',
title: 'Senarai Pengguna',
title: 'Senarai Daftar Anggota',
permission: 'lihat pengguna',
},
]
+27 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, 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'
@@ -115,6 +116,7 @@ const form = reactive({
phone_number: '',
member_number: '',
join_date: '',
leave_date: '',
birth_date: '',
birth_place: '',
})
@@ -132,6 +134,7 @@ function syncFormFromUser(user: Awaited<ReturnType<typeof getUser>>['data']) {
form.phone_number = user.phone_number ?? ''
form.member_number = user.member_number != null ? String(user.member_number) : ''
form.join_date = toDateInputValue(user.join_date)
form.leave_date = toDateInputValue(user.leave_date)
form.birth_date = toDateInputValue(user.birth_date)
form.birth_place = user.birth_place ?? ''
statusValue.value = apiValueToLabel(STATUS_OPTIONS, user.status ?? 'active')
@@ -159,6 +162,17 @@ async function fetchUser() {
}
}
const selectedStatus = computed(
() => labelToApiValue(STATUS_OPTIONS, statusValue.value[0]) ?? 'active',
)
const isInactiveStatus = computed(() => selectedStatus.value === 'inactive')
watch(selectedStatus, (newStatus, oldStatus) => {
if (newStatus === 'inactive' && oldStatus !== 'inactive' && !form.leave_date) {
form.leave_date = dayjs().format('YYYY-MM-DD')
}
})
async function handleSubmit() {
saving.value = true
error.value = null
@@ -176,6 +190,7 @@ async function handleSubmit() {
member_number: form.member_number ? Number(form.member_number) : null,
member_type: labelToApiValue(MEMBER_TYPE_OPTIONS, memberTypeValue.value[0]),
join_date: form.join_date || null,
leave_date: isInactiveStatus.value ? form.leave_date || null : null,
birth_date: form.birth_date || null,
birth_place: form.birth_place.trim() || null,
})
@@ -351,6 +366,17 @@ onMounted(() => {
<Input id="user-join-date" v-model="form.join_date" class="w-full" type="date" :disabled="formDisabled" />
</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"
/>
</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" />
+84 -8
View File
@@ -36,9 +36,9 @@ type StatusFilterChip = {
const STATUS_FILTER_CHIPS: StatusFilterChip[] = [
{ label: 'Semua', value: '', variant: 'ghost' },
{ label: 'Active', value: 'active', variant: 'success' },
{ label: 'Inactive', value: 'inactive', variant: 'danger' },
{ label: 'Pending', value: 'pending', variant: 'pending' },
{ label: 'Aktif', value: 'active', variant: 'success' },
{ label: 'Tidak Aktif', value: 'inactive', variant: 'danger' },
{ label: 'Menunggu', value: 'pending', variant: 'pending' },
]
const router = useRouter()
@@ -97,6 +97,11 @@ function formatDeletedAt(value: string | null | undefined): string {
return dayjs(value).format('DD MMM YYYY, HH:mm')
}
function formatUserDate(value: string | null | undefined): string {
if (!value) return '-'
return dayjs(value).format('DD MMM YYYY')
}
function openDeleteConfirmation(user: UserListItem) {
userToDelete.value = user
deleteError.value = null
@@ -217,9 +222,20 @@ async function confirmRestore() {
const headers: TableHeader[] = [
{ title: 'Bil.', key: '#', sortable: false },
{ title: 'Name', key: 'name', sortable: true },
{ title: 'Emel', key: 'email', sortable: true },
{ title: 'Jawatan', key: 'position', sortable: true },
{ title: 'No. Anggota', key: 'member_number', sortable: true, align: 'center' },
{
title: 'Tarikh Menjadi Anggota',
key: 'join_date',
sortable: true,
exportValue: (item) => formatUserDate(item.join_date),
},
{
title: 'Tarikh Berhenti',
key: 'leave_date',
sortable: true,
exportValue: (item) => formatUserDate(item.leave_date),
},
{
title: 'Peranan',
key: 'roles',
@@ -253,6 +269,14 @@ const {
error,
search,
statusFilter,
joinDateFrom,
joinDateTo,
leaveDateFrom,
leaveDateTo,
hasJoinDateFilters,
hasLeaveDateFilters,
clearJoinDateFilters,
clearLeaveDateFilters,
sortBy,
page,
itemsPerPage,
@@ -306,8 +330,8 @@ onMounted(() => {
<template>
<div class="w-full space-y-6">
<div>
<h2 class="text-lg font-medium">Senarai Pengguna</h2>
<p class="mt-1 text-sm opacity-70">Urus dan semak pengguna koperasi.</p>
<h2 class="text-lg font-medium">Senarai Daftar Anggota</h2>
<p class="mt-1 text-sm opacity-70">Urus dan semak anggota koperasi.</p>
</div>
<AlertRoot v-if="error" class="mt-6" variant="danger">
<AlertTitle>Error</AlertTitle>
@@ -315,8 +339,8 @@ onMounted(() => {
</AlertRoot>
<DataTable :headers="headers" :items="users" :loading="loading" :pagination="pagination" :current-sort="sortBy"
show-pagination exportable export-file-name="users" v-model:page="page" v-model:items-per-page="itemsPerPage"
@update:sort-by="handleSortUpdate">
show-pagination exportable export-file-name="users" export-pdf-title="Senarai Daftar Anggota" v-model:page="page"
v-model:items-per-page="itemsPerPage" @update:sort-by="handleSortUpdate">
<template #toolbar>
<div class="flex w-full flex-col gap-3">
<div class="flex w-full flex-wrap items-center gap-3">
@@ -341,6 +365,50 @@ onMounted(() => {
{{ chip.label }}
</Badge>
</div>
<div class="flex flex-col gap-3">
<div class="rounded-lg border border-foreground/10 p-3">
<div class="flex flex-wrap items-end gap-3">
<div class="flex min-w-[16rem] flex-1 flex-col gap-1.5">
<span class="text-sm font-medium">Tarikh Menjadi Anggota</span>
<div class="flex items-center gap-2">
<Input v-model="joinDateFrom" type="date" aria-label="Tarikh menjadi anggota dari" />
<span class="text-sm opacity-50"></span>
<Input v-model="joinDateTo" type="date" aria-label="Tarikh menjadi anggota hingga" />
</div>
</div>
<Button
v-if="hasJoinDateFilters"
type="button"
variant="ghost"
look="outline"
@click="clearJoinDateFilters"
>
Reset
</Button>
</div>
</div>
<div class="rounded-lg border border-foreground/10 p-3">
<div class="flex flex-wrap items-end gap-3">
<div class="flex min-w-[16rem] flex-1 flex-col gap-1.5">
<span class="text-sm font-medium">Tarikh Berhenti</span>
<div class="flex items-center gap-2">
<Input v-model="leaveDateFrom" type="date" aria-label="Tarikh berhenti dari" />
<span class="text-sm opacity-50"></span>
<Input v-model="leaveDateTo" type="date" aria-label="Tarikh berhenti hingga" />
</div>
</div>
<Button
v-if="hasLeaveDateFilters"
type="button"
variant="ghost"
look="outline"
@click="clearLeaveDateFilters"
>
Reset
</Button>
</div>
</div>
</div>
</div>
</template>
@@ -369,6 +437,14 @@ onMounted(() => {
<span class="text-center">{{ item.member_number }}</span>
</template>
<template #item.join_date="{ item }">
{{ formatUserDate(item.join_date) }}
</template>
<template #item.leave_date="{ item }">
{{ formatUserDate(item.leave_date) }}
</template>
<!-- centre align status -->
<template #item.status="{ item }">
<Badge :variant="statusBadgeVariant(item.status)" class="capitalize text-center">
@@ -107,6 +107,10 @@ function formatAddressLine(address: Address) {
<FieldLabel for="view-user-join-date">Tarikh Sertai</FieldLabel>
<Input id="view-user-join-date" :model-value="formatDate(user.join_date)" type="text" disabled />
</Field>
<Field v-if="user.status === 'inactive' || user.leave_date">
<FieldLabel for="view-user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel>
<Input id="view-user-leave-date" :model-value="formatDate(user.leave_date)" type="text" disabled />
</Field>
<Field>
<FieldLabel for="view-user-birth-date">Tarikh Lahir</FieldLabel>
<Input id="view-user-birth-date" :model-value="formatDate(user.birth_date)" type="text" disabled />
+8
View File
@@ -26,6 +26,8 @@ export interface UserListItem {
deleted_at?: string | null
roles: UserRole[]
member_number?: number | null
join_date?: string | null
leave_date?: string | null
}
export interface UserDetail {
@@ -42,6 +44,7 @@ export interface UserDetail {
member_number: number | null
member_type: string | null
join_date: string | null
leave_date: string | null
birth_date: string | null
birth_place: string | null
roles: UserRole[]
@@ -62,6 +65,7 @@ export interface UpdateUserPayload {
member_number?: number | null
member_type?: string | null
join_date?: string | null
leave_date?: string | null
birth_date?: string | null
birth_place?: string | null
}
@@ -89,6 +93,10 @@ export interface ListUsersParams {
sort_order: string
search?: string
status?: string
join_date_from?: string
join_date_to?: string
leave_date_from?: string
leave_date_to?: string
}
export interface ListDeletedUsersParams {