feat: add Snipe-IT asset synchronization

This commit is contained in:
2026-08-04 16:15:09 +01:00
parent 604180b735
commit 077015f17a
37 changed files with 1302 additions and 26 deletions

View File

@@ -0,0 +1,246 @@
import { Head, router } from '@inertiajs/react';
import { Database, PlugZap, RefreshCw, ServerCog } from 'lucide-react';
import { useState } from 'react';
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 AppLayout from '@/layouts/app-layout';
import type { BreadcrumbItem } from '@/types';
type SyncRun = {
id: number;
status: 'running' | 'succeeded' | 'failed';
started_at: string | null;
finished_at: string | null;
records_processed: number;
records_created: number;
records_updated: number;
records_disabled: number;
error_message: string | null;
};
type Props = {
configured: boolean;
baseUrl: string | null;
assets: {
total: number;
active: number;
requestable: number;
};
syncRuns: SyncRun[];
};
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'Administração', href: '/administracao/snipe-it' },
{ title: 'Snipe-IT', href: '/administracao/snipe-it' },
];
const statusLabel: Record<SyncRun['status'], string> = {
running: 'Em curso',
succeeded: 'Concluída',
failed: 'Falhou',
};
function formatDate(value: string | null) {
if (!value) {
return '—';
}
return new Intl.DateTimeFormat('pt-PT', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(value));
}
export default function SnipeItAdmin({
configured,
baseUrl,
assets,
syncRuns,
}: Props) {
const [action, setAction] = useState<'test' | 'sync' | null>(null);
const postAction = (url: string, currentAction: 'test' | 'sync') => {
router.post(
url,
{},
{
preserveScroll: true,
onStart: () => setAction(currentAction),
onFinish: () => setAction(null),
},
);
};
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Integração Snipe-IT" />
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
<PageHeading
eyebrow="Administração"
title="Integração Snipe-IT"
description="Estado do cache local e operações seguras de leitura da fonte oficial de inventário."
action={
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
disabled={!configured || action !== null}
onClick={() =>
postAction(
'/administracao/snipe-it/testar',
'test',
)
}
>
<PlugZap />
Testar ligação
</Button>
<Button
disabled={!configured || action !== null}
onClick={() =>
postAction(
'/administracao/snipe-it/sincronizar',
'sync',
)
}
>
<RefreshCw
className={
action === 'sync' ? 'animate-spin' : ''
}
/>
Sincronizar agora
</Button>
</div>
}
/>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<ServerCog className="size-4 text-primary" />
Configuração
</CardTitle>
</CardHeader>
<CardContent>
<Badge
variant={configured ? 'default' : 'destructive'}
>
{configured ? 'Configurada' : 'Por configurar'}
</Badge>
<p className="mt-3 truncate text-xs text-muted-foreground">
{baseUrl ?? 'Defina URL e token no ambiente.'}
</p>
</CardContent>
</Card>
{[
['Registos locais', assets.total],
['Ativos', assets.active],
['Requisitáveis', assets.requestable],
].map(([label, value]) => (
<Card key={label}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Database className="size-4 text-primary" />
{label}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold">
{value}
</p>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>Últimas sincronizações</CardTitle>
</CardHeader>
<CardContent>
{syncRuns.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
Ainda não existem sincronizações registadas.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b text-xs tracking-wide text-muted-foreground uppercase">
<tr>
<th className="px-3 py-3">
Início
</th>
<th className="px-3 py-3">
Estado
</th>
<th className="px-3 py-3">
Processados
</th>
<th className="px-3 py-3">
Criados
</th>
<th className="px-3 py-3">
Atualizados
</th>
<th className="px-3 py-3">
Desativados
</th>
</tr>
</thead>
<tbody className="divide-y">
{syncRuns.map((run) => (
<tr key={run.id}>
<td className="px-3 py-3">
{formatDate(run.started_at)}
</td>
<td className="px-3 py-3">
<Badge
variant={
run.status ===
'failed'
? 'destructive'
: run.status ===
'running'
? 'secondary'
: 'default'
}
title={
run.error_message ??
undefined
}
>
{
statusLabel[
run.status
]
}
</Badge>
</td>
<td className="px-3 py-3">
{run.records_processed}
</td>
<td className="px-3 py-3">
{run.records_created}
</td>
<td className="px-3 py-3">
{run.records_updated}
</td>
<td className="px-3 py-3">
{run.records_disabled}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</AppLayout>
);
}