feat: add Snipe-IT asset synchronization
This commit is contained in:
246
resources/js/pages/admin/snipe-it.tsx
Normal file
246
resources/js/pages/admin/snipe-it.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user