165 lines
5.7 KiB
TypeScript
165 lines
5.7 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { Plus, Trash2 } from 'lucide-react'
|
|
import { useState } from 'react'
|
|
import { useForm } from 'react-hook-form'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { z } from 'zod'
|
|
import { qk } from '@/shared/api/query-keys'
|
|
import { useApiError } from '@/shared/lib/use-api-error'
|
|
import { Badge } from '@/shared/ui/badge'
|
|
import { Button } from '@/shared/ui/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/shared/ui/dialog'
|
|
import { Input } from '@/shared/ui/input'
|
|
import { Label } from '@/shared/ui/label'
|
|
import { sortRows, useTableSort } from '@/shared/lib/table-sort'
|
|
import { SortHeader } from '@/shared/ui/sortable'
|
|
import { createRole, deleteRole, listRoles, updateRole } from './api'
|
|
|
|
const schema = z.object({ name: z.string().min(1).max(64) })
|
|
|
|
export function RolesPanel() {
|
|
const { t } = useTranslation()
|
|
const queryClient = useQueryClient()
|
|
const { data: roles, isLoading } = useQuery({ queryKey: qk.roles.all, queryFn: listRoles })
|
|
const { sort, toggle } = useTableSort('name', false)
|
|
const sortedRoles = sortRows(roles ?? [], sort, {
|
|
name: (r) => r.name.toLowerCase(),
|
|
system: (r) => r.isSystem,
|
|
})
|
|
|
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.roles.all })
|
|
const onError = useApiError()
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (name: string) => createRole(name),
|
|
onSuccess: invalidate,
|
|
})
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => deleteRole(id),
|
|
onSuccess: invalidate,
|
|
onError,
|
|
})
|
|
|
|
const renameMutation = useMutation({
|
|
mutationFn: ({ id, name }: Readonly<{ id: string; name: string }>) => updateRole(id, name),
|
|
onSuccess: invalidate,
|
|
onError,
|
|
})
|
|
|
|
const [open, setOpen] = useState(false)
|
|
const { register, handleSubmit, reset } = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) })
|
|
|
|
const onCreate = async (values: z.infer<typeof schema>) => {
|
|
try {
|
|
await createMutation.mutateAsync(values.name)
|
|
reset()
|
|
setOpen(false)
|
|
} catch (error) {
|
|
onError(error)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="crt-glow text-xl font-semibold">{t('admin.roles.title')}</h2>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm">
|
|
<Plus className="h-4 w-4" /> {t('admin.roles.create')}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('admin.roles.create')}</DialogTitle>
|
|
</DialogHeader>
|
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onCreate)}>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="roleName">{t('admin.roles.name')}</Label>
|
|
<Input id="roleName" {...register('name')} />
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="submit" disabled={createMutation.isPending}>
|
|
{t('common.create')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
<div className="crt-panel overflow-x-auto rounded-md">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b border-border text-left text-muted-foreground">
|
|
<tr>
|
|
<SortHeader
|
|
label={t('admin.roles.name')}
|
|
sortKey="name"
|
|
sort={sort}
|
|
onToggle={toggle}
|
|
/>
|
|
<SortHeader
|
|
label={t('admin.roles.system')}
|
|
sortKey="system"
|
|
sort={sort}
|
|
onToggle={toggle}
|
|
/>
|
|
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{isLoading && (
|
|
<tr>
|
|
<td className="px-4 py-3 text-muted-foreground" colSpan={3}>
|
|
{t('common.loading')}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{sortedRoles.map((role) => (
|
|
<tr key={role.id} className="border-b border-border last:border-0">
|
|
<td className="px-4 py-2">{role.name}</td>
|
|
<td className="px-4 py-2">
|
|
{role.isSystem ? <Badge variant="muted">{t('common.yes')}</Badge> : t('common.no')}
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={role.isSystem}
|
|
onClick={() => {
|
|
const nextName = window.prompt(t('admin.roles.rename'), role.name)
|
|
if (nextName && nextName !== role.name)
|
|
renameMutation.mutate({ id: role.id, name: nextName })
|
|
}}
|
|
>
|
|
{t('admin.roles.rename')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
disabled={role.isSystem}
|
|
onClick={() => deleteMutation.mutate(role.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|