feat: complete administration and reporting
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
Bell,
|
||||
BarChart3,
|
||||
CalendarDays,
|
||||
CalendarRange,
|
||||
LayoutGrid,
|
||||
@@ -10,6 +11,10 @@ import {
|
||||
PlusCircle,
|
||||
ServerCog,
|
||||
ShieldCheck,
|
||||
ScrollText,
|
||||
UsersRound,
|
||||
SlidersHorizontal,
|
||||
Mails,
|
||||
} from 'lucide-react';
|
||||
import AppLogo from '@/components/app-logo';
|
||||
import { NavFooter } from '@/components/nav-footer';
|
||||
@@ -82,16 +87,53 @@ export function AppSidebar() {
|
||||
},
|
||||
]
|
||||
: roleNavItems;
|
||||
const visibleNavItems = can.administer
|
||||
const calendarNavItems = can.viewGlobalCalendar
|
||||
? [
|
||||
...operationalNavItems,
|
||||
{
|
||||
title: 'Calendário global',
|
||||
href: '/calendario-global',
|
||||
icon: CalendarRange,
|
||||
},
|
||||
]
|
||||
: operationalNavItems;
|
||||
const reportNavItems = can.viewReports
|
||||
? [
|
||||
...calendarNavItems,
|
||||
{ title: 'Relatórios', href: '/relatorios', icon: BarChart3 },
|
||||
]
|
||||
: calendarNavItems;
|
||||
const oversightNavItems = can.viewAudit
|
||||
? [
|
||||
...reportNavItems,
|
||||
{ title: 'Auditoria', href: '/auditoria', icon: ScrollText },
|
||||
]
|
||||
: reportNavItems;
|
||||
const visibleNavItems = can.administer
|
||||
? [
|
||||
...oversightNavItems,
|
||||
{
|
||||
title: 'Utilizadores',
|
||||
href: '/administracao/utilizadores',
|
||||
icon: UsersRound,
|
||||
},
|
||||
{
|
||||
title: 'Regras de reserva',
|
||||
href: '/administracao/regras',
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
{
|
||||
title: 'Modelos de email',
|
||||
href: '/administracao/notificacoes/modelos',
|
||||
icon: Mails,
|
||||
},
|
||||
{
|
||||
title: 'Integração Snipe-IT',
|
||||
href: '/administracao/snipe-it',
|
||||
icon: ServerCog,
|
||||
},
|
||||
]
|
||||
: operationalNavItems;
|
||||
: oversightNavItems;
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
|
||||
144
resources/js/pages/admin/notification-templates.tsx
Normal file
144
resources/js/pages/admin/notification-templates.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { Mails, Save } from 'lucide-react';
|
||||
import type { FormEvent } from 'react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeading } from '@/components/page-heading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
type Template = { subject: string; message: string };
|
||||
type Definition = Template & { label: string };
|
||||
|
||||
export default function NotificationTemplates({
|
||||
definitions,
|
||||
templates,
|
||||
variables,
|
||||
}: {
|
||||
definitions: Record<string, Definition>;
|
||||
templates: Record<string, Template>;
|
||||
variables: string[];
|
||||
}) {
|
||||
const form = useForm({ templates });
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
form.put('/administracao/notificacoes/modelos', {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
function update(eventName: string, field: keyof Template, value: string) {
|
||||
form.setData('templates', {
|
||||
...form.data.templates,
|
||||
[eventName]: { ...form.data.templates[eventName], [field]: value },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Modelos de email" />
|
||||
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||
<PageHeading
|
||||
eyebrow="Administração"
|
||||
title="Modelos de notificação"
|
||||
description="Personalize o assunto e a mensagem usados no email e na caixa interna, mantendo as variáveis funcionais."
|
||||
/>
|
||||
<p className="rounded-xl border bg-muted/40 p-4 text-sm text-muted-foreground">
|
||||
Variáveis disponíveis:{' '}
|
||||
{variables.map((variable) => (
|
||||
<code
|
||||
key={variable}
|
||||
className="mx-1 rounded bg-background px-1.5 py-0.5"
|
||||
>
|
||||
{variable}
|
||||
</code>
|
||||
))}
|
||||
</p>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{Object.entries(definitions).map(
|
||||
([eventName, definition]) => (
|
||||
<Card
|
||||
key={eventName}
|
||||
className="gap-4 shadow-none"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Mails className="size-4 text-primary" />
|
||||
{definition.label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
Assunto
|
||||
<Input
|
||||
value={
|
||||
form.data.templates[
|
||||
eventName
|
||||
].subject
|
||||
}
|
||||
onChange={(event) =>
|
||||
update(
|
||||
eventName,
|
||||
'subject',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<InputError
|
||||
message={
|
||||
form.errors[
|
||||
`templates.${eventName}.subject`
|
||||
]
|
||||
}
|
||||
/>
|
||||
<label className="block space-y-1 text-sm font-medium">
|
||||
Mensagem
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-md border bg-transparent px-3 py-2 text-sm"
|
||||
value={
|
||||
form.data.templates[
|
||||
eventName
|
||||
].message
|
||||
}
|
||||
onChange={(event) =>
|
||||
update(
|
||||
eventName,
|
||||
'message',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<InputError
|
||||
message={
|
||||
form.errors[
|
||||
`templates.${eventName}.message`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" disabled={form.processing}>
|
||||
<Save /> Guardar modelos
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
NotificationTemplates.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Administração', href: '/administracao/notificacoes/modelos' },
|
||||
{
|
||||
title: 'Modelos de email',
|
||||
href: '/administracao/notificacoes/modelos',
|
||||
},
|
||||
],
|
||||
};
|
||||
112
resources/js/pages/admin/reservation-policy.tsx
Normal file
112
resources/js/pages/admin/reservation-policy.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { Clock3, Save, ShieldCheck } from 'lucide-react';
|
||||
import type { FormEvent } from 'react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeading } from '@/components/page-heading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
type Definition = {
|
||||
label: string;
|
||||
description: string;
|
||||
default: number;
|
||||
min: number;
|
||||
max: number;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
export default function ReservationPolicyPage({
|
||||
definitions,
|
||||
values,
|
||||
}: {
|
||||
definitions: Record<string, Definition>;
|
||||
values: Record<string, number>;
|
||||
}) {
|
||||
const form = useForm(values);
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
form.put('/administracao/regras', { preserveScroll: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Regras de reserva" />
|
||||
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||
<PageHeading
|
||||
eyebrow="Administração"
|
||||
title="Regras de reserva"
|
||||
description="Parâmetros aplicados centralmente a novos pedidos, conflitos temporais e prazos de aprovação."
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 rounded-xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-900 dark:bg-sky-950/30 dark:text-sky-100">
|
||||
<ShieldCheck className="mt-0.5 size-5 shrink-0" />
|
||||
<p>
|
||||
As margens já gravadas permanecem associadas às reservas
|
||||
existentes. Alterações entram em vigor imediatamente
|
||||
para novas requisições.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{Object.entries(definitions).map(
|
||||
([key, definition]) => (
|
||||
<Card key={key} className="gap-4 shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Clock3 className="size-4 text-primary" />
|
||||
{definition.label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="min-h-10 text-sm text-muted-foreground">
|
||||
{definition.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={definition.min}
|
||||
max={definition.max}
|
||||
value={form.data[key]}
|
||||
onChange={(event) =>
|
||||
form.setData(
|
||||
key,
|
||||
Number(
|
||||
event.target.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="min-w-24 text-sm text-muted-foreground">
|
||||
{definition.unit}
|
||||
</span>
|
||||
</div>
|
||||
<InputError
|
||||
message={form.errors[key]}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Intervalo permitido:{' '}
|
||||
{definition.min}–{definition.max}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" disabled={form.processing}>
|
||||
<Save /> Guardar e aplicar regras
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ReservationPolicyPage.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Administração', href: '/administracao/regras' },
|
||||
{ title: 'Regras de reserva', href: '/administracao/regras' },
|
||||
],
|
||||
};
|
||||
423
resources/js/pages/admin/users/index.tsx
Normal file
423
resources/js/pages/admin/users/index.tsx
Normal 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' },
|
||||
],
|
||||
};
|
||||
186
resources/js/pages/audit/index.tsx
Normal file
186
resources/js/pages/audit/index.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Search, ScrollText } from 'lucide-react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { PageHeading } from '@/components/page-heading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Paginated } from '@/types';
|
||||
|
||||
type AuditLog = {
|
||||
id: number;
|
||||
event: string;
|
||||
auditable_type: string | null;
|
||||
auditable_id: string | null;
|
||||
before: Record<string, unknown> | null;
|
||||
after: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
actor: { name: string; email: string } | null;
|
||||
};
|
||||
|
||||
export default function AuditIndex({
|
||||
logs,
|
||||
actors,
|
||||
events,
|
||||
filters,
|
||||
}: {
|
||||
logs: Paginated<AuditLog>;
|
||||
actors: { id: number; name: string }[];
|
||||
events: string[];
|
||||
filters: {
|
||||
event?: string;
|
||||
actor_id?: number;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
};
|
||||
}) {
|
||||
const [event, setEvent] = useState(filters.event ?? '');
|
||||
const [actorId, setActorId] = useState(filters.actor_id?.toString() ?? '');
|
||||
const [dateFrom, setDateFrom] = useState(filters.date_from ?? '');
|
||||
const [dateTo, setDateTo] = useState(filters.date_to ?? '');
|
||||
|
||||
function filter(submitEvent: FormEvent) {
|
||||
submitEvent.preventDefault();
|
||||
router.get('/auditoria', {
|
||||
event: event || undefined,
|
||||
actor_id: actorId || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Auditoria" />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||
<PageHeading
|
||||
eyebrow="Controlo"
|
||||
title="Registo de auditoria"
|
||||
description="Histórico imutável das decisões, operações e alterações administrativas do portal."
|
||||
/>
|
||||
<form
|
||||
onSubmit={filter}
|
||||
className="grid gap-2 rounded-xl border bg-card p-4 lg:grid-cols-[1fr_220px_160px_160px_auto]"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
list="audit-events"
|
||||
value={event}
|
||||
onChange={(e) => setEvent(e.target.value)}
|
||||
placeholder="Evento…"
|
||||
/>
|
||||
<datalist id="audit-events">
|
||||
{events.map((item) => (
|
||||
<option key={item} value={item} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<select
|
||||
className="rounded-md border bg-transparent px-3 text-sm"
|
||||
value={actorId}
|
||||
onChange={(e) => setActorId(e.target.value)}
|
||||
>
|
||||
<option value="">Todos os autores</option>
|
||||
{actors.map((actor) => (
|
||||
<option key={actor.id} value={actor.id}>
|
||||
{actor.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
aria-label="Data inicial"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
aria-label="Data final"
|
||||
/>
|
||||
<Button type="submit" variant="secondary">
|
||||
Filtrar
|
||||
</Button>
|
||||
</form>
|
||||
<section className="space-y-3">
|
||||
{logs.data.map((log) => (
|
||||
<Card key={log.id} className="gap-3 py-4 shadow-none">
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="flex items-center gap-2 font-mono text-sm font-semibold">
|
||||
<ScrollText className="size-4 text-primary" />
|
||||
{log.event}
|
||||
</p>
|
||||
<time className="text-xs text-muted-foreground">
|
||||
{new Intl.DateTimeFormat('pt-PT', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
}).format(new Date(log.created_at))}
|
||||
</time>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{log.actor
|
||||
? `${log.actor.name} · ${log.actor.email}`
|
||||
: 'Sistema'}
|
||||
{log.auditable_type
|
||||
? ` · ${log.auditable_type} #${log.auditable_id}`
|
||||
: ''}
|
||||
</p>
|
||||
{(log.before || log.after || log.metadata) && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs font-medium text-primary">
|
||||
Ver dados técnicos
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto rounded bg-slate-950 p-3 text-xs text-slate-100">
|
||||
{JSON.stringify(
|
||||
{
|
||||
before: log.before,
|
||||
after: log.after,
|
||||
metadata: log.metadata,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
{logs.last_page > 1 && (
|
||||
<nav className="flex flex-wrap gap-2">
|
||||
{logs.links.map((link) =>
|
||||
link.url ? (
|
||||
<Button
|
||||
key={link.label}
|
||||
asChild
|
||||
size="sm"
|
||||
variant={
|
||||
link.active ? 'default' : 'outline'
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={link.url}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: link.label,
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
) : null,
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
AuditIndex.layout = {
|
||||
breadcrumbs: [{ title: 'Auditoria', href: '/auditoria' }],
|
||||
};
|
||||
194
resources/js/pages/reports/index.tsx
Normal file
194
resources/js/pages/reports/index.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Download,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { PageHeading } from '@/components/page-heading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
type Summary = {
|
||||
total: number;
|
||||
completed: number;
|
||||
incidents: number;
|
||||
overdue: number;
|
||||
utilizationHours: number;
|
||||
};
|
||||
|
||||
export default function ReportsIndex({
|
||||
filters,
|
||||
summary,
|
||||
byStatus,
|
||||
byCategory,
|
||||
byDepartment,
|
||||
topAssets,
|
||||
}: {
|
||||
filters: { date_from: string; date_to: string };
|
||||
summary: Summary;
|
||||
byStatus: Record<string, number>;
|
||||
byCategory: Record<string, number>;
|
||||
byDepartment: Record<string, number>;
|
||||
topAssets: Record<string, number>;
|
||||
}) {
|
||||
const [dateFrom, setDateFrom] = useState(filters.date_from);
|
||||
const [dateTo, setDateTo] = useState(filters.date_to);
|
||||
|
||||
function filter(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
router.get('/relatorios', { date_from: dateFrom, date_to: dateTo });
|
||||
}
|
||||
|
||||
const exportUrl = `/relatorios/exportar?${new URLSearchParams({ date_from: dateFrom, date_to: dateTo })}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Relatórios" />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||
<PageHeading
|
||||
eyebrow="Gestão e análise"
|
||||
title="Relatórios operacionais"
|
||||
description="Indicadores consolidados por período, com exportação detalhada para tratamento externo."
|
||||
action={
|
||||
<Button asChild variant="outline">
|
||||
<a href={exportUrl}>
|
||||
<Download /> Exportar CSV
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<form
|
||||
onSubmit={filter}
|
||||
className="flex flex-wrap items-end gap-3 rounded-xl border bg-card p-4"
|
||||
>
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
De
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(event) =>
|
||||
setDateFrom(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
Até
|
||||
<Input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(event) => setDateTo(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" variant="secondary">
|
||||
Atualizar
|
||||
</Button>
|
||||
</form>
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<Metric
|
||||
icon={FileText}
|
||||
label="Requisições"
|
||||
value={summary.total}
|
||||
/>
|
||||
<Metric
|
||||
icon={CheckCircle2}
|
||||
label="Concluídas"
|
||||
value={summary.completed}
|
||||
/>
|
||||
<Metric
|
||||
icon={Clock3}
|
||||
label="Horas de utilização"
|
||||
value={summary.utilizationHours}
|
||||
/>
|
||||
<Metric
|
||||
icon={AlertTriangle}
|
||||
label="Incidentes"
|
||||
value={summary.incidents}
|
||||
/>
|
||||
<Metric
|
||||
icon={AlertTriangle}
|
||||
label="Atrasos"
|
||||
value={summary.overdue}
|
||||
/>
|
||||
</section>
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<Breakdown title="Por estado" values={byStatus} />
|
||||
<Breakdown title="Por categoria" values={byCategory} />
|
||||
<Breakdown title="Por departamento" values={byDepartment} />
|
||||
<Breakdown
|
||||
title="Equipamentos mais requisitados"
|
||||
values={topAssets}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<Card className="gap-3 py-5 shadow-none">
|
||||
<CardContent>
|
||||
<Icon className="mb-3 size-5 text-primary" />
|
||||
<p className="text-2xl font-semibold">{value}</p>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Breakdown({
|
||||
title,
|
||||
values,
|
||||
}: {
|
||||
title: string;
|
||||
values: Record<string, number>;
|
||||
}) {
|
||||
const max = Math.max(1, ...Object.values(values));
|
||||
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Object.keys(values).length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sem dados no período.
|
||||
</p>
|
||||
)}
|
||||
{Object.entries(values).map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<div className="mb-1 flex justify-between gap-3 text-sm">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
<div className="h-2 rounded bg-muted">
|
||||
<div
|
||||
className="h-2 rounded bg-primary"
|
||||
style={{ width: `${(value / max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
ReportsIndex.layout = {
|
||||
breadcrumbs: [{ title: 'Relatórios', href: '/relatorios' }],
|
||||
};
|
||||
@@ -10,9 +10,13 @@ const weekdays = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'];
|
||||
export default function ReservationCalendar({
|
||||
month,
|
||||
reservations,
|
||||
scope = 'personal',
|
||||
}: {
|
||||
month: string;
|
||||
reservations: Reservation[];
|
||||
reservations: (Reservation & {
|
||||
requester?: { name: string; department: string | null };
|
||||
})[];
|
||||
scope?: 'personal' | 'global';
|
||||
}) {
|
||||
const [year, monthNumber] = month.split('-').map(Number);
|
||||
const monthStart = new Date(year, monthNumber - 1, 1);
|
||||
@@ -32,21 +36,35 @@ export default function ReservationCalendar({
|
||||
}).format(monthStart);
|
||||
const previousMonth = monthKey(new Date(year, monthNumber - 2, 1));
|
||||
const nextMonth = monthKey(new Date(year, monthNumber, 1));
|
||||
const global = scope === 'global';
|
||||
const calendarUrl = global
|
||||
? '/calendario-global'
|
||||
: '/requisicoes/calendario';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="O meu calendário" />
|
||||
<Head title={global ? 'Calendário global' : 'O meu calendário'} />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||
<PageHeading
|
||||
eyebrow="Planeamento pessoal"
|
||||
title="O meu calendário"
|
||||
description="Consulte as suas requisições no mês e identifique rapidamente períodos sobrepostos."
|
||||
eyebrow={
|
||||
global
|
||||
? 'Planeamento institucional'
|
||||
: 'Planeamento pessoal'
|
||||
}
|
||||
title={global ? 'Calendário global' : 'O meu calendário'}
|
||||
description={
|
||||
global
|
||||
? 'Visão consolidada de todas as requisições, requisitantes e equipamentos.'
|
||||
: 'Consulte as suas requisições no mês e identifique rapidamente períodos sobrepostos.'
|
||||
}
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/requisicoes/nova">
|
||||
<Plus /> Nova requisição
|
||||
</Link>
|
||||
</Button>
|
||||
!global ? (
|
||||
<Button asChild>
|
||||
<Link href="/requisicoes/nova">
|
||||
<Plus /> Nova requisição
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -54,7 +72,7 @@ export default function ReservationCalendar({
|
||||
<header className="flex items-center justify-between border-b p-4">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href={`/requisicoes/calendario?month=${previousMonth}`}
|
||||
href={`${calendarUrl}?month=${previousMonth}`}
|
||||
>
|
||||
<ArrowLeft /> Anterior
|
||||
</Link>
|
||||
@@ -63,9 +81,7 @@ export default function ReservationCalendar({
|
||||
{title}
|
||||
</h2>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href={`/requisicoes/calendario?month=${nextMonth}`}
|
||||
>
|
||||
<Link href={`${calendarUrl}?month=${nextMonth}`}>
|
||||
Seguinte <ArrowRight />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -132,6 +148,10 @@ export default function ReservationCalendar({
|
||||
)}
|
||||
>
|
||||
{reservation.reference}
|
||||
{global &&
|
||||
reservation.requester
|
||||
? ` · ${reservation.requester.name}`
|
||||
: ''}
|
||||
</div>
|
||||
))}
|
||||
{entries.length > 3 && (
|
||||
@@ -202,8 +222,5 @@ function statusBorder(status: string): string {
|
||||
}
|
||||
|
||||
ReservationCalendar.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Requisições', href: '/requisicoes' },
|
||||
{ title: 'Calendário', href: '/requisicoes/calendario' },
|
||||
],
|
||||
breadcrumbs: [{ title: 'Calendário', href: '/requisicoes/calendario' }],
|
||||
};
|
||||
|
||||
@@ -21,11 +21,18 @@ export default function CreateReservation({
|
||||
selectedAssetId,
|
||||
period,
|
||||
periodSelected,
|
||||
policy,
|
||||
}: {
|
||||
assets: Asset[];
|
||||
selectedAssetId: number;
|
||||
period: { starts_at?: string; ends_at?: string };
|
||||
periodSelected: boolean;
|
||||
policy: {
|
||||
minNoticeHours: number;
|
||||
maxDurationHours: number;
|
||||
turnaroundMinutes: number;
|
||||
maxAssets: number;
|
||||
};
|
||||
}) {
|
||||
const form = useForm({
|
||||
purpose: '',
|
||||
@@ -70,6 +77,15 @@ export default function CreateReservation({
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<p className="rounded-xl border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
|
||||
Regras atuais: antecedência mínima de{' '}
|
||||
{policy.minNoticeHours} h, duração máxima de{' '}
|
||||
{policy.maxDurationHours} h, até {policy.maxAssets}{' '}
|
||||
equipamentos
|
||||
{policy.turnaroundMinutes > 0
|
||||
? ` e margem operacional de ${policy.turnaroundMinutes} min.`
|
||||
: '.'}
|
||||
</p>
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="grid gap-6 lg:grid-cols-[1fr_320px]"
|
||||
|
||||
3
resources/js/types/global.d.ts
vendored
3
resources/js/types/global.d.ts
vendored
@@ -20,6 +20,9 @@ declare module '@inertiajs/core' {
|
||||
administer: boolean;
|
||||
approveReservations: boolean;
|
||||
operateInventory: boolean;
|
||||
viewGlobalCalendar: boolean;
|
||||
viewReports: boolean;
|
||||
viewAudit: boolean;
|
||||
};
|
||||
unreadNotifications: number;
|
||||
sidebarOpen: boolean;
|
||||
|
||||
Reference in New Issue
Block a user