DONE: use full name for date in letter, include password in email after membership is done, block ic_number to exclude - (#9)
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:
@@ -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,
|
||||
|
||||
@@ -4,7 +4,7 @@ export const userMenu: Menu[] = [
|
||||
{
|
||||
icon: 'Users',
|
||||
route_name: 'list-users',
|
||||
title: 'Senarai Pengguna',
|
||||
title: 'Senarai Daftar Anggota',
|
||||
permission: 'lihat pengguna',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user