feat: update UI
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-12">
|
||||
<!-- Left: User List -->
|
||||
<div class="panel flex flex-col p-4 md:col-span-4" style="height: 620px;">
|
||||
<div class="mb-3">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search text-xs" />
|
||||
<InputText
|
||||
v-model.trim="userSearchTerm"
|
||||
placeholder="Search users..."
|
||||
class="search-input w-full text-sm"
|
||||
@input="onUserSearch"
|
||||
/>
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingUsers" class="flex flex-1 items-center justify-center">
|
||||
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||||
</div>
|
||||
<div v-else-if="users.length === 0" class="muted-note p-4 text-center">
|
||||
No users found
|
||||
</div>
|
||||
<div v-else class="flex-1 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="u in users"
|
||||
:key="u.id"
|
||||
class="flex cursor-pointer items-center justify-between rounded-xl p-3 transition-colors mb-1"
|
||||
:class="selectedUserId === u.id
|
||||
? 'bg-indigo-500/10 text-indigo-500 dark:bg-indigo-500/20 font-semibold'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800/60'"
|
||||
@click="selectUser(u.id)"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<Avatar
|
||||
:label="(u.displayName || u.username).slice(0, 2).toUpperCase()"
|
||||
style="background: var(--primary); color: #fff"
|
||||
shape="circle"
|
||||
size="normal"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-sm leading-tight">{{ u.displayName }}</div>
|
||||
<div class="muted-note text-xs">@{{ u.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<i v-if="selectedUserId === u.id" class="pi pi-chevron-right text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Top & Bottom: Roles (Unassigned / Assigned) -->
|
||||
<div class="flex flex-col gap-4 md:col-span-8">
|
||||
<div v-if="!selectedUserId" class="panel flex items-center justify-center p-8 muted-note" style="height: 620px;">
|
||||
Select a user from the left list to manage roles
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Top Right: Unassigned Roles -->
|
||||
<div class="panel flex flex-col p-4" style="height: 300px;">
|
||||
<div class="eyebrow mb-3 flex items-center gap-2">
|
||||
<i class="pi pi-plus-circle text-indigo-500 text-sm"></i> Unassigned Roles
|
||||
</div>
|
||||
<div v-if="loadingUserRoles" class="flex flex-1 items-center justify-center">
|
||||
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||||
</div>
|
||||
<div v-else-if="unassignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
|
||||
All available roles assigned
|
||||
</div>
|
||||
<div v-else class="flex-1 overflow-y-auto space-y-2 pr-1">
|
||||
<div
|
||||
v-for="r in unassignedRoles"
|
||||
:key="r.id"
|
||||
class="flex items-center justify-between rounded-xl border p-3"
|
||||
style="border-color: var(--hairline)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-sm">{{ r.name }}</span>
|
||||
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
|
||||
</div>
|
||||
<Button
|
||||
label="Assign"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
:loading="assigningRoleId === r.id"
|
||||
@click="handleAssignRole(r.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Right: Assigned Roles -->
|
||||
<div class="panel flex flex-col p-4" style="height: 304px;">
|
||||
<div class="eyebrow mb-3 flex items-center gap-2">
|
||||
<i class="pi pi-check-circle text-green-500 text-sm"></i> Assigned Roles
|
||||
</div>
|
||||
<div v-if="loadingUserRoles" class="flex flex-1 items-center justify-center">
|
||||
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||||
</div>
|
||||
<div v-else-if="assignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
|
||||
No roles assigned yet
|
||||
</div>
|
||||
<div v-else class="flex-1 overflow-y-auto space-y-2 pr-1">
|
||||
<div
|
||||
v-for="r in assignedRoles"
|
||||
:key="r.id"
|
||||
class="flex items-center justify-between rounded-xl border p-3"
|
||||
style="border-color: var(--hairline)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-sm">{{ r.name }}</span>
|
||||
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
|
||||
</div>
|
||||
<Button
|
||||
label="Unassign"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
text
|
||||
size="small"
|
||||
:loading="unassigningRoleId === r.id"
|
||||
@click="handleUnassignRole(r.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getUsers, getUserRoles, assignUserRole, unassignUserRole } from '../../../services/backend'
|
||||
import { errorMessage } from '../../../services/api'
|
||||
import type { User, Role } from '../../../types'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const users = ref<User[]>([])
|
||||
const loadingUsers = ref(false)
|
||||
const userSearchTerm = ref('')
|
||||
|
||||
const selectedUserId = ref<string | null>(null)
|
||||
const assignedRoles = ref<Role[]>([])
|
||||
const unassignedRoles = ref<Role[]>([])
|
||||
const loadingUserRoles = ref(false)
|
||||
const assigningRoleId = ref<string | null>(null)
|
||||
const unassigningRoleId = ref<string | null>(null)
|
||||
|
||||
let userSearchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function fetchUsers(q?: string) {
|
||||
loadingUsers.value = true
|
||||
try {
|
||||
users.value = await getUsers(q)
|
||||
if (users.value.length > 0 && !selectedUserId.value) {
|
||||
selectUser(users.value[0].id)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loadingUsers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onUserSearch() {
|
||||
clearTimeout(userSearchTimer)
|
||||
userSearchTimer = setTimeout(() => void fetchUsers(userSearchTerm.value), 300)
|
||||
}
|
||||
|
||||
async function selectUser(userId: string) {
|
||||
selectedUserId.value = userId
|
||||
loadingUserRoles.value = true
|
||||
try {
|
||||
const data = await getUserRoles(userId)
|
||||
assignedRoles.value = data.assignedRoles
|
||||
unassignedRoles.value = data.unassignedRoles
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loadingUserRoles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignRole(roleId: string) {
|
||||
if (!selectedUserId.value) return
|
||||
assigningRoleId.value = roleId
|
||||
try {
|
||||
await assignUserRole(selectedUserId.value, roleId)
|
||||
toast.add({ severity: 'success', summary: 'Role assigned', life: 2000 })
|
||||
await selectUser(selectedUserId.value)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
assigningRoleId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnassignRole(roleId: string) {
|
||||
if (!selectedUserId.value) return
|
||||
unassigningRoleId.value = roleId
|
||||
try {
|
||||
await unassignUserRole(selectedUserId.value, roleId)
|
||||
toast.add({ severity: 'success', summary: 'Role unassigned', life: 2000 })
|
||||
await selectUser(selectedUserId.value)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
unassigningRoleId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchUsers()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user