DONE: layout letter with letterhead and footer, notification for newly...
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
AccordionRoot,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from '@/components/ui/accordion'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
import {
|
||||
groupPermissionsByRoute,
|
||||
resolveDefaultOpenGroups,
|
||||
} from '../utils/groupPermissions'
|
||||
import type { RolePermission } from '../types/role.types'
|
||||
|
||||
const props = defineProps<{
|
||||
permissions: RolePermission[]
|
||||
selectedPermissionIds: Set<string>
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
checkedChange: [permissionId: string, checked: boolean]
|
||||
}>()
|
||||
|
||||
const permissionGroups = computed(() => groupPermissionsByRoute(props.permissions))
|
||||
|
||||
const defaultOpenGroups = computed(() =>
|
||||
resolveDefaultOpenGroups(permissionGroups.value, props.selectedPermissionIds),
|
||||
)
|
||||
|
||||
const accordionKey = computed(
|
||||
() =>
|
||||
`${permissionGroups.value.map((group) => group.key).join('|')}:${props.selectedPermissionIds.size}`,
|
||||
)
|
||||
|
||||
function setPermissionChecked(permissionId: string, checked: boolean) {
|
||||
emit('checkedChange', permissionId, checked)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-h-96 overflow-auto rounded-lg border border-foreground/10 p-3">
|
||||
<div v-if="!permissions.length && !loading" class="py-6 text-center opacity-70">
|
||||
Tiada permissions ditemui
|
||||
</div>
|
||||
|
||||
<AccordionRoot v-else-if="permissionGroups.length" :key="accordionKey" class="w-full"
|
||||
:default-value="defaultOpenGroups">
|
||||
<AccordionItem v-for="group in permissionGroups" :key="group.key" :value="group.key">
|
||||
<AccordionTrigger>{{ group.label }}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<CheckboxRoot v-for="permission in group.permissions" :key="permission.id"
|
||||
:checked="selectedPermissionIds.has(permission.id)" :disabled="loading || disabled"
|
||||
@checked-change="({ checked }) => setPermissionChecked(permission.id, checked === true)">
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>
|
||||
<span class="font-medium">{{ permission.name }}</span>
|
||||
</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
import { computed, 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 { listRoles } from '../services/role.service'
|
||||
import type { RoleListItem } from '../types/role.types'
|
||||
|
||||
function getSortValue(item: RoleListItem, key: string): string {
|
||||
if (key === 'permissions') {
|
||||
return item.permissions?.map((permission) => permission.name).join(', ') ?? ''
|
||||
}
|
||||
|
||||
const value = item[key as keyof RoleListItem]
|
||||
return value == null ? '' : String(value)
|
||||
}
|
||||
|
||||
export function useRoleList() {
|
||||
const allRoles = ref<RoleListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
const sortedRoles = computed(() => {
|
||||
const activeSort = sortBy.value[0]
|
||||
const sorted = [...allRoles.value]
|
||||
|
||||
if (!activeSort?.key) {
|
||||
return sorted
|
||||
}
|
||||
|
||||
return sorted.sort((left, right) => {
|
||||
const comparison = getSortValue(left, activeSort.key).localeCompare(
|
||||
getSortValue(right, activeSort.key),
|
||||
)
|
||||
|
||||
return activeSort.order === 'desc' ? -comparison : comparison
|
||||
})
|
||||
})
|
||||
|
||||
const roles = computed(() => {
|
||||
const start = (page.value - 1) * itemsPerPage.value
|
||||
return sortedRoles.value.slice(start, start + itemsPerPage.value)
|
||||
})
|
||||
|
||||
function updatePagination() {
|
||||
const total = sortedRoles.value.length
|
||||
const lastPage = Math.max(1, Math.ceil(total / itemsPerPage.value))
|
||||
const currentPage = Math.min(page.value, lastPage)
|
||||
const from = total === 0 ? null : (currentPage - 1) * itemsPerPage.value + 1
|
||||
const to = total === 0 ? null : Math.min(currentPage * itemsPerPage.value, total)
|
||||
|
||||
applyPagination({
|
||||
current_page: currentPage,
|
||||
per_page: itemsPerPage.value,
|
||||
total,
|
||||
last_page: lastPage,
|
||||
from,
|
||||
to,
|
||||
has_more_pages: currentPage < lastPage,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchRoles() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await listRoles({
|
||||
search: search.value.trim() || undefined,
|
||||
})
|
||||
allRoles.value = response.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai peranan.')
|
||||
allRoles.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
updatePagination()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortUpdate(value: SortConfig[]) {
|
||||
sortBy.value = value
|
||||
page.value = 1
|
||||
updatePagination()
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
page.value = 1
|
||||
fetchRoles()
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch([sortedRoles, page, itemsPerPage], () => {
|
||||
updatePagination()
|
||||
})
|
||||
|
||||
watch(itemsPerPage, () => {
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoles()
|
||||
})
|
||||
|
||||
return {
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
handleSortUpdate,
|
||||
fetchRoles,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ref } from 'vue'
|
||||
import * as select from '@zag-js/select'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
export const GUARD_OPTIONS: SelectOption[] = [
|
||||
{ label: 'api', value: 'api' },
|
||||
{ label: 'web', value: 'web' },
|
||||
]
|
||||
|
||||
export const CONTEXT_OPTIONS: SelectOption[] = [
|
||||
{ label: 'member', value: 'member' },
|
||||
{ label: 'admin', value: 'admin' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
export function useRoleSelectFields(
|
||||
initialGuard: 'api' | 'web' = 'api',
|
||||
initialContext: 'member' | 'admin' = 'member',
|
||||
) {
|
||||
const guardCollection = createSelectCollection(GUARD_OPTIONS)
|
||||
const contextCollection = createSelectCollection(CONTEXT_OPTIONS)
|
||||
|
||||
const guardValue = ref<string[]>([initialGuard])
|
||||
const contextValue = ref<string[]>([initialContext])
|
||||
const guardInitial = ref<string[]>([initialGuard])
|
||||
const contextInitial = ref<string[]>([initialContext])
|
||||
|
||||
function setGuardValue(details: { value: string[] }) {
|
||||
guardValue.value = details.value
|
||||
}
|
||||
|
||||
function setContextValue(details: { value: string[] }) {
|
||||
contextValue.value = details.value
|
||||
}
|
||||
|
||||
function syncFromRole(guard: string, roleContext: string) {
|
||||
const guardName = guard === 'web' ? 'web' : 'api'
|
||||
const contextName = roleContext === 'admin' ? 'admin' : 'member'
|
||||
guardValue.value = [guardName]
|
||||
contextValue.value = [contextName]
|
||||
guardInitial.value = [guardName]
|
||||
contextInitial.value = [contextName]
|
||||
}
|
||||
|
||||
function getGuardName(): 'api' | 'web' {
|
||||
return guardValue.value[0] === 'web' ? 'web' : 'api'
|
||||
}
|
||||
|
||||
function getContext(): 'member' | 'admin' {
|
||||
return contextValue.value[0] === 'admin' ? 'admin' : 'member'
|
||||
}
|
||||
|
||||
return {
|
||||
guardCollection,
|
||||
contextCollection,
|
||||
guardValue,
|
||||
contextValue,
|
||||
guardInitial,
|
||||
contextInitial,
|
||||
setGuardValue,
|
||||
setContextValue,
|
||||
syncFromRole,
|
||||
getGuardName,
|
||||
getContext,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { roleLayoutRoutes } from './routes'
|
||||
export { roleMenu } from './menu'
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const roleMenu: Menu[] = [
|
||||
{
|
||||
icon: 'ShieldCheck',
|
||||
route_name: 'list-roles',
|
||||
title: 'Senarai Peranan',
|
||||
permission: 'lihat peranan',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { 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 { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import RolePermissionsPicker from '../components/RolePermissionsPicker.vue'
|
||||
import { useRoleSelectFields } from '../composables/useRoleSelectFields'
|
||||
import { createRole, listPermissions } from '../services/role.service'
|
||||
import type { RolePermission } from '../types/role.types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const name = ref('')
|
||||
const fullname = ref('')
|
||||
const permissions = ref<RolePermission[]>([])
|
||||
const selectedPermissionIds = ref<Set<string>>(new Set())
|
||||
|
||||
const {
|
||||
guardCollection,
|
||||
contextCollection,
|
||||
guardInitial,
|
||||
contextInitial,
|
||||
setGuardValue,
|
||||
setContextValue,
|
||||
getGuardName,
|
||||
getContext,
|
||||
} = useRoleSelectFields()
|
||||
|
||||
const selectedCount = computed(() => selectedPermissionIds.value.size)
|
||||
|
||||
function setPermissionChecked(permissionId: string, checked: boolean) {
|
||||
const next = new Set(selectedPermissionIds.value)
|
||||
if (checked) next.add(permissionId)
|
||||
else next.delete(permissionId)
|
||||
selectedPermissionIds.value = next
|
||||
}
|
||||
|
||||
async function fetchPermissions() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await listPermissions()
|
||||
permissions.value = response.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai permissions.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
await createRole({
|
||||
name: name.value.trim(),
|
||||
fullname: fullname.value.trim(),
|
||||
guard_name: getGuardName(),
|
||||
context: getContext(),
|
||||
permissions: Array.from(selectedPermissionIds.value),
|
||||
})
|
||||
|
||||
successMessage.value = 'Peranan berjaya didaftarkan.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'list-roles' })
|
||||
}, 300)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mendaftarkan peranan.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPermissions()
|
||||
})
|
||||
</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 Peranan</h2>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving"
|
||||
@click="router.push({ name: 'list-roles' })">
|
||||
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">
|
||||
<Field>
|
||||
<FieldLabel for="role-name">Nama Peranan</FieldLabel>
|
||||
<Input id="role-name" v-model="name" class="w-full" type="text" placeholder="Contoh: ADMIN"
|
||||
:disabled="loading || saving" required />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="role-fullname">Nama Penuh Peranan</FieldLabel>
|
||||
<Input id="role-fullname" v-model="fullname" class="w-full" type="text" placeholder="Contoh: Pentadbir Sistem"
|
||||
:disabled="loading || saving" required />
|
||||
</Field>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Guard</FieldLabel>
|
||||
<SelectRoot class="w-full" :collection="guardCollection" :default-value="guardInitial" disabled
|
||||
@value-change="setGuardValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih guard" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Guard</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in guardCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Konteks</FieldLabel>
|
||||
<SelectRoot class="w-full" :collection="contextCollection" :default-value="contextInitial"
|
||||
:disabled="loading || saving" @value-change="setContextValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih konteks" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Konteks</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in contextCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="font-medium">Permissions</div>
|
||||
<div class="text-sm opacity-70">{{ selectedCount }} dipilih</div>
|
||||
</div>
|
||||
|
||||
<RolePermissionsPicker :permissions="permissions" :selected-permission-ids="selectedPermissionIds"
|
||||
:loading="loading" :disabled="saving" @checked-change="setPermissionChecked" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-2">
|
||||
<Button type="submit" variant="primary" look="outline" :disabled="loading || saving">
|
||||
{{ saving ? 'Mendaftar...' : 'Daftar' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,210 @@
|
||||
<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 { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import RolePermissionsPicker from '../components/RolePermissionsPicker.vue'
|
||||
import { useRoleSelectFields } from '../composables/useRoleSelectFields'
|
||||
import { getRole, listPermissions, updateRole } from '../services/role.service'
|
||||
import type { RolePermission } from '../types/role.types'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const roleId = 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 name = ref('')
|
||||
const fullname = ref('')
|
||||
const permissions = ref<RolePermission[]>([])
|
||||
const selectedPermissionIds = ref<Set<string>>(new Set())
|
||||
|
||||
const {
|
||||
guardCollection,
|
||||
contextCollection,
|
||||
guardInitial,
|
||||
contextInitial,
|
||||
setGuardValue,
|
||||
setContextValue,
|
||||
syncFromRole,
|
||||
getGuardName,
|
||||
getContext,
|
||||
} = useRoleSelectFields()
|
||||
|
||||
const selectedCount = computed(() => selectedPermissionIds.value.size)
|
||||
|
||||
function setPermissionChecked(permissionId: string, checked: boolean) {
|
||||
const next = new Set(selectedPermissionIds.value)
|
||||
if (checked) next.add(permissionId)
|
||||
else next.delete(permissionId)
|
||||
selectedPermissionIds.value = next
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const [roleResponse, permissionsResponse] = await Promise.all([
|
||||
getRole(roleId.value),
|
||||
listPermissions(),
|
||||
])
|
||||
|
||||
const role = roleResponse.data
|
||||
name.value = role.name
|
||||
fullname.value = role.fullname ?? ''
|
||||
syncFromRole(role.guard_name, role.context)
|
||||
|
||||
permissions.value = permissionsResponse.data
|
||||
selectedPermissionIds.value = new Set(role.permissions?.map((p) => p.id) ?? [])
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan maklumat peranan.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
await updateRole(roleId.value, {
|
||||
name: name.value.trim(),
|
||||
fullname: fullname.value.trim(),
|
||||
guard_name: getGuardName(),
|
||||
context: getContext(),
|
||||
permissions: Array.from(selectedPermissionIds.value),
|
||||
})
|
||||
|
||||
successMessage.value = 'Peranan berjaya dikemaskini.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'list-roles' })
|
||||
}, 300)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemaskini peranan.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</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 Peranan</h2>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving"
|
||||
@click="router.push({ name: 'list-roles' })">
|
||||
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">
|
||||
<Field>
|
||||
<FieldLabel for="role-name">Nama Peranan</FieldLabel>
|
||||
<Input id="role-name" v-model="name" class="w-full" type="text" placeholder="Contoh: ADMIN" disabled />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="role-fullname">Nama Penuh Peranan</FieldLabel>
|
||||
<Input id="role-fullname" v-model="fullname" class="w-full" type="text" placeholder="Contoh: Pentadbir Sistem"
|
||||
:disabled="loading || saving" required />
|
||||
</Field>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Guard</FieldLabel>
|
||||
<SelectRoot v-if="!loading" class="w-full" :collection="guardCollection" :default-value="guardInitial"
|
||||
disabled @value-change="setGuardValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih guard" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Guard</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in guardCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Konteks</FieldLabel>
|
||||
<SelectRoot v-if="!loading" class="w-full" :collection="contextCollection" :default-value="contextInitial"
|
||||
:disabled="loading || saving" @value-change="setContextValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih konteks" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Konteks</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in contextCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="font-medium">Permissions</div>
|
||||
<div class="text-sm opacity-70">{{ selectedCount }} dipilih</div>
|
||||
</div>
|
||||
|
||||
<RolePermissionsPicker :permissions="permissions" :selected-permission-ids="selectedPermissionIds"
|
||||
:loading="loading" :disabled="saving" @checked-change="setPermissionChecked" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-2">
|
||||
<Button type="submit" variant="primary" look="outline" :disabled="loading || saving">
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import {
|
||||
PaginationContext,
|
||||
PaginationRoot,
|
||||
PaginationItem,
|
||||
PaginationPrevTrigger,
|
||||
PaginationNextTrigger,
|
||||
PaginationEllipsis,
|
||||
} from '@/components/ui/pagination'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { useRoleList } from '../composables/useRoleList'
|
||||
import { deleteRole as deleteRoleService } from '../services/role.service'
|
||||
|
||||
const deleteConfirmationOpen = ref(false)
|
||||
const roleToDelete = ref<string | null>(null)
|
||||
const deleting = ref(false)
|
||||
const deleteError = ref<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const { roles, loading, error, search, page, pagination, fetchRoles } = useRoleList()
|
||||
|
||||
function handlePageChange(details: { page: number }) {
|
||||
page.value = details.page
|
||||
}
|
||||
|
||||
function contextBadgeVariant(context: string) {
|
||||
if (context === 'admin') return 'success'
|
||||
if (context === 'member') return 'pending'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
function goToCreateRole() {
|
||||
router.push({ name: 'create-role' })
|
||||
}
|
||||
|
||||
function goToEditRole(roleId: string) {
|
||||
router.push({ name: 'edit-role', params: { id: roleId } })
|
||||
}
|
||||
|
||||
function openDeleteConfirmation(roleId: string) {
|
||||
roleToDelete.value = roleId
|
||||
deleteError.value = null
|
||||
deleteConfirmationOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!roleToDelete.value || deleting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleting.value = true
|
||||
deleteError.value = null
|
||||
|
||||
try {
|
||||
await deleteRoleService(roleToDelete.value)
|
||||
deleteConfirmationOpen.value = false
|
||||
roleToDelete.value = null
|
||||
await fetchRoles()
|
||||
} catch (err) {
|
||||
deleteError.value = getApiErrorMessage(err, 'Gagal menghapus peranan.')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-lg font-medium">Senarai Peranan</h2>
|
||||
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<div class="mt-5 grid grid-cols-12 gap-x-6 gap-y-8">
|
||||
<div class="col-span-12 mt-2 flex flex-wrap items-center sm:flex-nowrap">
|
||||
<!-- Search -->
|
||||
<div class="w-full sm:w-auto">
|
||||
<div class="relative w-56">
|
||||
<Input v-model="search" class="w-56 pr-10" type="search" placeholder="Cari peranan..." />
|
||||
<Lucide class="absolute inset-y-0 right-0 my-auto mr-3 h-4 w-4" icon="Search" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Button -->
|
||||
<Button class="mt-3 sm:mt-0 sm:ml-auto" look="outline" variant="primary" :disabled="loading"
|
||||
@click="goToCreateRole">
|
||||
Tambah Peranan
|
||||
</Button>
|
||||
</div>
|
||||
<!-- BEGIN: Data List -->
|
||||
<div v-for="role in roles" :key="role.id" class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
|
||||
<Box class="p-0">
|
||||
<div class="p-5">
|
||||
<div class="rounded-lg border border-foreground/10 bg-foreground/2 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-base font-medium">
|
||||
{{ role.name }}
|
||||
</div>
|
||||
<div class="mt-1 truncate text-xs opacity-70">
|
||||
{{ role.fullname || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge class="shrink-0" look="outline" :variant="contextBadgeVariant(role.context)">
|
||||
{{ role.context }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 opacity-70">
|
||||
<div class="flex items-center">
|
||||
<Lucide class="mr-2 h-4 w-4" icon="Shield" />
|
||||
Guard: {{ role.guard_name }}
|
||||
</div>
|
||||
<div class="mt-2 flex items-center">
|
||||
<Lucide class="mr-2 h-4 w-4" icon="KeyRound" />
|
||||
Permissions: {{ role.permissions?.length ?? 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center border-t border-foreground/8 p-5 lg:justify-end">
|
||||
<a class="mr-3 flex items-center" href="#" @click.prevent="goToEditRole(role.id)">
|
||||
<Lucide class="mr-1 h-4 w-4" icon="CheckSquare" /> Edit
|
||||
</a>
|
||||
<a class="text-danger flex items-center" href="#"
|
||||
@click.prevent="openDeleteConfirmation(role.id)">
|
||||
<Lucide class="mr-1 h-4 w-4" icon="Trash" /> Delete
|
||||
</a>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Data List -->
|
||||
<div v-if="!loading && !roles.length" class="col-span-12">
|
||||
<Box class="p-6 text-center opacity-70">Tiada rekod ditemui</Box>
|
||||
</div>
|
||||
<!-- BEGIN: Pagination -->
|
||||
<div class="col-span-12 flex flex-wrap items-center sm:flex-row sm:flex-nowrap">
|
||||
<PaginationRoot :count="pagination.total" :page="page" :siblingCount="1"
|
||||
:onPageChange="handlePageChange" class="w-full sm:mr-auto sm:w-auto">
|
||||
<PaginationPrevTrigger>Previous</PaginationPrevTrigger>
|
||||
<PaginationContext v-slot="{ pagination }">
|
||||
<template v-for="(page, index) in pagination?.pages" :key="index">
|
||||
<PaginationItem v-if="page.type === 'page'" v-bind="{ ...page }">
|
||||
{{ page.value }}
|
||||
</PaginationItem>
|
||||
<PaginationEllipsis v-else :index="index" />
|
||||
</template>
|
||||
</PaginationContext>
|
||||
<PaginationNextTrigger>Next</PaginationNextTrigger>
|
||||
</PaginationRoot>
|
||||
</div>
|
||||
<!-- END: Pagination -->
|
||||
</div>
|
||||
<!-- BEGIN: Delete Confirmation Modal -->
|
||||
<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 rekod ini? <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"> Cancel </DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-24"
|
||||
type="button"
|
||||
variant="danger"
|
||||
look="outline"
|
||||
:disabled="deleting"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
{{ deleting ? 'Menghapus...' : 'Hapus' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
<!-- END: Delete Confirmation Modal -->
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const roleLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'list-roles',
|
||||
name: 'list-roles',
|
||||
component: () => import('./pages/RoleList.vue'),
|
||||
meta: { title: 'List Roles', module: 'role', permission: 'lihat peranan' },
|
||||
},
|
||||
{
|
||||
path: 'roles/create',
|
||||
name: 'create-role',
|
||||
component: () => import('./pages/RoleCreate.vue'),
|
||||
meta: { title: 'Create Role', module: 'role', permission: 'tambah peranan' },
|
||||
},
|
||||
{
|
||||
path: 'roles/:id/edit',
|
||||
name: 'edit-role',
|
||||
component: () => import('./pages/RoleEdit.vue'),
|
||||
meta: { title: 'Edit Role', module: 'role', permission: 'kemaskini peranan' },
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { CreateRolePayload, ListRolesParams, RoleListItem, RolePermission } from '../types/role.types'
|
||||
|
||||
type RolesApiResponse = {
|
||||
success: boolean
|
||||
data: RoleListItem[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
type RoleApiResponse = {
|
||||
success: boolean
|
||||
data: RoleListItem
|
||||
message?: string
|
||||
}
|
||||
|
||||
type PermissionsApiResponse = {
|
||||
success: boolean
|
||||
data: RolePermission[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function listRoles(params?: ListRolesParams): Promise<RolesApiResponse> {
|
||||
const { data } = await api.get<RolesApiResponse>('/v1/roles', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load roles')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getRole(id: string): Promise<RoleApiResponse> {
|
||||
const { data } = await api.get<RoleApiResponse>(`/v1/roles/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load role')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createRole(payload: CreateRolePayload): Promise<RoleApiResponse> {
|
||||
const { data } = await api.post<RoleApiResponse>('/v1/roles', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to create role')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateRole(
|
||||
id: string,
|
||||
payload: CreateRolePayload,
|
||||
): Promise<RoleApiResponse> {
|
||||
const { data } = await api.put<RoleApiResponse>(`/v1/roles/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to update role')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteRole(id: string): Promise<RoleApiResponse> {
|
||||
const { data } = await api.delete<RoleApiResponse>(`/v1/roles/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to delete role')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listPermissions(): Promise<PermissionsApiResponse> {
|
||||
const { data } = await api.get<PermissionsApiResponse>('/v1/permissions')
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load permissions')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface RolePermission {
|
||||
id: string
|
||||
name: string
|
||||
guard_name: string
|
||||
route_name: string | null
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string
|
||||
name: string
|
||||
guard_name: string
|
||||
fullname: string | null
|
||||
context: string
|
||||
permissions?: RolePermission[]
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface RoleListItem {
|
||||
id: string
|
||||
name: string
|
||||
guard_name: string
|
||||
fullname: string | null
|
||||
context: string
|
||||
permissions?: RolePermission[]
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface ListRolesParams {
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface CreateRolePayload {
|
||||
name: string
|
||||
fullname: string
|
||||
guard_name: 'api' | 'web'
|
||||
context: 'member' | 'admin'
|
||||
permissions?: string[]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { RolePermission } from '../types/role.types'
|
||||
|
||||
export interface PermissionGroup {
|
||||
key: string
|
||||
label: string
|
||||
permissions: RolePermission[]
|
||||
}
|
||||
|
||||
function toGroupKey(routeName: string): string {
|
||||
return routeName
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
export function groupPermissionsByRoute(
|
||||
permissions: RolePermission[],
|
||||
): PermissionGroup[] {
|
||||
const groups = new Map<string, RolePermission[]>()
|
||||
|
||||
for (const permission of permissions) {
|
||||
const label = permission.route_name?.trim() || 'Lain-lain'
|
||||
const bucket = groups.get(label) ?? []
|
||||
bucket.push(permission)
|
||||
groups.set(label, bucket)
|
||||
}
|
||||
|
||||
return Array.from(groups.entries())
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([label, items]) => ({
|
||||
key: toGroupKey(label) || 'lain-lain',
|
||||
label,
|
||||
permissions: [...items].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
export function resolveDefaultOpenGroups(
|
||||
groups: PermissionGroup[],
|
||||
selectedPermissionIds: Set<string>,
|
||||
): string[] {
|
||||
const openGroups = groups
|
||||
.filter((group) =>
|
||||
group.permissions.some((permission) =>
|
||||
selectedPermissionIds.has(permission.id),
|
||||
),
|
||||
)
|
||||
.map((group) => group.key)
|
||||
|
||||
if (openGroups.length) {
|
||||
return openGroups
|
||||
}
|
||||
|
||||
return groups[0] ? [groups[0].key] : []
|
||||
}
|
||||
Reference in New Issue
Block a user