DONE: layout letter with letterhead and footer, notification for newly...
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
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 { listDeletedUsers } from '../services/user.service'
|
||||
import type { UserListItem } from '../types/user.types'
|
||||
|
||||
export function useDeletedUserList(options?: { autoWatchFilters?: boolean; autoFetchOnMount?: boolean }) {
|
||||
const autoWatchFilters = options?.autoWatchFilters ?? true
|
||||
const autoFetchOnMount = options?.autoFetchOnMount ?? true
|
||||
const users = ref<UserListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const statusFilter = ref('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'deleted_at', order: 'desc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchUsers(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const activeSort = sortBy.value[0]
|
||||
const data = await listDeletedUsers({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
sort_by: activeSort?.key ?? 'deleted_at',
|
||||
sort_order: activeSort?.order ?? 'desc',
|
||||
search: search.value.trim() || undefined,
|
||||
status: statusFilter.value.trim() || undefined,
|
||||
})
|
||||
|
||||
users.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai pengguna yang dipadam.')
|
||||
users.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
fetchUsers(1)
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchUsers(1)
|
||||
}, 400)
|
||||
|
||||
if (autoWatchFilters) {
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch(statusFilter, () => {
|
||||
fetchUsers(1)
|
||||
})
|
||||
}
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchUsers(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchUsers(1)
|
||||
}
|
||||
})
|
||||
|
||||
if (autoFetchOnMount) {
|
||||
onMounted(() => {
|
||||
fetchUsers(1)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchUsers,
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ function userCanImpersonate(): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function targetCanBeImpersonated(target: UserListItem, currentUserId?: string): boolean {
|
||||
function isImpersonateTargetVisible(target: UserListItem, currentUserId?: string): boolean {
|
||||
if (!currentUserId || target.id === currentUserId) {
|
||||
return false
|
||||
}
|
||||
@@ -36,6 +36,14 @@ function targetCanBeImpersonated(target: UserListItem, currentUserId?: string):
|
||||
return !target.roles?.some((role) => role.name === DEVELOPER_ROLE)
|
||||
}
|
||||
|
||||
function targetCanBeImpersonated(target: UserListItem, currentUserId?: string): boolean {
|
||||
if (!isImpersonateTargetVisible(target, currentUserId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return target.status === 'active'
|
||||
}
|
||||
|
||||
export function useImpersonate() {
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
@@ -44,10 +52,26 @@ export function useImpersonate() {
|
||||
const impersonating = computed(() => authStore.isImpersonating)
|
||||
const canImpersonate = computed(() => userCanImpersonate())
|
||||
|
||||
function showImpersonateButton(target: UserListItem): boolean {
|
||||
return canImpersonate.value && isImpersonateTargetVisible(target, authStore.user?.id)
|
||||
}
|
||||
|
||||
function canImpersonateUser(target: UserListItem): boolean {
|
||||
return canImpersonate.value && targetCanBeImpersonated(target, authStore.user?.id)
|
||||
}
|
||||
|
||||
function impersonateButtonTitle(target: UserListItem): string {
|
||||
if (impersonating.value) {
|
||||
return 'Anda sedang menyamar pengguna'
|
||||
}
|
||||
|
||||
if (target.status !== 'active') {
|
||||
return 'Hanya pengguna aktif boleh disamar'
|
||||
}
|
||||
|
||||
return 'Menyamar sebagai pengguna'
|
||||
}
|
||||
|
||||
async function refreshImpersonationStatus() {
|
||||
await authStore.refreshImpersonationStatus()
|
||||
}
|
||||
@@ -136,7 +160,9 @@ export function useImpersonate() {
|
||||
|
||||
return {
|
||||
canImpersonate,
|
||||
showImpersonateButton,
|
||||
canImpersonateUser,
|
||||
impersonateButtonTitle,
|
||||
impersonating,
|
||||
loading,
|
||||
refreshImpersonationStatus,
|
||||
|
||||
@@ -85,5 +85,6 @@ export function useUserList() {
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchUsers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ export const userMenu: Menu[] = [
|
||||
icon: 'Users',
|
||||
route_name: 'list-users',
|
||||
title: 'Senarai Pengguna',
|
||||
permission: 'lihat pengguna',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import type { UserBankDetail, UserDetail } from '../types/user.types'
|
||||
|
||||
defineProps<{
|
||||
user: UserDetail
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const ACCOUNT_TYPE_LABEL: Record<string, string> = {
|
||||
Saving: 'Simpanan',
|
||||
Current: 'Semasa',
|
||||
}
|
||||
|
||||
function bankLabel(bankDetail: UserBankDetail): string {
|
||||
if (bankDetail.bank) {
|
||||
return `${bankDetail.bank.name} (${bankDetail.bank.code})`
|
||||
}
|
||||
return bankDetail.bank_id
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? '' : 'mt-5'">
|
||||
<Box raised="single" class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Akaun Bank</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Senarai akaun bank pengguna.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="user.bank_details?.length" class="space-y-3">
|
||||
<div
|
||||
v-for="bankDetail in user.bank_details"
|
||||
:key="bankDetail.id"
|
||||
class="rounded-lg border border-foreground/10 p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-slate-900">{{ bankLabel(bankDetail) }}</span>
|
||||
<Badge look="outline">
|
||||
{{ ACCOUNT_TYPE_LABEL[bankDetail.account_type] ?? bankDetail.account_type }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-medium text-slate-700">{{ bankDetail.account_name }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ bankDetail.account_number }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
Tiada akaun bank direkodkan.
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,441 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as select from '@zag-js/select'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { createUser } from '../services/user.service'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const STATUS_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Inactive', value: 'inactive' },
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
]
|
||||
|
||||
const GENDER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Lelaki', value: 'Lelaki' },
|
||||
{ label: 'Perempuan', value: 'Perempuan' },
|
||||
]
|
||||
|
||||
const MARRIAGE_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Belum Berkahwin', value: 'Belum Berkahwin' },
|
||||
{ label: 'Berkahwin', value: 'Berkahwin' },
|
||||
{ label: 'Bercerai', value: 'Bercerai' },
|
||||
{ label: 'Balu', value: 'Balu' },
|
||||
{ label: 'Duda', value: 'Duda' },
|
||||
]
|
||||
|
||||
const MEMBER_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Anggota', value: 'Anggota' },
|
||||
{ label: 'Pesara', value: 'Pesara' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToApiValue(options: SelectOption[], label: string | undefined): string | null {
|
||||
if (!label) return null
|
||||
return options.find((option) => option.label === label)?.value ?? null
|
||||
}
|
||||
|
||||
const statusCollection = createSelectCollection(STATUS_OPTIONS)
|
||||
const genderCollection = createSelectCollection(GENDER_OPTIONS)
|
||||
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
|
||||
const memberTypeCollection = createSelectCollection(MEMBER_TYPE_OPTIONS)
|
||||
|
||||
const statusValue = ref<string[]>(['Pending'])
|
||||
const genderValue = ref<string[]>([])
|
||||
const marriageStatusValue = ref<string[]>([])
|
||||
const memberTypeValue = ref<string[]>([])
|
||||
|
||||
const statusInitial = ref<string[]>(['Pending'])
|
||||
|
||||
function setStatusValue(details: { value: string[] }) {
|
||||
statusValue.value = details.value
|
||||
}
|
||||
|
||||
function setGenderValue(details: { value: string[] }) {
|
||||
genderValue.value = details.value
|
||||
}
|
||||
|
||||
function setMarriageStatusValue(details: { value: string[] }) {
|
||||
marriageStatusValue.value = details.value
|
||||
}
|
||||
|
||||
function setMemberTypeValue(details: { value: string[] }) {
|
||||
memberTypeValue.value = details.value
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
ic_number: '',
|
||||
position: '',
|
||||
phone_number: '',
|
||||
member_number: '',
|
||||
join_date: '',
|
||||
birth_date: '',
|
||||
birth_place: '',
|
||||
})
|
||||
|
||||
function requireSelectValue(label: string | undefined, fieldName: string): string {
|
||||
if (!label) {
|
||||
throw new Error(`${fieldName} diperlukan.`)
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const status = labelToApiValue(STATUS_OPTIONS, statusValue.value[0])
|
||||
const gender = labelToApiValue(GENDER_OPTIONS, genderValue.value[0])
|
||||
const marriageStatus = labelToApiValue(MARRIAGE_STATUS_OPTIONS, marriageStatusValue.value[0])
|
||||
const memberType = labelToApiValue(MEMBER_TYPE_OPTIONS, memberTypeValue.value[0])
|
||||
|
||||
if (!status) throw new Error('Status pengguna diperlukan.')
|
||||
requireSelectValue(genderValue.value[0], 'Jantina')
|
||||
requireSelectValue(marriageStatusValue.value[0], 'Status perkahwinan')
|
||||
requireSelectValue(memberTypeValue.value[0], 'Jenis anggota')
|
||||
|
||||
if (!form.member_number) throw new Error('Nombor anggota diperlukan.')
|
||||
if (!form.join_date) throw new Error('Tarikh sertai diperlukan.')
|
||||
if (!form.birth_date) throw new Error('Tarikh lahir diperlukan.')
|
||||
if (!form.birth_place.trim()) throw new Error('Tempat lahir diperlukan.')
|
||||
|
||||
await createUser({
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
ic_number: form.ic_number.trim(),
|
||||
position: form.position.trim(),
|
||||
phone_number: form.phone_number.trim() || null,
|
||||
status,
|
||||
gender: gender!,
|
||||
marriage_status: marriageStatus!,
|
||||
member_number: Number(form.member_number),
|
||||
member_type: memberType!,
|
||||
join_date: form.join_date,
|
||||
birth_date: form.birth_date,
|
||||
birth_place: form.birth_place.trim(),
|
||||
})
|
||||
|
||||
successMessage.value = 'Pengguna berjaya didaftarkan.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'list-users' })
|
||||
}, 300)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mendaftarkan pengguna.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formDisabled = computed(() => saving.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Daftar Pengguna</h2>
|
||||
<Button
|
||||
look="outline"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="router.push({ name: 'list-users' })"
|
||||
>
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box>
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-name">Nama</FieldLabel>
|
||||
<Input
|
||||
id="user-name"
|
||||
v-model="form.name"
|
||||
class="w-full"
|
||||
type="text"
|
||||
placeholder="Nama penuh"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-email">Emel</FieldLabel>
|
||||
<Input
|
||||
id="user-email"
|
||||
v-model="form.email"
|
||||
class="w-full"
|
||||
type="email"
|
||||
placeholder="emel@example.com"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-ic">Nombor Kad Pengenalan</FieldLabel>
|
||||
<Input
|
||||
id="user-ic"
|
||||
v-model="form.ic_number"
|
||||
class="w-full"
|
||||
type="text"
|
||||
placeholder="Nombor kad pengenalan"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-phone">Nombor Telefon</FieldLabel>
|
||||
<Input
|
||||
id="user-phone"
|
||||
v-model="form.phone_number"
|
||||
class="w-full"
|
||||
type="text"
|
||||
placeholder="Nombor telefon"
|
||||
:disabled="formDisabled"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-position">Jawatan</FieldLabel>
|
||||
<Input
|
||||
id="user-position"
|
||||
v-model="form.position"
|
||||
class="w-full"
|
||||
type="text"
|
||||
placeholder="Jawatan"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status Pengguna</FieldLabel>
|
||||
<SelectRoot
|
||||
class="w-full"
|
||||
:collection="statusCollection"
|
||||
:default-value="statusInitial"
|
||||
:disabled="formDisabled"
|
||||
@value-change="setStatusValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in statusCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Jantina</FieldLabel>
|
||||
<SelectRoot
|
||||
class="w-full"
|
||||
:collection="genderCollection"
|
||||
:disabled="formDisabled"
|
||||
@value-change="setGenderValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih jantina" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jantina</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in genderCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status Perkahwinan</FieldLabel>
|
||||
<SelectRoot
|
||||
class="w-full"
|
||||
:collection="marriageStatusCollection"
|
||||
:disabled="formDisabled"
|
||||
@value-change="setMarriageStatusValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih status perkahwinan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status Perkahwinan</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in marriageStatusCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-member-number">Nombor Anggota</FieldLabel>
|
||||
<Input
|
||||
id="user-member-number"
|
||||
v-model="form.member_number"
|
||||
class="w-full"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="Nombor anggota"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Jenis Anggota</FieldLabel>
|
||||
<SelectRoot
|
||||
class="w-full"
|
||||
:collection="memberTypeCollection"
|
||||
:disabled="formDisabled"
|
||||
@value-change="setMemberTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih jenis anggota" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jenis Anggota</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in memberTypeCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-join-date">Tarikh Sertai</FieldLabel>
|
||||
<Input
|
||||
id="user-join-date"
|
||||
v-model="form.join_date"
|
||||
class="w-full"
|
||||
type="date"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
/>
|
||||
</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"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-birth-place">Tempat Lahir</FieldLabel>
|
||||
<Input
|
||||
id="user-birth-place"
|
||||
v-model="form.birth_place"
|
||||
class="w-full"
|
||||
type="text"
|
||||
placeholder="Tempat lahir"
|
||||
:disabled="formDisabled"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-2">
|
||||
<Button type="submit" variant="primary" look="outline" :disabled="formDisabled">
|
||||
{{ saving ? 'Mendaftar...' : 'Daftar' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,374 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import * as select from '@zag-js/select'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getUser, updateUser } from '../services/user.service'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const STATUS_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Inactive', value: 'inactive' },
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
]
|
||||
|
||||
const GENDER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Lelaki', value: 'Lelaki' },
|
||||
{ label: 'Perempuan', value: 'Perempuan' },
|
||||
]
|
||||
|
||||
const MARRIAGE_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Belum Berkahwin', value: 'Belum Berkahwin' },
|
||||
{ label: 'Berkahwin', value: 'Berkahwin' },
|
||||
{ label: 'Bercerai', value: 'Bercerai' },
|
||||
{ label: 'Balu', value: 'Balu' },
|
||||
{ label: 'Duda', value: 'Duda' },
|
||||
]
|
||||
|
||||
const MEMBER_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Anggota', value: 'Anggota' },
|
||||
{ label: 'Pesara', value: 'Pesara' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToApiValue(options: SelectOption[], label: string | undefined): string | null {
|
||||
if (!label) return null
|
||||
return options.find((option) => option.label === label)?.value ?? null
|
||||
}
|
||||
|
||||
function apiValueToLabel(options: SelectOption[], value: string | null | undefined): string[] {
|
||||
if (!value) return []
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : []
|
||||
}
|
||||
|
||||
const statusCollection = createSelectCollection(STATUS_OPTIONS)
|
||||
const genderCollection = createSelectCollection(GENDER_OPTIONS)
|
||||
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
|
||||
const memberTypeCollection = createSelectCollection(MEMBER_TYPE_OPTIONS)
|
||||
|
||||
const statusValue = ref<string[]>([])
|
||||
const genderValue = ref<string[]>([])
|
||||
const marriageStatusValue = ref<string[]>([])
|
||||
const memberTypeValue = ref<string[]>([])
|
||||
|
||||
const statusInitial = ref<string[]>([])
|
||||
const genderInitial = ref<string[]>([])
|
||||
const marriageStatusInitial = ref<string[]>([])
|
||||
const memberTypeInitial = ref<string[]>([])
|
||||
|
||||
function setStatusValue(details: { value: string[] }) {
|
||||
statusValue.value = details.value
|
||||
}
|
||||
|
||||
function setGenderValue(details: { value: string[] }) {
|
||||
genderValue.value = details.value
|
||||
}
|
||||
|
||||
function setMarriageStatusValue(details: { value: string[] }) {
|
||||
marriageStatusValue.value = details.value
|
||||
}
|
||||
|
||||
function setMemberTypeValue(details: { value: string[] }) {
|
||||
memberTypeValue.value = details.value
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const userId = computed(() => String(route.params.id ?? ''))
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const email = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
ic_number: '',
|
||||
position: '',
|
||||
phone_number: '',
|
||||
member_number: '',
|
||||
join_date: '',
|
||||
birth_date: '',
|
||||
birth_place: '',
|
||||
})
|
||||
|
||||
function toDateInputValue(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
|
||||
function syncFormFromUser(user: Awaited<ReturnType<typeof getUser>>['data']) {
|
||||
email.value = user.email ?? ''
|
||||
form.name = user.name ?? ''
|
||||
form.ic_number = user.ic_number ?? ''
|
||||
form.position = user.position ?? ''
|
||||
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.birth_date = toDateInputValue(user.birth_date)
|
||||
form.birth_place = user.birth_place ?? ''
|
||||
statusValue.value = apiValueToLabel(STATUS_OPTIONS, user.status ?? 'active')
|
||||
genderValue.value = apiValueToLabel(GENDER_OPTIONS, user.gender)
|
||||
marriageStatusValue.value = apiValueToLabel(MARRIAGE_STATUS_OPTIONS, user.marriage_status)
|
||||
memberTypeValue.value = apiValueToLabel(MEMBER_TYPE_OPTIONS, user.member_type)
|
||||
statusInitial.value = [...statusValue.value]
|
||||
genderInitial.value = [...genderValue.value]
|
||||
marriageStatusInitial.value = [...marriageStatusValue.value]
|
||||
memberTypeInitial.value = [...memberTypeValue.value]
|
||||
}
|
||||
|
||||
async function fetchUser() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const response = await getUser(userId.value)
|
||||
syncFormFromUser(response.data)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan maklumat pengguna.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
await updateUser(userId.value, {
|
||||
name: form.name.trim(),
|
||||
ic_number: form.ic_number.trim(),
|
||||
position: form.position.trim(),
|
||||
phone_number: form.phone_number.trim() || null,
|
||||
status: labelToApiValue(STATUS_OPTIONS, statusValue.value[0]) ?? 'active',
|
||||
gender: labelToApiValue(GENDER_OPTIONS, genderValue.value[0]),
|
||||
marriage_status: labelToApiValue(MARRIAGE_STATUS_OPTIONS, marriageStatusValue.value[0]),
|
||||
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,
|
||||
birth_date: form.birth_date || null,
|
||||
birth_place: form.birth_place.trim() || null,
|
||||
})
|
||||
|
||||
successMessage.value = 'Pengguna berjaya dikemaskini.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'list-users' })
|
||||
}, 300)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemaskini pengguna.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formDisabled = computed(() => loading.value || saving.value)
|
||||
|
||||
onMounted(() => {
|
||||
fetchUser()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Kemaskini Pengguna</h2>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving"
|
||||
@click="router.push({ name: 'list-users' })">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box>
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-name">Nama</FieldLabel>
|
||||
<Input id="user-name" v-model="form.name" class="w-full" type="text" placeholder="Nama penuh"
|
||||
:disabled="formDisabled" required />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-email">Emel</FieldLabel>
|
||||
<Input id="user-email" v-model="email" class="w-full" type="email" disabled />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-ic">Nombor Kad Pengenalan</FieldLabel>
|
||||
<Input id="user-ic" v-model="form.ic_number" class="w-full" type="text" placeholder="Nombor kad pengenalan"
|
||||
:disabled="formDisabled" required />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-phone">Nombor Telefon</FieldLabel>
|
||||
<Input id="user-phone" v-model="form.phone_number" class="w-full" type="text" placeholder="Nombor telefon"
|
||||
:disabled="formDisabled" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-position">Jawatan</FieldLabel>
|
||||
<Input id="user-position" v-model="form.position" class="w-full" type="text" placeholder="Jawatan"
|
||||
:disabled="formDisabled" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status Pengguna</FieldLabel>
|
||||
<SelectRoot v-if="!loading" class="w-full" :collection="statusCollection" :default-value="statusInitial"
|
||||
:disabled="formDisabled" @value-change="setStatusValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Jantina</FieldLabel>
|
||||
<SelectRoot v-if="!loading" class="w-full" :collection="genderCollection" :default-value="genderInitial"
|
||||
:disabled="formDisabled" @value-change="setGenderValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih jantina" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jantina</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in genderCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status Perkahwinan</FieldLabel>
|
||||
<SelectRoot v-if="!loading" class="w-full" :collection="marriageStatusCollection"
|
||||
:default-value="marriageStatusInitial" :disabled="formDisabled" @value-change="setMarriageStatusValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih status perkahwinan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status Perkahwinan</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in marriageStatusCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-member-number">Nombor Anggota</FieldLabel>
|
||||
<Input id="user-member-number" v-model="form.member_number" class="w-full" type="number" min="0"
|
||||
placeholder="Nombor anggota" :disabled="formDisabled" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Jenis Anggota</FieldLabel>
|
||||
<SelectRoot v-if="!loading" class="w-full" :collection="memberTypeCollection"
|
||||
:default-value="memberTypeInitial" :disabled="formDisabled" @value-change="setMemberTypeValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih jenis anggota" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jenis Anggota</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in memberTypeCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-join-date">Tarikh Sertai</FieldLabel>
|
||||
<Input id="user-join-date" v-model="form.join_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" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-birth-place">Tempat Lahir</FieldLabel>
|
||||
<Input id="user-birth-place" v-model="form.birth_place" class="w-full" type="text" placeholder="Tempat lahir"
|
||||
:disabled="formDisabled" />
|
||||
</Field>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-2">
|
||||
<Button type="submit" variant="primary" look="outline" :disabled="formDisabled">
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import type { Employment } from '@/modules/profile/types/employment.types'
|
||||
import type { UserDetail } from '../types/user.types'
|
||||
|
||||
defineProps<{
|
||||
user: UserDetail
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const EMPLOYMENT_TYPE_LABEL: Record<string, string> = {
|
||||
Permanent: 'Tetap',
|
||||
Contract: 'Kontrak',
|
||||
Internship: 'Latihan Industri',
|
||||
Freelance: 'Freelance',
|
||||
}
|
||||
|
||||
function formatSalary(value: number | string | null | undefined): string {
|
||||
const amount = Number(value)
|
||||
if (Number.isNaN(amount)) return '-'
|
||||
return new Intl.NumberFormat('ms-MY', {
|
||||
style: 'currency',
|
||||
currency: 'MYR',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
function formatDateLabel(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat('ms-MY', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function formatEmploymentPeriod(employment: Employment): string {
|
||||
const start = formatDateLabel(employment.start_date)
|
||||
if (employment.is_current) {
|
||||
return `${start} - Kini`
|
||||
}
|
||||
const end = formatDateLabel(employment.end_date)
|
||||
return end ? `${start} - ${end}` : start
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? '' : 'mt-5'">
|
||||
<Box raised="single" class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Pekerjaan</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Senarai pekerjaan pengguna.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="user.employments?.length" class="space-y-3">
|
||||
<div
|
||||
v-for="employment in user.employments"
|
||||
:key="employment.id"
|
||||
class="rounded-lg border border-foreground/10 p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-slate-900">{{ employment.company_name }}</span>
|
||||
<Badge v-if="employment.is_current" class="bg-green-500 text-white">Semasa</Badge>
|
||||
<Badge look="outline">
|
||||
{{ EMPLOYMENT_TYPE_LABEL[employment.employment_type] ?? employment.employment_type }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-medium text-slate-700">{{ employment.job_title }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ formatEmploymentPeriod(employment) }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ formatSalary(employment.salary) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
Tiada pekerjaan direkodkan.
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts" setup>
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import type { UserDetail } from '../types/user.types'
|
||||
|
||||
defineProps<{
|
||||
user: UserDetail
|
||||
embedded?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? '' : 'mt-5'">
|
||||
<Box raised="single" class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Pewaris</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Senarai pewaris pengguna.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="user.heirs?.length" class="space-y-3">
|
||||
<div
|
||||
v-for="heir in user.heirs"
|
||||
:key="heir.id"
|
||||
class="rounded-lg border border-foreground/10 p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-slate-900">{{ heir.name }}</span>
|
||||
<Badge v-if="heir.is_primary" class="bg-green-500 text-white">Utama</Badge>
|
||||
<Badge look="outline">{{ heir.relationship }}</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ heir.ic_number }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ heir.phone_number }}</p>
|
||||
<p class="mt-1 text-sm text-slate-700">{{ heir.address }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
Tiada pewaris direkodkan.
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,15 +1,78 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { Search, HatGlasses } from '@lucide/vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { Search, HatGlasses, SquarePen, Trash2, Eye, Shield, RotateCcw } from '@lucide/vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listRoles } from '@/modules/role/services/role.service'
|
||||
import type { RoleListItem } from '@/modules/role/types/role.types'
|
||||
import { useImpersonate } from '../composables/useImpersonate'
|
||||
import { useDeletedUserList } from '../composables/useDeletedUserList'
|
||||
import { useUserList } from '../composables/useUserList'
|
||||
import {
|
||||
assignUserRoles as assignUserRolesService,
|
||||
deleteUser as deleteUserService,
|
||||
restoreUser as restoreUserService,
|
||||
} from '../services/user.service'
|
||||
import type { UserRole, UserListItem } from '../types/user.types'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
|
||||
|
||||
type StatusFilterChip = {
|
||||
label: string
|
||||
value: string
|
||||
variant: BadgeVariants['variant']
|
||||
}
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const deleteConfirmationOpen = ref(false)
|
||||
const userToDelete = ref<UserListItem | null>(null)
|
||||
const deleting = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
|
||||
const assignRolesOpen = ref(false)
|
||||
const userToAssignRoles = ref<UserListItem | null>(null)
|
||||
const availableRoles = ref<RoleListItem[]>([])
|
||||
const selectedRoleIds = ref<Set<string>>(new Set())
|
||||
const loadingRoles = ref(false)
|
||||
const savingRoles = ref(false)
|
||||
const assignRolesError = ref<string | null>(null)
|
||||
|
||||
const restoreConfirmationOpen = ref(false)
|
||||
const userToRestore = ref<UserListItem | null>(null)
|
||||
const restoring = ref(false)
|
||||
const restoreError = ref<string | null>(null)
|
||||
|
||||
function goToEditUser(userId: string) {
|
||||
router.push({ name: 'edit-user', params: { id: userId } })
|
||||
}
|
||||
|
||||
function goToCreateUser() {
|
||||
router.push({ name: 'create-user' })
|
||||
}
|
||||
|
||||
function goToViewUser(userId: string) {
|
||||
router.push({ name: 'view-user', params: { id: userId } })
|
||||
}
|
||||
|
||||
function formatUserRoles(roles: UserRole[] | undefined): string {
|
||||
return roles?.map((role) => role.name).join(', ') || '-'
|
||||
@@ -21,18 +84,166 @@ function statusBadgeVariant(status: string) {
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function isStatusFilterActive(value: string) {
|
||||
return statusFilter.value === value
|
||||
}
|
||||
|
||||
function setStatusFilter(value: string) {
|
||||
statusFilter.value = value
|
||||
}
|
||||
|
||||
function formatDeletedAt(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function openDeleteConfirmation(user: UserListItem) {
|
||||
userToDelete.value = user
|
||||
deleteError.value = null
|
||||
deleteConfirmationOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!userToDelete.value || deleting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleting.value = true
|
||||
deleteError.value = null
|
||||
|
||||
try {
|
||||
await deleteUserService(userToDelete.value.id)
|
||||
deleteConfirmationOpen.value = false
|
||||
userToDelete.value = null
|
||||
await Promise.all([fetchUsers(page.value), fetchDeletedUsers(deletedPage.value)])
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal mengpadam akaun pengguna.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvailableRoles() {
|
||||
if (availableRoles.value.length) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingRoles.value = true
|
||||
assignRolesError.value = null
|
||||
|
||||
try {
|
||||
const response = await listRoles()
|
||||
availableRoles.value = response.data.filter((role) => role.guard_name === 'api')
|
||||
} catch (err) {
|
||||
assignRolesError.value = getApiErrorMessage(err, 'Gagal memuatkan senarai peranan.')
|
||||
availableRoles.value = []
|
||||
} finally {
|
||||
loadingRoles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openAssignRolesDialog(user: UserListItem) {
|
||||
userToAssignRoles.value = user
|
||||
selectedRoleIds.value = new Set(user.roles?.map((role) => role.id) ?? [])
|
||||
assignRolesError.value = null
|
||||
assignRolesOpen.value = true
|
||||
await loadAvailableRoles()
|
||||
}
|
||||
|
||||
function setRoleChecked(roleId: string, checked: boolean) {
|
||||
const next = new Set(selectedRoleIds.value)
|
||||
if (checked) {
|
||||
next.add(roleId)
|
||||
} else {
|
||||
next.delete(roleId)
|
||||
}
|
||||
selectedRoleIds.value = next
|
||||
}
|
||||
|
||||
async function confirmAssignRoles() {
|
||||
if (!userToAssignRoles.value || savingRoles.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedRoleIds.value.size) {
|
||||
assignRolesError.value = 'Sila pilih sekurang-kurangnya satu peranan.'
|
||||
return
|
||||
}
|
||||
|
||||
savingRoles.value = true
|
||||
assignRolesError.value = null
|
||||
|
||||
try {
|
||||
await assignUserRolesService(
|
||||
userToAssignRoles.value.id,
|
||||
Array.from(selectedRoleIds.value),
|
||||
)
|
||||
assignRolesOpen.value = false
|
||||
userToAssignRoles.value = null
|
||||
await fetchUsers(page.value)
|
||||
} catch (err) {
|
||||
assignRolesError.value = getApiErrorMessage(err, 'Gagal menetapkan peranan pengguna.')
|
||||
} finally {
|
||||
savingRoles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openRestoreConfirmation(user: UserListItem) {
|
||||
userToRestore.value = user
|
||||
restoreError.value = null
|
||||
restoreConfirmationOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmRestore() {
|
||||
if (!userToRestore.value || restoring.value) {
|
||||
return
|
||||
}
|
||||
|
||||
restoring.value = true
|
||||
restoreError.value = null
|
||||
|
||||
try {
|
||||
await restoreUserService(userToRestore.value.id)
|
||||
restoreConfirmationOpen.value = false
|
||||
userToRestore.value = null
|
||||
await Promise.all([fetchUsers(page.value), fetchDeletedUsers(deletedPage.value)])
|
||||
} catch (err) {
|
||||
restoreError.value = getApiErrorMessage(err, 'Gagal memulihkan akaun pengguna.')
|
||||
} finally {
|
||||
restoring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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: 'Peranan',
|
||||
key: 'roles',
|
||||
sortable: false,
|
||||
exportValue: (item) => formatUserRoles(item.roles),
|
||||
},
|
||||
{ title: 'Status Pengguna', key: 'status', sortable: true },
|
||||
{ title: 'Status', key: 'status', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
const deletedHeaders: 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: 'Peranan',
|
||||
key: 'roles',
|
||||
sortable: false,
|
||||
exportValue: (item) => formatUserRoles(item.roles),
|
||||
},
|
||||
{ title: 'Status', key: 'status', sortable: true },
|
||||
{ title: 'Dipadam Pada', key: 'deleted_at', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
@@ -41,15 +252,43 @@ const {
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchUsers,
|
||||
} = useUserList()
|
||||
|
||||
const {
|
||||
users: deletedUsers,
|
||||
loading: deletedLoading,
|
||||
error: deletedError,
|
||||
search: deletedSearch,
|
||||
statusFilter: deletedStatusFilter,
|
||||
sortBy: deletedSortBy,
|
||||
page: deletedPage,
|
||||
itemsPerPage: deletedItemsPerPage,
|
||||
pagination: deletedPagination,
|
||||
handleSortUpdate: handleDeletedSortUpdate,
|
||||
fetchUsers: fetchDeletedUsers,
|
||||
} = useDeletedUserList({ autoWatchFilters: false, autoFetchOnMount: false })
|
||||
|
||||
const debouncedDeletedFetch = debounce(() => {
|
||||
fetchDeletedUsers(1)
|
||||
}, 400)
|
||||
|
||||
watch([search, statusFilter], () => {
|
||||
deletedSearch.value = search.value
|
||||
deletedStatusFilter.value = statusFilter.value
|
||||
debouncedDeletedFetch()
|
||||
})
|
||||
|
||||
const {
|
||||
showImpersonateButton,
|
||||
canImpersonateUser,
|
||||
impersonateButtonTitle,
|
||||
impersonating,
|
||||
loading: impersonateLoading,
|
||||
refreshImpersonationStatus,
|
||||
@@ -58,11 +297,18 @@ const {
|
||||
|
||||
onMounted(() => {
|
||||
refreshImpersonationStatus()
|
||||
deletedSearch.value = search.value
|
||||
deletedStatusFilter.value = statusFilter.value
|
||||
fetchDeletedUsers(1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
@@ -72,16 +318,50 @@ onMounted(() => {
|
||||
show-pagination exportable export-file-name="users" v-model:page="page" v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate">
|
||||
<template #toolbar>
|
||||
<div class="relative w-full max-w-md">
|
||||
<Search class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
|
||||
aria-hidden="true" />
|
||||
<Input v-model="search" type="search" placeholder="Search name, email, IC, phone, role..." class="w-full pl-9"
|
||||
aria-label="Search users" />
|
||||
<div class="flex w-full flex-col gap-3">
|
||||
<div class="flex w-full flex-wrap items-center gap-3">
|
||||
<div class="relative w-full max-w-md flex-1">
|
||||
<Search class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
|
||||
aria-hidden="true" />
|
||||
<Input v-model="search" type="search" placeholder="Search name, email, IC, phone, role..."
|
||||
class="w-full pl-9" aria-label="Search users" />
|
||||
</div>
|
||||
<Button v-if="hasPermission('daftar pengguna baru')" type="button" variant="primary" look="outline"
|
||||
@click="goToCreateUser">
|
||||
Daftar Pengguna
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm opacity-70">Status:</span>
|
||||
<Badge
|
||||
v-for="chip in STATUS_FILTER_CHIPS"
|
||||
:key="chip.value || 'all'"
|
||||
:variant="chip.variant"
|
||||
:look="isStatusFilterActive(chip.value) ? 'filled' : 'outline'"
|
||||
class="capitalize"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-pressed="isStatusFilterActive(chip.value)"
|
||||
@click="setStatusFilter(chip.value)"
|
||||
@keydown.enter="setStatusFilter(chip.value)"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.name="{ item }">
|
||||
<span class="font-medium">{{ item.name }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- profile image -->
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Profile Image" class="size-10 rounded-full" />
|
||||
<div v-else class="size-10 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<span class="text-gray-500">{{ item.name.charAt(0) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- name -->
|
||||
<span class="font-medium">{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.email="{ item }">
|
||||
@@ -92,20 +372,227 @@ onMounted(() => {
|
||||
{{ formatUserRoles(item.roles) }}
|
||||
</template>
|
||||
|
||||
<template #item.member_number="{ item }">
|
||||
<span class="text-center">{{ item.member_number }}</span>
|
||||
</template>
|
||||
|
||||
<!-- centre align status -->
|
||||
<template #item.status="{ item }">
|
||||
<Badge :variant="statusBadgeVariant(item.status)" class="capitalize">
|
||||
<Badge :variant="statusBadgeVariant(item.status)" class="capitalize text-center">
|
||||
{{ item.status }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<Button v-if="canImpersonateUser(item as UserListItem)" type="button" variant="outline" size="sm"
|
||||
class="gap-1.5 bg-orange-500 text-white" :disabled="impersonateLoading || impersonating"
|
||||
:title="impersonating ? 'Anda sedang menyamar pengguna' : 'Menyamar sebagai pengguna'"
|
||||
@click="impersonateUser(item as UserListItem)">
|
||||
<HatGlasses class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<div class="flex items-center">
|
||||
<Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="outline" size="sm"
|
||||
class="bg-purple-600 text-white disabled:opacity-50" :disabled="savingRoles"
|
||||
:title="savingRoles ? 'Menyimpan perubahan...' : 'Tetapkan peranan'"
|
||||
@click="openAssignRolesDialog(item as UserListItem)"> Urus Peranan
|
||||
<Shield class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="hasPermission('lihat pengguna')" type="button" variant="outline" size="sm"
|
||||
class="bg-green-600 text-white disabled:opacity-50" :disabled="savingRoles"
|
||||
:title="savingRoles ? 'Menyimpan perubahan...' : 'Lihat profil'" @click="goToViewUser(item.id)">
|
||||
<Eye class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="hasPermission('kemaskini pengguna')" type="button" variant="outline" size="sm"
|
||||
class="bg-yellow-600 text-white disabled:opacity-50" :disabled="savingRoles"
|
||||
:title="savingRoles ? 'Menyimpan perubahan...' : 'Kemaskini pengguna'" @click="goToEditUser(item.id)">
|
||||
<SquarePen class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="hasPermission('padam akaun pengguna')" type="button" variant="outline" size="sm"
|
||||
class="bg-red-600 text-white disabled:opacity-50" :disabled="deleting"
|
||||
@click="openDeleteConfirmation(item as UserListItem)">
|
||||
<Trash2 class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="showImpersonateButton(item as UserListItem)" type="button" variant="outline" size="sm"
|
||||
class="bg-orange-600 text-white disabled:opacity-50"
|
||||
:disabled="!canImpersonateUser(item as UserListItem) || impersonateLoading || impersonating"
|
||||
:title="impersonateButtonTitle(item as UserListItem)" @click="impersonateUser(item as UserListItem)">
|
||||
<HatGlasses class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<div class="space-y-4 border-t border-foreground/10 pt-8">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Pengguna Dipadam</h3>
|
||||
<p class="mt-1 text-sm opacity-70">Pengguna yang telah dipadam dan boleh dipulihkan.</p>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="deletedError" variant="danger">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{{ deletedError }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<DataTable
|
||||
:headers="deletedHeaders"
|
||||
:items="deletedUsers"
|
||||
:loading="deletedLoading"
|
||||
:pagination="deletedPagination"
|
||||
:current-sort="deletedSortBy"
|
||||
show-pagination
|
||||
exportable
|
||||
export-file-name="deleted-users"
|
||||
v-model:page="deletedPage"
|
||||
v-model:items-per-page="deletedItemsPerPage"
|
||||
@update:sort-by="handleDeletedSortUpdate"
|
||||
>
|
||||
<template #item.name="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Profile Image" class="size-10 rounded-full" />
|
||||
<div v-else class="flex size-10 items-center justify-center rounded-full bg-gray-200">
|
||||
<span class="text-gray-500">{{ item.name.charAt(0) }}</span>
|
||||
</div>
|
||||
<span class="font-medium">{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.email="{ item }">
|
||||
<span class="lowercase">{{ item.email }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.roles="{ item }">
|
||||
{{ formatUserRoles(item.roles) }}
|
||||
</template>
|
||||
|
||||
<template #item.member_number="{ item }">
|
||||
<span class="text-center">{{ item.member_number ?? '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge :variant="statusBadgeVariant(item.status)" class="capitalize text-center">
|
||||
{{ item.status }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<template #item.deleted_at="{ item }">
|
||||
{{ formatDeletedAt(item.deleted_at) }}
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
v-if="hasPermission('padam akaun pengguna')"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="bg-blue-600 text-white disabled:opacity-50"
|
||||
:disabled="restoring"
|
||||
title="Pulihkan pengguna"
|
||||
@click="openRestoreConfirmation(item as UserListItem)"
|
||||
>
|
||||
<RotateCcw class="size-4" aria-hidden="true" />
|
||||
Pulihkan
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<DialogRoot :open="assignRolesOpen" @openChange="(details) => (assignRolesOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5">
|
||||
<div class="text-center text-2xl font-medium">Tetapkan Peranan</div>
|
||||
<div v-if="userToAssignRoles" class="mt-2 text-center opacity-70">
|
||||
Pilih peranan untuk
|
||||
<span class="font-medium">{{ userToAssignRoles.name }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="assignRolesError" class="mt-4 text-center text-sm text-danger">
|
||||
{{ assignRolesError }}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 max-h-80 overflow-auto rounded-lg border border-foreground/10 p-3">
|
||||
<div v-if="loadingRoles" class="py-6 text-center opacity-70">
|
||||
Memuatkan peranan...
|
||||
</div>
|
||||
<div v-else-if="!availableRoles.length" class="py-6 text-center opacity-70">
|
||||
Tiada peranan ditemui
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-2">
|
||||
<CheckboxRoot v-for="role in availableRoles" :key="role.id" :checked="selectedRoleIds.has(role.id)"
|
||||
:disabled="savingRoles" @checked-change="({ checked }) => setRoleChecked(role.id, checked === true)">
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>
|
||||
<span class="font-medium">{{ role.name }}</span>
|
||||
<span v-if="role.fullname" class="ml-2 opacity-70">({{ role.fullname }})</span>
|
||||
</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="savingRoles">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button class="w-24" type="button" variant="primary" look="outline" :disabled="savingRoles || loadingRoles"
|
||||
@click="confirmAssignRoles">
|
||||
{{ savingRoles ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<DialogRoot :open="deleteConfirmationOpen" @openChange="(details) => (deleteConfirmationOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<Lucide class="text-danger mx-auto mt-3 size-16 stroke-1" icon="CircleX" />
|
||||
<div class="mt-5 text-2xl font-medium">Adakah anda yakin?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Adakah anda benar-benar mahu menghapus
|
||||
<span v-if="userToDelete" class="font-medium">{{ userToDelete.name }}</span>?
|
||||
<br />
|
||||
Proses ini tidak boleh dibatalkan.
|
||||
</div>
|
||||
<div v-if="deleteError" class="mt-4 text-sm text-danger">
|
||||
{{ deleteError }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="deleting">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button class="w-24" type="button" variant="danger" look="outline" :disabled="deleting"
|
||||
@click="confirmDelete">
|
||||
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<DialogRoot :open="restoreConfirmationOpen" @openChange="(details) => (restoreConfirmationOpen = details.open)">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<Lucide class="mx-auto mt-3 size-16 stroke-1 text-primary" icon="RotateCcw" />
|
||||
<div class="mt-5 text-2xl font-medium">Pulihkan pengguna?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
Adakah anda mahu memulihkan
|
||||
<span v-if="userToRestore" class="font-medium">{{ userToRestore.name }}</span>?
|
||||
</div>
|
||||
<div v-if="restoreError" class="mt-4 text-sm text-danger">
|
||||
{{ restoreError }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-24" :disabled="restoring">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="primary"
|
||||
look="outline"
|
||||
:disabled="restoring"
|
||||
@click="confirmRestore"
|
||||
>
|
||||
{{ restoring ? 'Memulihkan...' : 'Pulihkan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { Address } from '@/modules/profile/types/address.types'
|
||||
import type { UserDetail } from '../types/user.types'
|
||||
|
||||
const props = defineProps<{
|
||||
user: UserDetail
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const ADDRESS_TYPE_LABEL: Record<string, string> = {
|
||||
home: 'Rumah',
|
||||
office: 'Pejabat',
|
||||
billing: 'Bil',
|
||||
}
|
||||
|
||||
const displayValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value).trim() || '-'
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const status = props.user.status
|
||||
if (!status) return '-'
|
||||
return status.charAt(0).toUpperCase() + status.slice(1)
|
||||
})
|
||||
|
||||
const roleNames = computed(() => props.user.roles?.map((role) => role.name).join(', ') || '-')
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
|
||||
function formatAddressLine(address: Address) {
|
||||
return [address.address_line_1, address.address_line_2, address.postcode, address.city, address.state, address.country]
|
||||
.filter((part) => part?.trim())
|
||||
.join(', ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? 'space-y-8' : 'mt-5 space-y-8'">
|
||||
<Box raised="single" class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Maklumat Peribadi</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Paparan maklumat pengguna (baca sahaja).</p>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="view-user-name">Nama</FieldLabel>
|
||||
<Input id="view-user-name" :model-value="displayValue(user.name)" type="text" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="view-user-email">E-mel</FieldLabel>
|
||||
<Input id="view-user-email" :model-value="displayValue(user.email)" type="email" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="view-user-ic">No. Kad Pengenalan</FieldLabel>
|
||||
<Input id="view-user-ic" :model-value="displayValue(user.ic_number)" type="text" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="view-user-phone">No. Telefon</FieldLabel>
|
||||
<Input id="view-user-phone" :model-value="displayValue(user.phone_number)" type="text" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="view-user-position">Jawatan</FieldLabel>
|
||||
<Input id="view-user-position" :model-value="displayValue(user.position)" type="text" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="view-user-status">Status</FieldLabel>
|
||||
<Input id="view-user-status" :model-value="statusLabel" type="text" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="view-user-gender">Jantina</FieldLabel>
|
||||
<Input id="view-user-gender" :model-value="displayValue(user.gender)" type="text" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="view-user-marriage">Status Perkahwinan</FieldLabel>
|
||||
<Input
|
||||
id="view-user-marriage"
|
||||
:model-value="displayValue(user.marriage_status)"
|
||||
type="text"
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="view-user-member-number">Nombor Anggota</FieldLabel>
|
||||
<Input
|
||||
id="view-user-member-number"
|
||||
:model-value="displayValue(user.member_number)"
|
||||
type="text"
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="view-user-member-type">Jenis Anggota</FieldLabel>
|
||||
<Input id="view-user-member-type" :model-value="displayValue(user.member_type)" type="text" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<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>
|
||||
<FieldLabel for="view-user-birth-date">Tarikh Lahir</FieldLabel>
|
||||
<Input id="view-user-birth-date" :model-value="formatDate(user.birth_date)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel for="view-user-birth-place">Tempat Lahir</FieldLabel>
|
||||
<Input id="view-user-birth-place" :model-value="displayValue(user.birth_place)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel for="view-user-roles">Peranan</FieldLabel>
|
||||
<Input id="view-user-roles" :model-value="roleNames" type="text" disabled />
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</Box>
|
||||
|
||||
<Box raised="single" class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Alamat</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Senarai alamat pengguna.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="user.addresses?.length" class="space-y-3">
|
||||
<div
|
||||
v-for="address in user.addresses"
|
||||
:key="address.id"
|
||||
class="rounded-lg border border-foreground/10 p-4"
|
||||
>
|
||||
<Badge look="outline">
|
||||
{{ ADDRESS_TYPE_LABEL[address.address_type] ?? address.address_type }}
|
||||
</Badge>
|
||||
<p class="mt-2 text-sm text-slate-700">{{ formatAddressLine(address) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
Tiada alamat direkodkan.
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getUser } from '../services/user.service'
|
||||
import type { UserDetail } from '../types/user.types'
|
||||
import UserProfileTab from './UserProfileTab.vue'
|
||||
import UserEmploymentTab from './UserEmploymentTab.vue'
|
||||
import UserBankDetailTab from './UserBankDetailTab.vue'
|
||||
import UserHeirTab from './UserHeirTab.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const userId = computed(() => String(route.params.id ?? ''))
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const user = ref<UserDetail | null>(null)
|
||||
|
||||
const displayValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value).trim() || '-'
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const status = user.value?.status
|
||||
if (!status) return '-'
|
||||
return status.charAt(0).toUpperCase() + status.slice(1)
|
||||
})
|
||||
|
||||
const roleNames = computed(() =>
|
||||
user.value?.roles?.map((role) => role.name).join(', ') || '-',
|
||||
)
|
||||
|
||||
const avatarFallback = computed(() => {
|
||||
const name = user.value?.name?.trim()
|
||||
if (!name) return '--'
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
async function fetchUser() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await getUser(userId.value)
|
||||
user.value = response.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan profil pengguna.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUser()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Profil {{ user?.name }} - {{ user?.member_number }}</h2>
|
||||
<Button look="outline" variant="secondary" type="button" @click="router.push({ name: 'list-users' })">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-5" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div v-if="loading" class="mt-5 opacity-70">Memuatkan profil...</div>
|
||||
|
||||
<TabsRoot v-else-if="user" defaultValue="1">
|
||||
<Box raised="single" class="mt-5 p-0">
|
||||
<div class="flex flex-col border-b border-foreground/15 p-5 lg:flex-row">
|
||||
<div class="flex flex-1 items-center justify-center px-5 lg:justify-start">
|
||||
<AvatarRoot class="size-20 border-5 bg-background rounded-full sm:size-24 lg:size-32">
|
||||
<AvatarFallback>{{ avatarFallback }}</AvatarFallback>
|
||||
<AvatarImage v-if="user.image_url" :src="user.image_url" :alt="user.name" />
|
||||
</AvatarRoot>
|
||||
<div class="ml-5">
|
||||
<div class="w-24 truncate text-lg font-medium sm:w-40 sm:whitespace-normal">
|
||||
{{ displayValue(user.name) }}
|
||||
</div>
|
||||
<div class="opacity-70">{{ roleNames }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-6 flex-1 border-t border-l border-r border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-t-0 lg:pt-0">
|
||||
<div class="text-center font-medium lg:mt-3 lg:text-left">Maklumat Hubungan</div>
|
||||
<div class="mt-4 flex flex-col items-center justify-center lg:items-start">
|
||||
<div class="flex items-center truncate sm:whitespace-normal">
|
||||
<Lucide class="mr-2 size-4" icon="Mail" />
|
||||
{{ displayValue(user.email) }}
|
||||
</div>
|
||||
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
|
||||
<Lucide class="mr-2 size-4" icon="Phone" />
|
||||
{{ displayValue(user.phone_number) }}
|
||||
</div>
|
||||
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
|
||||
<Lucide class="mr-2 size-4" icon="IdCard" />
|
||||
{{ displayValue(user.ic_number) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-6 flex flex-1 items-center justify-center border-t border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-0 lg:pt-0">
|
||||
<div class="grid grid-cols-3 gap-5">
|
||||
<div class="text-center">
|
||||
<div class="truncate text-xl font-medium">{{ user.roles?.length ?? 0 }}</div>
|
||||
<div class="opacity-70">Peranan</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-medium">{{ statusLabel }}</div>
|
||||
<div class="opacity-70">Status</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="truncate text-xl font-medium capitalize">
|
||||
{{ displayValue(user.member_type) }}
|
||||
</div>
|
||||
<div class="opacity-70">Jenis Anggota</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4">
|
||||
<TabsList class="mb-0 w-full flex justify-between">
|
||||
<TabsTrigger class="inline-flex w-1/4 items-center justify-center" value="1">
|
||||
<Lucide class="mr-2 size-4" icon="User" /> Profil
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="inline-flex w-1/4 items-center justify-center" value="5">
|
||||
<Lucide class="mr-2 size-4" icon="Briefcase" /> Pekerjaan
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="inline-flex w-1/4 items-center justify-center" value="3">
|
||||
<Lucide class="mr-2 size-4" icon="Banknote" /> Bank
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="inline-flex w-1/4 items-center justify-center" value="6">
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Pewaris
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<TabsContent value="1" class="mt-8">
|
||||
<UserProfileTab :user="user" embedded />
|
||||
</TabsContent>
|
||||
<TabsContent value="5" class="mt-8">
|
||||
<UserEmploymentTab :user="user" embedded />
|
||||
</TabsContent>
|
||||
<TabsContent value="3" class="mt-8">
|
||||
<UserBankDetailTab :user="user" embedded />
|
||||
</TabsContent>
|
||||
<TabsContent value="6" class="mt-8">
|
||||
<UserHeirTab :user="user" embedded />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,6 +5,24 @@ export const userLayoutRoutes: RouteRecordRaw[] = [
|
||||
path: 'list-users',
|
||||
name: 'list-users',
|
||||
component: () => import('./pages/UserList.vue'),
|
||||
meta: { title: 'List Users', module: 'user' },
|
||||
meta: { title: 'List Users', module: 'user', permission: 'lihat pengguna' },
|
||||
},
|
||||
{
|
||||
path: 'users/create',
|
||||
name: 'create-user',
|
||||
component: () => import('./pages/UserCreate.vue'),
|
||||
meta: { title: 'Create User', module: 'user', permission: 'daftar pengguna baru' },
|
||||
},
|
||||
{
|
||||
path: 'users/:id/edit',
|
||||
name: 'edit-user',
|
||||
component: () => import('./pages/UserEdit.vue'),
|
||||
meta: { title: 'Edit User', module: 'user', permission: 'kemaskini pengguna' },
|
||||
},
|
||||
{
|
||||
path: 'users/:id/profile',
|
||||
name: 'view-user',
|
||||
component: () => import('./pages/UserProfileView.vue'),
|
||||
meta: { title: 'View User Profile', module: 'user', permission: 'lihat pengguna' },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type { ListUsersParams, UserListItem } from '../types/user.types'
|
||||
import type {
|
||||
CreateUserPayload,
|
||||
ListDeletedUsersParams,
|
||||
ListUsersParams,
|
||||
UpdateUserPayload,
|
||||
UserDetail,
|
||||
UserListItem,
|
||||
} from '../types/user.types'
|
||||
|
||||
type UserApiResponse = {
|
||||
success: boolean
|
||||
data: UserDetail
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function listUsers(
|
||||
params: ListUsersParams,
|
||||
@@ -15,3 +28,85 @@ export async function listUsers(
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listDeletedUsers(
|
||||
params: ListDeletedUsersParams,
|
||||
): Promise<PaginatedApiResponse<UserListItem>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<UserListItem>>('/v1/users/deleted', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load deleted users')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function restoreUser(id: string): Promise<UserApiResponse> {
|
||||
const { data } = await api.post<UserApiResponse>(`/v1/users/${id}/restore`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to restore user')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUser(id: string): Promise<UserApiResponse> {
|
||||
const { data } = await api.get<UserApiResponse>(`/v1/users/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load user')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createUser(payload: CreateUserPayload): Promise<UserApiResponse> {
|
||||
const { data } = await api.post<UserApiResponse>('/v1/users', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to create user')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
id: string,
|
||||
payload: UpdateUserPayload,
|
||||
): Promise<UserApiResponse> {
|
||||
const { data } = await api.patch<UserApiResponse>(`/v1/users/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to update user')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string): Promise<UserApiResponse> {
|
||||
const { data } = await api.delete<UserApiResponse>(`/v1/users/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to delete user')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function assignUserRoles(
|
||||
id: string,
|
||||
roleIds: string[],
|
||||
): Promise<UserApiResponse> {
|
||||
const { data } = await api.post<UserApiResponse>(`/v1/users/${id}/roles`, {
|
||||
roles: roleIds,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to assign roles')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { Address } from '@/modules/profile/types/address.types'
|
||||
import type { Bank } from '@/modules/profile/types/bank.types'
|
||||
import type { BankDetail } from '@/modules/profile/types/bankDetail.types'
|
||||
import type { Employment } from '@/modules/profile/types/employment.types'
|
||||
import type { Heir } from '@/modules/profile/types/heir.types'
|
||||
|
||||
export interface UserRole {
|
||||
id: string
|
||||
name: string
|
||||
guard_name: string
|
||||
}
|
||||
|
||||
export interface UserBankDetail extends BankDetail {
|
||||
bank?: Bank | null
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: string
|
||||
name: string
|
||||
@@ -13,7 +23,63 @@ export interface UserListItem {
|
||||
phone_number: string
|
||||
image_url: string | null
|
||||
status: string
|
||||
deleted_at?: string | null
|
||||
roles: UserRole[]
|
||||
member_number?: number | null
|
||||
}
|
||||
|
||||
export interface UserDetail {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
ic_number: string
|
||||
position: string
|
||||
phone_number: string | null
|
||||
image_url: string | null
|
||||
status: string
|
||||
gender: string | null
|
||||
marriage_status: string | null
|
||||
member_number: number | null
|
||||
member_type: string | null
|
||||
join_date: string | null
|
||||
birth_date: string | null
|
||||
birth_place: string | null
|
||||
roles: UserRole[]
|
||||
addresses?: Address[]
|
||||
employments?: Employment[]
|
||||
bank_details?: UserBankDetail[]
|
||||
heirs?: Heir[]
|
||||
}
|
||||
|
||||
export interface UpdateUserPayload {
|
||||
name: string
|
||||
ic_number: string
|
||||
position: string
|
||||
phone_number?: string | null
|
||||
status?: string | null
|
||||
gender?: string | null
|
||||
marriage_status?: string | null
|
||||
member_number?: number | null
|
||||
member_type?: string | null
|
||||
join_date?: string | null
|
||||
birth_date?: string | null
|
||||
birth_place?: string | null
|
||||
}
|
||||
|
||||
export interface CreateUserPayload {
|
||||
name: string
|
||||
email: string
|
||||
ic_number: string
|
||||
position: string
|
||||
phone_number?: string | null
|
||||
status: string
|
||||
gender: string
|
||||
marriage_status: string
|
||||
member_number: number
|
||||
member_type: string
|
||||
join_date: string
|
||||
birth_date: string
|
||||
birth_place: string
|
||||
}
|
||||
|
||||
export interface ListUsersParams {
|
||||
@@ -24,3 +90,12 @@ export interface ListUsersParams {
|
||||
search?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface ListDeletedUsersParams {
|
||||
page: number
|
||||
per_page: number
|
||||
sort_by: string
|
||||
sort_order: string
|
||||
search?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user