DONE: layout letter with letterhead and footer, notification for newly...

This commit is contained in:
ISMAIL MASSERAN
2026-06-28 02:16:49 +00:00
parent 94ecbe5887
commit 034ecf947f
400 changed files with 29802 additions and 5446 deletions
@@ -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,
}
}
+2
View File
@@ -0,0 +1,2 @@
export { roleLayoutRoutes } from './routes'
export { roleMenu } from './menu'
+10
View File
@@ -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',
},
]
+196
View File
@@ -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>
+210
View File
@@ -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>
+195
View File
@@ -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>
+22
View File
@@ -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
}
+40
View File
@@ -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] : []
}