db8e86b749
FMs move to a separate application. Drops Fm/FmPart tables + Repair.fmId column, deletes FM_OPENED/FM_CLOSED PartEvent rows, strips FM enums + webhook events + shared contracts, removes FM routes/services/pages/UI, and collapses dashboard admin ops to Repairs 7d/30d + trend + custody backlog. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
211 lines
6.7 KiB
TypeScript
211 lines
6.7 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import {
|
|
ArrowRight,
|
|
ArrowRightLeft,
|
|
LogIn,
|
|
LogOut,
|
|
Pencil,
|
|
type LucideIcon,
|
|
} from 'lucide-react';
|
|
import { Button, Skeleton } from '@vector/ui';
|
|
import { listHostTimeline } from '../../lib/api/hosts.js';
|
|
import { queryKeys } from '../../lib/queryKeys.js';
|
|
import type { HostTimelineEntry } from '../../lib/api/types.js';
|
|
|
|
const ENTRY_ICON: Record<HostTimelineEntry['type'], LucideIcon> = {
|
|
HOST_EVENT: Pencil,
|
|
REPAIR: ArrowRightLeft,
|
|
PART_ARRIVED: LogIn,
|
|
PART_DEPARTED: LogOut,
|
|
};
|
|
|
|
const HOST_EVENT_TITLE: Record<string, string> = {
|
|
CREATED: 'Created',
|
|
STATE_CHANGED: 'State changed',
|
|
STACK_CHANGED: 'Stack changed',
|
|
FIELD_UPDATED: 'Field updated',
|
|
};
|
|
|
|
function formatWhen(iso: string) {
|
|
return new Date(iso).toLocaleString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
function EntryRow({ entry }: { entry: HostTimelineEntry }) {
|
|
switch (entry.type) {
|
|
case 'HOST_EVENT': {
|
|
const { hostEvent } = entry;
|
|
return (
|
|
<>
|
|
<div className="flex flex-wrap items-center gap-x-2 text-sm">
|
|
<span className="font-medium text-foreground">
|
|
{HOST_EVENT_TITLE[hostEvent.type] ?? hostEvent.type}
|
|
</span>
|
|
{hostEvent.field && (
|
|
<span className="text-xs text-muted-foreground">· {hostEvent.field}</span>
|
|
)}
|
|
{(hostEvent.oldValue || hostEvent.newValue) && (
|
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
|
<span className="font-mono">{hostEvent.oldValue ?? '—'}</span>
|
|
<ArrowRight className="h-3 w-3" />
|
|
<span className="font-mono text-foreground">{hostEvent.newValue ?? '—'}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 text-xs text-muted-foreground">
|
|
{formatWhen(entry.at)}
|
|
{hostEvent.user?.username ? ` · ${hostEvent.user.username}` : ''}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
case 'REPAIR': {
|
|
const { repair } = entry;
|
|
return (
|
|
<>
|
|
<div className="flex flex-wrap items-center gap-x-2 text-sm">
|
|
<span className="font-medium text-foreground">Repair</span>
|
|
<span className="inline-flex flex-wrap items-center gap-1 text-xs">
|
|
<Link
|
|
to={`/parts/${repair.brokenPart.id}`}
|
|
className="font-mono text-muted-foreground hover:underline"
|
|
>
|
|
{repair.brokenPart.serialNumber}
|
|
</Link>
|
|
<span className="text-muted-foreground">→ BROKEN</span>
|
|
<span className="text-muted-foreground">·</span>
|
|
<Link
|
|
to={`/parts/${repair.replacement.id}`}
|
|
className="font-mono text-foreground hover:underline"
|
|
>
|
|
{repair.replacement.serialNumber}
|
|
</Link>
|
|
<span className="text-muted-foreground">→ DEPLOYED</span>
|
|
</span>
|
|
</div>
|
|
<div className="mt-0.5 text-xs text-muted-foreground">
|
|
{formatWhen(entry.at)}
|
|
{repair.performedBy?.username ? ` · ${repair.performedBy.username}` : ''}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
case 'PART_ARRIVED':
|
|
case 'PART_DEPARTED': {
|
|
const { part } = entry;
|
|
const label = entry.type === 'PART_ARRIVED' ? 'Part deployed' : 'Part departed';
|
|
return (
|
|
<>
|
|
<div className="flex flex-wrap items-center gap-x-2 text-sm">
|
|
<span className="font-medium text-foreground">{label}</span>
|
|
<Link
|
|
to={`/parts/${part.id}`}
|
|
className="font-mono text-xs text-muted-foreground hover:underline"
|
|
>
|
|
{part.serialNumber}
|
|
</Link>
|
|
<span className="text-xs text-muted-foreground">· {part.mpn}</span>
|
|
</div>
|
|
<div className="mt-0.5 text-xs text-muted-foreground">{formatWhen(entry.at)}</div>
|
|
</>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function entryKey(entry: HostTimelineEntry): string {
|
|
switch (entry.type) {
|
|
case 'HOST_EVENT':
|
|
return `he-${entry.hostEvent.id}`;
|
|
case 'REPAIR':
|
|
return `r-${entry.repair.id}`;
|
|
case 'PART_ARRIVED':
|
|
return `pa-${entry.partEventId}`;
|
|
case 'PART_DEPARTED':
|
|
return `pd-${entry.partEventId}`;
|
|
}
|
|
}
|
|
|
|
export function HostTimeline({ hostId }: { hostId: string }) {
|
|
const [page, setPage] = useState(1);
|
|
const pageSize = 20;
|
|
const query = useQuery({
|
|
queryKey: queryKeys.hosts.timeline(hostId, { page, pageSize }),
|
|
queryFn: () => listHostTimeline(hostId, { page, pageSize }),
|
|
placeholderData: (prev) => prev,
|
|
});
|
|
|
|
if (query.isPending) {
|
|
return (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-12 w-full" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (query.isError) {
|
|
return <p className="text-sm text-destructive">Could not load history.</p>;
|
|
}
|
|
|
|
const entries = query.data?.data ?? [];
|
|
const total = query.data?.total ?? 0;
|
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
|
|
|
if (entries.length === 0) {
|
|
return <p className="text-sm text-muted-foreground">No activity yet.</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<ol className="relative ml-3 border-l border-border">
|
|
{entries.map((entry) => {
|
|
const Icon = ENTRY_ICON[entry.type];
|
|
return (
|
|
<li key={entryKey(entry)} className="relative pl-6 pb-4 last:pb-0">
|
|
<span className="absolute -left-[11px] top-0 flex h-5 w-5 items-center justify-center rounded-full border border-border bg-background">
|
|
<Icon className="h-3 w-3 text-muted-foreground" />
|
|
</span>
|
|
<EntryRow entry={entry} />
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
|
|
{pageCount > 1 && (
|
|
<div className="flex items-center justify-end gap-2 pt-2 text-xs text-muted-foreground">
|
|
<span>
|
|
Page {page} of {pageCount}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7"
|
|
disabled={page <= 1 || query.isFetching}
|
|
onClick={() => setPage(page - 1)}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7"
|
|
disabled={page >= pageCount || query.isFetching}
|
|
onClick={() => setPage(page + 1)}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|