424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
import { Head, Link, router, usePage } from '@inertiajs/react';
|
|
import { Plus, Search, UserCog } from 'lucide-react';
|
|
import type { FormEvent } from 'react';
|
|
import { useState } from 'react';
|
|
import InputError from '@/components/input-error';
|
|
import { PageHeading } from '@/components/page-heading';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import type { Paginated } from '@/types';
|
|
|
|
type Role = { id: number; name: string; description: string | null };
|
|
type ManagedUser = {
|
|
id: number;
|
|
uuid: string;
|
|
name: string;
|
|
email: string;
|
|
department: string | null;
|
|
snipe_user_id: number | null;
|
|
active: boolean;
|
|
roles: Role[];
|
|
};
|
|
type UserFormState = {
|
|
name: string;
|
|
email: string;
|
|
department: string;
|
|
snipeUserId: string;
|
|
password: string;
|
|
active: boolean;
|
|
roles: string[];
|
|
};
|
|
|
|
const roleLabels: Record<string, string> = {
|
|
requester: 'Requisitante',
|
|
approver: 'Aprovador',
|
|
inventory_operator: 'Operador',
|
|
administrator: 'Administrador',
|
|
auditor: 'Auditor',
|
|
};
|
|
|
|
export default function UsersIndex({
|
|
users,
|
|
roles,
|
|
filters,
|
|
}: {
|
|
users: Paginated<ManagedUser>;
|
|
roles: Role[];
|
|
filters: { search?: string; role?: string; status?: string };
|
|
}) {
|
|
const [search, setSearch] = useState(filters.search ?? '');
|
|
const [role, setRole] = useState(filters.role ?? '');
|
|
const [status, setStatus] = useState(filters.status ?? '');
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
function submitFilters(event: FormEvent) {
|
|
event.preventDefault();
|
|
router.get('/administracao/utilizadores', {
|
|
search: search || undefined,
|
|
role: role || undefined,
|
|
status: status || undefined,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head title="Utilizadores" />
|
|
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
|
<PageHeading
|
|
eyebrow="Administração"
|
|
title="Utilizadores e perfis"
|
|
description="Gerir acesso, responsabilidades e a associação de cada conta ao utilizador correspondente no Snipe-IT."
|
|
action={
|
|
<Button onClick={() => setCreating((value) => !value)}>
|
|
<Plus /> Novo utilizador
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
{creating && (
|
|
<Card className="shadow-none">
|
|
<CardHeader>
|
|
<CardTitle>Criar conta institucional</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<UserForm
|
|
roles={roles}
|
|
initial={{
|
|
name: '',
|
|
email: '',
|
|
department: '',
|
|
snipeUserId: '',
|
|
password: '',
|
|
active: true,
|
|
roles: ['requester'],
|
|
}}
|
|
passwordRequired
|
|
submitLabel="Criar utilizador"
|
|
onSubmit={(data, finish) =>
|
|
router.post(
|
|
'/administracao/utilizadores',
|
|
payload(data),
|
|
{
|
|
preserveScroll: true,
|
|
onSuccess: () => setCreating(false),
|
|
onFinish: finish,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<form
|
|
onSubmit={submitFilters}
|
|
className="grid gap-2 rounded-xl border bg-card p-4 md:grid-cols-[1fr_220px_180px_auto]"
|
|
>
|
|
<div className="relative">
|
|
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
|
<Input
|
|
className="pl-9"
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
placeholder="Nome, email ou departamento…"
|
|
/>
|
|
</div>
|
|
<select
|
|
className="rounded-md border bg-transparent px-3 text-sm"
|
|
value={role}
|
|
onChange={(event) => setRole(event.target.value)}
|
|
>
|
|
<option value="">Todos os perfis</option>
|
|
{roles.map((item) => (
|
|
<option key={item.id} value={item.name}>
|
|
{roleLabels[item.name] ?? item.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
className="rounded-md border bg-transparent px-3 text-sm"
|
|
value={status}
|
|
onChange={(event) => setStatus(event.target.value)}
|
|
>
|
|
<option value="">Ativos e inativos</option>
|
|
<option value="active">Ativos</option>
|
|
<option value="inactive">Inativos</option>
|
|
</select>
|
|
<Button type="submit" variant="secondary">
|
|
Pesquisar
|
|
</Button>
|
|
</form>
|
|
|
|
<section className="space-y-4">
|
|
{users.data.map((user) => (
|
|
<Card key={user.id} className="gap-4 shadow-none">
|
|
<CardHeader className="flex-row items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<UserCog className="size-4 text-primary" />
|
|
{user.name}
|
|
</CardTitle>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{user.email} ·{' '}
|
|
{user.department ?? 'Sem departamento'}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap justify-end gap-1">
|
|
<Badge
|
|
variant={
|
|
user.active
|
|
? 'default'
|
|
: 'secondary'
|
|
}
|
|
>
|
|
{user.active ? 'Ativo' : 'Inativo'}
|
|
</Badge>
|
|
{user.roles.map((item) => (
|
|
<Badge key={item.id} variant="outline">
|
|
{roleLabels[item.name] ?? item.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<details>
|
|
<summary className="cursor-pointer text-sm font-medium text-primary">
|
|
Editar conta e perfis
|
|
</summary>
|
|
<div className="mt-4">
|
|
<UserForm
|
|
roles={roles}
|
|
initial={{
|
|
name: user.name,
|
|
email: user.email,
|
|
department:
|
|
user.department ?? '',
|
|
snipeUserId:
|
|
user.snipe_user_id?.toString() ??
|
|
'',
|
|
password: '',
|
|
active: user.active,
|
|
roles: user.roles.map(
|
|
(item) => item.name,
|
|
),
|
|
}}
|
|
submitLabel="Guardar alterações"
|
|
onSubmit={(data, finish) =>
|
|
router.put(
|
|
`/administracao/utilizadores/${user.id}`,
|
|
payload(data),
|
|
{
|
|
preserveScroll: true,
|
|
onFinish: finish,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</details>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</section>
|
|
|
|
{users.last_page > 1 && (
|
|
<nav
|
|
className="flex flex-wrap gap-2"
|
|
aria-label="Paginação"
|
|
>
|
|
{users.links.map((link) =>
|
|
link.url ? (
|
|
<Button
|
|
key={link.label}
|
|
asChild
|
|
variant={
|
|
link.active ? 'default' : 'outline'
|
|
}
|
|
size="sm"
|
|
>
|
|
<Link
|
|
href={link.url}
|
|
dangerouslySetInnerHTML={{
|
|
__html: link.label,
|
|
}}
|
|
/>
|
|
</Button>
|
|
) : null,
|
|
)}
|
|
</nav>
|
|
)}
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function UserForm({
|
|
roles,
|
|
initial,
|
|
passwordRequired = false,
|
|
submitLabel,
|
|
onSubmit,
|
|
}: {
|
|
roles: Role[];
|
|
initial: UserFormState;
|
|
passwordRequired?: boolean;
|
|
submitLabel: string;
|
|
onSubmit: (data: UserFormState, finish: () => void) => void;
|
|
}) {
|
|
const [data, setData] = useState(initial);
|
|
const [processing, setProcessing] = useState(false);
|
|
const { errors } = usePage().props;
|
|
|
|
function submit(event: FormEvent) {
|
|
event.preventDefault();
|
|
setProcessing(true);
|
|
onSubmit(data, () => setProcessing(false));
|
|
}
|
|
|
|
function toggleRole(name: string) {
|
|
setData((current) => ({
|
|
...current,
|
|
roles: current.roles.includes(name)
|
|
? current.roles.filter((item) => item !== name)
|
|
: [...current.roles, name],
|
|
}));
|
|
}
|
|
|
|
return (
|
|
<form
|
|
onSubmit={submit}
|
|
className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
|
>
|
|
<Field label="Nome">
|
|
<Input
|
|
value={data.name}
|
|
onChange={(event) =>
|
|
setData({ ...data, name: event.target.value })
|
|
}
|
|
required
|
|
/>
|
|
</Field>
|
|
<Field label="Email institucional">
|
|
<Input
|
|
type="email"
|
|
value={data.email}
|
|
onChange={(event) =>
|
|
setData({ ...data, email: event.target.value })
|
|
}
|
|
required
|
|
/>
|
|
</Field>
|
|
<Field label="Departamento">
|
|
<Input
|
|
value={data.department}
|
|
onChange={(event) =>
|
|
setData({ ...data, department: event.target.value })
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label="ID do utilizador no Snipe-IT">
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={data.snipeUserId}
|
|
onChange={(event) =>
|
|
setData({ ...data, snipeUserId: event.target.value })
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label={
|
|
passwordRequired
|
|
? 'Palavra-passe temporária'
|
|
: 'Nova palavra-passe (opcional)'
|
|
}
|
|
>
|
|
<Input
|
|
type="password"
|
|
minLength={12}
|
|
value={data.password}
|
|
onChange={(event) =>
|
|
setData({ ...data, password: event.target.value })
|
|
}
|
|
required={passwordRequired}
|
|
/>
|
|
</Field>
|
|
<label className="flex items-center gap-2 self-end pb-2 text-sm font-medium">
|
|
<input
|
|
type="checkbox"
|
|
checked={data.active}
|
|
onChange={(event) =>
|
|
setData({ ...data, active: event.target.checked })
|
|
}
|
|
/>
|
|
Conta ativa
|
|
</label>
|
|
<fieldset className="space-y-2 md:col-span-2 xl:col-span-3">
|
|
<legend className="text-sm font-medium">Perfis</legend>
|
|
<div className="flex flex-wrap gap-3">
|
|
{roles.map((role) => (
|
|
<label
|
|
key={role.id}
|
|
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={data.roles.includes(role.name)}
|
|
onChange={() => toggleRole(role.name)}
|
|
/>
|
|
{roleLabels[role.name] ?? role.name}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
<div className="space-y-1 md:col-span-2 xl:col-span-3">
|
|
{Object.values(errors).map((message, index) => (
|
|
<InputError key={`${message}-${index}`} message={message} />
|
|
))}
|
|
<Button
|
|
type="submit"
|
|
disabled={processing || data.roles.length === 0}
|
|
>
|
|
{submitLabel}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<label className="space-y-1 text-sm font-medium">
|
|
{label}
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function payload(data: UserFormState) {
|
|
return {
|
|
name: data.name,
|
|
email: data.email,
|
|
department: data.department || null,
|
|
snipe_user_id: data.snipeUserId ? Number(data.snipeUserId) : null,
|
|
password: data.password || null,
|
|
active: data.active,
|
|
roles: data.roles,
|
|
};
|
|
}
|
|
|
|
UsersIndex.layout = {
|
|
breadcrumbs: [
|
|
{ title: 'Administração', href: '/administracao/utilizadores' },
|
|
{ title: 'Utilizadores', href: '/administracao/utilizadores' },
|
|
],
|
|
};
|