feat: host detail page + FM host context
Add /hosts/:id detail page with unified timeline (HostEvents + FMs + Repairs + part arrivals/departures) and a deployed-parts table. Hosts list rows now link to the page. FM list + detail surface inline State/Stack badges next to the asset ID, with the asset ID linking to the host page. HostEvent audit model added; create/update in the hosts service now diff and log state, stack, and field changes the same way parts.ts does. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowRightLeft,
|
||||
CheckCircle2,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Pencil,
|
||||
Wrench,
|
||||
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,
|
||||
FM_OPENED: Wrench,
|
||||
FM_CLOSED: Wrench,
|
||||
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 'FM_OPENED':
|
||||
case 'FM_CLOSED': {
|
||||
const { fm } = entry;
|
||||
const label = entry.type === 'FM_OPENED' ? 'FM opened' : 'FM closed';
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-x-2 text-sm">
|
||||
<span className="font-medium text-foreground">{label}</span>
|
||||
<Link to={`/fms/${fm.id}`} className="text-xs text-muted-foreground hover:underline">
|
||||
{fm.problem}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{formatWhen(entry.at)}</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 arrived' : '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 'FM_OPENED':
|
||||
return `fo-${entry.fm.id}`;
|
||||
case 'FM_CLOSED':
|
||||
return `fc-${entry.fm.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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user