feat: complete administration and reporting

This commit is contained in:
2026-08-04 17:40:52 +01:00
parent d829f34580
commit 7a4ac48aaf
42 changed files with 2595 additions and 76 deletions

View File

@@ -0,0 +1,423 @@
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' },
],
};