Files
My-KOPKB/fe/src/modules/user/pages/UserList.vue
T

832 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import { computed, 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 { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
AccordionRoot,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from '@/components/ui/accordion'
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 { EMPLOYERS } from '@/constants/employers'
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: 'Aktif', value: 'active', variant: 'success' },
{ label: 'Tidak Aktif', value: 'inactive', variant: 'danger' },
{ label: 'Menunggu', value: 'pending', variant: 'pending' },
]
function getEmployerShortLabel(name: string): string {
const match = name.match(/\(([^)]+)\)/)
if (match?.[1]) return match[1]
if (name.startsWith('Pusat Perubatan An-Nisa')) return "Pusat Perubatan An-Nisa'"
if (name.startsWith('Kel Infra')) return 'Kel Infra'
return name
}
const UNIT_FILTER_CHIPS = [
{ label: 'Semua', value: '' },
...EMPLOYERS.map((employer) => ({
label: getEmployerShortLabel(employer.name),
value: employer.name,
})),
]
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(', ') || '-'
}
type QuickDatePreset = {
label: string
getRange: () => { from: string; to: string }
}
function formatDateInput(value: dayjs.Dayjs) {
return value.format('YYYY-MM-DD')
}
const QUICK_DATE_PRESETS: QuickDatePreset[] = [
{
label: 'Bulan ini',
getRange: () => ({
from: formatDateInput(dayjs().startOf('month')),
to: formatDateInput(dayjs().endOf('month')),
}),
},
{
label: 'Bulan lepas',
getRange: () => {
const prev = dayjs().subtract(1, 'month')
return {
from: formatDateInput(prev.startOf('month')),
to: formatDateInput(prev.endOf('month')),
}
},
},
{
label: 'Tahun ini',
getRange: () => ({
from: formatDateInput(dayjs().startOf('year')),
to: formatDateInput(dayjs().endOf('year')),
}),
},
]
function applyJoinDatePreset(preset: QuickDatePreset) {
const { from, to } = preset.getRange()
joinDateFrom.value = from
joinDateTo.value = to
}
function applyLeaveDatePreset(preset: QuickDatePreset) {
const { from, to } = preset.getRange()
leaveDateFrom.value = from
leaveDateTo.value = to
}
function getUserCompanyName(
item: Pick<UserListItem, 'company_name' | 'employments'>,
): string {
if (item.company_name) return item.company_name
const employments = item.employments ?? []
const current = employments.find((employment) => employment.is_current)
return (current ?? employments[0])?.company_name ?? '-'
}
const totalUsersLabel = computed(() => {
const value = stats.value.total || pagination.value.total
return value.toLocaleString()
})
const joinedThisMonthCountLabel = computed(() => stats.value.joined_this_month.toLocaleString())
function statusBadgeVariant(status: string) {
if (status === 'active') return 'success'
if (status === 'inactive') return 'danger'
return 'pending'
}
function isStatusFilterActive(value: string) {
return statusFilter.value === value
}
function setStatusFilter(value: string) {
statusFilter.value = value
}
function isUnitFilterActive(value: string) {
return unitFilter.value === value
}
function setUnitFilter(value: string) {
unitFilter.value = value
}
function formatDeletedAt(value: string | null | undefined): string {
if (!value) return '-'
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
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: 'Jenis Anggota', key: 'member_type', sortable: true },
{
title: 'Unit',
key: 'company_name',
sortable: false,
exportValue: (item) => getUserCompanyName(item),
},
{ 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',
sortable: false,
exportValue: (item) => formatUserRoles(item.roles),
},
{ title: 'Status', key: 'status', sortable: true },
{ title: 'Tindakan', key: 'actions', sortable: false },
]
// deleted users table headers
const deletedHeaders: TableHeader[] = [
{ title: 'Bil.', key: '#', sortable: false },
{ title: 'Name', key: 'name', sortable: true },
{
title: 'Unit',
key: 'company_name',
sortable: false,
exportValue: (item) => getUserCompanyName(item),
},
{ 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 },
]
const {
users,
loading,
stats,
statsLoading,
statsError,
error,
search,
statusFilter,
unitFilter,
joinDateFrom,
joinDateTo,
leaveDateFrom,
leaveDateTo,
hasJoinDateFilters,
hasLeaveDateFilters,
clearJoinDateFilters,
clearLeaveDateFilters,
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,
impersonateUser,
} = useImpersonate()
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 Daftar Anggota</h2>
<p class="mt-1 text-sm opacity-70">Urus dan semak anggota koperasi.</p>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Box class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-sm font-medium opacity-70">Jumlah Anggota</div>
<div class="mt-2 text-3xl font-semibold tabular-nums">{{ totalUsersLabel }}</div>
<div class="mt-1 text-xs opacity-60">Mengikut carian & penapis semasa</div>
</div>
<div class="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Lucide icon="Users" class="size-5" />
</div>
</div>
</Box>
<Box class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-sm font-medium opacity-70">Baru Sertai</div>
<div class="mt-2 text-3xl font-semibold tabular-nums">{{ joinedThisMonthCountLabel }}</div>
<div class="mt-1 text-xs opacity-60">Bulan ini (semua rekod)</div>
<div v-if="statsError" class="mt-1 text-xs text-danger">{{ statsError }}</div>
</div>
<div class="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Lucide icon="UserPlus" class="size-5" />
</div>
</div>
</Box>
</div>
<AlertRoot v-if="error" class="mt-6" variant="danger">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</AlertRoot>
<AccordionRoot class="w-full" variant="boxed">
<AccordionItem raised="single" value="date-filters">
<AccordionTrigger>Penapis Tarikh</AccordionTrigger>
<AccordionContent>
<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 flex-wrap items-center gap-2">
<span class="text-sm opacity-70">Pantas:</span>
<Badge v-for="preset in QUICK_DATE_PRESETS" :key="`join-${preset.label}`" variant="ghost"
look="outline" role="button" tabindex="0" @click="applyJoinDatePreset(preset)"
@keydown.enter="applyJoinDatePreset(preset)">
{{ preset.label }}
</Badge>
</div>
<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 flex-wrap items-center gap-2">
<span class="text-sm opacity-70">Pantas:</span>
<Badge v-for="preset in QUICK_DATE_PRESETS" :key="`leave-${preset.label}`" variant="ghost"
look="outline" role="button" tabindex="0" @click="applyLeaveDatePreset(preset)"
@keydown.enter="applyLeaveDatePreset(preset)">
{{ preset.label }}
</Badge>
</div>
<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>
</AccordionContent>
</AccordionItem>
</AccordionRoot>
<DataTable :headers="headers" :items="users" :loading="loading" :pagination="pagination" :current-sort="sortBy"
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">
<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="Cari nama, email, no. anggota, jawatan..."
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 class="flex flex-wrap items-center gap-2">
<span class="text-sm opacity-70">Unit:</span>
<Badge v-for="chip in UNIT_FILTER_CHIPS" :key="chip.value || 'all-units'" variant="ghost"
:look="isUnitFilterActive(chip.value) ? 'filled' : 'outline'" role="button" tabindex="0"
:title="chip.value || 'Semua unit'" :aria-pressed="isUnitFilterActive(chip.value)"
@click="setUnitFilter(chip.value)" @keydown.enter="setUnitFilter(chip.value)">
{{ chip.label }}
</Badge>
</div>
</div>
</template>
<template #item.name="{ item }">
<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 }">
<span class="lowercase">{{ item.email }}</span>
</template>
<template #item.member_type="{ item }">
<span class="capitalize">{{ item.member_type }}</span>
</template>
<template #item.company_name="{ item }">
<span>{{ getUserCompanyName(item) }}</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.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">
{{ item.status }}
</Badge>
</template>
<template #item.actions="{ item }">
<div class="flex items-center">
<Button v-if="hasPermission('lihat peranan')" type="button" variant="ghost" 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="ghost" 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="ghost" 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="ghost" 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="ghost" 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>
<!-- Start of deleted users table -->
<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.member_type="{ item }">
<span class="uppercase">{{ item.member_type }}</span>
</template>
<template #item.company_name="{ item }">
<span>{{ getUserCompanyName(item) }}</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="ghost" 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>
<!-- Assign roles dialog -->
<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>
<!-- Delete confirmation dialog -->
<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>
<!-- Restore confirmation dialog -->
<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>