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,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',
},
],
};

View 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' },
],
};

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' },
],
};

View 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' }],
};

View 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' }],
};

View File

@@ -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' }],
};

View File

@@ -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]"