Files
dticket.tootaio.com/app/pages/management/users/index.vue
xiaomai 4e40bfd804 feat(users): add drag-and-drop reordering for PICs
Introduce pic_sort_order to persist custom user ordering
Replace data table with a custom draggable grid layout
Add API endpoint to handle bulk order updates
2026-05-04 14:07:43 +08:00

641 lines
19 KiB
Vue

<template>
<UContainer class="py-8">
<div class="mx-auto max-w-6xl space-y-4">
<div class="space-y-1.5">
<UBadge label="Super Admin" color="primary" variant="soft" class="rounded-full" />
<h1 class="text-2xl font-bold text-highlighted sm:text-3xl">
User management
</h1>
</div>
<UAlert
v-if="issuedPasswordMessage"
title="Temporary password issued"
:description="issuedPasswordMessage"
color="success"
icon="i-lucide-key-round"
/>
<UCard class="border border-default bg-default shadow-sm" :ui="{ body: 'p-0 sm:p-0' }">
<template #header>
<div class="flex flex-col gap-2.5 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<UInput
v-model="searchQuery"
size="lg"
class="w-full sm:w-72"
placeholder="Search name, username, or phone"
/>
<UButton
label="Refresh"
color="neutral"
variant="outline"
icon="i-lucide-refresh-cw"
size="lg"
:loading="loadingUsers"
@click="refreshUsers"
/>
</div>
<UButton
label="Add User"
size="lg"
icon="i-lucide-plus"
class="justify-center"
@click="openCreateModal"
/>
</div>
</template>
<div class="overflow-x-auto">
<div class="min-w-[900px]" aria-label="Users">
<div class="grid grid-cols-[56px_minmax(190px,1.4fr)_minmax(150px,1fr)_120px_minmax(190px,1.2fr)_150px_minmax(230px,auto)] items-center gap-3 border-b border-default bg-muted px-4 py-2 text-xs font-semibold uppercase text-muted">
<div>Order</div>
<div>Display Name</div>
<div>PIC Phone</div>
<div>Role</div>
<div>Status</div>
<div>Last Login</div>
<div class="text-right">Actions</div>
</div>
<TransitionGroup tag="div" name="user-list" class="divide-y divide-default">
<div
v-for="user in filteredUsers"
:key="user.id"
:draggable="canReorderUsers"
class="grid grid-cols-[56px_minmax(190px,1.4fr)_minmax(150px,1fr)_120px_minmax(190px,1.2fr)_150px_minmax(230px,auto)] items-center gap-3 bg-default px-4 py-3 transition-[background,box-shadow,transform,opacity] duration-200"
:class="{
'cursor-grab hover:bg-muted/60': canReorderUsers,
'cursor-not-allowed opacity-70': !canReorderUsers,
'scale-[0.99] opacity-60 shadow-lg': draggedUserId === user.id,
'bg-muted': dragOverUserId === user.id
}"
@dragstart="startUserDrag($event, user)"
@dragover.prevent="overUserDrag($event, user)"
@drop.prevent="dropUserDrag"
@dragend="endUserDrag"
>
<div class="flex items-center">
<UTooltip :text="canReorderUsers ? 'Drag to reorder' : 'Clear search to reorder'">
<button
type="button"
class="inline-flex size-9 items-center justify-center rounded-md border border-default bg-default text-muted transition hover:text-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="canReorderUsers ? 'cursor-grab active:cursor-grabbing' : 'cursor-not-allowed opacity-50'"
:aria-label="`Drag ${user.fullName} to reorder PIC list`"
:disabled="!canReorderUsers"
>
<UIcon name="i-lucide-grip-vertical" class="size-4" />
</button>
</UTooltip>
</div>
<div class="min-w-0 space-y-0.5 py-1">
<div class="font-semibold leading-tight text-highlighted">
{{ user.fullName }}
</div>
<div class="text-xs text-muted">
@{{ user.username }}
</div>
</div>
<span class="text-sm" :class="user.phoneNumber ? 'text-default' : 'text-muted'">
{{ user.phoneNumber || 'Not set' }}
</span>
<div>
<UBadge
:label="user.role === 'super_admin' ? 'Super Admin' : 'Staff'"
:color="user.role === 'super_admin' ? 'primary' : 'neutral'"
variant="soft"
/>
</div>
<div class="space-y-1.5 py-1">
<div class="flex flex-wrap gap-1.5">
<UBadge
:label="user.mustChangePassword ? 'Password reset' : 'Password ready'"
:color="user.mustChangePassword ? 'warning' : 'success'"
variant="soft"
size="sm"
/>
<UBadge
:label="user.passkeyCount > 0 ? 'Passkey ready' : 'No passkey'"
:color="user.passkeyCount > 0 ? 'success' : 'neutral'"
variant="soft"
size="sm"
/>
</div>
<div class="text-xs text-muted">
{{ user.passkeyCount }} passkey{{ user.passkeyCount === 1 ? '' : 's' }}
</div>
</div>
<span class="text-xs text-muted sm:text-sm">
{{ formatDateTime(user.lastLoginAt, 'Never') }}
</span>
<div class="flex flex-wrap justify-end gap-1.5 py-1">
<UButton
label="Edit"
color="neutral"
variant="outline"
icon="i-lucide-pencil-line"
size="sm"
@click="openEditModal(user)"
/>
<UButton
label="Reset Password"
color="neutral"
variant="outline"
icon="i-lucide-key-round"
size="sm"
:loading="resettingUserId === user.id"
@click="resetPassword(user)"
/>
</div>
</div>
</TransitionGroup>
<div v-if="!filteredUsers.length" class="px-4 py-10 text-center text-sm text-muted">
{{ searchQuery.trim() ? 'No matching users found.' : 'No users available.' }}
</div>
</div>
</div>
</UCard>
<UModal
v-model:open="editorOpen"
:title="isEditMode ? 'Edit User' : 'Add User'"
:dismissible="!savingUser"
:close="!savingUser"
:content="{ class: 'sm:max-w-xl' }"
>
<template #body>
<UForm :state="userForm" :validate="validateUserForm" class="space-y-4" @submit="saveUser">
<UFormField name="fullName" label="Display Name" required>
<UInput v-model="userForm.fullName" size="lg" class="w-full" />
</UFormField>
<UFormField name="username" label="Username" required>
<UInput
v-model="userForm.username"
size="lg"
class="w-full"
:disabled="isEditMode"
/>
</UFormField>
<UFormField name="phoneNumber" label="Phone Number" required>
<UInput
v-model="userForm.phoneNumber"
size="lg"
type="tel"
class="w-full"
placeholder="e.g. +60123456789"
/>
</UFormField>
<UFormField name="role" label="Role" required>
<USelect
v-model="userForm.role"
size="lg"
class="w-full"
:items="roleOptions"
:disabled="isEditingCurrentUser"
/>
</UFormField>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
label="Cancel"
color="neutral"
variant="ghost"
class="justify-center"
:disabled="savingUser"
@click="closeEditor"
/>
<UButton
type="submit"
:label="isEditMode ? 'Save Changes' : 'Create User'"
class="justify-center"
:loading="savingUser"
/>
</div>
</UForm>
</template>
</UModal>
</div>
</UContainer>
</template>
<script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import {
DEFAULT_PHONE_COUNTRY_CODE,
hasValidFullName,
isValidPhoneNumber,
isValidUsername,
normalizeFullName,
normalizePhoneNumber,
normalizeUsername,
type ManagedUser,
type UserRole
} from '~~/shared/auth'
import { getErrorMessage } from '../../../utils/errors'
import { formatDateTime } from '../../../utils/formatters'
definePageMeta({
middleware: 'super-admin'
})
const toast = useToast()
const apiClient = useApiClient()
const auth = useAuth()
const users = ref<ManagedUser[]>([])
const loadingUsers = ref(false)
const savingUser = ref(false)
const savingPicOrder = ref(false)
const resettingUserId = ref<string | null>(null)
const issuedPasswordMessage = ref('')
const searchQuery = ref('')
const editorOpen = ref(false)
const editorMode = ref<'create' | 'edit'>('create')
const editingUserId = ref<string | null>(null)
const draggedUserId = ref<string | null>(null)
const dragOverUserId = ref<string | null>(null)
const dragSaveStarted = ref(false)
let usersBeforeDrag: ManagedUser[] = []
const userForm = reactive({
fullName: '',
username: '',
phoneNumber: DEFAULT_PHONE_COUNTRY_CODE,
role: 'staff' as UserRole
})
const roleOptions = [
{ label: 'Staff', value: 'staff' },
{ label: 'Super Admin', value: 'super_admin' }
]
const isEditMode = computed(() => editorMode.value === 'edit')
const isEditingCurrentUser = computed(() => {
return isEditMode.value && editingUserId.value === auth.user.value?.id
})
const canReorderUsers = computed(() => {
return !searchQuery.value.trim() && !loadingUsers.value && !savingPicOrder.value
})
const filteredUsers = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
if (!keyword) {
return users.value
}
return users.value.filter((user) => {
return [
user.fullName,
user.username,
user.phoneNumber || '',
user.role
].some((value) => value.toLowerCase().includes(keyword))
})
})
await refreshUsers()
function resetUserForm() {
userForm.fullName = ''
userForm.username = ''
userForm.phoneNumber = DEFAULT_PHONE_COUNTRY_CODE
userForm.role = 'staff'
editingUserId.value = null
}
function openCreateModal() {
editorMode.value = 'create'
resetUserForm()
editorOpen.value = true
}
function openEditModal(user: ManagedUser) {
editorMode.value = 'edit'
editingUserId.value = user.id
userForm.fullName = user.fullName
userForm.username = user.username
userForm.phoneNumber = user.phoneNumber ? normalizePhoneNumber(user.phoneNumber) : DEFAULT_PHONE_COUNTRY_CODE
userForm.role = user.role
editorOpen.value = true
}
function closeEditor() {
if (savingUser.value) {
return
}
editorOpen.value = false
resetUserForm()
}
function validateUserForm(state: typeof userForm): FormError[] {
const errors: FormError[] = []
if (!hasValidFullName(state.fullName)) {
errors.push({ name: 'fullName', message: 'Enter a display name with at least 2 characters.' })
}
if (!isEditMode.value && !isValidUsername(state.username)) {
errors.push({ name: 'username', message: 'Use 3 to 32 lowercase letters, numbers, dot, dash, or underscore.' })
}
if (!isValidPhoneNumber(state.phoneNumber)) {
errors.push({ name: 'phoneNumber', message: 'Use a valid phone number with country code, e.g. +60123456789.' })
}
return errors
}
async function refreshUsers() {
if (loadingUsers.value) {
return
}
loadingUsers.value = true
try {
const response = await apiClient<{ users: ManagedUser[] }>('/api/admin/users')
users.value = response.users
} catch (error: any) {
toast.add({
title: 'Unable to load users',
description: getErrorMessage(error, 'The user list could not be loaded.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
loadingUsers.value = false
}
}
function moveUserNearTarget(sourceUserId: string, targetUserId: string, insertAfterTarget: boolean) {
if (sourceUserId === targetUserId) {
return
}
const sourceIndex = users.value.findIndex((user) => user.id === sourceUserId)
const currentUserIds = users.value.map((user) => user.id)
if (sourceIndex === -1 || !currentUserIds.includes(targetUserId)) {
return
}
const reorderedUsers = [...users.value]
const [movedUser] = reorderedUsers.splice(sourceIndex, 1)
const targetIndex = reorderedUsers.findIndex((user) => user.id === targetUserId)
if (targetIndex === -1) {
return
}
reorderedUsers.splice(insertAfterTarget ? targetIndex + 1 : targetIndex, 0, movedUser)
const nextUserIds = reorderedUsers.map((user) => user.id)
if (nextUserIds.join('|') === currentUserIds.join('|')) {
return
}
users.value = reorderedUsers
}
function resetDragState() {
draggedUserId.value = null
dragOverUserId.value = null
dragSaveStarted.value = false
usersBeforeDrag = []
}
function startUserDrag(event: DragEvent, user: ManagedUser) {
if (!canReorderUsers.value) {
event.preventDefault()
return
}
draggedUserId.value = user.id
dragOverUserId.value = user.id
dragSaveStarted.value = false
usersBeforeDrag = [...users.value]
event.dataTransfer?.setData('text/plain', user.id)
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
function overUserDrag(event: DragEvent, user: ManagedUser) {
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
if (!canReorderUsers.value || !draggedUserId.value || draggedUserId.value === user.id) {
return
}
const target = event.currentTarget
if (!(target instanceof HTMLElement)) {
return
}
const rect = target.getBoundingClientRect()
const insertAfterTarget = event.clientY > rect.top + rect.height / 2
dragOverUserId.value = user.id
moveUserNearTarget(draggedUserId.value, user.id, insertAfterTarget)
}
async function dropUserDrag() {
if (!draggedUserId.value || savingPicOrder.value) {
return
}
dragSaveStarted.value = true
savingPicOrder.value = true
try {
const response = await apiClient<{ users: ManagedUser[] }>('/api/admin/users/order', {
method: 'PATCH',
body: {
userIds: users.value.map((user) => user.id)
}
})
users.value = response.users
toast.add({
title: 'PIC order saved',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error: any) {
users.value = usersBeforeDrag
toast.add({
title: 'PIC order could not be saved',
description: getErrorMessage(error, 'Please try again in a moment.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
savingPicOrder.value = false
resetDragState()
}
}
function endUserDrag() {
if (dragSaveStarted.value) {
return
}
if (draggedUserId.value && usersBeforeDrag.length) {
users.value = usersBeforeDrag
}
resetDragState()
}
async function saveUser(event: FormSubmitEvent<typeof userForm>) {
event.preventDefault()
if (savingUser.value) {
return
}
savingUser.value = true
try {
if (isEditMode.value && editingUserId.value) {
await apiClient(`/api/admin/users/${editingUserId.value}`, {
method: 'PATCH',
body: {
fullName: normalizeFullName(userForm.fullName),
phoneNumber: normalizePhoneNumber(userForm.phoneNumber),
role: userForm.role
}
})
if (editingUserId.value === auth.user.value?.id) {
await auth.refreshSession()
}
toast.add({
title: 'User updated',
description: `${normalizeFullName(userForm.fullName)} has been updated.`,
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} else {
const response = await apiClient<{
user: ManagedUser
defaultPassword: string
}>('/api/admin/users', {
method: 'POST',
body: {
fullName: normalizeFullName(userForm.fullName),
username: normalizeUsername(userForm.username),
phoneNumber: normalizePhoneNumber(userForm.phoneNumber),
role: userForm.role
}
})
issuedPasswordMessage.value = `${response.user.fullName} was created with the temporary password ${response.defaultPassword}.`
toast.add({
title: 'User created',
description: `${response.user.fullName} can now sign in with the temporary password.`,
color: 'success',
icon: 'i-lucide-check-circle-2'
})
}
closeEditor()
await refreshUsers()
} catch (error: any) {
toast.add({
title: isEditMode.value ? 'Update failed' : 'User creation failed',
description: getErrorMessage(
error,
isEditMode.value ? 'Unable to update this user.' : 'Unable to create the new user.'
),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
savingUser.value = false
}
}
async function resetPassword(user: ManagedUser) {
if (resettingUserId.value) {
return
}
resettingUserId.value = user.id
try {
const response = await apiClient<{
user: ManagedUser
defaultPassword: string
}>(`/api/admin/users/${user.id}/reset-password`, {
method: 'POST'
})
issuedPasswordMessage.value = `${user.fullName}'s password was reset to ${response.defaultPassword}. They must change it after login.`
toast.add({
title: 'Password reset',
description: `${user.fullName} now has a fresh temporary password.`,
color: 'success',
icon: 'i-lucide-check-circle-2'
})
await refreshUsers()
} catch (error: any) {
toast.add({
title: 'Reset failed',
description: getErrorMessage(error, 'Unable to reset this password.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
resettingUserId.value = null
}
}
</script>
<style scoped>
.user-list-move,
.user-list-enter-active,
.user-list-leave-active {
transition: transform 180ms ease, opacity 160ms ease, background-color 160ms ease;
}
.user-list-enter-from,
.user-list-leave-to {
opacity: 0;
transform: translateY(6px);
}
.user-list-leave-active {
position: absolute;
width: 100%;
}
</style>