191 lines
6.9 KiB
Vue
191 lines
6.9 KiB
Vue
<template>
|
|||
|
|
<div>
|
||
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-12">
|
||
|
|
<!-- Left: Role 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="roleSearchTerm"
|
||
|
|
placeholder="Search roles..."
|
||
|
|
class="search-input w-full text-sm"
|
||
|
|
/>
|
||
|
|
</IconField>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="loadingRoles" class="flex flex-1 items-center justify-center">
|
||
|
|
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||
|
|
</div>
|
||
|
|
<div v-else-if="filteredRoles.length === 0" class="muted-note p-4 text-center">
|
||
|
|
No roles found
|
||
|
|
</div>
|
||
|
|
<div v-else class="flex-1 overflow-y-auto pr-1 space-y-1">
|
||
|
|
<div
|
||
|
|
v-for="r in filteredRoles"
|
||
|
|
:key="r.id"
|
||
|
|
class="flex cursor-pointer items-center justify-between rounded-xl p-3 transition-colors"
|
||
|
|
:class="selectedRoleId === r.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="selectRole(r.id)"
|
||
|
|
>
|
||
|
|
<div class="flex items-center gap-2.5">
|
||
|
|
<span class="text-sm leading-tight">{{ r.name }}</span>
|
||
|
|
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
|
||
|
|
</div>
|
||
|
|
<i v-if="selectedRoleId === r.id" class="pi pi-chevron-right text-xs"></i>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Right: Role Screen Permissions Matrix -->
|
||
|
|
<div class="panel md:col-span-8 flex flex-col overflow-hidden p-4" style="height: 620px;">
|
||
|
|
<div v-if="!selectedRoleId" class="flex flex-1 items-center justify-center muted-note">
|
||
|
|
Select a role from the left list to view/edit permissions
|
||
|
|
</div>
|
||
|
|
<template v-else>
|
||
|
|
<div class="mb-3 flex items-center justify-between">
|
||
|
|
<div class="eyebrow">Screen Permissions ({{ selectedRole?.name }})</div>
|
||
|
|
<Button
|
||
|
|
label="Save Changes"
|
||
|
|
icon="pi pi-check"
|
||
|
|
size="small"
|
||
|
|
:loading="savingRolePermissions"
|
||
|
|
@click="handleSaveRolePermissions"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div v-if="loadingRoleDetail" class="flex flex-1 items-center justify-center">
|
||
|
|
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||
|
|
</div>
|
||
|
|
<div v-else class="flex-1 overflow-x-auto">
|
||
|
|
<table class="w-full border-collapse text-sm">
|
||
|
|
<thead>
|
||
|
|
<tr class="border-b" style="border-color: var(--hairline)">
|
||
|
|
<th class="py-2.5 text-left font-medium">Screen</th>
|
||
|
|
<th class="w-20 text-center font-medium">View</th>
|
||
|
|
<th class="w-20 text-center font-medium">Create</th>
|
||
|
|
<th class="w-20 text-center font-medium">Edit</th>
|
||
|
|
<th class="w-20 text-center font-medium">Delete</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
<tr v-for="item in rolePermissions" :key="item.screen" class="border-b" style="border-color: var(--hairline)">
|
||
|
|
<td class="py-3 font-medium">{{ screenLabel(item.screen) }}</td>
|
||
|
|
<td class="text-center">
|
||
|
|
<Checkbox v-model="item.canView" :binary="true" />
|
||
|
|
</td>
|
||
|
|
<td class="text-center">
|
||
|
|
<Checkbox v-model="item.canCreate" :binary="true" />
|
||
|
|
</td>
|
||
|
|
<td class="text-center">
|
||
|
|
<Checkbox v-model="item.canEdit" :binary="true" />
|
||
|
|
</td>
|
||
|
|
<td class="text-center">
|
||
|
|
<Checkbox v-model="item.canDelete" :binary="true" />
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { getRoles, getRole, updateRole } from '../../../services/backend'
|
||
|
|
import { errorMessage } from '../../../services/api'
|
||
|
|
import type { Role, PermissionEntry } from '../../../types'
|
||
|
|
|
||
|
|
const toast = useToast()
|
||
|
|
|
||
|
|
const roles = ref<Role[]>([])
|
||
|
|
const loadingRoles = ref(false)
|
||
|
|
const roleSearchTerm = ref('')
|
||
|
|
const selectedRoleId = ref<string | null>(null)
|
||
|
|
const selectedRole = ref<Role | null>(null)
|
||
|
|
const rolePermissions = ref<PermissionEntry[]>([])
|
||
|
|
const loadingRoleDetail = ref(false)
|
||
|
|
const savingRolePermissions = ref(false)
|
||
|
|
|
||
|
|
const ALL_SCREENS = [
|
||
|
|
{ key: 'dashboard', label: 'Dashboard' },
|
||
|
|
{ key: 'projects', label: 'Projects' },
|
||
|
|
{ key: 'documents', label: 'Documents' },
|
||
|
|
{ key: 'tasks', label: 'Tasks' },
|
||
|
|
{ key: 'users', label: 'Users' },
|
||
|
|
{ key: 'permissions', label: 'Permissions' },
|
||
|
|
{ key: 'masterdata', label: 'Master Data' },
|
||
|
|
]
|
||
|
|
|
||
|
|
const filteredRoles = computed(() => {
|
||
|
|
if (!roleSearchTerm.value) return roles.value
|
||
|
|
const term = roleSearchTerm.value.toLowerCase()
|
||
|
|
return roles.value.filter((r) => r.name.toLowerCase().includes(term))
|
||
|
|
})
|
||
|
|
|
||
|
|
async function fetchRoles() {
|
||
|
|
loadingRoles.value = true
|
||
|
|
try {
|
||
|
|
const res = await getRoles(1, 100)
|
||
|
|
roles.value = res.items
|
||
|
|
if (roles.value.length > 0 && !selectedRoleId.value) {
|
||
|
|
selectRole(roles.value[0].id)
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||
|
|
} finally {
|
||
|
|
loadingRoles.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function selectRole(roleId: string) {
|
||
|
|
selectedRoleId.value = roleId
|
||
|
|
loadingRoleDetail.value = true
|
||
|
|
try {
|
||
|
|
const data = await getRole(roleId)
|
||
|
|
selectedRole.value = data
|
||
|
|
rolePermissions.value = ALL_SCREENS.map((s) => {
|
||
|
|
const existing = data.permissions.find((p) => p.screen === s.key)
|
||
|
|
return {
|
||
|
|
screen: s.key,
|
||
|
|
canView: existing?.canView ?? false,
|
||
|
|
canCreate: existing?.canCreate ?? false,
|
||
|
|
canEdit: existing?.canEdit ?? false,
|
||
|
|
canDelete: existing?.canDelete ?? false,
|
||
|
|
}
|
||
|
|
})
|
||
|
|
} catch (e) {
|
||
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||
|
|
} finally {
|
||
|
|
loadingRoleDetail.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function screenLabel(key: string): string {
|
||
|
|
return ALL_SCREENS.find((s) => s.key === key)?.label ?? key
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleSaveRolePermissions() {
|
||
|
|
if (!selectedRoleId.value || !selectedRole.value) return
|
||
|
|
savingRolePermissions.value = true
|
||
|
|
try {
|
||
|
|
await updateRole(selectedRoleId.value, {
|
||
|
|
name: selectedRole.value.name,
|
||
|
|
permissions: rolePermissions.value,
|
||
|
|
})
|
||
|
|
toast.add({ severity: 'success', summary: 'Permissions saved', life: 2000 })
|
||
|
|
await fetchRoles()
|
||
|
|
} catch (e) {
|
||
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||
|
|
} finally {
|
||
|
|
savingRolePermissions.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
void fetchRoles()
|
||
|
|
})
|
||
|
|
</script>
|