@@ -2,10 +2,13 @@ import { Head, Link, router } from '@inertiajs/react';
|
||||
import {
|
||||
Boxes,
|
||||
CalendarClock,
|
||||
FilterX,
|
||||
MapPin,
|
||||
PackageCheck,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
@@ -67,6 +70,22 @@ export default function AssetsIndex({
|
||||
);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setSearch('');
|
||||
setCategory('all');
|
||||
setLocation('all');
|
||||
setStartsAt('');
|
||||
setEndsAt('');
|
||||
router.get('/equipamentos', {}, { preserveState: true });
|
||||
}
|
||||
|
||||
const hasActiveFilters =
|
||||
search ||
|
||||
category !== 'all' ||
|
||||
location !== 'all' ||
|
||||
startsAt ||
|
||||
endsAt;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Equipamentos" />
|
||||
@@ -77,81 +96,121 @@ export default function AssetsIndex({
|
||||
description="Consulte os equipamentos requisitáveis registados no Snipe-IT e inicie uma requisição."
|
||||
/>
|
||||
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="grid gap-3 rounded-xl border bg-card p-4 lg:grid-cols-6"
|
||||
>
|
||||
<div className="relative lg:col-span-2">
|
||||
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Nome, modelo ou etiqueta…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
Todas as categorias
|
||||
</SelectItem>
|
||||
{categories.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<CalendarClock className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="datetime-local"
|
||||
aria-label="Início do período"
|
||||
value={startsAt}
|
||||
onChange={(event) =>
|
||||
setStartsAt(event.target.value)
|
||||
}
|
||||
className="pl-9"
|
||||
required={Boolean(endsAt)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
aria-label="Fim do período"
|
||||
value={endsAt}
|
||||
onChange={(event) => setEndsAt(event.target.value)}
|
||||
required={Boolean(startsAt)}
|
||||
/>
|
||||
<Select value={location} onValueChange={setLocation}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Localização" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
Todas as localizações
|
||||
</SelectItem>
|
||||
{locations.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="submit" variant="secondary">
|
||||
<SlidersHorizontal /> Filtrar
|
||||
</Button>
|
||||
</form>
|
||||
<Card className="border-slate-200 shadow-none dark:border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) =>
|
||||
setSearch(event.target.value)
|
||||
}
|
||||
placeholder="Pesquisar equipamentos por nome, modelo ou etiqueta…"
|
||||
className="h-12 pl-10 text-base"
|
||||
/>
|
||||
</div>
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer items-center gap-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Mais filtros
|
||||
</summary>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-5">
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={setCategory}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
Todas as categorias
|
||||
</SelectItem>
|
||||
{categories.map((item) => (
|
||||
<SelectItem
|
||||
key={item}
|
||||
value={item}
|
||||
>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<CalendarClock className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="datetime-local"
|
||||
aria-label="Início do período"
|
||||
value={startsAt}
|
||||
onChange={(event) =>
|
||||
setStartsAt(event.target.value)
|
||||
}
|
||||
className="pl-9"
|
||||
required={Boolean(endsAt)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
aria-label="Fim do período"
|
||||
value={endsAt}
|
||||
onChange={(event) =>
|
||||
setEndsAt(event.target.value)
|
||||
}
|
||||
required={Boolean(startsAt)}
|
||||
/>
|
||||
<Select
|
||||
value={location}
|
||||
onValueChange={setLocation}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Localização" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
Todas as localizações
|
||||
</SelectItem>
|
||||
{locations.map((item) => (
|
||||
<SelectItem
|
||||
key={item}
|
||||
value={item}
|
||||
>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
>
|
||||
<SlidersHorizontal /> Filtrar
|
||||
</Button>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<FilterX />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
<span className="font-medium">
|
||||
{assets.total} equipamento
|
||||
{assets.total === 1 ? '' : 's'}
|
||||
</span>
|
||||
{lastSyncedAt && (
|
||||
<span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="size-2 rounded-full bg-emerald-500" />
|
||||
Sincronizado em{' '}
|
||||
{new Intl.DateTimeFormat('pt-PT', {
|
||||
dateStyle: 'short',
|
||||
@@ -182,36 +241,82 @@ export default function AssetsIndex({
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Altere os filtros e volte a tentar.
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={clearFilters}
|
||||
className="mt-2"
|
||||
>
|
||||
Limpar filtros
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assets.last_page > 1 && (
|
||||
<nav className="flex justify-center gap-2">
|
||||
{assets.links.map((link, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
asChild={Boolean(link.url)}
|
||||
disabled={!link.url}
|
||||
variant={link.active ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
>
|
||||
{link.url ? (
|
||||
<Link
|
||||
href={link.url}
|
||||
preserveScroll
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: link.label,
|
||||
}}
|
||||
/>
|
||||
{assets.links.map((link, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === assets.links.length - 1;
|
||||
|
||||
if (isFirst || isLast) {
|
||||
const icon = isFirst ? (
|
||||
<ChevronLeft className="size-4" />
|
||||
) : (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: link.label,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
<ChevronRight className="size-4" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
asChild={Boolean(link.url)}
|
||||
disabled={!link.url}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-w-[32px]"
|
||||
>
|
||||
{link.url ? (
|
||||
<Link
|
||||
href={link.url}
|
||||
preserveScroll
|
||||
>
|
||||
{icon}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{icon}</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
asChild={Boolean(link.url)}
|
||||
disabled={!link.url}
|
||||
variant={
|
||||
link.active ? 'default' : 'outline'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{link.url ? (
|
||||
<Link
|
||||
href={link.url}
|
||||
preserveScroll
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: link.label,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: link.label,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</main>
|
||||
@@ -238,35 +343,44 @@ function AssetCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="group gap-4 overflow-hidden border-slate-200 py-0 shadow-none transition hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md dark:border-slate-800">
|
||||
<div className="flex h-36 items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-950">
|
||||
<Card className="group gap-4 overflow-hidden border-slate-200 py-0 shadow-none transition-all hover:-translate-y-1 hover:border-primary/40 hover:shadow-lg dark:border-slate-800">
|
||||
<div className="relative flex h-40 items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-950">
|
||||
{asset.image_url ? (
|
||||
<img
|
||||
src={asset.image_url}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<PackageCheck className="size-12 text-slate-300 transition group-hover:text-primary/40" />
|
||||
<PackageCheck className="size-14 text-slate-300 transition group-hover:text-primary/50" />
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="absolute top-3 right-3 bg-white/90 backdrop-blur-sm dark:bg-slate-900/90"
|
||||
>
|
||||
{periodSelected ? 'Livre no período' : 'Requisitável'}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardContent className="space-y-4 pb-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold">{asset.name}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate font-semibold">{asset.name}</h2>
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||
{asset.model_name}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-emerald-700">
|
||||
{periodSelected ? 'Livre no período' : 'Requisitável'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{asset.asset_tag}</span>
|
||||
{asset.category_name && <span>{asset.category_name}</span>}
|
||||
<span className="rounded bg-muted px-2 py-0.5 font-mono">
|
||||
{asset.asset_tag}
|
||||
</span>
|
||||
{asset.category_name && (
|
||||
<span className="rounded bg-muted px-2 py-0.5">
|
||||
{asset.category_name}
|
||||
</span>
|
||||
)}
|
||||
{asset.location_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="flex items-center gap-1 rounded bg-muted px-2 py-0.5">
|
||||
<MapPin className="size-3" />
|
||||
{asset.location_name}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user