247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|